mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-04 08:12:44 +00:00
Compare commits
33 Commits
7f27499003
...
v2.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
45682f0d1a
|
|||
|
5f0bc45851
|
|||
|
c6f1f3f6fc
|
|||
|
32c2ce2f45
|
|||
|
c6e2f59b05
|
|||
|
2a872bafac
|
|||
|
a940d1b480
|
|||
|
f3b1d76666
|
|||
|
0b2695a380
|
|||
|
048dee0a6d
|
|||
|
977bdf3043
|
|||
|
9ca30f412e
|
|||
|
f2a18cafe8
|
|||
|
b27182dc35
|
|||
|
74ce6af318
|
|||
|
782f1fc38b
|
|||
|
|
087ff36d5d | ||
|
bd1c6147fd
|
|||
|
891abda0fb
|
|||
|
3611f65e24
|
|||
|
f76882a09c
|
|||
|
ff7e4f7b2e
|
|||
|
1c1c86241f
|
|||
|
dc8b3c3d66
|
|||
|
|
65911419b0 | ||
|
|
ccfb09e261 | ||
|
b841469505
|
|||
|
bc06e8ee80
|
|||
|
24fc861ef4
|
|||
|
5801e5fbbe
|
|||
|
92b52e08f7
|
|||
|
4192210d27
|
|||
|
|
0e5640a1ee |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -4,6 +4,8 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -7,6 +7,8 @@ node_modules
|
||||
.env.*
|
||||
!.env.example
|
||||
/src-tauri/target
|
||||
/openssl*
|
||||
/src/i18n/i18n*
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"stylelint-config-standard-scss",
|
||||
"stylelint-config-recommended-scss",
|
||||
"stylelint-config-html/svelte",
|
||||
"stylelint-config-clean-order",
|
||||
"stylelint-config-prettier-scss"
|
||||
],
|
||||
"rules": {
|
||||
|
||||
@@ -40,4 +40,4 @@ To generate the icons use the following command:
|
||||
|
||||
```shell
|
||||
npm run minify-icons
|
||||
```
|
||||
```
|
||||
|
||||
@@ -43,14 +43,17 @@ const config = {
|
||||
"arrow_back",
|
||||
"arrow_back_ios_new",
|
||||
"save",
|
||||
"step_over",
|
||||
"step_into",
|
||||
"step_out",
|
||||
"settings_backup_restore",
|
||||
"sound_detection_loud_sound",
|
||||
"ring_volume",
|
||||
"wifi",
|
||||
"power_settings_circle",
|
||||
"audio",
|
||||
"graphic_eq",
|
||||
"mail",
|
||||
"calculator",
|
||||
"calculate",
|
||||
"open_in_browser",
|
||||
"chevron_backward",
|
||||
"chevron_forward",
|
||||
@@ -80,6 +83,9 @@ const config = {
|
||||
"delete",
|
||||
"remove_selection",
|
||||
"bolt",
|
||||
"thunderstorm",
|
||||
"join_inner",
|
||||
"uppercase",
|
||||
"undo",
|
||||
"redo",
|
||||
"replay",
|
||||
|
||||
59
package.json
59
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "charachorder-device-manager",
|
||||
"version": "2.2.3",
|
||||
"version": "2.6.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@@ -36,59 +36,62 @@
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.5",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.6",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.7",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.20.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@sveltejs/kit": "^2.26.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.0",
|
||||
"@tauri-apps/api": "^1.6.0",
|
||||
"@tauri-apps/cli": "^1.6.0",
|
||||
"@types/dom-view-transitions": "^1.0.6",
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@types/w3c-web-usb": "^1.0.10",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@types/wicg-file-system-access": "^2023.10.6",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"codemirror": "^6.0.1",
|
||||
"cypress": "^14.2.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"cypress": "^14.5.3",
|
||||
"d3": "^7.9.0",
|
||||
"esptool-js": "^0.5.4",
|
||||
"flexsearch": "^0.8.147",
|
||||
"esptool-js": "^0.5.6",
|
||||
"flexsearch": "^0.8.205",
|
||||
"fontkit": "^2.0.4",
|
||||
"glob": "^11.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"matrix-js-sdk": "^37.2.0",
|
||||
"glob": "^11.0.3",
|
||||
"jsdom": "^26.1.0",
|
||||
"matrix-js-sdk": "^37.12.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-css-order": "^2.1.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.86.0",
|
||||
"sass": "^1.89.2",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stylelint": "^16.17.0",
|
||||
"stylelint": "^16.23.0",
|
||||
"stylelint-config-clean-order": "^7.0.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-recommended-scss": "^14.1.0",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
"svelte": "5.25.3",
|
||||
"svelte-check": "^4.1.5",
|
||||
"stylelint-config-recommended-scss": "^15.0.1",
|
||||
"stylelint-config-standard-scss": "^15.0.1",
|
||||
"svelte": "5.37.1",
|
||||
"svelte-check": "^4.3.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-mkcert": "^1.17.8",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^3.1.1",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vitest": "^3.2.4",
|
||||
"web-serial-polyfill": "^1.0.15",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
|
||||
1402
pnpm-lock.yaml
generated
1402
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "2.2.3"
|
||||
version = "2.6.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||
license = "AGPL-3"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": { "productName": "amacc1ng", "version": "2.2.3" },
|
||||
"package": { "productName": "amacc1ng", "version": "2.6.0" },
|
||||
"tauri": {
|
||||
"allowlist": { "all": false },
|
||||
"bundle": {
|
||||
|
||||
@@ -18,10 +18,10 @@ const de = {
|
||||
},
|
||||
backup: {
|
||||
TITLE: "Backup",
|
||||
AUTO_BACKUP: "Auto-backup",
|
||||
AUTO_BACKUP: "Beschleunigtes Verbinden",
|
||||
DISCLAIMER:
|
||||
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
||||
DOWNLOAD: "Alles",
|
||||
"<b>Nicht auf öffentlichen oder geteilten Computern einschalten.</b> Gerätedaten werden für schnelleren Zugriff lokal zwischengespeichert.",
|
||||
DOWNLOAD: "Komplettes Profil",
|
||||
RESTORE: "Wiederherstellen",
|
||||
},
|
||||
modal: {
|
||||
|
||||
@@ -14,10 +14,10 @@ const en = {
|
||||
},
|
||||
backup: {
|
||||
TITLE: "Backup",
|
||||
AUTO_BACKUP: "Auto-backup",
|
||||
AUTO_BACKUP: "Fast Connect",
|
||||
DISCLAIMER:
|
||||
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
|
||||
DOWNLOAD: "Everything",
|
||||
"<b>Turn off if using a shared or public computer.</b> Caches your device's data locally for quick access next time you connect.",
|
||||
DOWNLOAD: "Full profile",
|
||||
RESTORE: "Restore",
|
||||
},
|
||||
sync: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { expoIn, expoOut } from "svelte/easing";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
|
||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
|
||||
$props();
|
||||
|
||||
let inDirection = $state(0);
|
||||
|
||||
3
src/lib/assets/keymaps/keymap.d.ts
vendored
3
src/lib/assets/keymaps/keymap.d.ts
vendored
@@ -16,4 +16,7 @@ export interface ActionInfo {
|
||||
variant: "left" | "right";
|
||||
variantOf: number;
|
||||
keyCode: string;
|
||||
printable?: boolean;
|
||||
separator?: boolean;
|
||||
breaking?: boolean;
|
||||
}
|
||||
|
||||
10
src/lib/assets/layouts/t4g.yml
Normal file
10
src/lib/assets/layouts/t4g.yml
Normal file
@@ -0,0 +1,10 @@
|
||||
name: T4G
|
||||
col:
|
||||
- row:
|
||||
- switch: { e: 3, n: 5, w: 4, s: 6 }
|
||||
- offset: [0.5, 0]
|
||||
row:
|
||||
- key: 2
|
||||
- row:
|
||||
- key: 0
|
||||
- key: 1
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
settings,
|
||||
} from "$lib/undo-redo.js";
|
||||
import { get } from "svelte/store";
|
||||
import { serialPort } from "../serial/connection";
|
||||
import { activeProfile, serialPort } from "../serial/connection";
|
||||
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
|
||||
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
|
||||
|
||||
@@ -50,11 +50,9 @@ export function createLayoutBackup(): CharaLayoutFile {
|
||||
charaVersion: 1,
|
||||
type: "layout",
|
||||
device: get(serialPort)?.device,
|
||||
layout: get(layout).map((it) => it.map((it) => it.action)) as [
|
||||
number[],
|
||||
number[],
|
||||
number[],
|
||||
],
|
||||
layout: (get(layout)[get(activeProfile)]?.map((it) =>
|
||||
it.map((it) => it.action),
|
||||
) ?? []) as [number[], number[], number[]],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,7 +68,7 @@ export function createSettingsBackup(): CharaSettingsFile {
|
||||
return {
|
||||
charaVersion: 1,
|
||||
type: "settings",
|
||||
settings: get(settings).map((it) => it.value),
|
||||
settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,9 +95,11 @@ export function restoreFromFile(
|
||||
const recent = file.history[0];
|
||||
if (!recent) return;
|
||||
let backupDevice = recent[1].device;
|
||||
if (backupDevice === "TWO") backupDevice = "ONE";
|
||||
if (backupDevice === "TWO" || backupDevice === "M4G")
|
||||
backupDevice = "ONE";
|
||||
let currentDevice = get(serialPort)?.device;
|
||||
if (currentDevice === "TWO") currentDevice = "ONE";
|
||||
if (currentDevice === "TWO" || currentDevice === "M4G")
|
||||
currentDevice = "ONE";
|
||||
|
||||
if (backupDevice !== currentDevice) {
|
||||
alert("Backup is incompatible with this device");
|
||||
@@ -167,12 +167,13 @@ export function getChangesFromChordFile(file: CharaChordFile) {
|
||||
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
|
||||
const changes: Change[] = [];
|
||||
for (const [id, value] of file.settings.entries()) {
|
||||
const setting = get(settings)[id];
|
||||
const setting = get(settings)[get(activeProfile)]?.[id];
|
||||
if (setting !== undefined && setting.value !== value) {
|
||||
changes.push({
|
||||
type: ChangeType.Setting,
|
||||
id,
|
||||
setting: value,
|
||||
profile: get(activeProfile),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -183,12 +184,13 @@ export function getChangesFromLayoutFile(file: CharaLayoutFile) {
|
||||
const changes: Change[] = [];
|
||||
for (const [layer, keys] of file.layout.entries()) {
|
||||
for (const [id, action] of keys.entries()) {
|
||||
if (get(layout)[layer]?.[id]?.action !== action) {
|
||||
if (get(layout)[get(activeProfile)]?.[layer]?.[id]?.action !== action) {
|
||||
changes.push({
|
||||
type: ChangeType.Layout,
|
||||
layer,
|
||||
id,
|
||||
action,
|
||||
profile: get(activeProfile),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
26
src/lib/ccos/attachment.ts
Normal file
26
src/lib/ccos/attachment.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
import { browser } from "$app/environment";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
|
||||
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
|
||||
|
||||
export function ccosKeyInterceptor() {
|
||||
return ((element: Window) => {
|
||||
const ccos = browser
|
||||
? import("./ccos").then((module) => module.fetchCCOS(".test"))
|
||||
: Promise.resolve(undefined);
|
||||
|
||||
function onEvent(event: KeyboardEvent) {
|
||||
ccos.then((it) => it?.handleKeyEvent(event));
|
||||
}
|
||||
|
||||
element.addEventListener("keydown", onEvent, true);
|
||||
element.addEventListener("keyup", onEvent, true);
|
||||
|
||||
return () => {
|
||||
ccos.then((it) => it?.destroy());
|
||||
element.removeEventListener("keydown", onEvent, true);
|
||||
element.removeEventListener("keyup", onEvent, true);
|
||||
};
|
||||
}) satisfies Attachment<Window>;
|
||||
}
|
||||
37
src/lib/ccos/ccos-events.ts
Normal file
37
src/lib/ccos/ccos-events.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface CCOSInitEvent {
|
||||
type: "init";
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CCOSKeyPressEvent {
|
||||
type: "press";
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface CCOSKeyReleaseEvent {
|
||||
type: "release";
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface CCOSSerialEvent {
|
||||
type: "serial";
|
||||
data: number;
|
||||
}
|
||||
|
||||
export type CCOSInEvent =
|
||||
| CCOSInitEvent
|
||||
| CCOSKeyPressEvent
|
||||
| CCOSKeyReleaseEvent
|
||||
| CCOSSerialEvent;
|
||||
|
||||
export interface CCOSReportEvent {
|
||||
type: "report";
|
||||
modifiers: number;
|
||||
keys: number[];
|
||||
}
|
||||
|
||||
export interface CCOSReadyEvent {
|
||||
type: "ready";
|
||||
}
|
||||
|
||||
export type CCOSOutEvent = CCOSReportEvent | CCOSReadyEvent | CCOSSerialEvent;
|
||||
111
src/lib/ccos/ccos-interop.ts
Normal file
111
src/lib/ccos/ccos-interop.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export const KEYCODE_TO_SCANCODE = new Map<string, number | undefined>(
|
||||
Object.entries({
|
||||
KeyA: 0x04,
|
||||
KeyB: 0x05,
|
||||
KeyC: 0x06,
|
||||
KeyD: 0x07,
|
||||
KeyE: 0x08,
|
||||
KeyF: 0x09,
|
||||
KeyG: 0x0a,
|
||||
KeyH: 0x0b,
|
||||
KeyI: 0x0c,
|
||||
KeyJ: 0x0d,
|
||||
KeyK: 0x0e,
|
||||
KeyL: 0x0f,
|
||||
KeyM: 0x10,
|
||||
KeyN: 0x11,
|
||||
KeyO: 0x12,
|
||||
KeyP: 0x13,
|
||||
KeyQ: 0x14,
|
||||
KeyR: 0x15,
|
||||
KeyS: 0x16,
|
||||
KeyT: 0x17,
|
||||
KeyU: 0x18,
|
||||
KeyV: 0x19,
|
||||
KeyW: 0x1a,
|
||||
KeyX: 0x1b,
|
||||
KeyY: 0x1c,
|
||||
KeyZ: 0x1d,
|
||||
Digit1: 0x1e,
|
||||
Digit2: 0x1f,
|
||||
Digit3: 0x20,
|
||||
Digit4: 0x21,
|
||||
Digit5: 0x22,
|
||||
Digit6: 0x23,
|
||||
Digit7: 0x24,
|
||||
Digit8: 0x25,
|
||||
Digit9: 0x26,
|
||||
Digit0: 0x27,
|
||||
Enter: 0x28,
|
||||
Escape: 0x29,
|
||||
Backspace: 0x2a,
|
||||
Tab: 0x2b,
|
||||
Space: 0x2c,
|
||||
Minus: 0x2d,
|
||||
Equal: 0x2e,
|
||||
BracketLeft: 0x2f,
|
||||
BracketRight: 0x30,
|
||||
Backslash: 0x31,
|
||||
Semicolon: 0x33,
|
||||
Quote: 0x34,
|
||||
Backquote: 0x35,
|
||||
Comma: 0x36,
|
||||
Period: 0x37,
|
||||
Slash: 0x38,
|
||||
CapsLock: 0x39,
|
||||
F1: 0x3a,
|
||||
F2: 0x3b,
|
||||
F3: 0x3c,
|
||||
F4: 0x3d,
|
||||
F5: 0x3e,
|
||||
F6: 0x3f,
|
||||
F7: 0x40,
|
||||
F8: 0x41,
|
||||
F9: 0x42,
|
||||
F10: 0x43,
|
||||
F11: 0x44,
|
||||
F12: 0x45,
|
||||
PrintScreen: 0x46,
|
||||
ScrollLock: 0x47,
|
||||
Pause: 0x48,
|
||||
Insert: 0x49,
|
||||
Home: 0x4a,
|
||||
PageUp: 0x4b,
|
||||
Delete: 0x4c,
|
||||
End: 0x4d,
|
||||
PageDown: 0x4e,
|
||||
ArrowRight: 0x4f,
|
||||
ArrowLeft: 0x50,
|
||||
ArrowDown: 0x51,
|
||||
ArrowUp: 0x52,
|
||||
NumLock: 0x53,
|
||||
NumpadDivide: 0x54,
|
||||
NumpadMultiply: 0x55,
|
||||
NumpadSubtract: 0x56,
|
||||
NumpadAdd: 0x57,
|
||||
NumpadEnter: 0x58,
|
||||
Numpad1: 0x59,
|
||||
Numpad2: 0x5a,
|
||||
Numpad3: 0x5b,
|
||||
Numpad4: 0x5c,
|
||||
Numpad5: 0x5d,
|
||||
Numpad6: 0x5e,
|
||||
Numpad7: 0x5f,
|
||||
Numpad8: 0x60,
|
||||
Numpad9: 0x61,
|
||||
Numpad0: 0x62,
|
||||
NumpadDecimal: 0x63,
|
||||
ControlLeft: 0xe0,
|
||||
ShiftLeft: 0xe1,
|
||||
AltLeft: 0xe2,
|
||||
MetaLeft: 0xe3,
|
||||
ControlRight: 0xe4,
|
||||
ShiftRight: 0xe5,
|
||||
AltRight: 0xe6,
|
||||
MetaRight: 0xe7,
|
||||
}),
|
||||
);
|
||||
|
||||
export const SCANCODE_TO_KEYCODE = new Map<number, string>(
|
||||
KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]),
|
||||
);
|
||||
210
src/lib/ccos/ccos.ts
Normal file
210
src/lib/ccos/ccos.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { getMeta } from "$lib/meta/meta-storage";
|
||||
import { connectable, from, multicast, Subject } from "rxjs";
|
||||
import type {
|
||||
CCOSInitEvent,
|
||||
CCOSKeyPressEvent,
|
||||
CCOSKeyReleaseEvent,
|
||||
CCOSOutEvent,
|
||||
} from "./ccos-events";
|
||||
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
||||
|
||||
const device = ".zero_wasm";
|
||||
|
||||
class CCOSKeyboardEvent extends KeyboardEvent {
|
||||
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||
super(...params);
|
||||
}
|
||||
}
|
||||
|
||||
const MASK_CTRL = 0b0001_0001;
|
||||
const MASK_SHIFT = 0b0010_0010;
|
||||
const MASK_ALT = 0b0100_0100;
|
||||
const MASK_ALT_GRAPH = 0b0000_0100;
|
||||
const MASK_GUI = 0b1000_1000;
|
||||
|
||||
export class CCOS {
|
||||
private readonly currKeys = new Set<number>();
|
||||
|
||||
private readonly layout = new Map<string, string>();
|
||||
|
||||
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||
|
||||
private ready = false;
|
||||
|
||||
private lastEvent?: KeyboardEvent;
|
||||
|
||||
private onKey(
|
||||
type: ConstructorParameters<typeof KeyboardEvent>[0],
|
||||
modifiers: number,
|
||||
scanCode: number,
|
||||
) {
|
||||
if (!this.lastEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const code = SCANCODE_TO_KEYCODE.get(scanCode);
|
||||
if (code === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const layoutKey = [code];
|
||||
if (modifiers & MASK_SHIFT) {
|
||||
layoutKey.push("Shift");
|
||||
}
|
||||
if (modifiers & MASK_ALT_GRAPH) {
|
||||
layoutKey.push("AltGraph");
|
||||
}
|
||||
const key = this.layout.get(JSON.stringify(layoutKey)) ?? code;
|
||||
|
||||
const params: Required<KeyboardEventInit> = {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
location: this.lastEvent.location,
|
||||
repeat: this.lastEvent.repeat,
|
||||
detail: this.lastEvent.detail,
|
||||
view: this.lastEvent.view,
|
||||
isComposing: this.lastEvent.isComposing,
|
||||
which: this.lastEvent.which,
|
||||
composed: this.lastEvent.composed,
|
||||
key,
|
||||
code,
|
||||
charCode: key.charCodeAt(0),
|
||||
keyCode: this.lastEvent.keyCode,
|
||||
shiftKey: (modifiers & MASK_SHIFT) !== 0,
|
||||
ctrlKey: (modifiers & MASK_CTRL) !== 0,
|
||||
metaKey: (modifiers & MASK_GUI) !== 0,
|
||||
altKey: (modifiers & MASK_ALT) !== 0,
|
||||
modifierAltGraph: (modifiers & MASK_ALT_GRAPH) !== 0,
|
||||
modifierCapsLock: this.lastEvent.getModifierState("CapsLock"),
|
||||
modifierFn: this.lastEvent.getModifierState("Fn"),
|
||||
modifierFnLock: this.lastEvent.getModifierState("FnLock"),
|
||||
modifierHyper: this.lastEvent.getModifierState("Hyper"),
|
||||
modifierNumLock: this.lastEvent.getModifierState("NumLock"),
|
||||
modifierSuper: (modifiers & MASK_GUI) !== 0,
|
||||
modifierSymbol: this.lastEvent.getModifierState("Symbol"),
|
||||
modifierSymbolLock: this.lastEvent.getModifierState("SymbolLock"),
|
||||
modifierScrollLock: this.lastEvent.getModifierState("ScrollLock"),
|
||||
};
|
||||
|
||||
this.lastEvent.target?.dispatchEvent(new CCOSKeyboardEvent(type, params));
|
||||
}
|
||||
|
||||
private onReport(modifiers: number, keys: number[]) {
|
||||
const nextKeys = new Set<number>(keys);
|
||||
nextKeys.delete(0);
|
||||
for (const key of this.currKeys) {
|
||||
if (!nextKeys.has(key)) {
|
||||
this.onKey("keyup", modifiers, key);
|
||||
}
|
||||
}
|
||||
for (const key of nextKeys) {
|
||||
if (!this.currKeys.has(key)) {
|
||||
this.onKey("keydown", modifiers, key);
|
||||
}
|
||||
}
|
||||
this.currKeys.clear();
|
||||
for (const key of keys) {
|
||||
this.currKeys.add(key);
|
||||
}
|
||||
this.currKeys.delete(0);
|
||||
}
|
||||
|
||||
private outStream = new Subject<number>();
|
||||
|
||||
private readonly buffer: number[] = [];
|
||||
private readonly outStream = new WritableStream<number>({
|
||||
start(controller) {},
|
||||
});
|
||||
|
||||
readonly readable = connectable()
|
||||
readonly writable = new WritableStream<string>();
|
||||
|
||||
constructor(url: string) {
|
||||
this.worker.addEventListener(
|
||||
"message",
|
||||
(event: MessageEvent<CCOSOutEvent>) => {
|
||||
switch (event.data.type) {
|
||||
case "ready": {
|
||||
this.ready = true;
|
||||
break;
|
||||
}
|
||||
case "report": {
|
||||
this.onReport(event.data.modifiers, event.data.keys);
|
||||
break;
|
||||
}
|
||||
case "serial": {
|
||||
this.outStream.next(event.data.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
(navigator as any).keyboard
|
||||
?.getLayoutMap()
|
||||
?.then((it: Map<string, string>) =>
|
||||
it.entries().forEach(([key, value]) => {
|
||||
this.layout.set(JSON.stringify([key]), value);
|
||||
}),
|
||||
);
|
||||
this.worker.postMessage({
|
||||
type: "init",
|
||||
url,
|
||||
} satisfies CCOSInitEvent);
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
this.worker.terminate();
|
||||
}
|
||||
|
||||
async handleKeyEvent(event: KeyboardEvent) {
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement
|
||||
) {
|
||||
console.error("CCOS does not support input elements");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ready || event instanceof CCOSKeyboardEvent) {
|
||||
return;
|
||||
}
|
||||
event.stopImmediatePropagation();
|
||||
event.preventDefault();
|
||||
this.lastEvent = event;
|
||||
|
||||
const layoutKey = [event.code];
|
||||
if (event.getModifierState("Shift")) {
|
||||
layoutKey.push("Shift");
|
||||
}
|
||||
if (event.getModifierState("AltGraph")) {
|
||||
layoutKey.push("AltGraph");
|
||||
}
|
||||
this.layout.set(JSON.stringify(layoutKey), event.key);
|
||||
|
||||
const scanCode = KEYCODE_TO_SCANCODE.get(event.code);
|
||||
if (scanCode === undefined) return;
|
||||
if (event.type === "keydown") {
|
||||
this.worker.postMessage({
|
||||
type: "press",
|
||||
code: scanCode,
|
||||
} satisfies CCOSKeyPressEvent);
|
||||
} else {
|
||||
this.worker.postMessage({
|
||||
type: "release",
|
||||
code: scanCode,
|
||||
} satisfies CCOSKeyReleaseEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCCOS(
|
||||
version = ".test",
|
||||
fetch: typeof window.fetch = window.fetch,
|
||||
): Promise<CCOS | undefined> {
|
||||
const meta = await getMeta(device, version, fetch);
|
||||
if (!meta?.update.js || !meta?.update.wasm) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return new CCOS(`${meta.path}/${meta.update.js}`);
|
||||
}
|
||||
@@ -113,15 +113,15 @@
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
svg > :global(text) {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
fill: currentColor;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
@@ -83,24 +83,24 @@
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: grid;
|
||||
position: relative;
|
||||
margin: 1em;
|
||||
margin-bottom: 0;
|
||||
display: grid;
|
||||
height: 3em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.rating {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tile {
|
||||
border-radius: 0.1em;
|
||||
width: 100%;
|
||||
height: 0.2em;
|
||||
border-radius: 0.1em;
|
||||
}
|
||||
|
||||
kbd {
|
||||
@@ -112,19 +112,19 @@
|
||||
}
|
||||
|
||||
.chord {
|
||||
will-change: transform, opacity, scale;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline-end: 1em;
|
||||
padding-inline: 0.1em;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
translate 0.3s ease,
|
||||
scale 0.3s ease;
|
||||
will-change: transform, opacity, scale;
|
||||
margin-inline-end: 1em;
|
||||
padding-inline: 0.1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,16 +37,16 @@
|
||||
|
||||
<style lang="scss">
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
span {
|
||||
|
||||
@@ -53,17 +53,17 @@
|
||||
|
||||
.room {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding-block: 2px;
|
||||
min-height: 0;
|
||||
height: unset;
|
||||
padding-inline: 16px;
|
||||
padding-block: 4px;
|
||||
border-radius: 8px;
|
||||
padding-inline: 16px;
|
||||
padding-block: 2px;
|
||||
padding-block: 4px;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
min-height: 0;
|
||||
|
||||
&.active {
|
||||
background: var(--md-sys-color-primary-container);
|
||||
|
||||
@@ -177,19 +177,18 @@
|
||||
<style lang="scss">
|
||||
$border-radius: 16px;
|
||||
|
||||
|
||||
.input {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
flex-grow: 1;
|
||||
cursor: text;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: $border-radius;
|
||||
padding: 0.5em;
|
||||
font-size: 1rem;
|
||||
border-radius: $border-radius;
|
||||
|
||||
text-wrap: wrap;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
@@ -198,9 +197,9 @@
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding-block: 8px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -210,24 +209,23 @@
|
||||
}
|
||||
|
||||
.timeline {
|
||||
contain: content;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
flex-grow: 1;
|
||||
flex-direction: column-reverse;
|
||||
contain: content;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
justify-content: flex-end;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -255,21 +255,21 @@
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
right: 0;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
z-index: 100;
|
||||
border-radius: 4px;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
padding: 4px;
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
a,
|
||||
button {
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,10 +289,10 @@
|
||||
}
|
||||
|
||||
.dot {
|
||||
animation: bounce 1s infinite;
|
||||
border-radius: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
.sender,
|
||||
@@ -302,10 +302,10 @@
|
||||
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
translate: 0 2px;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
translate: 0 2px;
|
||||
}
|
||||
|
||||
div.avatar {
|
||||
@@ -322,18 +322,18 @@
|
||||
}
|
||||
|
||||
.reactions {
|
||||
grid-area: reactions;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
grid-area: reactions;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
|
||||
> .count {
|
||||
@@ -344,16 +344,16 @@
|
||||
.event {
|
||||
display: grid;
|
||||
position: relative;
|
||||
padding-inline: 0.5em;
|
||||
margin-inline: 0.5em;
|
||||
padding-block: 0.25em;
|
||||
border-radius: 4px;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
|
||||
grid-template-areas:
|
||||
"avatar sender date"
|
||||
"avatar content content"
|
||||
"none reactions reactions";
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
margin-inline: 0.5em;
|
||||
border-radius: 4px;
|
||||
padding-inline: 0.5em;
|
||||
padding-block: 0.25em;
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -370,12 +370,12 @@
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.25;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,9 +39,9 @@
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
max-height: 16em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { action as title } from "$lib/title";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { tooltip } from "$lib/hover-popover";
|
||||
|
||||
let {
|
||||
action,
|
||||
@@ -16,42 +16,51 @@
|
||||
);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
|
||||
let tooltip = $derived(
|
||||
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
||||
(info.title ?? "") +
|
||||
(info.variant === "left"
|
||||
? " (left)"
|
||||
: info.variant === "right"
|
||||
? " (right)"
|
||||
: ""),
|
||||
);
|
||||
let popover: HTMLElement | undefined = $state(undefined);
|
||||
</script>
|
||||
|
||||
{#snippet popoverSnippet()}
|
||||
<div bind:this={popover} popover="hint">
|
||||
<{info.id ?? `0x${info.code.toString(16)}`}>
|
||||
{#if info.title}
|
||||
{info.title}
|
||||
{/if}
|
||||
{#if info.variant === "left"}
|
||||
(Left)
|
||||
{:else if info.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if display === "keys"}
|
||||
<kbd
|
||||
class:icon={!!info.icon}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
use:title={{ title: tooltip }}
|
||||
{@attach tooltip(popover)}
|
||||
>
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}
|
||||
{@render popoverSnippet()}
|
||||
</kbd>
|
||||
{:else if display === "inline-keys"}
|
||||
{#if !info.icon && dynamicMapping?.length === 1}
|
||||
<span
|
||||
use:title={{ title: tooltip }}
|
||||
{@attach tooltip(popover)}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||
class:right={info.variant === "right"}
|
||||
>{dynamicMapping}{@render popoverSnippet()}</span
|
||||
>
|
||||
{:else if !info.icon && info.id?.length === 1}
|
||||
<span
|
||||
use:title={{ title: tooltip }}
|
||||
{@attach tooltip(popover)}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{info.id}</span
|
||||
class:right={info.variant === "right"}
|
||||
>{info.id}{@render popoverSnippet()}</span
|
||||
>
|
||||
{:else}
|
||||
<kbd
|
||||
@@ -59,22 +68,22 @@
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:icon={!!info.icon}
|
||||
use:title={{ title: tooltip }}
|
||||
{@attach tooltip(popover)}
|
||||
>
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}</kbd
|
||||
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
kbd:not(.inline-kbd) {
|
||||
height: 24px;
|
||||
padding-block: auto;
|
||||
transition: color 250ms ease;
|
||||
padding-block: auto;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.left {
|
||||
@@ -84,7 +93,6 @@
|
||||
border-right-width: 3px;
|
||||
}
|
||||
|
||||
|
||||
.inline-kbd {
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
|
||||
@@ -48,33 +48,33 @@
|
||||
<style lang="scss">
|
||||
button {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
border-radius: 8px;
|
||||
|
||||
@media not (forced-colors: active) {
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
|
||||
&:focus-visible {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
outline: none;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid ButtonBorder;
|
||||
margin-block: 4px;
|
||||
border: 1px solid ButtonBorder;
|
||||
|
||||
&:hover {
|
||||
color: ActiveText;
|
||||
@@ -86,8 +86,8 @@
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
@@ -29,15 +29,15 @@
|
||||
|
||||
<style lang="scss">
|
||||
.digits {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
transition: width 500ms ease;
|
||||
}
|
||||
|
||||
.digit-wrapper {
|
||||
display: inline-grid;
|
||||
height: 1em;
|
||||
width: 1ch;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
.digit {
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
<style lang="scss">
|
||||
button {
|
||||
cursor: pointer;
|
||||
color: var(--md-sys-color-on-background);
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--md-sys-color-on-background);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,63 +33,61 @@
|
||||
|
||||
<style lang="scss">
|
||||
form {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
|
||||
contain: strict;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
border-radius: 16px;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
font-size: 0.75rem;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-size: 0.75rem;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
fieldset::before {
|
||||
content: "$";
|
||||
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
content: "$";
|
||||
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
appearance: none;
|
||||
margin-block-start: -16px;
|
||||
border: none;
|
||||
background: var(--md-sys-color-secondary);
|
||||
padding: 8px;
|
||||
padding-block-start: 24px;
|
||||
padding-inline-start: calc(8px + 1.5ch);
|
||||
padding-block-start: 24px;
|
||||
width: 100%;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
font-weight: 600;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-weight: 600;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
|
||||
appearance: none;
|
||||
background: var(--md-sys-color-secondary);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.io {
|
||||
--scrollbar-color: var(--md-sys-color-secondary);
|
||||
flex: 1;
|
||||
|
||||
z-index: 1;
|
||||
border-radius: 0 0 16px 16px;
|
||||
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
|
||||
padding: 12px;
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
overflow-y: auto;
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
border-radius: 0 0 16px 16px;
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
@@ -99,10 +97,10 @@
|
||||
fieldset {
|
||||
all: unset;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: block;
|
||||
|
||||
position: relative;
|
||||
|
||||
opacity: 0.8;
|
||||
|
||||
transition: opacity 250ms ease;
|
||||
@@ -113,16 +111,16 @@
|
||||
}
|
||||
|
||||
.anchor {
|
||||
overflow-anchor: auto;
|
||||
height: 1px;
|
||||
overflow-anchor: auto;
|
||||
}
|
||||
|
||||
code,
|
||||
samp,
|
||||
p {
|
||||
display: block;
|
||||
overflow-anchor: none;
|
||||
margin-block: 0.15rem;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
p {
|
||||
@@ -130,24 +128,24 @@
|
||||
justify-content: center;
|
||||
|
||||
margin-block-end: 1rem;
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--md-sys-color-secondary);
|
||||
padding: 0.25rem;
|
||||
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
|
||||
background: var(--md-sys-color-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
code::before {
|
||||
content: "> ";
|
||||
margin-block-end: 0.25rem;
|
||||
font-weight: 900;
|
||||
content: "> ";
|
||||
color: var(--md-sys-color-primary);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
::selection {
|
||||
color: var(--md-sys-color-background);
|
||||
background: var(--md-sys-color-on-background);
|
||||
color: var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
|
||||
:global(kbd.icon) {
|
||||
display: inline-flex;
|
||||
font-size: inherit;
|
||||
translate: 0 0.2em;
|
||||
font-size: inherit;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -189,18 +189,17 @@
|
||||
border: none;
|
||||
|
||||
label {
|
||||
height: unset;
|
||||
padding-block: 2px;
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 6px;
|
||||
padding-inline: 4px;
|
||||
padding-block: 2px;
|
||||
height: unset;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 6px;
|
||||
|
||||
&:has(:checked) {
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
background: var(--md-sys-color-secondary);
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
@@ -211,34 +210,33 @@
|
||||
|
||||
dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
|
||||
background: rgba(0 0 0 / 60%);
|
||||
|
||||
border: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
aside {
|
||||
pointer-events: none;
|
||||
opacity: 0.4;
|
||||
|
||||
margin: 8px;
|
||||
|
||||
opacity: 0.4;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
> h3 {
|
||||
width: fit-content;
|
||||
margin-inline-start: 16px;
|
||||
margin-block-start: -13px;
|
||||
margin-block-end: 0;
|
||||
margin-inline-start: 16px;
|
||||
padding-inline: 8px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
padding-inline: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
@@ -253,26 +251,26 @@
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
transform-origin: top left;
|
||||
border-radius: 16px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
|
||||
width: calc(min(30cm, 90%));
|
||||
height: calc(min(100% - 128px, 90%));
|
||||
|
||||
color: var(--md-sys-color-on-background);
|
||||
overflow: hidden;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
border-radius: 16px;
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid CanvasText;
|
||||
@@ -280,39 +278,38 @@
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
transition: all 250ms ease;
|
||||
margin-block-end: 8px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
color: currentcolor;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
|
||||
transition: all 250ms ease;
|
||||
background: none;
|
||||
padding-inline: 16px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
color: currentcolor;
|
||||
font-size: 16px;
|
||||
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
scrollbar-gutter: both-edges stable;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline: 4px;
|
||||
height: 100%;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-gutter: both-edges stable;
|
||||
}
|
||||
|
||||
li {
|
||||
@@ -322,27 +319,27 @@
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-block-start: 8px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
> i {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
padding-inline: 6px;
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
|
||||
@@ -8,17 +8,16 @@
|
||||
import { dev } from "$app/environment";
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||
import { getContext, mount, unmount } from "svelte";
|
||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||
import { fly } from "svelte/transition";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { activeLayer, activeProfile } from "$lib/serial/connection";
|
||||
|
||||
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
if (dev) {
|
||||
// you have absolutely no idea what a difference this makes for performance
|
||||
@@ -125,8 +124,10 @@
|
||||
const keyInfo = layoutInfo.keys[index];
|
||||
if (!keyInfo) return;
|
||||
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
||||
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
||||
const nextAction =
|
||||
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
|
||||
const currentAction =
|
||||
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
|
||||
const component = mount(ActionSelector, {
|
||||
target: document.body,
|
||||
props: {
|
||||
@@ -142,6 +143,7 @@
|
||||
type: ChangeType.Layout,
|
||||
id: keyInfo.id,
|
||||
layer: get(activeLayer),
|
||||
profile: get(activeProfile),
|
||||
action,
|
||||
},
|
||||
]);
|
||||
@@ -217,9 +219,9 @@
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
overflow: visible;
|
||||
grid-area: "d";
|
||||
width: calc(min(100%, 35cm));
|
||||
max-height: calc(100% - 170px);
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
||||
@@ -7,10 +6,11 @@
|
||||
import { osLayout } from "$lib/os-layout.js";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { action } from "$lib/title";
|
||||
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
||||
"highlight-action",
|
||||
);
|
||||
@@ -33,7 +33,9 @@
|
||||
</script>
|
||||
|
||||
{#each positions as position, layer}
|
||||
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
|
||||
{@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
|
||||
key.id
|
||||
] ?? {
|
||||
action: 0,
|
||||
isApplied: true,
|
||||
}}
|
||||
@@ -81,15 +83,15 @@
|
||||
$transition: 200ms;
|
||||
|
||||
text {
|
||||
will-change: translate, scale;
|
||||
user-select: none;
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
transition:
|
||||
fill #{$focus-transition} ease,
|
||||
opacity #{$transition} ease,
|
||||
translate #{$transition} ease,
|
||||
scale #{$transition} ease;
|
||||
will-change: translate, scale;
|
||||
user-select: none;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
--inactive-opacity: 0.8;
|
||||
|
||||
@@ -113,14 +113,14 @@
|
||||
$transition: 200ms;
|
||||
|
||||
rect {
|
||||
transform-origin: center;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
path,
|
||||
g {
|
||||
transform-origin: top left;
|
||||
transform-box: fill-box;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
path,
|
||||
@@ -138,15 +138,15 @@
|
||||
|
||||
g.faded,
|
||||
g:hover {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
transition: opacity #{$transition} ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
g.highlight,
|
||||
g:focus-within {
|
||||
color: var(--md-sys-color-primary);
|
||||
outline: none;
|
||||
color: var(--md-sys-color-primary);
|
||||
|
||||
> path,
|
||||
> rect {
|
||||
|
||||
@@ -2,21 +2,11 @@
|
||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||
import { action } from "$lib/title";
|
||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { restoreFromFile } from "$lib/backup/backup";
|
||||
|
||||
let device = $derived($serialPort?.device);
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
const layers = [
|
||||
["Numeric Layer", "123", 1],
|
||||
["Primary Layer", "abc", 0],
|
||||
["Function Layer", "function", 2],
|
||||
] as const;
|
||||
|
||||
const layouts = {
|
||||
ONE: () =>
|
||||
import("$lib/assets/layouts/one.yml").then(
|
||||
@@ -42,23 +32,33 @@
|
||||
import("$lib/assets/layouts/m4gr.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
T4G: () =>
|
||||
import("$lib/assets/layouts/t4g.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if device}
|
||||
{#await layouts[device]() then visualLayout}
|
||||
{#if $serialPort}
|
||||
{#await layouts[$serialPort.device]() then visualLayout}
|
||||
<fieldset transition:fade>
|
||||
{#each layers as [title, icon, value]}
|
||||
<button
|
||||
class="icon"
|
||||
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
||||
onclick={() => ($activeLayer = value)}
|
||||
class:active={$activeLayer === value}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="layers">
|
||||
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
onclick={() => ($activeLayer = layer)}
|
||||
name="layer"
|
||||
value={layer}
|
||||
checked={$activeLayer === layer}
|
||||
/>
|
||||
{String.fromCodePoint(
|
||||
"A".codePointAt(0)! + $activeProfile,
|
||||
)}{layer + 1}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
{#if $deviceMeta?.factoryDefaults?.layout}
|
||||
<button
|
||||
use:action={{ title: "Reset Layout" }}
|
||||
@@ -80,8 +80,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -89,71 +89,23 @@
|
||||
}
|
||||
|
||||
fieldset {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
border: none;
|
||||
|
||||
padding: 8px;
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
cursor: pointer;
|
||||
.layers {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
z-index: 1;
|
||||
gap: 2px;
|
||||
|
||||
font-size: 24px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
border: none;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&:nth-child(2) {
|
||||
z-index: 2;
|
||||
|
||||
aspect-ratio: 1;
|
||||
|
||||
font-size: 32px;
|
||||
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&:first-child,
|
||||
&:nth-child(3) {
|
||||
aspect-ratio: unset;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-inline-end: -8px;
|
||||
padding-inline: 4px 24px;
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
margin-inline-start: -8px;
|
||||
padding-inline: 24px 4px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
&.reset-layout {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translate(100%, -50%);
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 900;
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background: var(--md-sys-color-tertiary);
|
||||
}
|
||||
margin-inline: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -83,9 +83,9 @@
|
||||
|
||||
<style lang="scss">
|
||||
h1 {
|
||||
color: var(--md-sys-color-error);
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
a {
|
||||
display: inline;
|
||||
color: var(--md-sys-color-primary);
|
||||
padding: 0;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,15 +16,15 @@
|
||||
|
||||
<style lang="scss">
|
||||
dialog {
|
||||
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
|
||||
border: none;
|
||||
border-radius: 38px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
min-width: 300px;
|
||||
max-width: 512px;
|
||||
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
border: none;
|
||||
border-radius: 38px;
|
||||
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
@font-face {
|
||||
font-family: "Material Symbols Rounded";
|
||||
font-weight: 100 700;
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
src: url("$lib/assets/icons.min.woff2") format("woff2");
|
||||
font-family: "Material Symbols Rounded";
|
||||
}
|
||||
|
||||
.icon {
|
||||
user-select: none;
|
||||
|
||||
direction: ltr;
|
||||
display: inline-block;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
|
||||
/* stylelint-disable-next-line */
|
||||
font-family: "Material Symbols Rounded";
|
||||
font-size: 24px;
|
||||
font-feature-settings: "liga";
|
||||
font-variation-settings:
|
||||
"FILL" var(--icon-fill, 0),
|
||||
"wght" var(--icon-weigth, 400),
|
||||
"GRAD" var(--icon-grade, 0);
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
font-feature-settings: "liga";
|
||||
letter-spacing: normal;
|
||||
|
||||
direction: ltr;
|
||||
user-select: none;
|
||||
text-transform: none;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
|
||||
transition: font-variation-settings 250ms ease;
|
||||
white-space: nowrap;
|
||||
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -66,28 +66,30 @@
|
||||
|
||||
/* noto-sans-mono-latin-ext-wght-normal */
|
||||
@font-face {
|
||||
font-family: "Noto Sans Mono Variable";
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
font-stretch: 62.5% 100%;
|
||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
||||
format("woff2-variations");
|
||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
|
||||
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||
font-family: "Noto Sans Mono Variable";
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
|
||||
U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* noto-sans-mono-latin-wght-normal */
|
||||
@font-face {
|
||||
font-family: "Noto Sans Mono";
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
font-weight: 100 900;
|
||||
font-stretch: 62.5% 100%;
|
||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
||||
format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
font-family: "Noto Sans Mono";
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
|
||||
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
39
src/lib/hover-popover.ts
Normal file
39
src/lib/hover-popover.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
|
||||
export const hotkeys = new Map<string, HTMLElement>();
|
||||
|
||||
export function tooltip(
|
||||
target: HTMLElement | undefined,
|
||||
shortcut?: string,
|
||||
): Attachment<HTMLElement> {
|
||||
return (node: HTMLElement) => {
|
||||
function show() {
|
||||
if (!target) return;
|
||||
target.showPopover({ source: node });
|
||||
}
|
||||
function hide() {
|
||||
if (!target) return;
|
||||
target.hidePopover();
|
||||
}
|
||||
|
||||
node.addEventListener("mouseenter", show);
|
||||
node.addEventListener("focus", show);
|
||||
node.addEventListener("mouseout", hide);
|
||||
node.addEventListener("blur", hide);
|
||||
|
||||
if (shortcut && node instanceof HTMLElement) {
|
||||
hotkeys.set(shortcut, node);
|
||||
}
|
||||
|
||||
return () => {
|
||||
node.removeEventListener("mouseenter", show);
|
||||
node.removeEventListener("focus", show);
|
||||
node.removeEventListener("mouseout", hide);
|
||||
node.removeEventListener("blur", hide);
|
||||
|
||||
if (shortcut && node instanceof HTMLElement) {
|
||||
hotkeys.delete(shortcut);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export async function getMeta(
|
||||
try {
|
||||
if (!browser) return fetchMeta(device, version, fetch);
|
||||
|
||||
const dbRequest = indexedDB.open("version-meta", 3);
|
||||
const dbRequest = indexedDB.open("version-meta", 4);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||
dbRequest.onerror = () => reject(dbRequest.error);
|
||||
@@ -120,6 +120,9 @@ async function fetchMeta(
|
||||
}
|
||||
return settings;
|
||||
})),
|
||||
changelog: await (meta?.changelog
|
||||
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
|
||||
: {}),
|
||||
actions: await (meta?.actions
|
||||
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
||||
: Promise.all<KeymapCategory[]>(
|
||||
|
||||
@@ -22,6 +22,16 @@ export interface SettingsItemMeta {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry {
|
||||
summary: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Changelog {
|
||||
features: ChangelogEntry[];
|
||||
fixes: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export interface RawVersionMeta {
|
||||
version: string;
|
||||
target: string;
|
||||
@@ -32,6 +42,7 @@ export interface RawVersionMeta {
|
||||
development_mode: number;
|
||||
actions: string;
|
||||
settings: string;
|
||||
changelog: string;
|
||||
factory_defaults: {
|
||||
layout: string;
|
||||
settings: string;
|
||||
@@ -57,6 +68,7 @@ export interface VersionMeta {
|
||||
developmentBuild: boolean;
|
||||
actions: KeymapCategory[];
|
||||
settings: SettingsMeta[];
|
||||
changelog: Changelog;
|
||||
factoryDefaults?: {
|
||||
layout: CharaLayoutFile;
|
||||
settings: CharaSettingsFile;
|
||||
|
||||
@@ -69,5 +69,8 @@ export function hashChord(actions: number[]) {
|
||||
for (let i = 0; i < 16; i++) {
|
||||
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
|
||||
}
|
||||
if ((hash & 0xff) === 0xff) {
|
||||
hash ^= 0xff;
|
||||
}
|
||||
return hash & 0x3fff_ffff;
|
||||
}
|
||||
|
||||
@@ -29,21 +29,24 @@ export const deviceChords = persistentWritable<Chord[]>(
|
||||
/**
|
||||
* Layout as read from the device
|
||||
*/
|
||||
export const deviceLayout = persistentWritable<CharaLayout>(
|
||||
"layout",
|
||||
[[], [], []],
|
||||
export const deviceLayout = persistentWritable<CharaLayout[]>(
|
||||
"layout-profiles",
|
||||
[],
|
||||
() => get(userPreferences).backup,
|
||||
);
|
||||
|
||||
/**
|
||||
* Settings as read from the device
|
||||
*/
|
||||
export const deviceSettings = persistentWritable<number[]>(
|
||||
"device-settings",
|
||||
export const deviceSettings = persistentWritable<number[][]>(
|
||||
"settings-profiles",
|
||||
[],
|
||||
() => get(userPreferences).backup,
|
||||
);
|
||||
|
||||
export const activeProfile = persistentWritable<number>("active-profile", 0);
|
||||
export const activeLayer = persistentWritable<number>("active-profile", 0);
|
||||
|
||||
export const syncStatus: Writable<
|
||||
"done" | "error" | "downloading" | "uploading"
|
||||
> = writable("done");
|
||||
@@ -80,30 +83,49 @@ export async function sync() {
|
||||
.map((it) => it.items.length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const max = maxSettings + device.keyCount * 3 + chordCount;
|
||||
const max =
|
||||
(maxSettings + device.keyCount * device.layerCount) * device.profileCount +
|
||||
chordCount;
|
||||
let current = 0;
|
||||
activeProfile.update((it) => Math.min(it, device.profileCount - 1));
|
||||
activeLayer.update((it) => Math.min(it, device.layerCount - 1));
|
||||
syncProgress.set({ max, current });
|
||||
function progressTick() {
|
||||
current++;
|
||||
syncProgress.set({ max, current });
|
||||
}
|
||||
|
||||
const parsedSettings: number[] = [];
|
||||
for (const category of meta.settings) {
|
||||
for (const setting of category.items) {
|
||||
try {
|
||||
parsedSettings[setting.id] = await device.getSetting(setting.id);
|
||||
} catch {}
|
||||
const parsedSettings: number[][] = Array.from(
|
||||
{ length: device.profileCount },
|
||||
() => [],
|
||||
);
|
||||
for (const [profile, settings] of parsedSettings.entries()) {
|
||||
for (const category of meta.settings) {
|
||||
for (const setting of category.items) {
|
||||
try {
|
||||
settings[setting.id] = await device.getSetting(profile, setting.id);
|
||||
} catch {}
|
||||
}
|
||||
progressTick();
|
||||
}
|
||||
progressTick();
|
||||
}
|
||||
deviceSettings.set(parsedSettings);
|
||||
|
||||
const parsedLayout: CharaLayout = [[], [], []];
|
||||
for (let layer = 1; layer <= 3; layer++) {
|
||||
for (let i = 0; i < device.keyCount; i++) {
|
||||
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
|
||||
progressTick();
|
||||
const parsedLayout: CharaLayout[] = Array.from(
|
||||
{ length: device.profileCount },
|
||||
() =>
|
||||
Array.from({ length: device.layerCount }, () =>
|
||||
Array.from({ length: device.keyCount }, () => 0),
|
||||
),
|
||||
);
|
||||
for (const [profile, layout] of parsedLayout.entries()) {
|
||||
for (const [layer, keys] of layout.entries()) {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
try {
|
||||
keys[i] = await device.getLayoutKey(profile, layer + 1, i);
|
||||
} catch {}
|
||||
progressTick();
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceLayout.set(parsedLayout);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
|
||||
import { serialLog } from "$lib/serial/connection";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
import { SemVer } from "$lib/serial/sem-ver";
|
||||
import {
|
||||
parseChordActions,
|
||||
parsePhrase,
|
||||
@@ -10,15 +9,18 @@ import {
|
||||
} from "$lib/serial/chord";
|
||||
import { browser } from "$app/environment";
|
||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||
import semverGte from "semver/functions/gte";
|
||||
|
||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
|
||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
|
||||
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
||||
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
|
||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
||||
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
||||
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
|
||||
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
|
||||
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
|
||||
["T4G S2", { usbProductId: 0x82f2, usbVendorId: 0x303a }],
|
||||
]);
|
||||
|
||||
const KEY_COUNTS = {
|
||||
@@ -28,6 +30,7 @@ const KEY_COUNTS = {
|
||||
X: 256,
|
||||
M4G: 90,
|
||||
M4GR: 90,
|
||||
T4G: 7,
|
||||
} as const;
|
||||
|
||||
if (
|
||||
@@ -99,11 +102,13 @@ export class CharaDevice {
|
||||
private readonly suspendDebounce = 100;
|
||||
private suspendDebounceId?: number;
|
||||
|
||||
version!: SemVer;
|
||||
version!: string;
|
||||
company!: "CHARACHORDER" | "FORGE";
|
||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
|
||||
chipset!: "M0" | "S2" | "S3";
|
||||
keyCount!: 90 | 67 | 256;
|
||||
layerCount = 3;
|
||||
profileCount = 1;
|
||||
|
||||
get portInfo() {
|
||||
return this.port.getInfo();
|
||||
@@ -134,9 +139,12 @@ export class CharaDevice {
|
||||
});
|
||||
await this.port.close();
|
||||
|
||||
this.version = new SemVer(
|
||||
await this.send(1, ["VERSION"]).then(([version]) => version),
|
||||
this.version = await this.send(1, ["VERSION"]).then(
|
||||
([version]) => version,
|
||||
);
|
||||
if (semverGte(this.version, "2.2.0-beta.4")) {
|
||||
this.profileCount = 3;
|
||||
}
|
||||
const [company, device, chipset] = await this.send(3, ["ID"]);
|
||||
this.company = company as typeof this.company;
|
||||
this.device = device as typeof this.device;
|
||||
@@ -368,11 +376,16 @@ export class CharaDevice {
|
||||
* @param id id of the key, refer to the individual device for where each key is
|
||||
* @param action the assigned action id
|
||||
*/
|
||||
async setLayoutKey(layer: number, id: number, action: number) {
|
||||
async setLayoutKey(
|
||||
profile: number,
|
||||
layer: number,
|
||||
id: number,
|
||||
action: number,
|
||||
) {
|
||||
const [status] = await this.send(1, [
|
||||
"VAR",
|
||||
"B4",
|
||||
`A${layer}`,
|
||||
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
|
||||
id.toString(),
|
||||
action.toString(),
|
||||
]);
|
||||
@@ -385,11 +398,11 @@ export class CharaDevice {
|
||||
* @param id id of the key, refer to the individual device for where each key is
|
||||
* @returns the assigned action id
|
||||
*/
|
||||
async getLayoutKey(layer: number, id: number) {
|
||||
async getLayoutKey(profile: number, layer: number, id: number) {
|
||||
const [position, status] = await this.send(2, [
|
||||
"VAR",
|
||||
"B3",
|
||||
`A${layer}`,
|
||||
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
|
||||
id.toString(),
|
||||
]);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
@@ -414,11 +427,11 @@ export class CharaDevice {
|
||||
* Settings are applied until the next reboot or loss of power.
|
||||
* To permanently store the settings, you *must* call commit.
|
||||
*/
|
||||
async setSetting(id: number, value: number) {
|
||||
async setSetting(profile: number, id: number, value: number) {
|
||||
const [status] = await this.send(1, [
|
||||
"VAR",
|
||||
"B2",
|
||||
id.toString(16).toUpperCase(),
|
||||
(id + profile * 0x100).toString(16).toUpperCase(),
|
||||
value.toString(),
|
||||
]);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
@@ -427,11 +440,11 @@ export class CharaDevice {
|
||||
/**
|
||||
* Retrieves a setting from the device
|
||||
*/
|
||||
async getSetting(id: number): Promise<number> {
|
||||
async getSetting(profile: number, id: number): Promise<number> {
|
||||
const [value, status] = await this.send(2, [
|
||||
"VAR",
|
||||
"B1",
|
||||
id.toString(16).toUpperCase(),
|
||||
(id + profile * 0x100).toString(16).toUpperCase(),
|
||||
]);
|
||||
if (status !== "0")
|
||||
throw new Error(
|
||||
@@ -477,10 +490,14 @@ export class CharaDevice {
|
||||
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
|
||||
}
|
||||
|
||||
async updateFirmware(file: File | Blob): Promise<void> {
|
||||
async updateFirmware(
|
||||
file: ArrayBuffer,
|
||||
progress: (transferred: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
while (this.lock) {
|
||||
await this.lock;
|
||||
}
|
||||
|
||||
let resolveLock: (result: true) => void;
|
||||
this.lock = new Promise<true>((resolve) => {
|
||||
resolveLock = resolve;
|
||||
@@ -510,46 +527,46 @@ export class CharaDevice {
|
||||
});
|
||||
return it;
|
||||
});
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
// Wait for the device to be ready
|
||||
const signal = await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
// Wait for the device to be ready
|
||||
const signal = await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
await file.stream().pipeTo(this.port.writable!);
|
||||
const chunkSize = 128;
|
||||
for (let i = 0; i < file.byteLength; i += chunkSize) {
|
||||
const chunk = file.slice(i, i + chunkSize);
|
||||
await writer.write(new Uint8Array(chunk));
|
||||
progress(i + chunk.byteLength, file.byteLength);
|
||||
}
|
||||
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: `...${file.size} bytes`,
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: `...${file.byteLength} bytes`,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
const result = (await this.reader.read()).value!.trim();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: result!,
|
||||
const result = (await this.reader.read()).value!.trim();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: result!,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
if (result !== "OTA OK") {
|
||||
throw new Error(result);
|
||||
}
|
||||
if (result !== "OTA OK") {
|
||||
throw new Error(result);
|
||||
}
|
||||
|
||||
const writer2 = this.port.writable!.getWriter();
|
||||
try {
|
||||
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
||||
await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
@@ -558,7 +575,7 @@ export class CharaDevice {
|
||||
return it;
|
||||
});
|
||||
} finally {
|
||||
writer2.releaseLock();
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
await this.suspend();
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
export class SemVer {
|
||||
major = 0;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
preRelease?: string;
|
||||
meta?: string;
|
||||
|
||||
constructor(versionString: string) {
|
||||
const result =
|
||||
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
|
||||
versionString,
|
||||
);
|
||||
if (!result) {
|
||||
console.error("Invalid version string:", versionString);
|
||||
} else {
|
||||
const [, major, minor, patch, preRelease, meta] = result;
|
||||
this.major = Number.parseInt(major ?? "NaN");
|
||||
this.minor = Number.parseInt(minor ?? "NaN");
|
||||
this.patch = Number.parseInt(patch ?? "NaN");
|
||||
if (preRelease) this.preRelease = preRelease;
|
||||
if (meta) this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
return (
|
||||
`${this.major}.${this.minor}.${this.patch}` +
|
||||
(this.preRelease ? `-${this.preRelease}` : "") +
|
||||
(this.meta ? `+${this.meta}` : "")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,10 @@ import { fromBase64, toBase64 } from "$lib/serialization/base64";
|
||||
export interface NewCharaLayout {
|
||||
charaLayoutVersion: 1;
|
||||
device: "one" | "lite" | string;
|
||||
/**
|
||||
* Layers A1-A3, with numeric action codes on each
|
||||
*/
|
||||
layers: [number[], number[], number[]];
|
||||
layers: number[][];
|
||||
}
|
||||
|
||||
export type CharaLayout = [number[], number[], number[]];
|
||||
export type CharaLayout = number[][];
|
||||
|
||||
/**
|
||||
* Serialize a layout into a micro package
|
||||
|
||||
@@ -1,5 +1,88 @@
|
||||
import type { Action } from "svelte/action";
|
||||
import { changes, ChangeType, settings } from "$lib/undo-redo";
|
||||
import { activeProfile } from "./serial/connection";
|
||||
import { combineLatest, map } from "rxjs";
|
||||
import { fromReadable } from "./util/from-readable";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
/**
|
||||
* https://gist.github.com/mjackson/5311256
|
||||
*/
|
||||
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
const v = max;
|
||||
|
||||
const d = max - min;
|
||||
const s = max == 0 ? 0 : d / max;
|
||||
|
||||
if (max == min) {
|
||||
h = 0; // achromatic
|
||||
} else {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return [Math.floor(h * 0xffff), Math.floor(s * 0xff), Math.floor(v * 0xff)];
|
||||
}
|
||||
|
||||
/**
|
||||
* https://gist.github.com/mjackson/5311256
|
||||
*/
|
||||
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
h /= 0xffff;
|
||||
s /= 0xff;
|
||||
v /= 0xff;
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
|
||||
switch (i % 6) {
|
||||
case 0:
|
||||
((r = v), (g = t), (b = p));
|
||||
break;
|
||||
case 1:
|
||||
((r = q), (g = v), (b = p));
|
||||
break;
|
||||
case 2:
|
||||
((r = p), (g = v), (b = t));
|
||||
break;
|
||||
case 3:
|
||||
((r = p), (g = q), (b = v));
|
||||
break;
|
||||
case 4:
|
||||
((r = t), (g = p), (b = v));
|
||||
break;
|
||||
case 5:
|
||||
((r = v), (g = p), (b = q));
|
||||
break;
|
||||
}
|
||||
|
||||
return [Math.floor(r * 0xff), Math.floor(g * 0xff), Math.floor(b * 0xff)];
|
||||
}
|
||||
|
||||
export const setting: Action<
|
||||
HTMLInputElement | HTMLSelectElement,
|
||||
@@ -9,7 +92,12 @@ export const setting: Action<
|
||||
{ id, inverse, scale },
|
||||
) {
|
||||
node.setAttribute("disabled", "");
|
||||
const type = node.getAttribute("type") as "number" | "checkbox" | "range";
|
||||
const type = node.getAttribute("type") as
|
||||
| "number"
|
||||
| "checkbox"
|
||||
| "range"
|
||||
| "color";
|
||||
const isColor = type === "color";
|
||||
const isNumeric =
|
||||
type === "number" || type === "range" || node instanceof HTMLSelectElement;
|
||||
const min = node.hasAttribute("min")
|
||||
@@ -19,36 +107,50 @@ export const setting: Action<
|
||||
? Number(node.getAttribute("max"))
|
||||
: undefined;
|
||||
|
||||
const unsubscribe = settings.subscribe(async (settings) => {
|
||||
if (id in settings) {
|
||||
const { value, isApplied } = settings[id]!;
|
||||
if (isNumeric) {
|
||||
node.value = (
|
||||
inverse !== undefined
|
||||
? inverse / value
|
||||
: scale !== undefined
|
||||
? scale * value
|
||||
: value
|
||||
).toString();
|
||||
const subscription = combineLatest([
|
||||
fromReadable(settings),
|
||||
fromReadable(activeProfile),
|
||||
])
|
||||
.pipe(map(([settings, profile]) => settings[profile]!))
|
||||
.subscribe(async (settings) => {
|
||||
if (id in settings) {
|
||||
const { value, isApplied } = settings[id]!;
|
||||
if (isNumeric) {
|
||||
node.value = (
|
||||
inverse !== undefined
|
||||
? inverse / value
|
||||
: scale !== undefined
|
||||
? scale * value
|
||||
: value
|
||||
).toString();
|
||||
} else if (isColor) {
|
||||
const rgb = hsvToRgb(
|
||||
settings[id]!.value,
|
||||
settings[id + 1]!.value,
|
||||
settings[id + 2]!.value,
|
||||
);
|
||||
node.value = `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
||||
} else {
|
||||
node.checked = value !== 0;
|
||||
}
|
||||
if (isApplied) {
|
||||
node.classList.remove("pending-changes");
|
||||
} else {
|
||||
node.classList.add("pending-changes");
|
||||
}
|
||||
node.removeAttribute("disabled");
|
||||
} else {
|
||||
node.checked = value !== 0;
|
||||
node.setAttribute("disabled", "");
|
||||
}
|
||||
if (isApplied) {
|
||||
node.classList.remove("pending-changes");
|
||||
} else {
|
||||
node.classList.add("pending-changes");
|
||||
}
|
||||
node.removeAttribute("disabled");
|
||||
} else {
|
||||
node.setAttribute("disabled", "");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function listener() {
|
||||
let value: number;
|
||||
if (isNumeric) {
|
||||
value = Number(node.value);
|
||||
if (Number.isNaN(value)) return;
|
||||
if (min !== undefined) value = Math.max(min, value);
|
||||
if (max !== undefined) value = Math.min(max, value);
|
||||
value = Math.floor(
|
||||
inverse !== undefined
|
||||
? inverse / value
|
||||
@@ -56,8 +158,23 @@ export const setting: Action<
|
||||
? value / scale
|
||||
: value,
|
||||
);
|
||||
if (min !== undefined) value = Math.max(min, value);
|
||||
if (max !== undefined) value = Math.min(max, value);
|
||||
} else if (isColor) {
|
||||
const r = parseInt(node.value.slice(1, 3), 16);
|
||||
const g = parseInt(node.value.slice(3, 5), 16);
|
||||
const b = parseInt(node.value.slice(5, 7), 16);
|
||||
const hsv = rgbToHsv(r, g, b);
|
||||
changes.update((changes) => {
|
||||
changes.push(
|
||||
hsv.map((value, i) => ({
|
||||
type: ChangeType.Setting,
|
||||
id: id + i,
|
||||
setting: value,
|
||||
profile: get(activeProfile),
|
||||
})),
|
||||
);
|
||||
return changes;
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
value = node.checked ? 1 : 0;
|
||||
}
|
||||
@@ -68,6 +185,7 @@ export const setting: Action<
|
||||
type: ChangeType.Setting,
|
||||
id: id,
|
||||
setting: value,
|
||||
profile: get(activeProfile),
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
@@ -79,7 +197,7 @@ export const setting: Action<
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("change", listener);
|
||||
unsubscribe();
|
||||
subscription.unsubscribe();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
kbd {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
margin-block: 6px;
|
||||
padding: 4px;
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: currentcolor;
|
||||
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 4px;
|
||||
padding: 4px;
|
||||
|
||||
height: 20px;
|
||||
color: currentcolor;
|
||||
font-weight: normal;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&.icon {
|
||||
padding: 2px;
|
||||
@@ -21,8 +21,8 @@ kbd {
|
||||
|
||||
&:has(> kbd) {
|
||||
gap: 4px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
> kbd {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
h1 {
|
||||
margin-block-start: 0;
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: var(--md-sys-color-secondary);
|
||||
font-weight: 700;
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
67
src/lib/style/elements/_popover.scss
Normal file
67
src/lib/style/elements/_popover.scss
Normal file
@@ -0,0 +1,67 @@
|
||||
$animation-duration: 150ms;
|
||||
$translate: translateY(8px);
|
||||
|
||||
[popover] {
|
||||
position: absolute;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition:
|
||||
transform $animation-duration ease,
|
||||
opacity $animation-duration linear,
|
||||
overlay $animation-duration allow-discrete,
|
||||
display $animation-duration allow-discrete;
|
||||
|
||||
margin: 0;
|
||||
inset: unset;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
background: var(--md-sys-color-surface);
|
||||
padding: 8px;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
font-weight: initial;
|
||||
font-size: initial;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
|
||||
position-area: bottom span-all;
|
||||
position-try-fallbacks:
|
||||
top span-all,
|
||||
bottom span-right,
|
||||
top span-right,
|
||||
bottom span-left,
|
||||
top span-left;
|
||||
|
||||
position-visibility: no-overflow;
|
||||
|
||||
&:popover-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
> h1:first-child,
|
||||
h2:first-child,
|
||||
h3:first-child {
|
||||
margin-top: 0;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
[popover="auto"] {
|
||||
transform: $translate;
|
||||
}
|
||||
|
||||
[popover="hint"] {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
[popover]:popover-open {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
[popover="auto"] {
|
||||
transform: $translate;
|
||||
}
|
||||
}
|
||||
@@ -5,31 +5,30 @@ a {
|
||||
a,
|
||||
label:has(input),
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: all 250ms ease;
|
||||
cursor: pointer;
|
||||
border-radius: 32px;
|
||||
padding-inline: 16px;
|
||||
padding-block: 8px;
|
||||
|
||||
width: max-content;
|
||||
height: 48px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: 32px;
|
||||
transition: all 250ms ease;
|
||||
|
||||
@media not (forced-colors: active) {
|
||||
color: currentcolor;
|
||||
background: transparent;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: currentcolor;
|
||||
|
||||
&.primary {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,17 +39,17 @@ button {
|
||||
|
||||
&.icon {
|
||||
display: inline-flex;
|
||||
border-radius: 50%;
|
||||
padding-inline: 0;
|
||||
padding-block: 0;
|
||||
|
||||
aspect-ratio: 1;
|
||||
padding-block: 0;
|
||||
padding-inline: 0;
|
||||
|
||||
font-size: 24px;
|
||||
border-radius: 50%;
|
||||
|
||||
@media (forced-colors: active) {
|
||||
padding: 2px;
|
||||
margin: 2px;
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +87,8 @@ button {
|
||||
}
|
||||
&.active,
|
||||
&:active {
|
||||
color: SelectedItemText;
|
||||
background: SelectedItem;
|
||||
color: SelectedItemText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
src/lib/style/form/_radio.scss
Normal file
35
src/lib/style/form/_radio.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
label:has(input[type="radio"]) {
|
||||
z-index: 1;
|
||||
|
||||
transition: all 250ms ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
padding-inline: 12px;
|
||||
|
||||
aspect-ratio: unset;
|
||||
height: 1.5em;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
font-size: 16px;
|
||||
|
||||
> input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
&:has(:checked) {
|
||||
background: var(--md-sys-color-tertiary);
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
font-weight: 900;
|
||||
}
|
||||
}
|
||||
@@ -3,57 +3,55 @@ $border: 2px;
|
||||
$height: 1.5em;
|
||||
|
||||
label:has(input[type="checkbox"]) {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
gap: $padding;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: $padding;
|
||||
cursor: pointer;
|
||||
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
|
||||
input[type="checkbox"] {
|
||||
$width: calc($height * (5 / 3));
|
||||
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
|
||||
$radius: calc($diameter / 2);
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
|
||||
outline: $border solid currentcolor;
|
||||
outline-offset: calc(-1 * $border);
|
||||
border-radius: calc($height / 2);
|
||||
|
||||
width: $width;
|
||||
height: $height;
|
||||
|
||||
font-size: inherit;
|
||||
overflow: hidden;
|
||||
color: inherit;
|
||||
|
||||
border-radius: calc($height / 2);
|
||||
outline: $border solid currentcolor;
|
||||
outline-offset: calc(-1 * $border);
|
||||
font-size: inherit;
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
|
||||
position: absolute;
|
||||
top: calc($padding + $border);
|
||||
left: calc($padding + $border);
|
||||
|
||||
display: block;
|
||||
transition: all 250ms ease;
|
||||
|
||||
width: $diameter;
|
||||
height: $diameter;
|
||||
|
||||
border-radius: calc($radius);
|
||||
outline-color: inherit;
|
||||
outline-style: solid;
|
||||
outline-width: $radius;
|
||||
outline-offset: calc(-1 * $radius);
|
||||
border-radius: calc($radius);
|
||||
|
||||
transition: all 250ms ease;
|
||||
width: $diameter;
|
||||
height: $diameter;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&:checked::after {
|
||||
@@ -62,4 +60,83 @@ label:has(input[type="checkbox"]) {
|
||||
outline-offset: calc($padding / 2);
|
||||
}
|
||||
}
|
||||
|
||||
&:has(span.icon) {
|
||||
$line-width: 10%;
|
||||
$side: calc(($line-width * 2) / sqrt(2));
|
||||
$mid: calc($side / 2);
|
||||
|
||||
> input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> span.icon {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
clip-path: polygon(
|
||||
0% $side,
|
||||
$mid $mid,
|
||||
calc(100% - $mid) calc(100% - $mid),
|
||||
calc(100% - $side) 100%,
|
||||
0% 100%,
|
||||
0% $side,
|
||||
$side 0%,
|
||||
100% calc(100% - $side),
|
||||
calc(100% - $side) 100%,
|
||||
calc(100% - $side) 100%,
|
||||
100% calc(100% - $side),
|
||||
100% calc(100% - $side),
|
||||
100% 0%,
|
||||
$side 0%
|
||||
);
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
|
||||
font-size: inherit;
|
||||
|
||||
&::before {
|
||||
display: block;
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, 0) rotate(45deg);
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
background-color: currentcolor;
|
||||
|
||||
width: calc(100% * sqrt(2));
|
||||
height: $line-width;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
&:has(:checked) > span.icon {
|
||||
clip-path: polygon(
|
||||
0% $side,
|
||||
$mid $mid,
|
||||
calc(100% - $mid) calc(100% - $mid),
|
||||
calc(100% - $side) 100%,
|
||||
0% 100%,
|
||||
0% $side,
|
||||
$side 0%,
|
||||
100% calc(100% - $side),
|
||||
calc(100% - $side) 100%,
|
||||
0% $side,
|
||||
$side 0%,
|
||||
100% calc(100% - $side),
|
||||
100% 0%,
|
||||
$side 0%
|
||||
);
|
||||
|
||||
&::before {
|
||||
transform: translate(-50%, 0) rotate(45deg) translateX(-100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
@media not (forced-colors: active) {
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-color, white);
|
||||
border-radius: 4px;
|
||||
transition: all 250ms ease;
|
||||
border-radius: 4px;
|
||||
background: var(--scrollbar-color, white);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(120%);
|
||||
|
||||
@@ -2,34 +2,35 @@
|
||||
|
||||
@use "form/button";
|
||||
@use "form/toggle";
|
||||
@use "form/checkbox";
|
||||
@use "form/radio";
|
||||
|
||||
@use "kbd";
|
||||
@use "print";
|
||||
|
||||
@use "elements/h1";
|
||||
@use "elements/popover";
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
overflow: hidden;
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
}
|
||||
|
||||
main {
|
||||
contain: strict;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
contain: strict;
|
||||
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
$padding: 16px;
|
||||
|
||||
.tippy-box[data-theme~="surface-variant"] {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
filter: drop-shadow(0 0 12px #000a);
|
||||
border-radius: calc(24px + $padding);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
.tippy-content {
|
||||
padding: $padding;
|
||||
@@ -24,10 +24,10 @@ $padding: 16px;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
color: CanvasText;
|
||||
background-color: Canvas;
|
||||
filter: none;
|
||||
border: 1px solid CanvasText;
|
||||
background-color: Canvas;
|
||||
color: CanvasText;
|
||||
|
||||
> .tippy-arrow {
|
||||
display: none;
|
||||
@@ -36,16 +36,16 @@ $padding: 16px;
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="tooltip"] {
|
||||
color: var(--md-sys-color-on-background);
|
||||
background-color: var(--md-sys-color-background);
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
background-color: var(--md-sys-color-background);
|
||||
color: var(--md-sys-color-on-background);
|
||||
}
|
||||
|
||||
.tippy-box[data-theme~="search-completion"] {
|
||||
overflow: hidden;
|
||||
filter: none;
|
||||
border-radius: 0 0 16px 16px;
|
||||
overflow: hidden;
|
||||
|
||||
.tippy-content {
|
||||
padding: 0;
|
||||
|
||||
@@ -5,6 +5,9 @@ import Tooltip from "$lib/components/Tooltip.svelte";
|
||||
|
||||
export const hotkeys = new Map<string, HTMLElement>();
|
||||
|
||||
/**
|
||||
* @deprecated Use `tooltip` instead.
|
||||
*/
|
||||
export const action: Action<Element, { title?: string; shortcut?: string }> = (
|
||||
node: Element,
|
||||
{ title, shortcut },
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface LayoutChange {
|
||||
id: number;
|
||||
layer: number;
|
||||
action: number;
|
||||
profile?: number;
|
||||
}
|
||||
|
||||
export interface ChordChange {
|
||||
@@ -33,6 +34,7 @@ export interface SettingChange {
|
||||
type: ChangeType.Setting;
|
||||
id: number;
|
||||
setting: number;
|
||||
profile?: number;
|
||||
}
|
||||
|
||||
export interface ChangeInfo {
|
||||
@@ -45,23 +47,29 @@ export type Change = LayoutChange | ChordChange | SettingChange;
|
||||
export const changes = persistentWritable<Change[][]>("changes", []);
|
||||
|
||||
export interface Overlay {
|
||||
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
|
||||
layout: Array<Array<Map<number, number> | undefined> | undefined>;
|
||||
chords: Map<string, Chord & { deleted: boolean }>;
|
||||
settings: Map<number, number>;
|
||||
settings: Array<Map<number, number> | undefined>;
|
||||
}
|
||||
|
||||
export const overlay = derived(changes, (changes) => {
|
||||
const overlay: Overlay = {
|
||||
layout: [new Map(), new Map(), new Map()],
|
||||
layout: [],
|
||||
chords: new Map(),
|
||||
settings: new Map(),
|
||||
settings: [],
|
||||
};
|
||||
|
||||
for (const changeset of changes) {
|
||||
for (const change of changeset) {
|
||||
switch (change.type) {
|
||||
case ChangeType.Layout:
|
||||
overlay.layout[change.layer]?.set(change.id, change.action);
|
||||
change.profile ??= 0;
|
||||
overlay.layout[change.profile] ??= [];
|
||||
overlay.layout[change.profile]![change.layer] ??= new Map();
|
||||
overlay.layout[change.profile]![change.layer]!.set(
|
||||
change.id,
|
||||
change.action,
|
||||
);
|
||||
break;
|
||||
case ChangeType.Chord:
|
||||
overlay.chords.set(JSON.stringify(change.id), {
|
||||
@@ -71,7 +79,9 @@ export const overlay = derived(changes, (changes) => {
|
||||
});
|
||||
break;
|
||||
case ChangeType.Setting:
|
||||
overlay.settings.set(change.id, change.setting);
|
||||
change.profile ??= 0;
|
||||
overlay.settings[change.profile] ??= new Map();
|
||||
overlay.settings[change.profile]!.set(change.id, change.setting);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -82,21 +92,25 @@ export const overlay = derived(changes, (changes) => {
|
||||
|
||||
export const settings = derived(
|
||||
[overlay, deviceSettings],
|
||||
([overlay, settings]) =>
|
||||
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
|
||||
value: overlay.settings.get(id) ?? value,
|
||||
isApplied: !overlay.settings.has(id),
|
||||
})),
|
||||
([overlay, profiles]) =>
|
||||
profiles.map((settings, profile) =>
|
||||
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
|
||||
value: overlay.settings[profile]?.get(id) ?? value,
|
||||
isApplied: !overlay.settings[profile]?.has(id),
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
export type KeyInfo = { action: number } & ChangeInfo;
|
||||
export const layout = derived([overlay, deviceLayout], ([overlay, layout]) =>
|
||||
layout.map(
|
||||
(actions, layer) =>
|
||||
actions.map<KeyInfo>((action, id) => ({
|
||||
action: overlay.layout[layer]?.get(id) ?? action,
|
||||
isApplied: !overlay.layout[layer]?.has(id),
|
||||
})) as [KeyInfo, KeyInfo, KeyInfo],
|
||||
export const layout = derived([overlay, deviceLayout], ([overlay, profiles]) =>
|
||||
profiles.map((layout, profile) =>
|
||||
layout.map(
|
||||
(actions, layer) =>
|
||||
actions.map<KeyInfo>((action, id) => ({
|
||||
action: overlay.layout[profile]?.[layer]?.get(id) ?? action,
|
||||
isApplied: !overlay.layout[profile]?.[layer]?.has(id),
|
||||
})) as [KeyInfo, KeyInfo, KeyInfo],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
47
src/lib/util/debounce.ts
Normal file
47
src/lib/util/debounce.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Creates a debounced function that delays invoking the provided function
|
||||
* until after 'wait' milliseconds have elapsed since the last time it was
|
||||
* invoked.
|
||||
*
|
||||
* I could use _.debounce(), but bringing dependency on lodash didn't feel
|
||||
* justified yet.
|
||||
*
|
||||
* @param func The function to debounce
|
||||
* @param wait The number of milliseconds to delay execution
|
||||
* @returns A debounced version of the provided function
|
||||
*/
|
||||
function debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): T & { cancel: () => void } {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounced = function (
|
||||
this: ThisParameterType<T>,
|
||||
...args: Parameters<T>
|
||||
): void {
|
||||
const context = this;
|
||||
|
||||
const later = function () {
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
};
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
|
||||
debounced.cancel = function () {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced as T & { cancel: () => void };
|
||||
}
|
||||
|
||||
export default debounce;
|
||||
10
src/lib/util/from-readable.ts
Normal file
10
src/lib/util/from-readable.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Observable } from "rxjs";
|
||||
import type { Readable } from "svelte/store";
|
||||
|
||||
export function fromReadable<T>(store: Readable<T>): Observable<T> {
|
||||
return new Observable((subscriber) =>
|
||||
store.subscribe((value) => {
|
||||
subscriber.next(value);
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -131,14 +131,13 @@
|
||||
|
||||
<style lang="scss">
|
||||
.layout {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-areas:
|
||||
"sidebar main"
|
||||
"sidebar footer";
|
||||
grid-template-columns: auto 1fr;
|
||||
grid-template-rows: 1fr;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,17 +26,17 @@
|
||||
dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border: none;
|
||||
|
||||
background: var(--md-sys-color-error);
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
color: var(--md-sys-color-on-error);
|
||||
|
||||
background: var(--md-sys-color-error);
|
||||
border: none;
|
||||
|
||||
> * {
|
||||
max-width: 20cm;
|
||||
}
|
||||
@@ -54,8 +54,8 @@
|
||||
|
||||
div > p {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,12 +173,11 @@
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
.sync-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
text-wrap: nowrap;
|
||||
@@ -187,14 +186,14 @@
|
||||
|
||||
progress {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
overflow: hidden;
|
||||
z-index: -1;
|
||||
border-radius: 4px;
|
||||
width: calc(100% - 32px);
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-bar {
|
||||
@@ -206,32 +205,32 @@
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--md-sys-color-error);
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
cursor: pointer;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
inline-size: 20px;
|
||||
block-size: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
&::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -243,16 +242,16 @@
|
||||
|
||||
footer {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
opacity: 0.4;
|
||||
padding: 8px;
|
||||
padding-inline-end: 16px;
|
||||
padding-block-start: 0;
|
||||
|
||||
opacity: 0.4;
|
||||
width: 100%;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
opacity: 0.8;
|
||||
@@ -265,8 +264,8 @@
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -289,13 +288,13 @@
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
padding-inline: 12px;
|
||||
|
||||
font-size: 12px;
|
||||
text-decoration: none;
|
||||
|
||||
padding-inline: 12px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -81,15 +81,15 @@
|
||||
|
||||
<style lang="scss">
|
||||
.sidebar {
|
||||
margin: 8px;
|
||||
padding-inline-end: 8px;
|
||||
width: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
|
||||
grid-area: sidebar;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
margin: 8px;
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
padding-inline-end: 8px;
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
li {
|
||||
@@ -104,18 +104,18 @@
|
||||
font-size: 12px;
|
||||
|
||||
&.wip {
|
||||
color: var(--md-sys-color-error);
|
||||
opacity: 0.5;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
|
||||
transition: all 250ms ease;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
> .content {
|
||||
@@ -132,24 +132,24 @@
|
||||
}
|
||||
|
||||
.icon {
|
||||
border-radius: 50%;
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
ul + ul::before {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 1px;
|
||||
background: var(--md-sys-color-outline);
|
||||
margin: 16px 0;
|
||||
background: var(--md-sys-color-outline);
|
||||
height: 1px;
|
||||
content: "";
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
div {
|
||||
@@ -42,10 +42,10 @@
|
||||
}
|
||||
|
||||
progress {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
progress::-webkit-progress-bar {
|
||||
@@ -53,6 +53,7 @@
|
||||
}
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
transition: width 2s ease;
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -97,8 +97,8 @@
|
||||
}
|
||||
|
||||
.correct-device {
|
||||
color: var(--md-sys-color-primary);
|
||||
opacity: 1;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.incorrect-device {
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
<style lang="scss">
|
||||
ul {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
@@ -38,13 +38,13 @@
|
||||
}
|
||||
|
||||
a {
|
||||
outline: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
transition:
|
||||
background-color 200ms ease,
|
||||
color 200ms ease,
|
||||
outline-offset 200ms ease,
|
||||
outline-color 200ms ease;
|
||||
outline: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
@@ -71,9 +71,9 @@
|
||||
}
|
||||
|
||||
.highlight {
|
||||
outline-width: 2px;
|
||||
outline-color: var(--md-sys-color-primary);
|
||||
animation: wiggle 500ms ease 2 alternate;
|
||||
outline-color: var(--md-sys-color-primary);
|
||||
outline-width: 2px;
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
@@ -38,21 +38,21 @@
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
height: 2em;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
height 200ms ease,
|
||||
opacity 200ms ease;
|
||||
height: 2em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
label {
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
@@ -64,8 +64,8 @@
|
||||
margin-block-end: 0;
|
||||
|
||||
em {
|
||||
font-style: normal;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,13 +73,13 @@
|
||||
time {
|
||||
opacity: 0.5;
|
||||
&:before {
|
||||
content: "•";
|
||||
padding-inline: 0.4ch;
|
||||
content: "•";
|
||||
}
|
||||
}
|
||||
|
||||
div.title:has(input:not(:checked)) ~ ul .pre-release {
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import type { DirectoryListing } from "$lib/meta/types/listing";
|
||||
import { compare } from "semver";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const result = await fetch(
|
||||
@@ -9,7 +10,7 @@ export const load = (async ({ fetch, params }) => {
|
||||
|
||||
return {
|
||||
versions: (data as DirectoryListing[]).sort((a, b) =>
|
||||
b.name.localeCompare(a.name),
|
||||
compare(b.name, a.name),
|
||||
),
|
||||
device: params.device,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { downloadBackup } from "$lib/backup/backup";
|
||||
import { initSerial, serialPort } from "$lib/serial/connection";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { lt as semverLt } from "semver";
|
||||
import type { LoaderOptions, ESPLoader } from "esptool-js";
|
||||
|
||||
let { data } = $props();
|
||||
@@ -10,9 +11,14 @@
|
||||
let success = $state(false);
|
||||
let error = $state<Error | undefined>(undefined);
|
||||
|
||||
let isTooOld = $derived(
|
||||
$serialPort ? semverLt($serialPort.version, "2.0.0") : false,
|
||||
);
|
||||
|
||||
let unsafeUpdate = $state(false);
|
||||
|
||||
let terminalOutput = $state("");
|
||||
let progress = $state(0);
|
||||
|
||||
let step = $state(0);
|
||||
let eraseAll = $state(false);
|
||||
@@ -28,9 +34,11 @@
|
||||
try {
|
||||
const file = await fetch(
|
||||
`${data.meta.path}/${data.meta.update.ota}`,
|
||||
).then((it) => it.blob());
|
||||
).then((it) => it.arrayBuffer());
|
||||
|
||||
await port.updateFirmware(file);
|
||||
await port.updateFirmware(file, (transferred, total) => {
|
||||
progress = transferred / total;
|
||||
});
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
@@ -194,13 +202,23 @@
|
||||
<section>
|
||||
<button
|
||||
class="update-button"
|
||||
class:working
|
||||
class:working={working && (progress <= 0 || progress >= 1)}
|
||||
class:progress={working && progress > 0 && progress < 1}
|
||||
style:--progress="{progress * 100}%"
|
||||
class:primary={!buttonError}
|
||||
class:error={buttonError}
|
||||
disabled={working || $serialPort === undefined || !isCorrectDevice}
|
||||
disabled={isTooOld ||
|
||||
working ||
|
||||
$serialPort === undefined ||
|
||||
!isCorrectDevice}
|
||||
onclick={update}>Apply Update</button
|
||||
>
|
||||
{#if $serialPort && isCorrectDevice}
|
||||
{#if isTooOld}
|
||||
<div class="error" transition:slide>
|
||||
Your device's firmware is too old to be updated via OTA. Follow the
|
||||
instruction below to update it manually.
|
||||
</div>
|
||||
{:else if $serialPort && isCorrectDevice}
|
||||
<div transition:slide>
|
||||
Your
|
||||
<b
|
||||
@@ -228,9 +246,11 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<label class="unsafe-opt-in"
|
||||
><input type="checkbox" /> Unsafe recovery options</label
|
||||
>
|
||||
{#if !isTooOld}
|
||||
<label class="unsafe-opt-in"
|
||||
><input type="checkbox" /> Unsafe recovery options</label
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="unsafe-updates">
|
||||
@@ -282,7 +302,7 @@
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{#if data.meta.update.esptool}
|
||||
{#if false && data.meta.update.esptool}
|
||||
<section>
|
||||
<h3>Factory Flash (WIP)</h3>
|
||||
<p>
|
||||
@@ -314,11 +334,56 @@
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<section class="changelog">
|
||||
<h2>Changelog</h2>
|
||||
{#if data.meta.changelog.features}
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
{#each data.meta.changelog.features as feature}
|
||||
<li>
|
||||
<b>{@html feature.summary}</b>
|
||||
{@html feature.description}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if data.meta.changelog.fixes}
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
{#each data.meta.changelog.fixes as fix}
|
||||
<li>
|
||||
<b>{@html fix.summary}</b>
|
||||
{@html fix.description}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
h3 {
|
||||
margin-block-start: 4em;
|
||||
.changelog:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.changelog ul {
|
||||
padding-inline-start: 0em;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.changelog li {
|
||||
margin-block: 0.2em;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.changelog b {
|
||||
display: inline-block;
|
||||
translate: -0.5em -0.2em;
|
||||
border-radius: 8px;
|
||||
background: var(--md-sys-color-tertiary-container);
|
||||
padding: 0.2em 0.5em;
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
}
|
||||
|
||||
pre {
|
||||
@@ -326,8 +391,8 @@
|
||||
}
|
||||
|
||||
.unsafe-opt-in {
|
||||
margin-block: 1em;
|
||||
opacity: 0.6;
|
||||
margin-block: 1em;
|
||||
font-size: 0.7em;
|
||||
|
||||
& + .unsafe-updates {
|
||||
@@ -375,22 +440,22 @@
|
||||
|
||||
button.inline-button {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: unset;
|
||||
font-size: inherit;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-size: inherit;
|
||||
|
||||
.icon {
|
||||
font-size: 1.2em;
|
||||
translate: 0 0.1em;
|
||||
padding-inline-end: 0.2em;
|
||||
font-size: 1.2em;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.ok {
|
||||
font-size: 1.2em;
|
||||
translate: 0 0.1em;
|
||||
font-size: 1.2em;
|
||||
--icon-fill: 1;
|
||||
}
|
||||
|
||||
@@ -399,17 +464,7 @@
|
||||
}
|
||||
|
||||
button.update-button {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
height: 42px;
|
||||
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 8px;
|
||||
|
||||
outline: 2px dashed currentcolor;
|
||||
outline-offset: 4px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
transition:
|
||||
border 200ms ease,
|
||||
color 200ms ease;
|
||||
@@ -417,43 +472,62 @@
|
||||
margin: 6px;
|
||||
margin-block: 16px;
|
||||
|
||||
outline: 2px dashed currentcolor;
|
||||
outline-offset: 4px;
|
||||
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
height: 42px;
|
||||
overflow: hidden;
|
||||
|
||||
&.primary {
|
||||
color: var(--md-sys-color-primary);
|
||||
background: none;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
&.progress,
|
||||
&.working {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.working::before {
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: 8px;
|
||||
background: var(--md-sys-color-background);
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
border-radius: 8px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&.working::after {
|
||||
z-index: -2;
|
||||
position: absolute;
|
||||
content: "";
|
||||
background: var(--md-sys-color-primary);
|
||||
z-index: -2;
|
||||
animation: rotate 1s ease-out forwards infinite;
|
||||
height: 30%;
|
||||
background: var(--md-sys-color-primary);
|
||||
width: 120%;
|
||||
height: 30%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&.progress::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0.2;
|
||||
z-index: -2;
|
||||
background: var(--md-sys-color-primary);
|
||||
width: var(--progress);
|
||||
height: 100%;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.version {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.incorrect-device {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
@@ -55,8 +55,8 @@
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
@@ -65,20 +65,20 @@
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chats {
|
||||
@@ -86,7 +86,7 @@
|
||||
}
|
||||
|
||||
.space {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.timeline {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -175,7 +174,7 @@
|
||||
}
|
||||
|
||||
.members {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,6 +47,28 @@
|
||||
if (!port) return;
|
||||
$syncStatus = "uploading";
|
||||
|
||||
const layoutChanges = $overlay.layout.reduce(
|
||||
(acc, profile) =>
|
||||
acc + profile.reduce((acc, layer) => acc + layer.size, 0),
|
||||
0,
|
||||
);
|
||||
const settingChanges = $overlay.settings.reduce(
|
||||
(acc, profile) => acc + profile.size,
|
||||
0,
|
||||
);
|
||||
const chordChanges = $overlay.chords.size;
|
||||
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||
|
||||
let progressCurrent = 0;
|
||||
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent,
|
||||
});
|
||||
|
||||
console.log($overlay);
|
||||
|
||||
for (const [id, chord] of $overlay.chords) {
|
||||
if (!chord.deleted) {
|
||||
if (id !== JSON.stringify(chord.actions)) {
|
||||
@@ -83,16 +105,37 @@
|
||||
} else {
|
||||
await port.deleteChord({ actions: chord.actions });
|
||||
}
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [layer, actions] of $overlay.layout.entries()) {
|
||||
for (const [id, action] of actions) {
|
||||
await port.setLayoutKey(layer + 1, id, action);
|
||||
for (const [profile, layout] of $overlay.layout.entries()) {
|
||||
if (layout === undefined) continue;
|
||||
for (const [layer, actions] of layout.entries()) {
|
||||
if (actions === undefined) continue;
|
||||
for (const [id, action] of actions) {
|
||||
if (action === undefined) continue;
|
||||
await port.setLayoutKey(profile, layer + 1, id, action);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, setting] of $overlay.settings) {
|
||||
await port.setSetting(id, setting);
|
||||
for (const [profile, settings] of $overlay.settings.entries()) {
|
||||
if (settings === undefined) continue;
|
||||
for (const [id, setting] of settings.entries()) {
|
||||
if (setting === undefined) continue;
|
||||
await port.setSetting(profile, id, setting);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Yes, this is a completely arbitrary and unnecessary delay.
|
||||
@@ -102,32 +145,19 @@
|
||||
// would be if they click it every time they change a setting.
|
||||
// Because of that, we don't need to show a fearmongering message such as
|
||||
// "Your device will break after you click this 10,000 times!"
|
||||
const virtualWriteTime = 1000;
|
||||
const startStamp = performance.now();
|
||||
await new Promise<void>((resolve) => {
|
||||
function animate() {
|
||||
const delta = performance.now() - startStamp;
|
||||
syncProgress.set({
|
||||
max: virtualWriteTime,
|
||||
current: delta,
|
||||
});
|
||||
if (delta >= virtualWriteTime) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
});
|
||||
await port.commit();
|
||||
if (needsCommit) {
|
||||
await port.commit();
|
||||
}
|
||||
|
||||
$deviceLayout = $layout.map((layer) =>
|
||||
layer.map<number>(({ action }) => action),
|
||||
) as [number[], number[], number[]];
|
||||
$deviceLayout = $layout.map((profile) =>
|
||||
profile.map((layer) => layer.map<number>(({ action }) => action)),
|
||||
);
|
||||
$deviceChords = $chords
|
||||
.filter(({ deleted }) => !deleted)
|
||||
.map(({ actions, phrase }) => ({ actions, phrase }));
|
||||
$deviceSettings = $settings.map(({ value }) => value);
|
||||
$deviceSettings = $settings.map((profile) =>
|
||||
profile.map(({ value }) => value),
|
||||
);
|
||||
$changes = [];
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
@@ -163,19 +193,19 @@
|
||||
<style lang="scss">
|
||||
.click-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: fit-content;
|
||||
align-items: center;
|
||||
margin-inline: 8px;
|
||||
padding-block: 2px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 12px;
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-primary);
|
||||
border: 2px solid var(--md-sys-color-primary);
|
||||
border-radius: 18px;
|
||||
outline: 2px dashed var(--md-sys-color-primary);
|
||||
outline-offset: 2px;
|
||||
border: 2px solid var(--md-sys-color-primary);
|
||||
border-radius: 18px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 12px;
|
||||
padding-block: 2px;
|
||||
height: fit-content;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-weight: bold;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { fly } from "svelte/transition";
|
||||
import { canShare, triggerShare } from "$lib/share";
|
||||
import { action } from "$lib/title";
|
||||
import { activeProfile, serialPort } from "$lib/serial/connection";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import EditActions from "./EditActions.svelte";
|
||||
</script>
|
||||
@@ -11,6 +12,23 @@
|
||||
<EditActions />
|
||||
</div>
|
||||
|
||||
<div class="profiles">
|
||||
{#if $serialPort}
|
||||
{#if $serialPort.profileCount > 1}
|
||||
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
|
||||
<label
|
||||
><input
|
||||
type="radio"
|
||||
name="profile"
|
||||
checked={profile == $activeProfile}
|
||||
onclick={() => ($activeProfile = profile)}
|
||||
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if $canShare}
|
||||
<button
|
||||
@@ -37,35 +55,33 @@
|
||||
<style lang="scss">
|
||||
nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
margin-inline: auto;
|
||||
margin-block: 8px;
|
||||
padding-inline: 16px;
|
||||
|
||||
width: calc(min(100%, 28cm));
|
||||
margin-block: 8px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
aspect-ratio: 1;
|
||||
padding: 0;
|
||||
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
|
||||
background: transparent;
|
||||
transition: all 250ms ease;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
|
||||
transition: all 250ms ease;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
|
||||
aspect-ratio: 1;
|
||||
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -77,8 +93,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
.profiles {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
:disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
codes: Map<number, KeyInfo>,
|
||||
): Promise<FlexSearch.Index> {
|
||||
if (chords.length === 0 || !browser) return index;
|
||||
|
||||
|
||||
index = new FlexSearch.Index({
|
||||
tokenize: "full",
|
||||
encode(phrase: string) {
|
||||
@@ -149,43 +149,45 @@
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
let abort = false;
|
||||
abortIndexing = () => {
|
||||
abort = true;
|
||||
};
|
||||
|
||||
|
||||
const batchSize = 200;
|
||||
const batches = Math.ceil(chords.length / batchSize);
|
||||
|
||||
|
||||
for (let b = 0; b < batches; b++) {
|
||||
if (abort) return index;
|
||||
|
||||
|
||||
const start = b * batchSize;
|
||||
const end = Math.min((b + 1) * batchSize, chords.length);
|
||||
const batch = chords.slice(start, end);
|
||||
|
||||
|
||||
const promises = batch.map((chord, i) => {
|
||||
const chordIndex = start + i;
|
||||
progress = chordIndex + 1;
|
||||
|
||||
|
||||
if ("phrase" in chord) {
|
||||
const encodedChord = encodeChord(chord, osLayout, codes);
|
||||
return index.addAsync(chordIndex, encodedChord);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
const searchFilter = writable<number[] | undefined>(undefined);
|
||||
let currentSearchQuery = $state("");
|
||||
|
||||
async function search(index: FlexSearch.Index, event: Event) {
|
||||
const query = (event.target as HTMLInputElement).value;
|
||||
currentSearchQuery = query;
|
||||
searchFilter.set(
|
||||
query && searchIndex
|
||||
? ((await index.searchAsync(query)) as number[])
|
||||
@@ -194,6 +196,13 @@
|
||||
page = 0;
|
||||
}
|
||||
|
||||
// Re-run search when chords change to fix stale indices
|
||||
$effect(() => {
|
||||
if (currentSearchQuery && $searchIndex) {
|
||||
search($searchIndex, { target: { value: currentSearchQuery } } as any);
|
||||
}
|
||||
});
|
||||
|
||||
function insertChord(actions: number[]) {
|
||||
const id = JSON.stringify(actions);
|
||||
if ($chords.some((it) => JSON.stringify(it.actions) === id)) {
|
||||
@@ -273,6 +282,7 @@
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress)}
|
||||
value={currentSearchQuery}
|
||||
oninput={(event) => $searchIndex && search($searchIndex, event)}
|
||||
class:loading={progress !== $chords.length}
|
||||
/>
|
||||
@@ -350,8 +360,8 @@
|
||||
<style lang="scss">
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.paginator {
|
||||
@@ -376,14 +386,14 @@
|
||||
textarea {
|
||||
flex: 1;
|
||||
transition: outline-color 250ms ease;
|
||||
background: none;
|
||||
color: inherit;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
margin: 2px;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -1px;
|
||||
margin: 2px;
|
||||
padding: 8px;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
padding: 8px;
|
||||
color: inherit;
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--md-sys-color-primary);
|
||||
@@ -403,34 +413,33 @@
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
width: 512px;
|
||||
transition: all 250ms ease;
|
||||
margin-block-start: 16px;
|
||||
padding-block: 8px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-size: 16px;
|
||||
color: inherit;
|
||||
|
||||
background: none;
|
||||
border: 0 solid var(--md-sys-color-surface-variant);
|
||||
border-bottom-width: 1px;
|
||||
|
||||
transition: all 250ms ease;
|
||||
background: none;
|
||||
padding-inline: 16px;
|
||||
padding-block: 8px;
|
||||
width: 512px;
|
||||
color: inherit;
|
||||
|
||||
font-size: 16px;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
border-color: var(--md-sys-color-outline);
|
||||
border-style: dashed;
|
||||
border-color: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
opacity: 0.8;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--md-sys-color-primary);
|
||||
border-style: solid;
|
||||
outline: none;
|
||||
border-style: solid;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
&.loading {
|
||||
@@ -439,25 +448,25 @@
|
||||
}
|
||||
|
||||
section {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
height: 100%;
|
||||
padding-inline: 8px;
|
||||
position: relative;
|
||||
|
||||
border-radius: 16px;
|
||||
padding-inline: 8px;
|
||||
|
||||
height: 100%;
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.results {
|
||||
height: 100%;
|
||||
min-width: min(90vw, 16.5cm);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
transition: all 1s ease;
|
||||
height: fit-content;
|
||||
overflow-y: hidden;
|
||||
transition: all 1s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -176,9 +176,9 @@
|
||||
}
|
||||
|
||||
.add {
|
||||
font-size: 18px;
|
||||
height: 20px;
|
||||
opacity: 0;
|
||||
height: 20px;
|
||||
font-size: 18px;
|
||||
--icon-fill: 1;
|
||||
}
|
||||
|
||||
@@ -187,13 +187,12 @@
|
||||
}
|
||||
|
||||
.chord {
|
||||
position: relative;
|
||||
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
gap: 4px;
|
||||
margin-inline: 4px;
|
||||
|
||||
height: 32px;
|
||||
margin-inline: 4px;
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
@@ -201,22 +200,21 @@
|
||||
}
|
||||
|
||||
.chord::after {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform-origin: center left;
|
||||
translate: -20px 0;
|
||||
scale: 0 1;
|
||||
|
||||
width: calc(100% - 60px);
|
||||
height: 1px;
|
||||
|
||||
background: currentcolor;
|
||||
|
||||
transition:
|
||||
scale 250ms ease,
|
||||
color 250ms ease;
|
||||
|
||||
background: currentcolor;
|
||||
|
||||
width: calc(100% - 60px);
|
||||
height: 1px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.edited {
|
||||
|
||||
@@ -133,11 +133,11 @@
|
||||
.separator {
|
||||
display: inline-flex;
|
||||
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
|
||||
opacity: 0.2;
|
||||
background: currentcolor;
|
||||
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -153,12 +153,12 @@
|
||||
}
|
||||
|
||||
.table-buttons {
|
||||
opacity: 0;
|
||||
transition: opacity 75ms ease;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(100%, -50%);
|
||||
opacity: 0;
|
||||
transition: opacity 75ms ease;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { changes, ChangeType } from "$lib/undo-redo";
|
||||
import type { ChordInfo } from "$lib/undo-redo";
|
||||
@@ -6,11 +7,16 @@
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import { selectAction } from "./action-selector";
|
||||
import { inputToAction } from "./input-converter";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||
import { get } from "svelte/store";
|
||||
import { action } from "$lib/title";
|
||||
import semverGte from "semver/functions/gte";
|
||||
|
||||
let { chord }: { chord: ChordInfo } = $props();
|
||||
|
||||
const JOIN_ACTION = 574;
|
||||
const NO_CONCATENATOR_ACTION = 256;
|
||||
|
||||
onMount(() => {
|
||||
if (chord.phrase.length === 0) {
|
||||
box?.focus();
|
||||
@@ -102,35 +108,143 @@
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAutospace(autospace: boolean) {
|
||||
if (autospace) {
|
||||
if (chord.phrase.at(-1) === JOIN_ACTION) {
|
||||
if (
|
||||
chord.phrase.every(
|
||||
(action, i, arr) =>
|
||||
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
|
||||
)
|
||||
) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (isPrintable) {
|
||||
return;
|
||||
} else if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
} else {
|
||||
insertAction(chord.phrase.length, JOIN_ACTION);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (chord.phrase.at(-1) === JOIN_ACTION) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
} else {
|
||||
if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
if (
|
||||
chord.phrase.every(
|
||||
(action, i, arr) =>
|
||||
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
}
|
||||
} else {
|
||||
insertAction(chord.phrase.length, NO_CONCATENATOR_ACTION);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let button: HTMLButtonElement | undefined = $state();
|
||||
let box: HTMLDivElement | undefined = $state();
|
||||
let cursorPosition = 0;
|
||||
let cursorOffset = $state(0);
|
||||
|
||||
let hasFocus = $state(false);
|
||||
|
||||
let isPrintable = $derived(
|
||||
chord.phrase.every(
|
||||
(action) => $KEYMAP_CODES.get(action)?.printable === true,
|
||||
),
|
||||
);
|
||||
let supportsAutospace = $derived(
|
||||
semverGte($deviceMeta?.version ?? "0.0.0", "2.1.0"),
|
||||
);
|
||||
let hasAutospace = $derived(
|
||||
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
||||
);
|
||||
|
||||
let displayPhrase = $derived(
|
||||
chord.phrase.filter(
|
||||
(it, i, arr) =>
|
||||
!(
|
||||
(i === 0 && it === JOIN_ACTION) ||
|
||||
(i === arr.length - 1 &&
|
||||
(it === JOIN_ACTION || it === NO_CONCATENATOR_ACTION))
|
||||
),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
onkeydown={keypress}
|
||||
onmousedown={clickCursor}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
bind:this={box}
|
||||
class="wrapper"
|
||||
class:edited={!chord.deleted && chord.phraseChanged}
|
||||
onfocusin={() => (hasFocus = true)}
|
||||
onfocusout={(event) => {
|
||||
if (event.relatedTarget !== button) hasFocus = false;
|
||||
onclick={() => {
|
||||
box.focus();
|
||||
}}
|
||||
>
|
||||
{#if hasFocus}
|
||||
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
|
||||
<button class="icon" bind:this={button} onclick={addSpecial}>add</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
<!-- placeholder for cursor placement -->
|
||||
{#if supportsAutospace}
|
||||
<label
|
||||
class="auto-space-edit"
|
||||
use:action={{ title: "Remove previous concatenator" }}
|
||||
><span class="icon">join_inner</span><input
|
||||
checked={chord.phrase[0] === JOIN_ACTION}
|
||||
onchange={(event) => {
|
||||
const autospace = hasAutospace;
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
if (chord.phrase[0] !== JOIN_ACTION) {
|
||||
insertAction(0, JOIN_ACTION);
|
||||
}
|
||||
} else {
|
||||
if (chord.phrase[0] === JOIN_ACTION) {
|
||||
deleteAction(0, 1);
|
||||
}
|
||||
}
|
||||
tick().then(() => resolveAutospace(autospace));
|
||||
}}
|
||||
type="checkbox"
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<div
|
||||
onkeydown={keypress}
|
||||
onmousedown={clickCursor}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
bind:this={box}
|
||||
onfocusin={() => (hasFocus = true)}
|
||||
onfocusout={(event) => {
|
||||
if (event.relatedTarget !== button) hasFocus = false;
|
||||
}}
|
||||
>
|
||||
{#if hasFocus}
|
||||
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
|
||||
<button class="icon" bind:this={button} onclick={addSpecial}>add</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
<!-- placeholder for cursor placement -->
|
||||
{/if}
|
||||
<ActionString actions={displayPhrase} />
|
||||
</div>
|
||||
{#if supportsAutospace}
|
||||
<label class="auto-space-edit" use:action={{ title: "Add concatenator" }}
|
||||
><span class="icon">space_bar</span><input
|
||||
checked={hasAutospace}
|
||||
onchange={(event) =>
|
||||
resolveAutospace((event.target as HTMLInputElement).checked)}
|
||||
type="checkbox"
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<ActionString actions={chord.phrase} />
|
||||
<sup>•</sup>
|
||||
</div>
|
||||
|
||||
@@ -146,26 +260,26 @@
|
||||
transform: translateX(-50%);
|
||||
translate: 0 0;
|
||||
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
transition: translate 50ms ease;
|
||||
|
||||
background: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
transition: translate 50ms ease;
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 0;
|
||||
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 12px 12px 12px 0;
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
padding: 0;
|
||||
|
||||
height: 24px;
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,33 +291,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
[role="textbox"] {
|
||||
cursor: text;
|
||||
.auto-space-edit {
|
||||
margin-inline: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--md-sys-color-tertiary-container);
|
||||
padding-inline: 0;
|
||||
height: 1em;
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
font-size: 1.3em;
|
||||
|
||||
&:first-of-type:not(:has(:checked)),
|
||||
&:last-of-type:has(:checked) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .auto-space-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
height: 1em;
|
||||
padding-block: 4px;
|
||||
|
||||
height: 1em;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
content: "";
|
||||
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
|
||||
opacity: 0;
|
||||
background: currentcolor;
|
||||
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
scale 250ms ease;
|
||||
background: currentcolor;
|
||||
|
||||
width: calc(100% - 8px);
|
||||
height: 1px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::after {
|
||||
@@ -215,13 +346,22 @@
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:has(> :focus-within)::after {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[role="textbox"] {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
white-space: pre;
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
|
||||
&::after {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { serializeActions } from "$lib/serial/chord";
|
||||
import { chords } from "$lib/undo-redo";
|
||||
import ChordEdit from "../ChordEdit.svelte";
|
||||
|
||||
export function hashChord(actions: number[]) {
|
||||
const chord = new Uint8Array(16);
|
||||
const view = new DataView(chord.buffer);
|
||||
const serialized = serializeActions(actions);
|
||||
view.setBigUint64(0, serialized & 0xffff_ffff_ffff_ffffn, true);
|
||||
view.setBigUint64(8, serialized >> 64n, true);
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < 16; i++) {
|
||||
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
|
||||
}
|
||||
return hash & 0x3fff_ffff;
|
||||
}
|
||||
|
||||
const broken = $derived(
|
||||
$chords.filter((it) => (hashChord(it.actions) & 0xff) === 0xff),
|
||||
);
|
||||
</script>
|
||||
|
||||
<h1>Will my compound break</h1>
|
||||
<p>
|
||||
Pre-2.2.0 there was a bug where creating a compound with specific chords as a
|
||||
base could corrupt your library.
|
||||
</p>
|
||||
|
||||
{#if broken.length > 0}
|
||||
<p class="warning">Chords have been detected.</p>
|
||||
<p>
|
||||
If you have ever tried to create a compound chord with <b class="warning"
|
||||
>any of these as a base</b
|
||||
>, your library might have been corrupted.
|
||||
</p>
|
||||
{#each broken as chord}
|
||||
<ChordEdit {chord} onduplicate={() => {}} />
|
||||
{/each}
|
||||
{:else}
|
||||
<p>No problematic chords found</p>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.warning {
|
||||
color: var(--md-sys-color-error);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chord {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.compound {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 600px;
|
||||
}
|
||||
</style>
|
||||
@@ -6,7 +6,6 @@
|
||||
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||
import SharePopup from "../SharePopup.svelte";
|
||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||
import { writable } from "svelte/store";
|
||||
import { layout } from "$lib/undo-redo";
|
||||
|
||||
async function shareLayout(event: Event) {
|
||||
@@ -49,8 +48,6 @@
|
||||
fontSize: 9,
|
||||
iconFontSize: 14,
|
||||
});
|
||||
|
||||
setContext("active-layer", writable(0));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -67,8 +64,8 @@
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -40,157 +40,155 @@
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<fieldset>
|
||||
<legend>{$LL.backup.TITLE()}</legend>
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
use:preference={"backup"}
|
||||
/>{$LL.backup.AUTO_BACKUP()}</label
|
||||
>
|
||||
<p class="disclaimer">
|
||||
{$LL.backup.DISCLAIMER()}
|
||||
</p>
|
||||
<div class="row" style="margin-top: auto">
|
||||
<button onclick={() => downloadFile(createChordBackup())}>
|
||||
<span class="icon">piano</span>
|
||||
{$LL.configure.chords.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
||||
<span class="icon">keyboard</span>
|
||||
{$LL.configure.layout.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
||||
<span class="icon">settings</span>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={downloadBackup}
|
||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||
>
|
||||
<label class="button"
|
||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
||||
>settings_backup_restore</span
|
||||
>{$LL.backup.RESTORE()}</label
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Device</legend>
|
||||
<label
|
||||
>{$LL.deviceManager.AUTO_CONNECT()}<input
|
||||
type="checkbox"
|
||||
use:preference={"autoConnect"}
|
||||
/></label
|
||||
>
|
||||
{#if $serialPort}
|
||||
{#if $deviceMeta?.factoryDefaults?.settings}
|
||||
<button
|
||||
use:action={{ title: "Reset Settings" }}
|
||||
transition:fly={{ x: -8 }}
|
||||
onclick={() => restoreFromFile($deviceMeta.factoryDefaults!.settings)}
|
||||
><span class="icon">reset_settings</span>Reset Settings</button
|
||||
>
|
||||
{/if}
|
||||
<button class="outline" use:popup={ResetPopup}>Recovery...</button>
|
||||
<nav>
|
||||
<a href="#connection">Connection</a>
|
||||
{#if $deviceMeta}
|
||||
{#each $deviceMeta?.settings as category}
|
||||
<a href={`#${category.name}`}>{titlecase(category.name)}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</fieldset>
|
||||
{#if $deviceMeta}
|
||||
{#each $deviceMeta.settings as category}
|
||||
<fieldset>
|
||||
<legend>
|
||||
{#if category.items[0]?.name === "enable"}
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
use:setting={{ id: category.items[0].id }}
|
||||
/>{titlecase(category.name)}</label
|
||||
>
|
||||
{:else}
|
||||
<a href="#backup">Backup</a>
|
||||
</nav>
|
||||
<div class="content">
|
||||
<fieldset id="connection">
|
||||
<legend>Connection</legend>
|
||||
<label
|
||||
><input type="checkbox" use:preference={"autoConnect"} />
|
||||
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
><input type="checkbox" use:preference={"backup"} />
|
||||
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
|
||||
<div class="description">{@html $LL.backup.DISCLAIMER()}</div>
|
||||
</label>
|
||||
</fieldset>
|
||||
{#if $deviceMeta}
|
||||
{#each $deviceMeta.settings as category}
|
||||
<fieldset id={category.name}>
|
||||
<legend>
|
||||
{titlecase(category.name)}
|
||||
</legend>
|
||||
{#if category.description}
|
||||
<p>{category.description}</p>
|
||||
{/if}
|
||||
</legend>
|
||||
{#if category.description}
|
||||
<p>{category.description}</p>
|
||||
{#each category.items as item}
|
||||
{#if item.unit === "H"}
|
||||
<label
|
||||
><input type="color" use:setting={{ id: item.id }} /> Color</label
|
||||
>
|
||||
{:else if item.unit !== "S" && item.unit !== "B"}
|
||||
<label class:enable-item={item.name === "enable"}
|
||||
>{#if item.enum}
|
||||
<select class="value" use:setting={{ id: item.id }}>
|
||||
{#each item.enum as name, value}
|
||||
<option {value}>{titlecase(name)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if item.range[0] === 0 && item.range[1] === 1}
|
||||
<input
|
||||
class="value"
|
||||
type="checkbox"
|
||||
use:setting={{ id: item.id }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="value unit">
|
||||
<input
|
||||
type="number"
|
||||
min={settingValue(item.range[0], item)}
|
||||
max={settingValue(item.range[1], item)}
|
||||
step={item.inverse !== undefined ||
|
||||
item.scale !== undefined ||
|
||||
item.step === undefined
|
||||
? undefined
|
||||
: settingValue(item.step, item)}
|
||||
use:setting={{
|
||||
id: item.id,
|
||||
inverse: item.inverse,
|
||||
scale: item.scale,
|
||||
}}
|
||||
/>{item.unit}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="title">{titlecase(item.name)}</div>
|
||||
{#if item.description}
|
||||
<div class="description">{item.description}</div>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<fieldset id="backup">
|
||||
<legend>{$LL.backup.TITLE()}</legend>
|
||||
<div class="row" style="margin-top: auto">
|
||||
<button onclick={() => downloadFile(createChordBackup())}>
|
||||
<span class="icon">piano</span>
|
||||
{$LL.configure.chords.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
||||
<span class="icon">keyboard</span>
|
||||
{$LL.configure.layout.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
||||
<span class="icon">settings</span>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={downloadBackup}
|
||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||
>
|
||||
<label class="button"
|
||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
||||
>settings_backup_restore</span
|
||||
>{$LL.backup.RESTORE()}</label
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="footer">
|
||||
{#if $serialPort}
|
||||
{#if $deviceMeta?.factoryDefaults?.settings}
|
||||
<button
|
||||
use:action={{ title: "Reset Settings" }}
|
||||
transition:fly={{ x: -8 }}
|
||||
onclick={() =>
|
||||
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
|
||||
><span class="icon">reset_settings</span>Reset Settings</button
|
||||
>
|
||||
{/if}
|
||||
{#each category.items as item}
|
||||
{#if item.name !== "enable"}
|
||||
<label
|
||||
>{#if item.enum}
|
||||
<select use:setting={{ id: item.id }}>
|
||||
{#each item.enum as name, value}
|
||||
<option {value}>{titlecase(name)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if item.range[0] === 0 && item.range[1] === 1}
|
||||
<input type="checkbox" use:setting={{ id: item.id }} />
|
||||
{:else}
|
||||
<span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
min={settingValue(item.range[0], item)}
|
||||
max={settingValue(item.range[1], item)}
|
||||
step={settingValue(item.step, item)}
|
||||
use:setting={{
|
||||
id: item.id,
|
||||
inverse: item.inverse,
|
||||
scale: item.scale,
|
||||
}}
|
||||
/>{item.unit}</span
|
||||
>
|
||||
{/if}
|
||||
{#if item.description}
|
||||
<span
|
||||
>{titlecase(item.name)}
|
||||
<p>{item.description}</p></span
|
||||
>
|
||||
{:else}
|
||||
{titlecase(item.name)}
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
<button popovertarget="reset-device" popovertargetaction="toggle"
|
||||
>Recovery...</button
|
||||
>
|
||||
<div id="reset-device" popover="auto"><ResetPopup /></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 20cm;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
|
||||
margin-block: auto;
|
||||
padding-block-end: 48px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
button.outline {
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 8px;
|
||||
height: 2em;
|
||||
margin-block: 2em;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
legend,
|
||||
legend > label {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
legend {
|
||||
position: relative;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
legend:has(label) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend:not(:has(label)) {
|
||||
opacity: 0.8;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-weight: bold;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
@@ -199,25 +197,31 @@
|
||||
|
||||
fieldset {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
margin-inline: 0;
|
||||
margin-block-end: 32px;
|
||||
border: none;
|
||||
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
|
||||
/*&:has(> legend input:not(:checked)) > :not(legend) {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}*/
|
||||
> p {
|
||||
padding-inline-start: 16px;
|
||||
}
|
||||
|
||||
> label {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
appearance: none;
|
||||
|
||||
margin-block: 4px;
|
||||
padding: 8px;
|
||||
width: fit-content;
|
||||
height: auto;
|
||||
font-weight: normal;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
@@ -228,39 +232,59 @@
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.enable-item {
|
||||
margin-inline-start: 8px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 16px;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-inline-start: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
text-wrap: wrap;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.unit {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
|
||||
width: 67px;
|
||||
padding-inline-end: auto;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 16px;
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
border-radius: 16px;
|
||||
padding-inline-end: auto;
|
||||
|
||||
width: 67px;
|
||||
overflow: hidden;
|
||||
font-weight: bold;
|
||||
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
display: flex;
|
||||
border: none;
|
||||
|
||||
background: var(--md-sys-color-secondary);
|
||||
padding-block: 4px;
|
||||
|
||||
width: 5ch;
|
||||
height: 100%;
|
||||
padding-block: 4px;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
text-align: end;
|
||||
|
||||
background: var(--md-sys-color-secondary);
|
||||
border: none;
|
||||
|
||||
&::-webkit-inner-spin-button {
|
||||
display: none;
|
||||
}
|
||||
@@ -275,16 +299,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--md-sys-color-secondary);
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// stylelint-disable-next-line
|
||||
label:global(:has(.pending-changes)) {
|
||||
color: var(--md-sys-color-primary);
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
font-size: 16px;
|
||||
top: 0.5em;
|
||||
right: 0.25em;
|
||||
content: "•";
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,9 +326,15 @@
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
margin-block: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
|
||||
let { challenge, onconfirm }: { challenge: string; onconfirm: () => void } =
|
||||
$props();
|
||||
|
||||
let challengeInput = $state("");
|
||||
let challengeString = $derived(`${challenge} ${$serialPort!.device}`);
|
||||
let isValid = $derived(challengeInput === challengeString);
|
||||
</script>
|
||||
|
||||
<h3>Type the following to confirm the action</h3>
|
||||
|
||||
<p>{challengeString}</p>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
bind:value={challengeInput}
|
||||
placeholder={challengeString}
|
||||
/>
|
||||
|
||||
<button disabled={!isValid} onclick={onconfirm}>Confirm {challenge}</button>
|
||||
|
||||
<style lang="scss">
|
||||
input[type="text"] {
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid currentcolor;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--md-sys-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { confirmChallenge } from "./confirm-challenge";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
|
||||
const options = [
|
||||
@@ -17,21 +16,27 @@
|
||||
</script>
|
||||
|
||||
<h3>Reset Device</h3>
|
||||
<p>Resetting might take <b>up to 2 Minutes</b>.</p>
|
||||
{#each options as category, i}
|
||||
{#if i > 0}
|
||||
<hr />
|
||||
{/if}
|
||||
{#each category as [command, description]}
|
||||
<button
|
||||
class="error"
|
||||
use:confirmChallenge={{
|
||||
onConfirm() {
|
||||
$serialPort?.reset(command);
|
||||
$serialPort = undefined;
|
||||
},
|
||||
challenge: description,
|
||||
}}>{description}...</button
|
||||
<form
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
$serialPort?.reset(command);
|
||||
$serialPort = undefined;
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={description}
|
||||
required
|
||||
pattern="^{description}$"
|
||||
/>
|
||||
<button class="icon" type="submit">send</button>
|
||||
</form>
|
||||
{/each}
|
||||
{/each}
|
||||
|
||||
@@ -39,4 +44,43 @@
|
||||
hr {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
p {
|
||||
width: 22ch;
|
||||
}
|
||||
|
||||
button.icon {
|
||||
opacity: 0.5;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
border: none;
|
||||
border-bottom: 1px solid transparent;
|
||||
background: none;
|
||||
width: fit-content;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--md-sys-color-outline);
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"]:valid {
|
||||
color: var(--md-sys-color-error);
|
||||
|
||||
& + button {
|
||||
opacity: 1;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { Action } from "svelte/action";
|
||||
import ConfirmChallenge from "./ConfirmChallenge.svelte";
|
||||
import tippy from "tippy.js";
|
||||
import { mount, unmount } from "svelte";
|
||||
|
||||
export const confirmChallenge: Action<
|
||||
HTMLElement,
|
||||
{ onConfirm: () => void; challenge: string }
|
||||
> = (node, { onConfirm, challenge }) => {
|
||||
let component: {} | undefined;
|
||||
let target: HTMLElement | undefined;
|
||||
const edit = tippy(node, {
|
||||
interactive: true,
|
||||
trigger: "click",
|
||||
onShow(instance) {
|
||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||
target.classList.add("active");
|
||||
if (component === undefined) {
|
||||
component = mount(ConfirmChallenge, {
|
||||
target,
|
||||
props: {
|
||||
challenge,
|
||||
onconfirm() {
|
||||
edit.hide();
|
||||
onConfirm();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
onHidden() {
|
||||
if (component) {
|
||||
unmount(component);
|
||||
}
|
||||
target?.classList.remove("active");
|
||||
component = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
edit.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
232
src/routes/(app)/e2e/+page.svelte.wip
Normal file
232
src/routes/(app)/e2e/+page.svelte.wip
Normal file
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
compileLayout,
|
||||
type VisualLayout,
|
||||
} from "$lib/serialization/visual-layout";
|
||||
import ccxLayout from "$lib/assets/layouts/generic/103-key.yml";
|
||||
import keycodes from "./keycodes.json";
|
||||
|
||||
let width = $state(16);
|
||||
let height = $state(16);
|
||||
|
||||
let layout = $state(compileLayout(ccxLayout as VisualLayout));
|
||||
let layoutMargin = $state(0.2);
|
||||
|
||||
let timelineCanvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
|
||||
interface Report {
|
||||
modifiers?: number;
|
||||
keys?: number[];
|
||||
}
|
||||
|
||||
interface Tick {
|
||||
ms?: number;
|
||||
reports?: Report[];
|
||||
keys?: number[];
|
||||
}
|
||||
|
||||
let test: Tick[] = $state([
|
||||
{ ms: 1, reports: [{ keys: [4] }], keys: [4] },
|
||||
{ ms: 2, reports: [{ keys: [4, 2] }], keys: [4, 12] },
|
||||
]);
|
||||
|
||||
function timelineData<T extends { ms: number }>(
|
||||
ticks: T[],
|
||||
value: (tick: T) => number[],
|
||||
) {
|
||||
let totalTicks = 0;
|
||||
const result = new Map<number, [number, number][]>();
|
||||
for (const tick of ticks) {
|
||||
const key = value(tick);
|
||||
}
|
||||
}
|
||||
|
||||
let timelineData = $derived.by(() => {
|
||||
const result = new Map<number, [number, number][]>();
|
||||
for (const tick of test) {
|
||||
if (!tick.keys) continue;
|
||||
if (Array.isArray(action)) {
|
||||
if (typeof action[0] === "number") {
|
||||
ticks.push([action[0]]);
|
||||
totalTicks++;
|
||||
} else if (action.length === 0) {
|
||||
ticks.push([1]);
|
||||
totalTicks++;
|
||||
}
|
||||
}
|
||||
if (typeof action !== "number") continue;
|
||||
if (action >= 0) {
|
||||
if (!result.has(action)) {
|
||||
result.set(action, []);
|
||||
}
|
||||
result.get(action)!.push([totalTicks, test.length - 1]);
|
||||
} else {
|
||||
const value = result.get(~action)?.at(-1);
|
||||
if (!value || value[1] !== test.length - 1) continue;
|
||||
value[1] = totalTicks;
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalTicks,
|
||||
ticks,
|
||||
presses: [...result.entries()].sort(([a], [b]) => a - b),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>E2E Testing</h1>
|
||||
|
||||
{#snippet Layout(keys: Set<number>)}
|
||||
<svg viewBox="0 0 {layout.size[0]} {layout.size[1]}">
|
||||
{#each layout.keys as key}
|
||||
{#if key.shape === "square"}
|
||||
<rect
|
||||
x={key.pos[0] + layoutMargin / 2}
|
||||
y={key.pos[1] + layoutMargin / 2}
|
||||
rx={0.5 - layoutMargin / 2}
|
||||
width={key.size[0] - layoutMargin}
|
||||
height={key.size[1] - layoutMargin}
|
||||
fill={keys.has(key.id)
|
||||
? "var(--md-sys-color-primary)"
|
||||
: "var(--md-sys-color-on-surface)"}
|
||||
opacity={keys.has(key.id) ? 1 : 0.1}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<canvas bind:this={timelineCanvas}></canvas>
|
||||
|
||||
<div class="t">
|
||||
{#each test as { ms, reports, keys }}
|
||||
<div class="tick">
|
||||
{ms}ms
|
||||
<div class="keys">
|
||||
{#each keys ?? [] as key}
|
||||
<kbd>{keycodes[key] ?? key}</kbd>
|
||||
{/each}
|
||||
<button class="icon">+</button>
|
||||
</div>
|
||||
{@render Layout(new Set(keys))}
|
||||
{#each reports ?? [] as report}
|
||||
<div class="report">
|
||||
<div class="modifiers">{report.modifiers}</div>
|
||||
<div class="keys">
|
||||
{#each report.keys ?? [] as key}
|
||||
<kbd>{keycodes[key] ?? key}</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#each test as action, i}
|
||||
{@const isActionTick = Array.isArray(action)}
|
||||
{@const isActionPress = typeof action === "number" && action >= 0}
|
||||
{@const isActionRelease = typeof action === "number" && action < 0}
|
||||
{#if isActionTick}
|
||||
<div class="tick">
|
||||
<span class="icon">step_over</span>
|
||||
{action[0]}ms
|
||||
</div>
|
||||
{#if action[1]}
|
||||
<div class="report">
|
||||
{#each Array.from({ length: 8 }) as _, j}
|
||||
<div class="modifier">{j}</div>
|
||||
{/each}
|
||||
{#each action[1][1] as key}
|
||||
<div class="key">
|
||||
{key}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if typeof action === "string"}
|
||||
<div>Command: {action}</div>
|
||||
{:else if isActionPress}
|
||||
<button class="release" onclick={() => (test[i] = ~action)}
|
||||
>{action}</button
|
||||
>
|
||||
{:else if isActionRelease}
|
||||
<button class="press" onclick={() => (test[i] = ~action)}
|
||||
>{~action}</button
|
||||
>
|
||||
{:else}
|
||||
<div>Unsupported {action}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
$shadow-inset: 1px;
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
grid-template-rows: auto repeat(auto-fit, minmax(var(--height), 1fr));
|
||||
}
|
||||
|
||||
.timeline-press {
|
||||
margin-inline: calc(var(--width) / 2);
|
||||
border-radius: calc(var(--height) / 2);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
height: var(--height);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: ew-resize;
|
||||
padding: 0.5rem;
|
||||
user-select: none;
|
||||
|
||||
span.icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
aspect-ratio: 1;
|
||||
user-select: none;
|
||||
|
||||
&.release {
|
||||
box-shadow:
|
||||
inset #{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(0, 0, 0, 0.6),
|
||||
inset -#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
&.press {
|
||||
box-shadow:
|
||||
#{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(0, 0, 0, 0.6),
|
||||
-#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
251
src/routes/(app)/e2e/keycodes.json
Normal file
251
src/routes/(app)/e2e/keycodes.json
Normal file
@@ -0,0 +1,251 @@
|
||||
[
|
||||
"reserved",
|
||||
"esc",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"-",
|
||||
"=",
|
||||
"bksp",
|
||||
"tab",
|
||||
"q",
|
||||
"w",
|
||||
"e",
|
||||
"r",
|
||||
"t",
|
||||
"y",
|
||||
"u",
|
||||
"i",
|
||||
"o",
|
||||
"p",
|
||||
"[",
|
||||
"]",
|
||||
"enter",
|
||||
"lctrl",
|
||||
"a",
|
||||
"s",
|
||||
"d",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
";",
|
||||
"'",
|
||||
"`",
|
||||
"lshift",
|
||||
"\\",
|
||||
"z",
|
||||
"x",
|
||||
"c",
|
||||
"v",
|
||||
"b",
|
||||
"n",
|
||||
"m",
|
||||
",",
|
||||
".",
|
||||
"/",
|
||||
"rshift",
|
||||
"kp*",
|
||||
"lalt",
|
||||
"_",
|
||||
"capslock",
|
||||
"f1",
|
||||
"f2",
|
||||
"f3",
|
||||
"f4",
|
||||
"f5",
|
||||
"f6",
|
||||
"f7",
|
||||
"f8",
|
||||
"f9",
|
||||
"f10",
|
||||
"numlock",
|
||||
"scrolllock",
|
||||
"kp7",
|
||||
"kp8",
|
||||
"kp9",
|
||||
"kp-",
|
||||
"kp4",
|
||||
"kp5",
|
||||
"kp6",
|
||||
"kp+",
|
||||
"kp1",
|
||||
"kp2",
|
||||
"kp3",
|
||||
"kp0",
|
||||
"kp.",
|
||||
"ksc_84",
|
||||
"zenkaku_hankaku",
|
||||
"102nd",
|
||||
"f11",
|
||||
"f12",
|
||||
"ro",
|
||||
"katakana",
|
||||
"hiragana",
|
||||
"henkan",
|
||||
"katakana_hiragana",
|
||||
"muhenkan",
|
||||
"kp,",
|
||||
"kp_enter",
|
||||
"rctrl",
|
||||
"kp/",
|
||||
"sysrq",
|
||||
"ralt",
|
||||
"linefeed",
|
||||
"home",
|
||||
"up",
|
||||
"pageup",
|
||||
"left",
|
||||
"right",
|
||||
"end",
|
||||
"down",
|
||||
"pagedown",
|
||||
"insert",
|
||||
"delete",
|
||||
"macro",
|
||||
"mute",
|
||||
"volume_down",
|
||||
"volume_up",
|
||||
"power",
|
||||
"kp=",
|
||||
"kp+-",
|
||||
"pause",
|
||||
"scale",
|
||||
"kp,",
|
||||
"hangeul",
|
||||
"hanja",
|
||||
"yen",
|
||||
"lmeta",
|
||||
"rmeta",
|
||||
"compose",
|
||||
"stop",
|
||||
"again",
|
||||
"props",
|
||||
"undo",
|
||||
"front",
|
||||
"copy",
|
||||
"open",
|
||||
"paste",
|
||||
"find",
|
||||
"cut",
|
||||
"help",
|
||||
"menu",
|
||||
"calc",
|
||||
"setup",
|
||||
"sleep",
|
||||
"wakeup",
|
||||
"file",
|
||||
"sendfile",
|
||||
"deletefile",
|
||||
"xfer",
|
||||
"prog1",
|
||||
"prog2",
|
||||
"www",
|
||||
"msdos",
|
||||
"coffee",
|
||||
"rotate_display",
|
||||
"cyclewindows",
|
||||
"mail",
|
||||
"bookmarks",
|
||||
"computer",
|
||||
"back",
|
||||
"forward",
|
||||
"close_cd",
|
||||
"eject_cd",
|
||||
"eject_close_cd",
|
||||
"next_song",
|
||||
"play_pause",
|
||||
"prev_song",
|
||||
"stop_cd",
|
||||
"record",
|
||||
"rewind",
|
||||
"phone",
|
||||
"iso",
|
||||
"config",
|
||||
"homepage",
|
||||
"refresh",
|
||||
"exit",
|
||||
"move",
|
||||
"edit",
|
||||
"scroll_up",
|
||||
"scroll_down",
|
||||
"kp_left_paren",
|
||||
"kp_right_paren",
|
||||
"new",
|
||||
"redo",
|
||||
"f13",
|
||||
"f14",
|
||||
"f15",
|
||||
"f16",
|
||||
"f17",
|
||||
"f18",
|
||||
"f19",
|
||||
"f20",
|
||||
"f21",
|
||||
"f22",
|
||||
"f23",
|
||||
"f24",
|
||||
"sc_195",
|
||||
"sc_196",
|
||||
"sc_197",
|
||||
"sc_198",
|
||||
"sc_199",
|
||||
"play_cd",
|
||||
"pause_cd",
|
||||
"prog3",
|
||||
"prog4",
|
||||
"all_applications",
|
||||
"suspend",
|
||||
"close",
|
||||
"play",
|
||||
"fastforward",
|
||||
"bass_boost",
|
||||
"print",
|
||||
"hp",
|
||||
"camera",
|
||||
"sound",
|
||||
"question",
|
||||
"email",
|
||||
"chat",
|
||||
"search",
|
||||
"connect",
|
||||
"finance",
|
||||
"sport",
|
||||
"shop",
|
||||
"alterase",
|
||||
"cancel",
|
||||
"brightness_down",
|
||||
"brightness_up",
|
||||
"media",
|
||||
"switch_video_mode",
|
||||
"kbd_illum_toggle",
|
||||
"kbd_illum_down",
|
||||
"kbd_illum_up",
|
||||
"send",
|
||||
"reply",
|
||||
"forward_mail",
|
||||
"save",
|
||||
"documents",
|
||||
"battery",
|
||||
"bluetooth",
|
||||
"wlan",
|
||||
"uwb",
|
||||
"unknown",
|
||||
"video_next",
|
||||
"video_prev",
|
||||
"brightness_cycle",
|
||||
"brightness_auto",
|
||||
"display_off",
|
||||
"wwan",
|
||||
"rfkill",
|
||||
"mic_mute"
|
||||
]
|
||||
@@ -102,21 +102,21 @@
|
||||
left: 0;
|
||||
transition: opacity 0.1s;
|
||||
padding: 16px;
|
||||
padding-left: 0;
|
||||
padding-bottom: 5em;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding-right: 16px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
backdrop-filter: blur(10px);
|
||||
padding-right: 16px;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
|
||||
<style lang="scss">
|
||||
ul {
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
list-style-type: none;
|
||||
margin: 16px;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -186,22 +186,21 @@
|
||||
@use "sass:math";
|
||||
|
||||
input {
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
width: 5ch;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div {
|
||||
min-width: 20ch;
|
||||
padding: 1ch;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1ch;
|
||||
min-width: 20ch;
|
||||
}
|
||||
|
||||
.stats {
|
||||
|
||||
@@ -114,12 +114,11 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { page } from "$app/stores";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import debounce from "$lib/util/debounce";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import { shuffleInPlace } from "$lib/util/shuffle";
|
||||
import { fade, fly, slide } from "svelte/transition";
|
||||
@@ -12,6 +13,34 @@
|
||||
import { browser } from "$app/environment";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { goto } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
type PageParam,
|
||||
SENTENCE_TRAINER_PAGE_PARAMS,
|
||||
} from "./configuration";
|
||||
import {
|
||||
AVG_WORD_LENGTH,
|
||||
MILLIS_IN_SECOND,
|
||||
SECONDS_IN_MINUTE,
|
||||
} from "./constants";
|
||||
import { pickNextWord } from "./word-selector";
|
||||
|
||||
/**
|
||||
* Resolves parameter from search URL or returns default
|
||||
* @param param {@link PageParam} generic parameter that can be provided
|
||||
* in search url
|
||||
* @return Value of the parameter converted to its type or default value
|
||||
* if parameter is not present in the URL.
|
||||
*/
|
||||
function getParamOrDefault<T>(param: PageParam<T>): T {
|
||||
if (browser) {
|
||||
const value = $page.url.searchParams.get(param.key);
|
||||
if (null !== value) {
|
||||
return param.parse ? param.parse(value) : (value as unknown as T);
|
||||
}
|
||||
}
|
||||
return param.default;
|
||||
}
|
||||
|
||||
function viaLocalStorage<T>(key: string, initial: T) {
|
||||
try {
|
||||
@@ -21,6 +50,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to ensure cursor is visible after focus is set.
|
||||
// it is a workaround for conflict between goto call on sentence update
|
||||
// and cursor focus when next word is selected.
|
||||
const CURSOR_FOCUS_DELAY_MS = 10;
|
||||
|
||||
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
|
||||
viaLocalStorage("mastery-thresholds", [
|
||||
[1500, 1050, "Words"],
|
||||
@@ -29,28 +63,36 @@
|
||||
]),
|
||||
);
|
||||
|
||||
const avgWordLength = 5;
|
||||
|
||||
function reset() {
|
||||
localStorage.removeItem("mastery-thresholds");
|
||||
localStorage.removeItem("idle-timeout");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
let inputSentence = $derived(
|
||||
(browser && $page.url.searchParams.get("sentence")) || "Hello World",
|
||||
const inputSentence = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
|
||||
);
|
||||
let wpmTarget = $derived(
|
||||
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
|
||||
|
||||
const wpmTarget = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
|
||||
);
|
||||
let devTools = $derived(
|
||||
browser && $page.url.searchParams.get("dev") === "true",
|
||||
|
||||
const devTools = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
|
||||
);
|
||||
let sentenceWords = $derived(inputSentence.split(" "));
|
||||
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
|
||||
let totalMs = $derived(inputSentence.length * msPerChar);
|
||||
|
||||
let chordInputContainer: HTMLDivElement | null = null;
|
||||
|
||||
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
|
||||
|
||||
let inputSentenceLength = $derived(inputSentence.length);
|
||||
let msPerChar = $derived(
|
||||
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
|
||||
MILLIS_IN_SECOND,
|
||||
);
|
||||
let totalMs = $derived(inputSentenceLength * msPerChar);
|
||||
let msPerWord = $derived(
|
||||
(inputSentence.length * msPerChar) / inputSentence.split(" ").length,
|
||||
(inputSentenceLength * msPerChar) / sentenceWords.length,
|
||||
);
|
||||
let currentWord = $state("");
|
||||
let wordStats = new SvelteMap<string, number[]>();
|
||||
@@ -90,7 +132,7 @@
|
||||
});
|
||||
|
||||
let words = $derived.by(() => {
|
||||
const words = inputSentence.trim().split(" ");
|
||||
const words = sentenceWords;
|
||||
switch (level) {
|
||||
case 0: {
|
||||
shuffleInPlace(words);
|
||||
@@ -160,18 +202,16 @@
|
||||
});
|
||||
|
||||
function selectNextWord() {
|
||||
const unmasteredWords = words
|
||||
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
|
||||
.filter(([, it]) => it !== 1);
|
||||
unmasteredWords.sort(([, a], [, b]) => a - b);
|
||||
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR";
|
||||
for (const [word] of unmasteredWords) {
|
||||
if (word === currentWord || Math.random() > 0.5) continue;
|
||||
nextWord = word;
|
||||
break;
|
||||
}
|
||||
const nextWord = pickNextWord(
|
||||
words,
|
||||
wordMastery,
|
||||
untrack(() => currentWord),
|
||||
);
|
||||
currentWord = nextWord;
|
||||
recorder = new ReplayRecorder(nextWord);
|
||||
setTimeout(() => {
|
||||
chordInputContainer?.focus();
|
||||
}, CURSOR_FOCUS_DELAY_MS);
|
||||
}
|
||||
|
||||
function checkInput() {
|
||||
@@ -215,19 +255,38 @@
|
||||
idle = true;
|
||||
}, idleTime);
|
||||
}
|
||||
|
||||
function updateSentence(event: Event) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(
|
||||
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
|
||||
(event.target as HTMLInputElement).value,
|
||||
);
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const debouncedUpdateSentence = debounce(
|
||||
updateSentence,
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
|
||||
);
|
||||
|
||||
function handleInputAreaKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault(); // Prevent new line.
|
||||
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
|
||||
updateSentence(event); // Update immediately
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Sentence Trainer</h1>
|
||||
<input
|
||||
type="text"
|
||||
value={inputSentence}
|
||||
onchange={(it) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set("sentence", (it.target as HTMLInputElement).value);
|
||||
goto(`?${params.toString()}`);
|
||||
}}
|
||||
/>
|
||||
<textarea
|
||||
rows="7"
|
||||
cols="80"
|
||||
oninput={debouncedUpdateSentence}
|
||||
onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
|
||||
>
|
||||
|
||||
<div class="levels">
|
||||
{#each masteryThresholds as [, , title], i}
|
||||
@@ -371,6 +430,7 @@
|
||||
<ChordHud {chords} />
|
||||
<div class="container">
|
||||
<div
|
||||
bind:this={chordInputContainer}
|
||||
class="input-section"
|
||||
onkeydown={onkey}
|
||||
onkeyup={onkey}
|
||||
@@ -398,24 +458,24 @@
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(totalMs)}</span
|
||||
>ms</td
|
||||
>
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Char</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(msPerChar)}</span
|
||||
>ms</td
|
||||
>
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Word</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(msPerWord)}</span
|
||||
>ms</td
|
||||
>
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -440,8 +500,9 @@
|
||||
<td
|
||||
style:color="var(--md-sys-color-{mastery === 1
|
||||
? 'primary'
|
||||
: 'tertiary'})">{Math.round(mastery * 100)}%</td
|
||||
>
|
||||
: 'tertiary'})"
|
||||
>{Math.round(mastery * 100)}%
|
||||
</td>
|
||||
{#each stats as stat}
|
||||
<td>{stat}</td>
|
||||
{/each}
|
||||
@@ -465,9 +526,9 @@
|
||||
}
|
||||
|
||||
.wpm {
|
||||
width: min-content;
|
||||
display: grid;
|
||||
transition: scale 0.2s ease;
|
||||
width: min-content;
|
||||
|
||||
* {
|
||||
grid-row: 1;
|
||||
@@ -477,25 +538,25 @@
|
||||
.finish {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
font-weight: bold;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sentence {
|
||||
display: grid;
|
||||
width: min-content;
|
||||
gap: 4px 1ch;
|
||||
grid-template-rows: repeat(4, auto);
|
||||
gap: 4px 1ch;
|
||||
margin-block: 1rem;
|
||||
width: min-content;
|
||||
|
||||
.word,
|
||||
.arch {
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&.mastered {
|
||||
color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,40 +568,40 @@
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
width: auto;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
grid-row: 2;
|
||||
border: none;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
width: auto;
|
||||
height: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
transition: transform 0.2s;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: var(--md-sys-color-outline);
|
||||
transform: translateX(var(--progress));
|
||||
background: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: var(--md-sys-color-primary);
|
||||
transform: translateX(var(--mastered));
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.threshold {
|
||||
width: auto;
|
||||
grid-row: 1;
|
||||
justify-self: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
grid-row: 1;
|
||||
width: auto;
|
||||
|
||||
&.mastered,
|
||||
&.active {
|
||||
@@ -560,23 +621,25 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
font-size: 1.5rem;
|
||||
padding: 1rem;
|
||||
max-width: 16cm;
|
||||
outline: 2px dashed transparent;
|
||||
border-radius: 0.25rem;
|
||||
margin-block: 1rem;
|
||||
transition:
|
||||
outline 0.2s ease,
|
||||
border-radius 0.2s ease;
|
||||
margin-block: 1rem;
|
||||
outline: 2px dashed transparent;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
max-width: 16cm;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.input-section:focus-within {
|
||||
outline: none;
|
||||
|
||||
.input {
|
||||
outline-color: var(--md-sys-color-primary);
|
||||
border-radius: 1rem;
|
||||
@@ -586,11 +649,4 @@
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface PageParam<T> {
|
||||
key: string;
|
||||
default: T;
|
||||
parse?: (value: string) => T;
|
||||
}
|
||||
|
||||
export const SENTENCE_TRAINER_PAGE_PARAMS: {
|
||||
sentence: PageParam<string>;
|
||||
wpm: PageParam<number>;
|
||||
showDevTools: PageParam<boolean>;
|
||||
textAreaDebounceInMillis: PageParam<number>;
|
||||
} = {
|
||||
sentence: {
|
||||
key: "sentence",
|
||||
default: "This text has been typed at the speed of thought",
|
||||
},
|
||||
wpm: {
|
||||
key: "wpm",
|
||||
default: 250,
|
||||
parse: (value) => Number(value),
|
||||
},
|
||||
showDevTools: {
|
||||
key: "dev",
|
||||
default: false,
|
||||
parse: (value) => value === "true",
|
||||
},
|
||||
textAreaDebounceInMillis: {
|
||||
key: "debounceMillis",
|
||||
default: 5000,
|
||||
parse: (value) => Number(value),
|
||||
},
|
||||
};
|
||||
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Domain constants
|
||||
export const AVG_WORD_LENGTH = 5;
|
||||
export const SECONDS_IN_MINUTE = 60;
|
||||
export const MILLIS_IN_SECOND = 1000;
|
||||
|
||||
// Error messages.
|
||||
export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE =
|
||||
"The sentence is too short to make N-Grams, please enter longer sentence";
|
||||
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, beforeEach, expect, vi } from "vitest";
|
||||
import { pickNextWord } from "./word-selector";
|
||||
import { untrack } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
||||
|
||||
// Mock untrack so it simply executes the callback, allowing us to spy on its usage.
|
||||
vi.mock("svelte", () => ({
|
||||
untrack: vi.fn((fn: any) => fn()),
|
||||
}));
|
||||
|
||||
describe("pickNextWord", () => {
|
||||
let words: string[];
|
||||
let wordMastery: SvelteMap<string, number>;
|
||||
let currentWord: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up sample words and mastery values.
|
||||
words = ["alpha", "beta", "gamma"];
|
||||
wordMastery = new SvelteMap<string, number>();
|
||||
// For this test, assume none of the words are mastered.
|
||||
words.forEach((word) => wordMastery.set(word, 0));
|
||||
currentWord = "alpha";
|
||||
});
|
||||
|
||||
it("should return a word different from current", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.3);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since currentWord ("alpha") should be skipped, we expect next word.
|
||||
expect(nextWord).toBe("beta");
|
||||
});
|
||||
|
||||
it("should randomly skip words", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since currentWord ("alpha") should be skipped as current
|
||||
// and "beta" should be randomly skipped we expect "gamma".
|
||||
expect(nextWord).toBe("gamma");
|
||||
});
|
||||
|
||||
it("should return current word if all other words were randomly skipped", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since all other words have been randomly skipped, we expect
|
||||
// current word to be returned.
|
||||
expect(nextWord).toBe("alpha");
|
||||
});
|
||||
|
||||
it("current word should be passed untracked", () => {
|
||||
pickNextWord(words, wordMastery, currentWord);
|
||||
expect(untrack).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => {
|
||||
const result = pickNextWord([], wordMastery, currentWord);
|
||||
expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user