1 Commits

Author SHA1 Message Date
John de St Germain
29756834f8 Fix misspelling 2024-05-13 09:20:46 -05:00
104 changed files with 12726 additions and 11451 deletions

View File

@@ -21,22 +21,18 @@ jobs:
- name: ⏬ Install Python dependencies - name: ⏬ Install Python dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: 🐉 Use Node.js 18.16.x - name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: 18.16.x node-version: 18.16.x
cache: "pnpm" cache: "npm"
- name: ⏬ Install Node dependencies - name: ⏬ Install Node dependencies
run: pnpm install run: npm ci
- name: 🔥 Optimize icon font - name: 🔥 Optimize icon font
run: pnpm minify-icons run: npm run minify-icons
- name: 🔨 Build site - name: 🔨 Build site
run: pnpm build run: npm run build
- name: 📦 Upload build artifacts - name: 📦 Upload build artifacts
uses: actions/upload-artifact@v3.1.2 uses: actions/upload-artifact@v3.1.2

View File

@@ -29,7 +29,7 @@ You may need to run through some additional setup to get Rust running inside Int
- Python >=3.10 - Python >=3.10
- Rust Stable (For Tauri Development) - Rust Stable (For Tauri Development)
I know, python in JS projects is extremely annoying. Unfortunately, I know, python in JS projects is extremely annoying, unfortunately,
it seems to be the only platform that offers a functional it seems to be the only platform that offers a functional
way to subset variable woff2 fonts with ligatures. way to subset variable woff2 fonts with ligatures.

58
flake.lock generated
View File

@@ -5,11 +5,29 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1710146030, "lastModified": 1689068808,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,11 +38,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1722415718, "lastModified": 1689752456,
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=", "narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce", "rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -36,11 +54,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1718428119, "lastModified": 1681358109,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -59,14 +77,15 @@
}, },
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1722391647, "lastModified": 1690942540,
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=", "narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8", "rev": "aa3994f054038262df55122dfa552b9eab71a994",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -89,6 +108,21 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View File

@@ -4,35 +4,19 @@
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = outputs = {
{
self, self,
nixpkgs, nixpkgs,
flake-utils, flake-utils,
rust-overlay, rust-overlay,
}: }:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (system: let
system:
let
overlays = [(import rust-overlay)]; overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;}; pkgs = import nixpkgs {inherit system overlays;};
rust-bin = pkgs.rust-bin.stable.latest.default.override { rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
"rust-src"
"rust-std"
"clippy"
"rust-analyzer"
];
}; };
fontMin = pkgs.python311.withPackages ( fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
ps:
with ps;
[
brotli
fonttools
]
++ (with fonttools.optional-dependencies; [ woff ])
);
tauriPkgs = nixpkgs.legacyPackages.${system}; tauriPkgs = nixpkgs.legacyPackages.${system};
libraries = with tauriPkgs; [ libraries = with tauriPkgs; [
webkitgtk webkitgtk
@@ -46,8 +30,7 @@
]; ];
packages = packages =
(with pkgs; [ (with pkgs; [
nodejs_22 nodejs_18
nodePackages.pnpm
rust-bin rust-bin
fontMin fontMin
]) ])
@@ -65,14 +48,12 @@
# serial plugin # serial plugin
udev udev
]); ]);
in in {
{
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = packages; buildInputs = packages;
shellHook = '' shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
''; '';
}; };
} });
);
} }

View File

@@ -94,13 +94,6 @@ const config = {
"description", "description",
"add_circle", "add_circle",
"refresh", "refresh",
"tune",
"edit_document",
"chat",
"account_circle",
"experiment",
"code",
"dictionary",
], ],
codePoints: { codePoints: {
speed: "e9e4", speed: "e9e4",
@@ -119,9 +112,6 @@ const config = {
upload_2: "ff52", upload_2: "ff52",
stat_minus_2: "e69c", stat_minus_2: "e69c",
stat_2: "e699", stat_2: "e699",
routine: "e20c",
experiment: "e686",
dictionary: "f539",
}, },
}; };

11684
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,8 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "1.5.2", "version": "1.5.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": {
"node": ">=18.16",
"pnpm": ">=8.6"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/CharaChorder/DeviceManager.git" "url": "https://github.com/CharaChorder/DeviceManager.git"
@@ -34,58 +30,52 @@
"typesafe-i18n": "typesafe-i18n" "typesafe-i18n": "typesafe-i18n"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.17.0", "@codemirror/autocomplete": "^6.15.0",
"@codemirror/commands": "^6.6.0", "@codemirror/commands": "^6.3.3",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.2", "@codemirror/language": "^6.10.1",
"@codemirror/state": "^6.4.1", "@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.29.1", "@fontsource-variable/material-symbols-rounded": "^5.0.27",
"@fontsource-variable/material-symbols-rounded": "^5.0.36", "@fontsource-variable/noto-sans-mono": "^5.0.19",
"@fontsource-variable/noto-sans-mono": "^5.0.20", "@material/material-color-utilities": "^0.2.7",
"@lezer/highlight": "^1.2.0",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.83.0",
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@sveltejs/adapter-static": "^3.0.2", "@sveltejs/adapter-static": "^2.0.3",
"@sveltejs/kit": "^2.5.18", "@sveltejs/kit": "^1.30.4",
"@sveltejs/vite-plugin-svelte": "^3.1.1", "@sveltejs/vite-plugin-svelte": "^2.5.3",
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.5.3",
"@tauri-apps/cli": "^1.6.0", "@tauri-apps/cli": "^1.5.11",
"@types/dom-view-transitions": "^1.0.5", "@types/dom-view-transitions": "^1.0.4",
"@types/flexsearch": "^0.7.6", "@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.6", "@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10", "@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.6.0", "@vite-pwa/sveltekit": "^0.2.10",
"autoprefixer": "^10.4.19", "autoprefixer": "^10.4.19",
"codemirror": "^6.0.1", "codemirror": "^6.0.1",
"cypress": "^13.13.2", "cypress": "^13.7.2",
"d3": "^7.9.0",
"flexsearch": "^0.7.43", "flexsearch": "^0.7.43",
"fontkit": "^2.0.2", "fontkit": "^2.0.2",
"glob": "^11.0.0", "glob": "^10.3.12",
"jsdom": "^24.1.1", "jsdom": "^22.1.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.3.3", "prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.2.2",
"sass": "^1.77.8", "sass": "^1.74.1",
"stylelint": "^16.8.1", "stylelint": "^15.11.0",
"stylelint-config-clean-order": "^6.1.0", "stylelint-config-clean-order": "^5.4.2",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0", "stylelint-config-recommended-scss": "^13.1.0",
"stylelint-config-standard-scss": "^13.1.0", "stylelint-config-standard-scss": "^11.1.0",
"svelte": "5.0.0-next.221", "svelte": "^4.2.12",
"svelte-check": "^3.8.5", "svelte-check": "^3.6.9",
"svelte-preprocess": "^6.0.2", "svelte-preprocess": "^5.1.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2", "typesafe-i18n": "^5.26.2",
"typescript": "^5.5.4", "typescript": "^5.4.4",
"vite": "^5.3.5", "vite": "^4.5.3",
"vite-plugin-mkcert": "^1.17.5", "vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.20.1", "vite-plugin-pwa": "^0.17.5",
"vitest": "^2.0.5", "vitest": "^0.34.6"
"workbox-window": "^7.1.0"
}, },
"type": "module" "type": "module"
} }

8259
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "1.5.2" version = "1.5.1"
description = "A Tauri App" description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"] authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3" license = "AGPL-3"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173", "devPath": "http://localhost:5173",
"distDir": "../build" "distDir": "../build"
}, },
"package": { "productName": "amacc1ng", "version": "1.5.2" }, "package": { "productName": "amacc1ng", "version": "1.5.1" },
"tauri": { "tauri": {
"allowlist": { "all": false }, "allowlist": { "all": false },
"bundle": { "bundle": {

View File

@@ -9,193 +9,145 @@ actions:
This action is unique in this way. Technically it is "printable", but it is not visible. This action is unique in this way. Technically it is "printable", but it is not visible.
39: 39:
id: "'" id: "'"
keyCode: Quote
title: Single Quote title: Single Quote
44: 44:
id: "," id: ","
keyCode: Comma
title: Comma title: Comma
45: 45:
id: "-" id: "-"
keyCode: Minus
title: Minus title: Minus
46: 46:
id: "." id: "."
keyCode: Period
title: Period title: Period
47: 47:
id: "/" id: "/"
keyCode: Slash
title: Forward Slash title: Forward Slash
48: 48:
id: "0" id: "0"
keyCode: Digit0
title: Zero title: Zero
49: 49:
id: "1" id: "1"
keyCode: Digit1
title: One title: One
50: 50:
id: "2" id: "2"
keyCode: Digit2
title: Two title: Two
51: 51:
id: "3" id: "3"
keyCode: Digit3
title: Three title: Three
52: 52:
id: "4" id: "4"
keyCode: Digit4
title: Four title: Four
53: 53:
id: "5" id: "5"
keyCode: Digit5
title: Five title: Five
54: 54:
id: "6" id: "6"
keyCode: Digit6
title: Six title: Six
55: 55:
id: "7" id: "7"
keyCode: Digit7
title: Seven title: Seven
56: 56:
id: "8" id: "8"
keyCode: Digit8
title: Eight title: Eight
57: 57:
id: "9" id: "9"
keyCode: Digit9
title: Nine title: Nine
59: 59:
id: ";" id: ";"
keyCode: Semicolon
title: Semicolon title: Semicolon
61: 61:
id: "=" id: "="
keyCode: Equal
title: Equals title: Equals
91: 91:
id: "[" id: "["
keyCode: BracketLeft
title: Left Bracket title: Left Bracket
92: 92:
id: "\\" id: "\\"
keyCode: Backslash
title: Backslash title: Backslash
93: 93:
id: "]" id: "]"
keyCode: BracketRight
title: Right Bracket title: Right Bracket
96: 96:
id: "`" id: "`"
keyCode: Backquote
title: Backtick title: Backtick
97: 97:
id: "a" id: "a"
keyCode: KeyA
title: Lowercase a title: Lowercase a
98: 98:
id: "b" id: "b"
keyCode: KeyB
title: Lowercase b title: Lowercase b
99: 99:
id: "c" id: "c"
keyCode: KeyC
title: Lowercase c title: Lowercase c
100: 100:
id: "d" id: "d"
keyCode: KeyD
title: Lowercase d title: Lowercase d
101: 101:
id: "e" id: "e"
keyCode: KeyE
title: Lowercase e title: Lowercase e
102: 102:
id: "f" id: "f"
keyCode: KeyF
title: Lowercase f title: Lowercase f
103: 103:
id: "g" id: "g"
keyCode: KeyG
title: Lowercase g title: Lowercase g
104: 104:
id: "h" id: "h"
keyCode: KeyH
title: Lowercase h title: Lowercase h
105: 105:
id: "i" id: "i"
keyCode: KeyI
title: Lowercase i title: Lowercase i
106: 106:
id: "j" id: "j"
keyCode: KeyJ
title: Lowercase j title: Lowercase j
107: 107:
id: "k" id: "k"
keyCode: KeyK
title: Lowercase k title: Lowercase k
108: 108:
id: "l" id: "l"
keyCode: KeyL
title: Lowercase l title: Lowercase l
109: 109:
id: "m" id: "m"
keyCode: KeyM
title: Lowercase m title: Lowercase m
110: 110:
id: "n" id: "n"
keyCode: KeyN
title: Lowercase n title: Lowercase n
111: 111:
id: "o" id: "o"
keyCode: KeyO
title: Lowercase o title: Lowercase o
112: 112:
id: "p" id: "p"
keyCode: KeyP
title: Lowercase p title: Lowercase p
113: 113:
id: "q" id: "q"
keyCode: KeyQ
title: Lowercase q title: Lowercase q
114: 114:
id: "r" id: "r"
keyCode: KeyR
title: Lowercase r title: Lowercase r
115: 115:
id: "s" id: "s"
keyCode: KeyS
title: Lowercase s title: Lowercase s
116: 116:
id: "t" id: "t"
keyCode: KeyT
title: Lowercase t title: Lowercase t
117: 117:
id: "u" id: "u"
keyCode: KeyU
title: Lowercase u title: Lowercase u
118: 118:
id: "v" id: "v"
keyCode: KeyV
title: Lowercase v title: Lowercase v
119: 119:
id: "w" id: "w"
KeyCode: KeyW
title: Lowercase w title: Lowercase w
120: 120:
id: "x" id: "x"
keyCode: KeyX
title: Lowercase x title: Lowercase x
121: 121:
id: "y" id: "y"
keyCode: KeyY
title: Lowercase y title: Lowercase y
122: 122:
id: "z" id: "z"
keyCode: KeyZ
title: Lowercase z title: Lowercase z
127: 127:
id: "DEL" id: "DEL"
keyCode: Delete
title: Delete title: Delete

View File

@@ -70,9 +70,6 @@ actions:
title: Primary Keymap title: Primary Keymap
icon: counter_1 icon: counter_1
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
549: 549:
variantOf: 548 variantOf: 548
<<: *primary_keymap <<: *primary_keymap
@@ -83,9 +80,6 @@ actions:
title: Numeric Layer title: Numeric Layer
icon: counter_2 icon: counter_2
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
551: 551:
variantOf: 550 variantOf: 550
<<: *secondary_keymap <<: *secondary_keymap
@@ -96,31 +90,11 @@ actions:
title: Function Layer title: Function Layer
icon: counter_3 icon: counter_3
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
553: 553:
variationOf: 552 variationOf: 552
<<: *tertiary_keymap <<: *tertiary_keymap
id: "KM_3_R" id: "KM_3_R"
variant: right variant: right
558:
id: HOLD_COMPOUND
title: Dynamic Library
icon: layers
description: |
Allows for the activation & creation of dynamic chord libraries.
When included as part of a chord output,
that chord's input becomes the seed for a dynamic chord library,
and that library is activated.
Any new chords created while a dynamic library is active are established one level above its seed.
559:
id: RELEASE_COMPOUND
title: Base Library
icon: layers_clear
description: |
Re-activates your base chord library,
and deactivates any currently active dynamic chord library.
576: 576:
id: ACTION_DELAY_1000 id: ACTION_DELAY_1000
icon: clock_loader_90 icon: clock_loader_90

View File

@@ -395,7 +395,7 @@ actions:
350: 350:
id: "KP_6" id: "KP_6"
keyCode: "Numpad6" keyCode: "Numpad6"
title: Keypad 6 and Right Arrow title: Keypad 6 and Rigth Arrow
351: 351:
id: "KP_7" id: "KP_7"
keyCode: "Numpad7" keyCode: "Numpad7"
@@ -422,8 +422,8 @@ actions:
title: Keyboard Non-US \ and | (US English) title: Keyboard Non-US \ and | (US English)
357: 357:
id: "COMPOSE" id: "COMPOSE"
icon: menu
title: Keyboard Application title: Keyboard Application
description: Officially supported by Win, Unix, and Boot
358: 358:
id: "POWER" id: "POWER"
keyCode: "Power" keyCode: "Power"
@@ -944,99 +944,99 @@ actions:
title: Keyboard Right GUI title: Keyboard Right GUI
488: 488:
id: "KSC_E8" id: "KSC_E8"
icon: play_pause
keyCode: "MediaPlayPause" keyCode: "MediaPlayPause"
title: Media Play Pause title: Media Play Pause
description: Not required to be supported by any OS. Possibly deprecated.
489: 489:
id: "KSC_E9" id: "KSC_E9"
icon: stop
keyCode: "MediaStop" keyCode: "MediaStop"
title: Media Stop CD title: Media Stop CD
description: Not required to be supported by any OS. Possibly deprecated.
490: 490:
id: "KSC_EA" id: "KSC_EA"
icon: skip_previous
keyCode: "MediaTrackPrevious" keyCode: "MediaTrackPrevious"
title: Media Previous Song title: Media Previous Song
description: Not required to be supported by any OS. Possibly deprecated.
491: 491:
id: "KSC_EB" id: "KSC_EB"
icon: skip_next
keyCode: "MediaTrackNext" keyCode: "MediaTrackNext"
title: Media Next Song title: Media Next Song
description: Not required to be supported by any OS. Possibly deprecated.
492: 492:
id: "KSC_EC" id: "KSC_EC"
icon: eject
keyCode: "Eject" keyCode: "Eject"
title: Media Eject CD title: Media Eject CD
description: MacOS only description: Not required to be supported by any OS. Possibly deprecated.
493: 493:
id: "KSC_ED" id: "KSC_ED"
icon: volume_up
keyCode: "AudioVolumeUp" keyCode: "AudioVolumeUp"
title: Media Volume Up title: Media Volume Up
description: Not required to be supported by any OS. Possibly deprecated.
494: 494:
id: "KSC_EE" id: "KSC_EE"
icon: volume_down
keyCode: "AudioVolumeDown" keyCode: "AudioVolumeDown"
title: Media Volume Down title: Media Volume Down
description: Not required to be supported by any OS. Possibly deprecated.
495: 495:
id: "KSC_EF" id: "KSC_EF"
icon: volume_off
keyCode: "AudioVolumeMute" keyCode: "AudioVolumeMute"
title: Media Mute title: Media Mute
description: Not required to be supported by any OS. Possibly deprecated.
496: 496:
id: "KSC_F0" id: "KSC_F0"
icon: language title: Media www
title: Media Browser description: Not required to be supported by any OS. Possibly deprecated.
497: 497:
id: "KSC_F1" id: "KSC_F1"
keyCode: "BrowserBack" keyCode: "BrowserBack"
title: Media Browser Back title: Media Back
description: Not required to be supported by any OS. Possibly deprecated.
498: 498:
id: "KSC_F2" id: "KSC_F2"
keyCode: "BrowserForward" keyCode: "BrowserForward"
title: Media Browser Forward title: Media Forward
description: Not required to be supported by any OS. Possibly deprecated.
499: 499:
id: "KSC_F3" id: "KSC_F3"
keyCode: "BrowserStop" keyCode: "BrowserStop"
title: Media Browser Stop title: Media Stop
description: Not supported on MacOS description: Not required to be supported by any OS. Possibly deprecated.
500: 500:
id: "KSC_F4" id: "KSC_F4"
icon: search
keyCode: "BrowserSearch" keyCode: "BrowserSearch"
title: Media Browser Search title: Media Find
description: Not required to be supported by any OS. Possibly deprecated.
501: 501:
id: "KSC_F5" id: "KSC_F5"
icon: brightness_high title: Media Scroll Up
title: Media Brightness Up description: Not required to be supported by any OS. Possibly deprecated.
502: 502:
id: "KSC_F6" id: "KSC_F6"
icon: brightness_low title: Media Scroll Down
title: Media Brightness Down description: Not required to be supported by any OS. Possibly deprecated.
503: 503:
id: "KSC_F7" id: "KSC_F7"
title: Media Edit title: Media Edit
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
504: 504:
id: "KSC_F8" id: "KSC_F8"
icon: bedtime
keyCode: "Sleep" keyCode: "Sleep"
title: Media System Sleep title: Media Sleep
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
505: 505:
id: "KSC_F9" id: "KSC_F9"
icon: routine
keyCode: "WakeUp" keyCode: "WakeUp"
title: Media System Wake title: Media Coffee
description: Not supported on Windows description: Not required to be supported by any OS. Possibly deprecated.
506: 506:
id: "KSC_FA" id: "KSC_FA"
keyCode: "BrowserRefresh" keyCode: "BrowserRefresh"
title: Media Browser Refresh title: Media Refresh
description: Not required to be supported by any OS. Possibly deprecated.
507: 507:
id: "KSC_FB" id: "KSC_FB"
title: Media Calculator title: Media Calc
description: Not supported on MacOS description: Not required to be supported by any OS. Possibly deprecated.
508: 508:
id: "KSC_FC" id: "KSC_FC"
description: Not required to be supported by any OS. description: Not required to be supported by any OS.

View File

@@ -1,37 +0,0 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 }
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }

View File

@@ -96,12 +96,7 @@ export function restoreFromFile(
case "backup": { case "backup": {
const recent = file.history[0]; const recent = file.history[0];
if (!recent) return; if (!recent) return;
let backupDevice = recent[1].device; if (recent[1].device !== get(serialPort)?.device) {
if (backupDevice === "TWO") backupDevice = "ONE";
let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO") currentDevice = "ONE";
if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device"); alert("Backup is incompatible with this device");
throw new Error("Backup is incompatible with this device"); throw new Error("Backup is incompatible with this device");
} }

View File

@@ -0,0 +1,26 @@
e + b + a,babe
e + c + b,because
f + e + c + a,face
h + e + c + a,each
i + d + ',I'd
i + g + b,big
i + g + e,give
k + b + a,back
k + e + a,take
l + e + a,late
l + e + d + a,lead
l + f + e,feel
l + g + e + a,large
l + h + e,help
l + i + a,Lia
l + i + f,fill
l + i + f + e,life
l + i + g + b + a,gitlab
l + k + i + e,like
m + e + a,make
m + i + ',I'm
n + c + a,can
n + d + a,and
n + e + b,been
n + e + b + a,enable
n + e + d,end

View File

@@ -1,137 +0,0 @@
<script lang="ts">
import { browser } from "$app/environment";
import { ReplayPlayer } from "./core/player.js";
import { ReplayStepper } from "./core/step.js";
import type { Replay } from "./core/types.js";
import { TextRenderer } from "./renderer/renderer.js";
import { setContext, type Snippet } from "svelte";
let {
replay,
cursor = false,
keys = false,
children,
}: {
replay: ReplayPlayer | Replay;
cursor?: boolean;
keys?: boolean;
children?: Snippet;
} = $props();
let replayPlayer: ReplayPlayer | undefined = $state();
setContext("replay", {
get player() {
return replayPlayer;
},
});
let finalText = $derived(
replay instanceof ReplayPlayer
? undefined
: new ReplayStepper(replay.keys).text.map((token) => token.text).join(""),
);
let svg: SVGSVGElement | undefined = $state();
let text: Text = (browser ? document.createTextNode("") : undefined)!;
let textRenderer: TextRenderer | undefined = $state();
$effect(() => {
if (!textRenderer) return;
textRenderer.showCursor = cursor;
});
$effect(() => {
if (!svg || !text) return;
const player =
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
replayPlayer = player;
const renderer = new TextRenderer(svg.parentNode as HTMLElement, svg, text);
const apply = () => {
text.textContent =
finalText ??
(player.stepper.text.map((token) => token.text).join("") || "n");
renderer.text = player.stepper.text;
renderer.cursor = player.stepper.cursor;
if (keys) {
renderer.held = player.stepper.held;
}
};
const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer;
player.start();
apply();
setTimeout(() => {
renderer.animated = true;
});
return () => {
unsubscribePlayer();
player?.destroy();
};
});
export function innerText(node: HTMLElement, text: Text) {
node.appendChild(text);
return {
destroy() {
text.remove();
},
};
}
</script>
{#key replay}
<svg bind:this={svg}></svg>
{#if browser}
<span use:innerText={text}></span>
{:else if !(replay instanceof ReplayPlayer)}
{finalText}
{/if}
{/key}
{#if children}
{@render children()}
{/if}
<style>
:global(*):has(svg) {
position: relative;
}
span {
opacity: 0;
white-space: pre-wrap;
overflow-wrap: break-word;
}
svg {
position: absolute;
top: 0;
left: 0;
font-family: inherit;
font-size: inherit;
color: inherit;
user-select: none;
}
svg > :global(text) {
font-family: inherit;
font-size: inherit;
fill: currentColor;
dominant-baseline: middle;
}
svg > :global(text[incorrect]) {
fill: red;
}
svg > :global(rect) {
fill: currentcolor;
}
svg > :global(.animated) {
transition: transform 100ms ease;
}
</style>

View File

@@ -1,130 +0,0 @@
<script lang="ts">
import { fly, scale } from "svelte/transition";
import { KBD_ICONS } from "./renderer/kbd-icon.js";
import { expoOut } from "svelte/easing";
import type { InferredChord } from "./core/types.js";
let { chords }: { chords: InferredChord[] } = $props();
function getPercent(
deviation: number,
inputCount: number,
perfect: number,
fail: number,
) {
const failAdjusted = fail * inputCount;
const perfectAdjusted = perfect * inputCount;
return Math.min(
1,
Math.max(
0,
Math.max(0, deviation - perfectAdjusted) /
(failAdjusted - perfectAdjusted),
),
);
}
function getColor(percent: number, alpha = 1) {
return `hsl(${(1 - percent) * 120}deg 50% 50% / ${alpha})`;
}
</script>
<section>
{#each chords as { input, id, deviation }, i (id)}
{@const a = getPercent(deviation[0], input.length, 10, 25)}
{@const b = getPercent(deviation[1], input.length, 10, 18)}
{@const max = Math.max(a, b)}
<div
class="chord"
out:fly={{ x: -100 }}
style:translate="calc(-{(chords.length - i - 1) * 5}em - 50%) 0"
style:scale={1 - (chords.length - i) / 6}
style:opacity={1 - (chords.length - i - 1) / 6}
title="Press: {deviation[0]}ms, Release: {deviation[1]}ms"
>
<div
class="rating"
style:color={getColor(max)}
style:text-shadow="0 0 {Math.round((1 - max) * 10)}px {getColor(
max,
0.6,
)}"
in:scale={{
start: 1.5 + 1.2 * (1 - max),
easing: expoOut,
duration: 1000,
}}
>
{#if max === 1}
Close
{:else if max > 0.5}
Okay
{:else if max > 0}
Good
{:else}
Perfect
{/if}
</div>
<div
in:fly={{ y: 20, easing: expoOut, duration: 1000 }}
class="tile"
style:background="linear-gradient(to right, {getColor(a)}, {getColor(
b,
)})"
></div>
<div in:fly={{ y: 60, easing: expoOut, duration: 1000 }}>
{#each input as token}
<kbd>{KBD_ICONS.get(token.code)}</kbd>
{/each}
</div>
</div>
{/each}
</section>
<style>
section {
position: relative;
margin: 1em;
margin-bottom: 0;
display: grid;
height: 3em;
font-size: 2em;
}
.rating {
font-weight: bold;
font-style: italic;
text-transform: uppercase;
}
.tile {
width: 100%;
height: 0.2em;
border-radius: 0.1em;
}
kbd {
font-size: 0.6em;
}
kbd + kbd {
margin-inline-start: 0.3em;
}
.chord {
will-change: transform, opacity, scale;
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;
}
</style>

View File

@@ -1,30 +0,0 @@
<script lang="ts">
import { getContext } from "svelte";
import { browser } from "$app/environment";
import type { InferredChord } from "./core/types.js";
import { ChordsReplayPlugin } from "./core/plugins/chords.js";
import type { ReplayPlayer } from "./core/player.js";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let {
chords = $bindable([]),
count = 1,
}: {
chords: InferredChord[];
count?: number;
} = $props();
if (browser) {
$effect(() => {
if (!player.player) return;
const tracker = new ChordsReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
chords = value.slice(-count);
});
return unsubscribe;
});
}
</script>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import { getContext } from "svelte";
import { RollingWpmReplayPlugin } from "./core/plugins/rolling-wpm";
import type { ReplayPlayer } from "./core/player";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { wpm = $bindable(0) } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new RollingWpmReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
wpm = value;
});
return unsubscribe;
});
</script>

View File

@@ -1,134 +0,0 @@
import { ReplayStepper } from "./step";
import type { ReplayPlugin, Replay, TextToken } from "./types";
export const ROBOT_THRESHOLD = 20;
export class ReplayPlayer {
stepper = new ReplayStepper();
private replayCursor = 0;
private releaseAt = new Map<string, number>();
startTime = performance.now();
private animationFrameId: number | null = null;
timescale = 1;
private subscribers = new Set<(value: TextToken | undefined) => void>();
constructor(
readonly replay: Replay,
plugins: ReplayPlugin[] = [],
) {
for (const plugin of plugins) {
plugin.register(this);
}
}
/** @type {import('./types').StoreContract<import('./types').TextToken | undefined>['subscribe']} */
subscribe(subscription: (value: TextToken | undefined) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
private updateLoop() {
if (
this.replayCursor >= this.replay.keys.length &&
this.releaseAt.size === 0
)
return;
const now = performance.now() - this.startTime;
while (
this.replayCursor < this.replay.keys.length &&
this.replay.keys[this.replayCursor]![2] * this.timescale -
this.replay.start <=
now
) {
const [key, code, at, duration] = this.replay.keys[this.replayCursor++]!;
this.stepper.held.set(code, duration > ROBOT_THRESHOLD);
this.releaseAt.set(code, now + duration * this.timescale);
const token = this.stepper.step(key, code, at, duration);
for (const subscription of this.subscribers) {
subscription(token);
}
}
for (const [key, releaseAt] of this.releaseAt) {
if (releaseAt > now) continue;
this.stepper.held.delete(key);
this.releaseAt.delete(key);
for (const subscription of this.subscribers) {
subscription(undefined);
}
}
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}
playLiveEvent(key: string, code: string): (duration: number) => void {
this.replay.start = this.startTime;
const at = performance.now();
this.stepper.held.set(code, false);
const token = this.stepper.step(key, code, at) ?? {
text: key,
code,
stamp: at,
correct: true,
source: "robot",
};
for (const subscription of this.subscribers) {
subscription(token);
}
const timeout = setTimeout(() => {
token.source = "human";
this.stepper.held.set(code, true);
for (const subscription of this.subscribers) {
subscription(undefined);
}
}, ROBOT_THRESHOLD);
return (duration) => {
clearTimeout(timeout);
if (token) {
// TODO: will this cause performance issues with long text?
const index = this.stepper.text.indexOf(token);
if (index >= 0) {
this.stepper.text[index]!.duration = duration;
this.stepper.text[index]!.source =
duration < ROBOT_THRESHOLD ? "robot" : "human";
}
}
this.stepper.held.delete(code);
for (const subscription of this.subscribers) {
subscription(undefined);
}
};
}
start(delay = 200): this {
this.replayCursor = 0;
this.stepper = new ReplayStepper([], this.replay.challenge);
if (this.replay.keys.length === 0) return this;
setTimeout(() => {
this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}, delay);
return this;
}
destroy() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
}
}

View File

@@ -1,112 +0,0 @@
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
import type {
StoreContract,
ReplayPlugin,
InferredChord,
TextToken,
} from "../types";
function isValid(human: TextToken[], robot: TextToken[]) {
return human.length > 1 && human.length <= 10 && robot.length > 0;
}
export class ChordsReplayPlugin
implements StoreContract<InferredChord[]>, ReplayPlugin
{
private readonly subscribers = new Set<(value: InferredChord[]) => void>();
private readonly chords: InferredChord[] = [];
private tokens: TextToken[] = [];
private timeout: Parameters<typeof clearTimeout>[0] = NaN;
private infer(human: TextToken[], robo: TextToken[]) {
const output = robo
.filter((token) => token.text.length === 1)
.map((token) => token.text)
.join("");
this.chords.push({
id: human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0),
input: human,
output,
deviation: [
human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0) -
human.reduce((acc, curr) => Math.min(acc, curr.stamp), Infinity),
human.reduce(
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
0,
) -
human.reduce(
(acc, curr) => Math.min(acc, curr.stamp + (curr.duration ?? 0)),
Infinity,
),
],
});
for (const subscription of this.subscribers) {
subscription(this.chords);
}
}
register(replay: ReplayPlayer) {
replay.subscribe((token) => {
if (token) {
this.tokens.push(token);
}
let last = NaN;
let roboStart = NaN;
let roboEnd = NaN;
for (let i = 0; i < this.tokens.length; i++) {
const token = this.tokens[i]!;
if (!token.duration || !token.source) break;
if (
Number.isNaN(roboStart) &&
token.source === "human" &&
token.stamp > last
) {
this.tokens = [];
}
if (Number.isNaN(last) || token.stamp + token.duration > last) {
last = token.stamp + token.duration;
}
if (Number.isNaN(roboStart) && token.source === "robot") {
roboStart = i;
} else if (!Number.isNaN(roboStart) && token.source === "human") {
roboEnd = i;
const human = this.tokens.splice(0, roboStart);
const robot = this.tokens.splice(0, roboEnd - roboStart);
if (isValid(human, robot)) {
this.infer(human, robot);
}
}
}
console.log(this.tokens);
clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) {
this.timeout = setTimeout(() => {
if (this.tokens.length > 0) {
const human = this.tokens.splice(
0,
this.tokens.findIndex((it) => it.source === "robot"),
);
const robot = this.tokens.splice(0, this.tokens.length);
if (isValid(human, robot)) {
this.infer(human, robot);
}
}
}, ROBOT_THRESHOLD);
}
});
}
subscribe(subscription: (value: InferredChord[]) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,71 +0,0 @@
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
import type { GraphData, ReplayPlugin, StoreContract } from "../types";
export class MetaReplayPlugin
implements StoreContract<GraphData>, ReplayPlugin
{
private subscribers = new Set<(value: GraphData) => void>();
private graphData: GraphData = { min: [0, 0], max: [0, 0], tokens: [] };
private liveHeldRoboFilter = new Set<string>();
register(replay: ReplayPlayer) {
replay.subscribe((token) => {
if (!token) return;
const lastHeld = this.graphData.tokens
.at(-1)
?.reduce(
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
0,
);
if (
lastHeld &&
(lastHeld === -1 || lastHeld > token.stamp + (token.duration ?? 0))
) {
this.graphData.tokens.at(-1)!.push(token);
} else {
this.graphData.tokens.push([token]);
}
if (this.graphData.tokens.length === 1) {
this.graphData.min = [token.stamp, 0];
}
this.graphData.max = [
this.graphData.tokens
.at(-1)!
.reduce(
(acc, { stamp, duration }) =>
Math.max(acc, stamp + (duration ?? 0)),
0,
),
Math.max(this.graphData.max[1], this.graphData.tokens.at(-1)!.length),
];
this.liveHeldRoboFilter.add(token.code);
if (token.duration === undefined) {
setTimeout(() => {
if (this.liveHeldRoboFilter.has(token.code)) {
token.source = "human";
for (const subscription of this.subscribers) {
subscription(this.graphData);
}
}
}, ROBOT_THRESHOLD);
} else {
setTimeout(() => {
this.liveHeldRoboFilter.delete(token.code);
}, token.duration);
}
for (const subscription of this.subscribers) {
subscription(this.graphData);
}
});
}
subscribe(subscription: (value: GraphData) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,48 +0,0 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
import { avgWordLength } from "./wpm";
export class RollingWpmReplayPlugin
implements StoreContract<number>, ReplayPlugin
{
subscribers = new Set<(value: number) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
let i = 0;
const index = Math.max(
0,
replay.stepper.text.findLastIndex((char) => {
if (char.source === "ghost") return false;
if (char.text === " " && i < 10) {
i++;
} else if (char.text === " ") {
return true;
}
return false;
}),
);
const length =
replay.stepper.text.length - replay.stepper.ghostCount - index;
const msPerChar =
((replay.stepper.text[
replay.stepper.text.length - replay.stepper.ghostCount - 1
]?.stamp ?? 0) -
(replay.stepper.text[index]?.stamp ?? 0)) /
length;
const value = 60_000 / (msPerChar * avgWordLength);
if (Number.isFinite(value)) {
for (const subscription of this.subscribers) {
subscription(value);
}
}
});
}
subscribe(subscription: (value: number) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,26 +0,0 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
export const avgWordLength = 5;
export class WpmReplayPlugin implements StoreContract<number>, ReplayPlugin {
private subscribers = new Set<(value: number) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
const msPerChar =
((replay.stepper.text.at(-1)?.stamp ?? 0) - replay.startTime) /
replay.stepper.text.length;
const value = 60_000 / (msPerChar * avgWordLength);
for (const subscription of this.subscribers) {
subscription(value);
}
});
}
subscribe(subscription: (value: number) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,67 +0,0 @@
import { ReplayPlayer } from "./player.js";
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
export class ReplayRecorder {
private held = new Map<string, [string, number]>();
private heldHandles = new Map<
string,
ReturnType<ReplayPlayer["playLiveEvent"]>
>();
replay: ReplayEvent[] = [];
private start = performance.now();
private isFirstPress = true;
player: ReplayPlayer;
constructor(challenge?: Replay["challenge"]) {
this.player = new ReplayPlayer({
start: this.start,
finish: this.start,
keys: [],
challenge,
});
}
next(event: TransmittableKeyEvent) {
if (this.isFirstPress) {
this.player.startTime = event.timeStamp;
this.isFirstPress = false;
}
this.player.replay.finish = event.timeStamp;
if (event.type === "keydown") {
this.held.set(event.code, [event.key, event.timeStamp]);
this.heldHandles.set(
event.code,
this.player.playLiveEvent(event.key, event.code),
);
} else {
const [key, start] = this.held.get(event.code)!;
const delta = event.timeStamp - start;
this.held.delete(event.code);
const element = Object.freeze([key, event.code, start, delta] as const);
this.replay.push(element);
this.heldHandles.get(event.code)?.(delta);
this.heldHandles.delete(event.code);
}
}
finish(trim = true) {
return {
start: trim ? this.replay[0]?.[2] : this.start,
finish: trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
keys: this.replay
.map(
([key, code, at, duration]) =>
[key, code, Math.round(at), Math.round(duration)] as const,
)
.sort((a, b) => a[2] - b[2]),
};
}
}

View File

@@ -1,132 +0,0 @@
import { ROBOT_THRESHOLD } from "./player";
import type { LiveReplayEvent, ReplayEvent, TextToken } from "./types";
/**
* This is the "heart" of the player logic
*/
export class ReplayStepper {
held = new Map<string, boolean>();
text: TextToken[];
cursor = 0;
challenge: TextToken[];
ghostCount: number;
mistakeCount = 0;
constructor(initialReplay: ReplayEvent[] = [], challenge = "") {
this.challenge = challenge.split("").map((text) => ({
stamp: 0,
duration: 0,
code: "",
text,
source: "ghost",
correct: true,
}));
this.text = [...this.challenge];
this.ghostCount = this.challenge.length;
for (const key of initialReplay) {
this.step(...key);
}
}
step(
...[output, code, at, duration]: ReplayEvent | LiveReplayEvent
): TextToken | undefined {
let token: TextToken | undefined = undefined;
if (output === "Backspace") {
if (this.held.has("ControlLeft") || this.held.has("ControlRight")) {
let wordIndex = 0;
for (let i = this.cursor - 1; i >= 0; i--) {
if (/\w+/.test(/** @type {TextToken} */ this.text[i]!.text)) {
wordIndex = i;
} else if (wordIndex !== 0) {
break;
}
}
this.text.splice(wordIndex, this.cursor - wordIndex);
} else if (this.cursor !== 0) {
this.text.splice(this.cursor - 1, 1);
}
this.cursor = Math.min(
this.cursor,
this.text.length - this.ghostCount + 1,
);
}
if (output.length === 1) {
token = {
stamp: at,
duration,
code,
text: output,
source:
duration === undefined
? undefined
: duration < ROBOT_THRESHOLD
? "robot"
: "human",
correct: true,
};
this.text.splice(this.cursor, 0, token);
}
if (code === "ArrowLeft" || code === "Backspace") {
this.cursor = Math.max(this.cursor - 1, 0);
}
if (code === "ArrowRight" || output.length === 1) {
this.cursor = Math.min(
this.cursor + 1,
this.text.length - this.ghostCount,
);
}
if (code === "Enter") {
token = {
stamp: at,
code,
duration,
text: "\n",
source:
duration === undefined
? undefined
: duration < ROBOT_THRESHOLD
? "robot"
: "human",
correct: true,
};
this.text.splice(this.cursor, 0, token);
this.cursor++;
}
if (this.challenge.length > 0) {
let challengeIndex = 0;
this.mistakeCount = 0;
for (let i = 0; i < this.text.length - this.ghostCount; i++) {
if (this.text[i]!.text === this.challenge[challengeIndex]?.text) {
this.text[i]!.correct = true;
} else {
this.mistakeCount++;
this.text[i]!.correct = false;
}
challengeIndex++;
}
const currentGhostCount = this.ghostCount;
this.ghostCount = Math.max(0, this.challenge.length - challengeIndex);
this.text.splice(
this.text.length - currentGhostCount,
Math.max(0, currentGhostCount - this.ghostCount),
...this.challenge.slice(
challengeIndex,
challengeIndex + Math.max(0, this.ghostCount - currentGhostCount),
),
);
}
return token;
}
}

View File

@@ -1,58 +0,0 @@
import { ReplayPlayer } from "./player.js";
export interface Replay {
start: number;
finish: number;
keys: ReplayEvent[];
challenge?: string;
}
export type LiveReplayEvent = readonly [
output: string,
code: string,
at: number,
];
export type ReplayEvent = readonly [...LiveReplayEvent, duration: number];
export interface TextToken {
stamp: number;
duration?: number;
text: string;
code: string;
source?: "human" | "robot" | "ghost";
correct: boolean;
}
export interface GraphData {
min: [number, number];
max: [number, number];
tokens: TextToken[][];
}
export interface ReplayStepResult {
text: TextToken[];
cursor: number;
challengeCursor: number;
token: TextToken | undefined;
}
export type TransmittableKeyEvent = Pick<
KeyboardEvent,
"timeStamp" | "type" | "code" | "key"
>;
export interface InferredChord {
id: number;
input: TextToken[];
output: string;
deviation: [number, number];
}
export interface ReplayPlugin {
register(replay: ReplayPlayer): void;
}
export interface StoreContract<T> {
subscribe(subscription: (value: T) => void): () => void;
set?: (value: T) => void;
}

View File

@@ -1,96 +0,0 @@
export const KBD_ICONS = new Map([
["KeyA", "a"],
["KeyB", "b"],
["KeyC", "c"],
["KeyD", "d"],
["KeyE", "e"],
["KeyF", "f"],
["KeyG", "g"],
["KeyH", "h"],
["KeyI", "i"],
["KeyJ", "j"],
["KeyK", "k"],
["KeyL", "l"],
["KeyM", "m"],
["KeyN", "n"],
["KeyO", "o"],
["KeyP", "p"],
["KeyQ", "q"],
["KeyR", "r"],
["KeyS", "s"],
["KeyT", "t"],
["KeyU", "u"],
["KeyV", "v"],
["KeyW", "w"],
["KeyX", "x"],
["KeyY", "y"],
["KeyZ", "z"],
["Digit0", "0"],
["Digit1", "1"],
["Digit2", "2"],
["Digit3", "3"],
["Digit4", "4"],
["Digit5", "5"],
["Digit6", "6"],
["Digit7", "7"],
["Digit8", "8"],
["Digit9", "9"],
["Period", "."],
["Comma", ","],
["Semicolon", ";"],
["Quote", "'"],
["BracketLeft", "["],
["BracketRight", "]"],
["Backslash", "\\"],
["Slash", "/"],
["Minus", "-"],
["Equal", "="],
["Backquote", "`"],
["IntlBackslash", "¦"],
["IntlRo", "ろ"],
["IntlYen", "¥"],
["IntlHash", "#"],
["BracketLeft", "["],
["BracketRight", "]"],
["NumLock", "⇭"],
["ScrollLock", "⇳"],
["Backspace", "⌫"],
["Delete", "⌦"],
["Enter", "↵"],
["Space", "␣"],
["Tab", "⇥"],
["ArrowLeft", "←"],
["ArrowRight", "→"],
["ArrowUp", "↑"],
["ArrowDown", "↓"],
["ShiftLeft", "⇧"],
["ShiftRight", "⇧"],
["ControlLeft", "Ctrl"],
["ControlRight", "Ctrl"],
["AltLeft", "Alt"],
["AltRight", "Alt"],
["MetaLeft", "⌘"],
["MetaRight", "⌘"],
["CapsLock", "⇪"],
["Escape", "Esc"],
["F1", "F1"],
["F2", "F2"],
["F3", "F3"],
["F4", "F4"],
["F5", "F5"],
["F6", "F6"],
["F7", "F7"],
["F8", "F8"],
["F9", "F9"],
["F10", "F10"],
["F11", "F11"],
["F12", "F12"],
["PrintScreen", "PrtSc"],
["Pause", "Pause"],
["Insert", "Ins"],
["Home", "Home"],
["End", "End"],
["PageUp", "PgUp"],
["PageDown", "PgDn"],
["ContextMenu", "Menu"],
]);

View File

@@ -1,287 +0,0 @@
import type { TextToken } from "../core/types";
import { KBD_ICONS } from "./kbd-icon";
export class TextRenderer {
shinyChords = true;
shiny: number[] | undefined;
readonly cursorNode: SVGRectElement;
private readonly nodes = new Map<TextToken, SVGTextElement>();
private readonly heldNodes = new Map<string, SVGTextElement>();
private readonly occupiedHeld: Array<boolean | undefined> = [];
private readonly occupied: number[] = [];
animationOptions: KeyframeAnimationOptions = {
duration: 100,
easing: "ease",
};
heldKeySize = 0.8;
ghostText = "";
constructor(
readonly node: HTMLElement,
readonly svg: SVGSVGElement,
readonly textNode: Text,
) {
this.cursorNode = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0");
this.svg.appendChild(this.cursorNode);
}
set showCursor(value: boolean) {
this.cursorNode.style.visibility = value ? "visible" : "hidden";
}
getAtRange(i: number): [number, number] {
const range = document.createRange();
const rangeIndex = Math.max(0, Math.min(i, this.textNode.length - 1));
range.setStart(this.textNode, rangeIndex);
range.setEnd(
this.textNode,
this.textNode.length === 0 ? 0 : rangeIndex + 1,
);
const charBounds = range.getBoundingClientRect();
return [
i > this.textNode.length - 1
? charBounds.x + charBounds.width
: charBounds.x,
charBounds.y + charBounds.height / 2 + 1,
];
}
set held(keys: Map<string, boolean>) {
const prev = new Set(this.heldNodes.keys());
const fontSize = getComputedStyle(this.node).fontSize;
for (const [code, isHuman] of keys) {
if (!isHuman) continue;
prev.delete(code);
let node = this.heldNodes.get(code);
if (!node) {
let i = this.occupiedHeld.findIndex((it) => it === undefined);
if (i === -1) {
i = this.occupiedHeld.length;
this.occupiedHeld.push(true);
} else {
this.occupiedHeld[i] = true;
}
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
node.textContent = KBD_ICONS.get(code) ?? null;
node.setAttribute("i", i.toString());
this.heldNodes.set(code, node);
node.style.transform = `${this.cursorNode.style.transform} translateY(calc(${fontSize} * ${
i + 1.5
}))`;
node.style.fontSize = `calc(${fontSize} * ${this.heldKeySize})`;
this.svg.appendChild(node);
node
.animate(
[
{
transform: `translateY(calc(-${fontSize} * ${this.heldNodes.size})) scale(0)`,
},
{ transform: "translateY(0px) scale(1)" },
],
{ duration: 200, composite: "add", easing: "ease-out" },
)
.play();
}
}
for (const code of prev) {
const node = this.heldNodes.get(code);
if (!node) continue;
this.heldNodes.delete(code);
this.occupiedHeld[Number(node.getAttribute("i"))] = undefined;
node
.animate(
[
{ transform: "translateX(0px)" },
{ transform: "translateX(-10px)" },
],
{
duration: 500,
composite: "accumulate",
easing: "ease-in",
},
)
.play();
const animation = node.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 500,
easing: "ease-in",
});
animation.onfinish = () => {
node.remove();
};
animation.play();
}
}
get animated(): boolean {
return this.cursorNode.classList.contains("animated");
}
set animated(value: boolean) {
if (value) {
this.cursorNode.classList.add("animated");
} else {
this.cursorNode.classList.remove("animated");
}
}
set cursor(cursor: number) {
const bounds = this.node.getBoundingClientRect();
const style = getComputedStyle(this.node);
const pos = this.getAtRange(cursor);
const x = pos[0] - bounds.x;
const y = pos[1] - bounds.y;
this.cursorNode.setAttribute("height", style.fontSize);
this.cursorNode.setAttribute("width", "1");
this.cursorNode.style.transform = `translate(${x}px, calc(${y}px - ${style.fontSize} / 2))`;
}
set text(text: TextToken[]) {
const prev = new Set(this.nodes.keys());
const bounds = this.node.getBoundingClientRect();
this.svg.setAttribute("width", bounds.width.toFixed(2));
this.svg.setAttribute("height", bounds.height.toFixed(2));
this.svg.setAttribute(
"viewBox",
`0 0 ${bounds.width.toFixed(2)} ${bounds.height.toFixed(2)}`,
);
text.forEach((token, i) => {
prev.delete(token);
let node = this.nodes.get(token);
const pos = this.getAtRange(i);
const x = pos[0] - bounds.x;
const y = pos[1] - bounds.y;
const xStr = x.toFixed(2);
const yStr = y.toFixed(2);
if (!node) {
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
this.nodes.set(token, node);
this.svg.appendChild(node);
node.setAttribute("x", xStr);
node.setAttribute("y", yStr);
node.setAttribute("i", i.toString());
if (token.source === "ghost") {
node.setAttribute("opacity", "0.5");
}
this.occupied[i] ??= 0;
if (this.animated) {
if (this.occupied[i] > 0) {
node
.animate([{ opacity: 0 }, { opacity: 1 }], {
...this.animationOptions,
easing: "ease-out",
})
.play();
} else {
node
.animate(
[
{ opacity: 0, transform: "translateY(10px)" },
{ opacity: 1, transform: "translateY(0px)" },
],
{ ...this.animationOptions, easing: "ease-out" },
)
.play();
}
}
this.occupied[i]++;
}
if (!token.correct) {
node.setAttribute("incorrect", "");
} else {
node.removeAttribute("incorrect");
}
const prevX = node.getAttribute("x");
if (prevX && prevX !== xStr) {
const prev = parseFloat(prevX);
node.setAttribute("x", xStr);
/*if (this.animated) {
node.animate(
[{ transform: `translateX(${prev - x}px)` }, { transform: `translateX(0px)` }],
this.animationOptions
);
}*/
}
const prevY = node.getAttribute("y");
if (prevY && prevY !== yStr) {
const prev = parseFloat(prevY);
node.setAttribute("y", yStr);
/*if (this.animated) {
node.animate(
[{ transform: `translateY(${prev - y}px)` }, { transform: `translateY(0px)` }],
this.animationOptions
);
}*/
}
if (node.textContent !== token.text) {
node.textContent = token.text;
}
});
for (const token of prev) {
const node = this.nodes.get(token)!;
const i = parseInt(node.getAttribute("i")!);
this.nodes.delete(token);
if (this.animated) {
const animation = node.animate(
[{ opacity: 1 }, { opacity: 0 }],
this.animationOptions,
);
setTimeout(() => {
if (this.occupied[i] === 1) {
node
.animate(
[
{ transform: "translateY(0px)" },
{ transform: "translateY(10px)" },
],
this.animationOptions,
)
.play();
}
}, 10);
animation.onfinish = () => {
node.remove();
this.occupied[i]!--;
};
animation.play();
} else {
node.remove();
this.occupied[i]!--;
}
}
}
private isShiny(char: TextToken, index: number) {
return (
this.shiny?.includes(index) ||
(this.shinyChords && char.source === "robot")
);
}
}

View File

@@ -3,53 +3,47 @@
import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { KeyInfo } from "$lib/serial/keymap-codes";
import { action as title } from "$lib/title"; import { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout"; import { osLayout } from "$lib/os-layout";
import LL from "../../i18n/i18n-svelte";
let { export let action: number | KeyInfo;
action, export let display: "inline-keys" | "keys" = "inline-keys";
display,
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
let info = $derived( $: info =
typeof action === "number" typeof action === "number"
? (KEYMAP_CODES.get(action) ?? { code: action }) ? KEYMAP_CODES.get(action) ?? { code: action }
: action, : action;
); $: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let tooltip = $derived( $: tooltip =
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` + `&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(info.title ?? "") + (info.title ?? "") +
(info.variant === "left" (info.variant === "left"
? " (left)" ? " (left)"
: info.variant === "right" : info.variant === "right"
? " (right)" ? " (right)"
: ""), : "");
);
</script> </script>
{#if display === "keys"} {#if dynamicMapping}
<span
use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
class="dynamic"
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:inline={display === "inline-keys"}>{dynamicMapping}</span
>
{:else if display === "keys"}
<kbd <kbd
class:icon={!!info.icon} class:icon={!!info.icon}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"} class:right={info.variant === "right"}
use:title={{ title: tooltip }} use:title={{ title: tooltip }}
> >
{dynamicMapping ?? {info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
</kbd> </kbd>
{:else if display === "inline-keys"} {:else if display === "inline-keys"}
{#if !info.icon && dynamicMapping?.length === 1} {#if !info.icon && info.id?.length === 1}
<span <span
use:title={{ title: tooltip }}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{dynamicMapping}</span
>
{:else if !info.icon && info.id?.length === 1}
<span
use:title={{ title: tooltip }}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span class:right={info.variant === "right"}>{info.id}</span
> >
@@ -61,8 +55,7 @@
class:icon={!!info.icon} class:icon={!!info.icon}
use:title={{ title: tooltip }} use:title={{ title: tooltip }}
> >
{dynamicMapping ?? {info.icon ??
info.icon ??
info.display ?? info.display ??
info.id ?? info.id ??
`0x${info.code.toString(16)}`}</kbd `0x${info.code.toString(16)}`}</kbd

View File

@@ -1,24 +1,17 @@
<script lang="ts"> <script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes"; import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { KeyInfo } from "$lib/serial/keymap-codes";
import LL from "$i18n/i18n-svelte"; import LL from "../../i18n/i18n-svelte";
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
import type { MouseEventHandler } from "svelte/elements";
let { export let id: number | KeyInfo;
id,
onclick,
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
$props();
let key = $derived( $: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
| number | number
| KeyInfo, | KeyInfo;
);
</script> </script>
<button {onclick}> <button on:click>
{#if typeof key === "object"} {#if typeof key === "object"}
<div class="title"> <div class="title">
<b> <b>

View File

@@ -2,11 +2,8 @@
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { KeyInfo } from "$lib/serial/keymap-codes";
let { export let actions: Array<number | KeyInfo>;
actions, export let display: "keys" | "inline-keys" = "inline-keys";
display = "inline-keys",
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
$props();
</script> </script>
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)} {#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}

View File

@@ -6,7 +6,7 @@
</script> </script>
{#if $needRefresh} {#if $needRefresh}
<button title="Update ready" onclick={() => updateServiceWorker(true)} <button title="Update ready" on:click={() => updateServiceWorker(true)}
>Update <span class="icon">update</span></button >Update <span class="icon">update</span></button
> >
{:else if $offlineReady} {:else if $offlineReady}

View File

@@ -9,11 +9,11 @@
io.scrollTo({ top: io.scrollHeight }); io.scrollTo({ top: io.scrollHeight });
} }
let value: string = $state(""); let value: string;
let io: HTMLDivElement; let io: HTMLDivElement;
</script> </script>
<form onsubmit={submit}> <form on:submit={submit}>
<div bind:this={io} class="io"> <div bind:this={io} class="io">
{#each $serialLog as { type, value }} {#each $serialLog as { type, value }}
{#if type === "input"} {#if type === "input"}
@@ -24,10 +24,10 @@
<p transition:slide>{value}</p> <p transition:slide>{value}</p>
{/if} {/if}
{/each} {/each}
<div class="anchor"></div> <div class="anchor" />
</div> </div>
<fieldset> <fieldset>
<input onsubmit={submit} bind:value /> <input on:submit={submit} bind:value />
</fieldset> </fieldset>
</form> </form>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
let { title, shortcut }: { title?: string; shortcut?: string } = $props(); export let title: string | undefined;
export let shortcut: string | undefined;
</script> </script>
{#if title} {#if title}

View File

@@ -5,22 +5,13 @@
KEYMAP_IDS, KEYMAP_IDS,
} from "$lib/serial/keymap-codes"; } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch"; import FlexSearch from "flexsearch";
import { onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte"; import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte"; import LL from "../../../i18n/i18n-svelte";
import { action } from "$lib/title"; import { action } from "$lib/title";
let { export let currentAction: number | undefined = undefined;
currentAction = undefined, export let nextAction: number | undefined = undefined;
nextAction = undefined,
onselect,
onclose,
}: {
currentAction?: number;
nextAction?: number;
onselect: (id: number) => void;
onclose: () => void;
} = $props();
onMount(() => { onMount(() => {
searchBox.focus(); searchBox.focus();
@@ -48,13 +39,13 @@
function select(id?: number) { function select(id?: number) {
if (id !== undefined) { if (id !== undefined) {
onselect(id); dispatch("select", id);
} }
} }
function keyboardNavigation(event: KeyboardEvent) { function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter" && exact !== undefined) { if (event.shiftKey && event.key === "Enter") {
onselect(exact); dispatch("select", exact);
} else if (event.key === "ArrowDown") { } else if (event.key === "ArrowDown") {
const element = const element =
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:focus-within")?.nextSibling ??
@@ -76,45 +67,40 @@
event.preventDefault(); event.preventDefault();
} }
let results: number[] = $state([]); let results: number[] = [];
let exact: number | undefined = $state(undefined); let exact: number | undefined = undefined;
let code: number = $state(Number.NaN); let code: number = Number.NaN;
const dispatch = createEventDispatcher();
let searchBox: HTMLInputElement; let searchBox: HTMLInputElement;
let resultList: HTMLUListElement; let resultList: HTMLUListElement;
let filter = $state(new Set<number>()); let filter: Set<number>;
</script> </script>
<svelte:window on:keydown={keyboardNavigation} /> <svelte:window on:keydown={keyboardNavigation} />
<!-- svelte-ignore a11y_click_events_have_key_events --> <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <dialog open on:click|self={() => dispatch("close")}>
<dialog
open
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
>
<div class="content"> <div class="content">
<div class="search-row"> <div class="search-row">
<input <input
type="search" type="search"
bind:this={searchBox} bind:this={searchBox}
oninput={search} on:input={search}
onkeypress={(event) => { on:keypress={(event) => {
if (event.key === "Enter") { if (event.key === "Enter") {
select(exact); select(exact);
} }
}} }}
placeholder={$LL.actionSearch.PLACEHOLDER()} placeholder={$LL.actionSearch.PLACEHOLDER()}
/> />
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }} <button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button >{$LL.actionSearch.DELETE()}</button
> >
<button <button
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }} use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon" class="icon"
onclick={onclose}>close</button on:click={() => dispatch("close")}>close</button
> >
</div> </div>
<fieldset class="filters"> <fieldset class="filters">
@@ -154,12 +140,12 @@
{#if exact !== undefined} {#if exact !== undefined}
<li class="exact"> <li class="exact">
<i>Exact match</i> <i>Exact match</i>
<ActionListItem id={exact} onclick={() => select(exact)} /> <ActionListItem id={exact} on:click={() => select(exact)} />
</li> </li>
{/if} {/if}
{#if !exact && code} {#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13} {#if code >= 2 ** 5 && code < 2 ** 13}
<li><button onclick={() => select(code)}>USE CODE</button></li> <li><button on:click={() => select(code)}>USE CODE</button></li>
{:else} {:else}
<li>Action code is out of range</li> <li>Action code is out of range</li>
{/if} {/if}
@@ -170,7 +156,7 @@
? Array.from(KEYMAP_CODES, ([it]) => it) ? Array.from(KEYMAP_CODES, ([it]) => it)
: results} : results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)} {#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li> <li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each} {/each}
{/if} {/if}
</ul> </ul>

View File

@@ -10,7 +10,7 @@
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte"; import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext, mount, unmount } from "svelte"; import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js"; import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo"; import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
@@ -30,8 +30,8 @@
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer"); console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
} }
let { visualLayout }: { visualLayout: VisualLayout } = $props(); export let visualLayout: VisualLayout;
let layoutInfo = $state(compileLayout(visualLayout)); $: layoutInfo = compileLayout(visualLayout);
function getCenter(key: CompiledLayoutKey): [x: number, y: number] { function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]; return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
@@ -127,26 +127,11 @@
const clickedGroup = groupParent.children.item(index) as SVGGElement; const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id]; const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id]; const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, { const component = new ActionSelector({
target: document.body, target: document.body,
props: { props: {
currentAction, currentAction,
nextAction: nextAction?.isApplied ? undefined : nextAction?.action, nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
onclose() {
closed();
},
onselect(action) {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action,
});
return changes;
});
closed();
},
}, },
}); });
const dialog = document.querySelector("dialog > div") as HTMLDivElement; const dialog = document.querySelector("dialog > div") as HTMLDivElement;
@@ -182,8 +167,22 @@
await dialogAnimation.finished; await dialogAnimation.finished;
unmount(component); component.$destroy();
} }
component.$on("close", closed);
component.$on("select", ({ detail }) => {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action: detail,
});
return changes;
});
closed();
});
} }
let focusKey: CompiledLayoutKey; let focusKey: CompiledLayoutKey;
@@ -202,9 +201,9 @@
<KeyboardKey <KeyboardKey
{i} {i}
{key} {key}
onfocusin={() => (focusKey = key)} on:focusin={() => (focusKey = key)}
onclick={() => edit(i)} on:click={() => edit(i)}
onkeypress={({ key }) => { on:keypress={({ key }) => {
if (key === "Enter") { if (key === "Enter") {
edit(i); edit(i);
} }

View File

@@ -12,21 +12,14 @@
getContext<VisualLayoutConfig>("visual-layout-config"); getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer"); const activeLayer = getContext<Writable<number>>("active-layer");
let { export let key: CompiledLayoutKey;
key, export let fontSizeMultiplier = 1;
fontSizeMultiplier = 1,
middle, export let middle: [number, number];
pos, export let pos: [number, number];
rotate, export let rotate: number;
positions,
}: { export let positions: [[number, number], [number, number], [number, number]];
key: CompiledLayoutKey;
fontSizeMultiplier?: number;
middle: [number, number];
pos: [number, number];
rotate: number;
positions: [[number, number], [number, number], [number, number]];
} = $props();
</script> </script>
{#each positions as position, layer} {#each positions as position, layer}

View File

@@ -3,41 +3,24 @@
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js"; import type { VisualLayoutConfig } from "./visual-layout.js";
import KeyText from "$lib/components/layout/KeyText.svelte"; import KeyText from "$lib/components/layout/KeyText.svelte";
import type {
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
} from "svelte/elements";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>( const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config", "visual-layout-config",
); );
export let i: number;
export let key: CompiledLayoutKey;
let { $: posX = key.pos[0] * scale;
i, $: posY = key.pos[1] * scale;
key, $: sizeX = key.size[0] * scale;
onclick, $: sizeY = key.size[1] * scale;
onkeypress,
onfocusin,
}: {
i: number;
key: CompiledLayoutKey;
onclick: MouseEventHandler<SVGGElement>;
onkeypress: KeyboardEventHandler<SVGGElement>;
onfocusin: FocusEventHandler<SVGGElement>;
} = $props();
let posX = $derived(key.pos[0] * scale);
let posY = $derived(key.pos[1] * scale);
let sizeX = $derived(key.size[0] * scale);
let sizeY = $derived(key.size[1] * scale);
</script> </script>
<g <g
class="key-group" class="key-group"
{onclick} on:click
{onkeypress} on:keypress
{onfocusin} on:focusin
role="button" role="button"
tabindex={i + 1} tabindex={i + 1}
> >

View File

@@ -7,7 +7,7 @@
import type { VisualLayout } from "$lib/serialization/visual-layout"; import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition"; import { fade } from "svelte/transition";
let device = $derived($serialPort?.device); $: device = $serialPort?.device;
const activeLayer = getContext<Writable<number>>("active-layer"); const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [ const layers = [
@@ -21,10 +21,6 @@
import("$lib/assets/layouts/one.yml").then( import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as VisualLayout,
), ),
TWO: () =>
import("$lib/assets/layouts/one.yml").then(
(it) => it.default as VisualLayout,
),
LITE: () => LITE: () =>
import("$lib/assets/layouts/lite.yml").then( import("$lib/assets/layouts/lite.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as VisualLayout,
@@ -33,10 +29,6 @@
import("$lib/assets/layouts/generic/103-key.yml").then( import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as VisualLayout,
), ),
M4G: () =>
import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout,
),
}; };
</script> </script>
@@ -48,7 +40,7 @@
<button <button
class="icon" class="icon"
use:action={{ title, shortcut: `alt+${value + 1}` }} use:action={{ title, shortcut: `alt+${value + 1}` }}
onclick={() => ($activeLayer = value)} on:click={() => ($activeLayer = value)}
class:active={$activeLayer === value} class:active={$activeLayer === value}
> >
{icon} {icon}

View File

@@ -1,24 +1,16 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import Dialog from "$lib/dialogs/Dialog.svelte"; import Dialog from "$lib/dialogs/Dialog.svelte";
import ActionString from "$lib/components/ActionString.svelte"; import ActionString from "$lib/components/ActionString.svelte";
let { export let title: string;
title, export let message: string | undefined;
message, export let abortTitle: string;
abortTitle, export let confirmTitle: string;
confirmTitle,
actions = [], export let actions: number[] = [];
onabort,
onconfirm, const dispatch = createEventDispatcher();
}: {
title: string;
message?: string;
abortTitle: string;
confirmTitle: string;
actions: number[];
onabort: () => void;
onconfirm: () => void;
} = $props();
</script> </script>
<Dialog> <Dialog>
@@ -28,8 +20,10 @@
{/if} {/if}
<p><ActionString {actions} /></p> <p><ActionString {actions} /></p>
<div class="buttons"> <div class="buttons">
<button onclick={onabort}>{abortTitle}</button> <button on:click={() => dispatch("abort")}>{abortTitle}</button>
<button class="primary" onclick={onconfirm}>{confirmTitle}</button> <button class="primary" on:click={() => dispatch("confirm")}
>{confirmTitle}</button
>
</div> </div>
</Dialog> </Dialog>

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, type Snippet } from "svelte"; import { onMount } from "svelte";
let { children }: { children: Snippet } = $props();
onMount(() => { onMount(() => {
modal.showModal(); modal.showModal();
@@ -11,7 +9,7 @@
</script> </script>
<dialog bind:this={modal}> <dialog bind:this={modal}>
{@render children()} <slot />
</dialog> </dialog>
<style lang="scss"> <style lang="scss">

View File

@@ -8,7 +8,7 @@
} from "$lib/undo-redo"; } from "$lib/undo-redo";
import { ChangeType, chords } from "$lib/undo-redo"; import { ChangeType, chords } from "$lib/undo-redo";
import ActionString from "$lib/components/ActionString.svelte"; import ActionString from "$lib/components/ActionString.svelte";
import LL from "$i18n/i18n-svelte"; import LL from "../../i18n/i18n-svelte";
import { KEYMAP_IDS } from "$lib/serial/keymap-codes"; import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
export let changes: Change[] = [ export let changes: Change[] = [

View File

@@ -1,101 +0,0 @@
import { osLayout } from "$lib/os-layout";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { persistentWritable } from "$lib/storage";
import { type ChordInfo, chords } from "$lib/undo-redo";
import { derived } from "svelte/store";
export const words = derived(
[chords, osLayout],
([chords, layout]) =>
new Map<string, ChordInfo>(
chords
.map((chord) => ({
chord,
output: chord.phrase.map((action) =>
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
),
}))
.filter(({ output }) => output.every((it) => !!it))
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
),
);
interface Score {
lastTyped: number;
score: number;
total: number;
}
export const scores = persistentWritable<Record<string, Score>>("scores", {});
export const learnConfigDefault = {
maxScore: 3,
minScore: -3,
scoreBlend: 0.5,
weakRate: 0.8,
weakBoost: 0.5,
maxWeak: 3,
newRate: 0.3,
initialNewRate: 0.9,
initialCount: 10,
};
export const learnConfigStored = persistentWritable<
Partial<typeof learnConfigDefault>
>("learn-config", {});
export const learnConfig = derived(learnConfigStored, (config) => ({
...learnConfigDefault,
...config,
}));
let lastWord: string | undefined;
function shuffle<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!];
}
return array;
}
function randomLog2<T>(array: T[], max = array.length): T | undefined {
return array[
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
];
}
export const nextWord = derived(
[words, scores, learnConfig],
([words, scores, config]) => {
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
values.sort(([, a], [, b]) => a.score - b.score);
const weakCount =
(values.findIndex(([, { score }]) => score > 0) + 1 ||
values.length + 1) - 1;
const weak = randomLog2(values, weakCount);
if (weak && Math.random() / weakCount < config.weakRate) {
lastWord = weak[0];
return weak[0];
}
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
const recent = randomLog2(values);
const newRate =
values.length < config.initialCount
? config.initialNewRate
: config.newRate;
if (
recent &&
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
Math.random() > newRate)
) {
lastWord = recent[0];
return recent[0];
}
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
const word = newWord || recent?.[0] || weak?.[0];
lastWord = word;
return word;
},
);

View File

@@ -1,28 +1,25 @@
import tippy from "tippy.js"; import tippy from "tippy.js";
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import { unmount, mount, type Component } from "svelte"; import type { ComponentType, SvelteComponent } from "svelte";
export const popup: Action<HTMLButtonElement, Component> = ( export const popup: Action<HTMLButtonElement, ComponentType> = (
node, node,
Component, Component,
) => { ) => {
let component: {} | undefined; let component: SvelteComponent | undefined;
let target: HTMLElement | undefined; let target: HTMLElement | undefined;
const edit = tippy(node, { const edit = tippy(node, {
interactive: true, interactive: true,
placement: "right",
trigger: "click", trigger: "click",
onShow(instance) { onShow(instance) {
target = instance.popper.querySelector(".tippy-content") as HTMLElement; target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active"); target.classList.add("active");
component ??= mount(Component, { target }); component ??= new Component({ target });
}, },
onHidden() { onHidden() {
if (component) { component?.$destroy();
unmount(component);
component = undefined;
}
target?.classList.remove("active"); target?.classList.remove("active");
component = undefined;
}, },
}); });

View File

@@ -1,12 +1,9 @@
<script lang="ts"> <script lang="ts">
let { import { createEventDispatcher } from "svelte";
ports,
onconfirm, export let ports: SerialPort[];
}: { const dispatch = createEventDispatcher<{ confirm: SerialPort | undefined }>();
ports: SerialPort[]; let selected = ports[0]?.getInfo().name;
onconfirm: (port: SerialPort | undefined) => void;
} = $props();
let selected = $state(ports[0]?.getInfo().name);
</script> </script>
<dialog> <dialog>
@@ -22,9 +19,12 @@
> >
{/each} {/each}
<button onclick={() => onconfirm(undefined)}>Cancel</button> <button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
<button <button
onclick={() => on:click={() =>
onconfirm(ports.find((it) => it.getInfo().name === selected))}>Ok</button dispatch(
"confirm",
ports.find((it) => it.getInfo().name === selected),
)}>Ok</button
> >
</dialog> </dialog>

View File

@@ -55,19 +55,3 @@ export function deserializeActions(native: bigint): number[] {
return actions; return actions;
} }
/**
* Hashes a chord input the same way as CCOS
*/
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;
}

View File

@@ -12,19 +12,15 @@ import { browser } from "$app/environment";
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }], ["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }], ["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }], ["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
["X", { usbProductId: 33163, usbVendorId: 12346 }], ["X", { usbProductId: 33163, usbVendorId: 12346 }],
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
]); ]);
const KEY_COUNTS = { const KEY_COUNTS = {
ONE: 90, ONE: 90,
TWO: 90,
LITE: 67, LITE: 67,
X: 256, X: 256,
M4G: 90,
} as const; } as const;
if ( if (
@@ -90,9 +86,9 @@ export class CharaDevice {
private suspendDebounceId?: number; private suspendDebounceId?: number;
version!: SemVer; version!: SemVer;
company!: "CHARACHORDER" | "FORGE"; company!: "CHARACHORDER";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G"; device!: "ONE" | "LITE" | "X";
chipset!: "M0" | "S2" | "S3"; chipset!: "M0" | "S2";
keyCount!: 90 | 67 | 256; keyCount!: 90 | 67 | 256;
get portInfo() { get portInfo() {
@@ -128,9 +124,9 @@ export class CharaDevice {
await this.send(1, "VERSION").then(([version]) => version), await this.send(1, "VERSION").then(([version]) => version),
); );
const [company, device, chipset] = await this.send(3, "ID"); const [company, device, chipset] = await this.send(3, "ID");
this.company = company as typeof this.company; this.company = company as "CHARACHORDER";
this.device = device as typeof this.device; this.device = device as "ONE" | "LITE" | "X";
this.chipset = chipset as typeof this.chipset; this.chipset = chipset as "M0" | "S2";
this.keyCount = KEY_COUNTS[this.device]; this.keyCount = KEY_COUNTS[this.device];
} catch (e) { } catch (e) {
alert(e); alert(e);

View File

@@ -72,6 +72,7 @@ export async function charaFileFromUriComponent<T extends CharaFiles>(
.stream() .stream()
.pipeThrough(new DecompressionStream("deflate")); .pipeThrough(new DecompressionStream("deflate"));
const actions = new Uint8Array(await new Response(stream).arrayBuffer()); const actions = new Uint8Array(await new Response(stream).arrayBuffer());
console.log(actions);
file[key] = deserializeActionArray(actions); file[key] = deserializeActionArray(actions);
} }
} }

View File

@@ -1,4 +0,0 @@
* {
box-sizing: border-box;
appearance: none;
}

View File

@@ -1,6 +0,0 @@
h1 {
margin-block-start: 0;
font-size: 4rem;
font-weight: 700;
color: var(--md-sys-color-secondary);
}

View File

@@ -1,35 +1,10 @@
@import "./reset";
@import "./form/button"; @import "./form/button";
@import "./form/toggle"; @import "./form/toggle";
@import "./form/checkbox"; @import "./form/checkbox";
@import "./kbd"; @import "./kbd";
@import "./print"; @import "./print";
@import "./elements/h1"; * {
box-sizing: border-box;
body { appearance: none;
overflow: hidden;
display: flex;
flex-direction: column;
width: 100vw;
height: 100vh;
margin: 0;
font-family: "Noto Sans Mono", monospace;
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
}
main {
contain: strict;
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: center;
padding-inline: 16px;
} }

View File

@@ -1,6 +1,6 @@
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import tippy from "tippy.js"; import tippy from "tippy.js";
import { mount, unmount, type SvelteComponent } from "svelte"; import type { SvelteComponent } from "svelte";
import Tooltip from "$lib/components/Tooltip.svelte"; import Tooltip from "$lib/components/Tooltip.svelte";
export const hotkeys = new Map<string, HTMLElement>(); export const hotkeys = new Map<string, HTMLElement>();
@@ -9,22 +9,20 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element, node: Element,
{ title, shortcut }, { title, shortcut },
) => { ) => {
let component: {} | undefined; let component: SvelteComponent | undefined;
const tooltip = tippy(node, { const tooltip = tippy(node, {
arrow: false, arrow: false,
theme: "tooltip", theme: "tooltip",
animation: "fade", animation: "fade",
onShow(instance) { onShow(instance) {
component ??= mount(Tooltip, { component ??= new Tooltip({
target: instance.popper.querySelector(".tippy-content") as Element, target: instance.popper.querySelector(".tippy-content") as Element,
props: { title, shortcut }, props: { title, shortcut },
}); });
}, },
onHidden() { onHidden() {
if (component) { component?.$destroy();
unmount(component);
component = undefined; component = undefined;
}
}, },
}); });

View File

@@ -1,6 +1,6 @@
import { persistentWritable } from "$lib/storage"; import { persistentWritable } from "$lib/storage";
import { derived } from "svelte/store"; import { derived } from "svelte/store";
import { hashChord, type Chord } from "$lib/serial/chord"; import type { Chord } from "$lib/serial/chord";
import { import {
deviceChords, deviceChords,
deviceLayout, deviceLayout,
@@ -158,9 +158,3 @@ export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
a.localeCompare(b), a.localeCompare(b),
); );
}); });
export const chordHashes = derived(
chords,
(chords) =>
new Map(chords.map((chord) => [hashChord(chord.actions), chord] as const)),
);

View File

@@ -1,13 +0,0 @@
import type { LayoutLoad } from "./$types";
import { browser } from "$app/environment";
import { charaFileFromUriComponent } from "$lib/share/share-url";
export const load = (async ({ url, data, fetch }) => {
const importFile = browser && new URLSearchParams(url.search).get("import");
return {
...data,
importFile: importFile
? await charaFileFromUriComponent(importFile, fetch)
: undefined,
};
}) satisfies LayoutLoad;

View File

@@ -1,134 +0,0 @@
<script lang="ts">
import { browser } from "$app/environment";
import { LL } from "$i18n/i18n-svelte";
import { popup } from "$lib/popup";
import { userPreferences } from "$lib/preferences";
import { serialPort, syncStatus } from "$lib/serial/connection";
import { action } from "$lib/title";
import BackupPopup from "./BackupPopup.svelte";
import ConnectionPopup from "./ConnectionPopup.svelte";
import { onMount } from "svelte";
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click();
}
});
const routes = [
[
{ href: "/config/chords/", icon: "dictionary", title: "Chords" },
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
{ href: "/config/settings/", icon: "tune", title: "Config" },
],
[
{ href: "/learn", icon: "school", title: "Learn", wip: true },
{ href: "/learn", icon: "description", title: "Docs" },
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
],
[
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
],
] satisfies { href: string; icon: string; title: string; wip?: boolean }[][];
let connectButton: HTMLButtonElement;
</script>
<div class="sidebar">
<nav>
{#each routes as group}
<ul>
{#each group as { href, icon, title, wip }}
<li>
<a class:wip {href}>
<div class="icon">{icon}</div>
<div class="content">
{title}
</div>
</a>
</li>
{/each}
</ul>
{/each}
</nav>
<ul class="sidebar-footer">
<li>
<button
bind:this={connectButton}
use:action={{ title: $LL.deviceManager.TITLE() }}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
</li>
<li>
<button
use:action={{ title: $LL.backup.TITLE() }}
use:popup={BackupPopup}
class="icon {$syncStatus}"
>
account_circle
</button>
</li>
</ul>
</div>
<style lang="scss">
.sidebar {
margin: 8px;
padding-inline-end: 8px;
width: 64px;
display: flex;
flex-direction: column;
justify-content: space-between;
grid-area: sidebar;
border-right: 1px solid var(--md-sys-color-outline);
}
li {
display: flex;
justify-content: center;
}
a {
display: flex;
flex-direction: column;
margin: 8px 0;
font-size: 12px;
&.wip {
color: var(--md-sys-color-error);
opacity: 0.5;
}
.icon {
display: flex;
justify-content: center;
font-size: 24px;
}
> .content {
display: flex;
justify-content: center;
align-items: center;
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
ul + ul::before {
content: "";
display: block;
height: 1px;
background: var(--md-sys-color-outline);
margin: 16px 0;
}
</style>

View File

@@ -1 +0,0 @@
<h2>WIP</h2>

View File

@@ -1,12 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
import Navigation from "./Navigation.svelte";
let { children }: { children?: Snippet } = $props();
</script>
<Navigation />
{#if children}
{@render children()}
{/if}

View File

@@ -1,125 +0,0 @@
<script lang="ts">
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import type { InferredChord, Replay } from "$lib/charrecorder/core/types";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import TrackRollingWpm from "$lib/charrecorder/TrackRollingWpm.svelte";
import { fade } from "svelte/transition";
let recorder: ReplayRecorder = $state(new ReplayRecorder());
let replay: Replay | undefined = $state();
let wpm = $state(0);
let chords: InferredChord[] = $state([]);
function handleRawKey(event: KeyboardEvent) {
event.preventDefault();
keyEvent(event);
}
function keyEvent(event: KeyboardEvent) {
if (event.key === "Tab") {
clear();
} else {
if (replay) {
replay = undefined;
}
recorder.next(event);
}
}
function clear() {
recorder = new ReplayRecorder();
}
function runReplay() {
replay = recorder.finish();
}
function save() {
const replay = recorder.finish();
const blob = new Blob([JSON.stringify(replay)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "replay.json";
a.click();
}
</script>
<svelte:head>
<title>Editor</title>
</svelte:head>
<svelte:window onkeydown={handleRawKey} onkeyup={handleRawKey} />
<section>
<h2>Editor</h2>
{#if replay}
<div class="replay" transition:fade={{ duration: 100 }}>
<CharRecorder {replay} cursor={true} keys={true} />
</div>
{/if}
{#key recorder}
<div
class="editor"
out:fade={{ duration: 100 }}
style:opacity={replay ? 0 : undefined}
>
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
<TrackRollingWpm bind:wpm />
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
<div class="toolbar">
<div>
<button onclick={clear}>Clear <kbd>TAB</kbd></button>
<button onclick={runReplay}>Replay</button>
<button onclick={save}>Export</button>
</div>
<div>
<div><b>{Math.round(wpm)}</b><sub>WPM</sub></div>
</div>
</div>
</section>
<style lang="scss">
section {
position: relative;
width: 100%;
}
.replay,
.editor {
position: absolute;
top: 3em;
left: 0;
transition: opacity 0.1s;
padding: 16px;
padding-left: 0;
padding-bottom: 5em;
}
.toolbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding-right: 16px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
backdrop-filter: blur(10px);
> div {
display: flex;
}
}
</style>

View File

@@ -1,231 +0,0 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import {
words,
nextWord,
scores,
learnConfigDefault,
learnConfig,
learnConfigStored,
} from "$lib/learn/chords";
import { blur, fade } from "svelte/transition";
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
import type { InferredChord } from "$lib/charrecorder/core/types";
let recorder = $derived(new ReplayRecorder($nextWord));
let start = performance.now();
$effect(() => {
start = recorder && performance.now();
});
let chords: InferredChord[] = $state([]);
function onkeyboard(event: KeyboardEvent) {
recorder.next(event);
}
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
$inspect(chords);
$effect(() => {
const [chord] = chords;
if (!chord) return;
console.log(chord);
if (chord.output.trim() === $nextWord) {
scores.update((scores) => {
const score = Math.max(
$learnConfig.minScore,
$learnConfig.maxScore - (performance.now() - start) / 1000,
);
if (!scores[$nextWord]) {
scores[$nextWord] = {
score,
lastTyped: performance.now(),
total: 1,
};
return scores;
}
const oldScore = scores[$nextWord].score;
scores[$nextWord].score = lerp(
score,
oldScore,
$learnConfig.scoreBlend,
);
scores[$nextWord].lastTyped = performance.now();
scores[$nextWord].total += 1;
return scores;
});
}
});
function skip() {
button?.blur();
scores.update((scores) => {
return scores;
});
}
let button = $state<HTMLButtonElement>();
</script>
<h2>WIP</h2>
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
{#key $nextWord}
<h3>
{$nextWord}
{#if $scores[$nextWord!] === undefined}
<sup class="new-word">new</sup>
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
<sup class="weak">weak</sup>
{/if}
</h3>
<div class="chord" in:fade>
<CharRecorder replay={recorder.player} cursor={true}>
<TrackChords bind:chords />
</CharRecorder>
</div>
{/key}
{#key $nextWord}
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
</div>
{/key}
<button onclick={skip} bind:this={button}>skip</button>
<section class="stats">
<table>
<thead>
<tr><th>Weak</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => a.score - b.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Strong</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.score - a.score)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
<td><i>{score.score.toFixed(2)}</i></td>
</tr>
{/each}
</tbody>
</table>
<table>
<thead>
<tr><th>Rehearse</th></tr>
</thead>
<tbody>
{#each Object.entries($scores)
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
.splice(0, 10) as [word, score]}
<tr class="decay">
<td>{word}</td>
</tr>
{/each}
</tbody>
</table>
</section>
<details>
<summary>Settings</summary>
<button onclick={() => ($scores = {})}>Reset</button>
<table>
<tbody>
{#each Object.entries(learnConfigDefault) as [key, value]}
<tr>
<th>{key}</th>
<td
><input
type="number"
value={$learnConfig[key] ?? value}
step="0.1"
oninput={(event) =>
($learnConfigStored[key] = event.target.value)}
/>
</td>
<td>
<button
disabled={!$learnConfigStored[key]}
onclick={() => ($learnConfigStored[key] = undefined)}></button
>
</td>
</tr>
{/each}
</tbody>
</table>
</details>
<style lang="scss">
@use "sass:math";
input {
background: none;
font: inherit;
color: inherit;
border: none;
width: 5ch;
text-align: right;
}
div {
min-width: 20ch;
padding: 1ch;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.stats {
display: flex;
gap: 3em;
}
sup {
font-weight: normal;
font-size: 0.8em;
&.new-word {
color: var(--md-sys-color-primary);
}
&.weak {
color: var(--md-sys-color-error);
}
}
@for $i from 1 through 10 {
tr.decay:nth-child(#{$i}) {
opacity: 1 - math.div($i, 10);
}
}
</style>

View File

@@ -1,263 +0,0 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { onMount } from "svelte";
import { basicSetup, EditorView } from "codemirror";
import { javascript, javascriptLanguage } from "@codemirror/lang-javascript";
import { defaultKeymap } from "@codemirror/commands";
import { keymap } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import LL from "$i18n/i18n-svelte";
import type { CompletionContext, Completion } from "@codemirror/autocomplete";
import { syntaxTree } from "@codemirror/language";
import { serialPort } from "$lib/serial/connection";
import examplePlugin from "./example-plugin.js?raw";
import {
charaMethods,
type ChannelCharaEventData,
type ChannelResponseEventData,
} from "./plugin-types";
let theme = EditorView.baseTheme({
".cm-editor .cm-content": {
fontFamily: '"Noto Sans Mono", monospace',
},
".cm-FoldPlaceholder": {
backgroundColor: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
},
".cm-gutters": {
backgroundColor: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
borderColor: "var(--md-sys-color-outline)",
},
".cm-activeLineGutter": {
backgroundColor: "var(--md-sys-color-tertiary)",
color: "var(--md-sys-color-on-tertiary)",
},
".cm-activeLine": {
backgroundColor: "transparent",
},
".cm-cursor": {
borderColor: "var(--md-sys-color-on-background)",
},
".cm-selectionBackground": {
background: "transparent !important",
backdropFilter: "invert(0.3)",
},
".cm-tooltip": {
backgroundColor: "var(--md-sys-color-background) !important",
color: "var(--md-sys-color-on-background)",
borderColor: "var(--md-sys-color-outline)",
},
".cm-tooltip-autocomplete ul li[aria-selected]": {
backgroundColor: "var(--md-sys-color-primary) !important",
color: "var(--md-sys-color-on-primary) !important",
},
".cm-completionIcon.cm-completionIcon-keyword::after": {
content: "'🗝'",
},
});
const highlightStyle = HighlightStyle.define(
[
{ tag: tags.keyword, color: "var(--md-sys-color-primary)" },
{ tag: tags.number, color: "var(--md-sys-color-secondary)" },
{ tag: tags.string, color: "var(--md-sys-color-tertiary)" },
{
tag: tags.comment,
color: "var(--md-sys-color-on-background)",
opacity: 0.6,
},
],
{
all: { fontFamily: '"Noto Sans Mono", monospace', fontSize: "14px" },
},
);
const globalsCompletion: Completion[] = [
{ label: "Chara", type: "class", boost: 90 },
{ label: "Actions", type: "class", boost: 90 },
];
const actionsCompletion: Completion[] = Array.from(
KEYMAP_CODES,
([id, info]) => {
const isValidIdentifier =
info.id && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(info.id);
return {
label: info.id
? isValidIdentifier
? info.id
: `["${info.id}"]`
: info.id!,
displayLabel: info.id,
detail: [info.title, `(0x${id.toString(16)})`, info.description]
.filter((it) => !!it)
.join(" "),
section: info.category,
boost: isValidIdentifier ? Math.min(info.id?.length ?? 0, 10) + 50 : 40,
type: "property",
};
},
).filter((it) => it.label !== undefined);
const completion = javascriptLanguage.data.of({
autocomplete: function completeGlobals(context: CompletionContext) {
let nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
if (nodeBefore.name === "VariableName") {
return {
from: nodeBefore.from,
options: globalsCompletion,
};
} else if (nodeBefore.name === "Script") {
return {
from: context.pos,
options: globalsCompletion,
};
} else if (
(nodeBefore.name === "PropertyName" || nodeBefore.name === ".") &&
nodeBefore.parent?.name === "MemberExpression" &&
nodeBefore.parent.firstChild
) {
const variable = nodeBefore.parent.firstChild;
const variableName = context.state.sliceDoc(variable.from, variable.to);
if (variableName === "Actions") {
return {
from:
nodeBefore.name === "PropertyName"
? nodeBefore.from
: nodeBefore.to,
options: actionsCompletion,
};
}
let parent = nodeBefore.prevSibling;
while (parent !== null && parent?.name !== "VariableName") {
parent = parent.prevSibling;
}
if (parent) {
}
}
return null;
},
});
onMount(() => {
editorView = new EditorView({
extensions: [
basicSetup,
javascript(),
keymap.of(defaultKeymap),
theme,
syntaxHighlighting(highlightStyle),
completion,
],
parent: editor,
doc: examplePlugin,
});
});
let channels = $derived(
$serialPort
? ({
getVersion: async (..._args: unknown[]) => $serialPort.version,
getDevice: async (..._args: unknown[]) => $serialPort.device,
commit: async (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return $serialPort.commit();
}
},
...Object.fromEntries(
charaMethods.map(
(it) => [it, $serialPort[it].bind($serialPort)] as const,
),
),
} satisfies Record<string, Function>)
: ({} as any),
);
async function onMessage(event: MessageEvent) {
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
const [channel, params] = event.data;
const response = channels[channel as keyof typeof channels](...params);
frame.contentWindow!.postMessage(
{ response: await response } satisfies ChannelResponseEventData,
"*",
);
}
function runPlugin() {
frame.contentWindow?.postMessage(
{
actionCodes: KEYMAP_CODES,
script: editorView.state.doc.toString(),
charaChannels: Object.keys(channels),
} satisfies ChannelCharaEventData,
"*",
);
}
let frame: HTMLIFrameElement;
let editor: HTMLDivElement;
let editorView: EditorView;
</script>
<svelte:window onmessage={onMessage} />
<section>
<h3>Plugin</h3>
<button onclick={runPlugin}
><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button
>
<div class="editor-root" bind:this={editor}></div>
</section>
<iframe
aria-hidden="true"
title="code sandbox"
bind:this={frame}
src="/sandbox/"
sandbox="allow-scripts"
></iframe>
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
iframe {
display: none;
}
button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: min-content;
padding-inline-start: 0;
padding-inline-end: 8px;
font-size: 14px;
font-weight: bold;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border: none;
border-radius: 4px;
}
.editor-root {
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,30 +0,0 @@
import type { CharaDevice } from "$lib/serial/device";
import type { KeyInfo } from "$lib/serial/keymap-codes";
export const charaMethods = [
"reboot",
"bootloader",
"getRamBytesAvailable",
"getSetting",
"setSetting",
"getLayoutKey",
"setLayoutKey",
"deleteChord",
"setChord",
"getChordPhrase",
"getChordCount",
"getChord",
"send",
] as const satisfies Array<keyof CharaDevice>;
export interface ChannelResponseEventData {
response: unknown;
}
export interface ChannelCharaEventData {
charaChannels: string[];
script: string;
actionCodes: Map<number, KeyInfo>;
}
export type ChannelEventData = ChannelResponseEventData | ChannelCharaEventData;

View File

@@ -4,13 +4,13 @@
import "$lib/style/scrollbar.scss"; import "$lib/style/scrollbar.scss";
import "$lib/style/tippy.scss"; import "$lib/style/tippy.scss";
import "$lib/style/theme.scss"; import "$lib/style/theme.scss";
import Sidebar from "./Sidebar.svelte"; import { onDestroy, onMount } from "svelte";
import { onDestroy, onMount, type Snippet } from "svelte";
import { import {
applyTheme, applyTheme,
argbFromHex, argbFromHex,
themeFromSourceColor, themeFromSourceColor,
} from "@material/material-color-utilities"; } from "@material/material-color-utilities";
import Navigation from "./Navigation.svelte";
import { canAutoConnect } from "$lib/serial/device"; import { canAutoConnect } from "$lib/serial/device";
import { initSerial } from "$lib/serial/connection"; import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
@@ -20,10 +20,10 @@
import "tippy.js/dist/tippy.css"; import "tippy.js/dist/tippy.css";
import tippy from "tippy.js"; import tippy from "tippy.js";
import { theme, userPreferences } from "$lib/preferences.js"; import { theme, userPreferences } from "$lib/preferences.js";
import { LL, setLocale } from "$i18n/i18n-svelte"; import { LL, setLocale } from "../i18n/i18n-svelte";
import { loadLocale } from "$i18n/i18n-util.sync"; import { loadLocale } from "../i18n/i18n-util.sync";
import { detectLocale } from "$i18n/i18n-util"; import { detectLocale } from "../i18n/i18n-util";
import type { Locales } from "$i18n/i18n-types"; import type { Locales } from "../i18n/i18n-types";
import Footer from "./Footer.svelte"; import Footer from "./Footer.svelte";
import { osLayout, runLayoutDetection } from "$lib/os-layout.js"; import { osLayout, runLayoutDetection } from "$lib/os-layout.js";
import PageTransition from "./PageTransition.svelte"; import PageTransition from "./PageTransition.svelte";
@@ -49,7 +49,7 @@
}); });
} }
let { data, children }: { data: LayoutData; children: Snippet } = $props(); export let data: LayoutData;
onMount(async () => { onMount(async () => {
theme.subscribe((it) => { theme.subscribe((it) => {
@@ -79,7 +79,7 @@
stopLayoutDetection?.(); stopLayoutDetection?.();
}); });
let webManifestLink = $state(""); let webManifestLink = "";
function handleHotkey(event: KeyboardEvent) { function handleHotkey(event: KeyboardEvent) {
let key = $osLayout.get(event.code); let key = $osLayout.get(event.code);
@@ -108,23 +108,20 @@
</script> </script>
<svelte:head> <svelte:head>
<!--{@html webManifestLink}--> {@html webManifestLink}
<title>{$LL.TITLE()}</title> <title>{$LL.TITLE()}</title>
<meta name="description" content={$LL.DESCRIPTION()} /> <meta name="description" content={$LL.DESCRIPTION()} />
<meta name="theme-color" content={data.themeColor} /> <meta name="theme-color" content={data.themeColor} />
</svelte:head> </svelte:head>
<svelte:window onkeydown={handleHotkey} /> <svelte:window on:keydown={handleHotkey} />
<div class="layout"> <Navigation />
<Sidebar />
<!-- <PickChangesDialog /> --> <!-- <PickChangesDialog /> -->
<PageTransition> <PageTransition>
{#if children} <slot />
{@render children()}
{/if}
</PageTransition> </PageTransition>
<Footer /> <Footer />
@@ -132,18 +129,36 @@
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)} {#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning /> <BrowserWarning />
{/if} {/if}
</div>
<style lang="scss"> <style lang="scss" global>
.layout { body {
overflow: hidden;
display: flex;
flex-direction: column;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
margin: 0;
display: grid; font-family: "Noto Sans Mono", monospace;
grid-template-areas: color: var(--md-sys-color-on-background);
"sidebar main"
"sidebar footer"; background: var(--md-sys-color-background);
grid-template-columns: auto 1fr; }
grid-template-rows: 1fr;
main {
contain: strict;
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: center;
padding-inline: 16px;
}
h1 {
margin-block-start: 0;
font-size: 4rem;
font-weight: 700;
color: var(--md-sys-color-secondary);
} }
</style> </style>

View File

@@ -1,2 +1,16 @@
import type { LayoutLoad } from "./$types";
import { browser } from "$app/environment";
import { charaFileFromUriComponent } from "$lib/share/share-url";
export const prerender = true; export const prerender = true;
export const trailingSlash = "always"; export const trailingSlash = "always";
export const load = (async ({ url, data, fetch }) => {
const importFile = browser && new URLSearchParams(url.search).get("import");
return {
...data,
importFile: importFile
? await charaFileFromUriComponent(importFile, fetch)
: undefined,
};
}) satisfies LayoutLoad;

View File

@@ -2,5 +2,5 @@ import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load = (() => { export const load = (() => {
redirect(302, "/config/layout/"); throw redirect(302, "/config/");
}) satisfies PageLoad; }) satisfies PageLoad;

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { preference } from "$lib/preferences"; import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
import { import {
createChordBackup, createChordBackup,
createLayoutBackup, createLayoutBackup,
@@ -25,25 +25,25 @@
</p> </p>
<fieldset> <fieldset>
<legend>{$LL.backup.INDIVIDUAL()}</legend> <legend>{$LL.backup.INDIVIDUAL()}</legend>
<button onclick={() => downloadFile(createChordBackup())}> <button on:click={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span> <span class="icon">piano</span>
{$LL.configure.chords.TITLE()} {$LL.configure.chords.TITLE()}
</button> </button>
<button onclick={() => downloadFile(createLayoutBackup())}> <button on:click={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span> <span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()} {$LL.configure.layout.TITLE()}
</button> </button>
<button onclick={() => downloadFile(createSettingsBackup())}> <button on:click={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span> <span class="icon">settings</span>
{$LL.configure.settings.TITLE()} {$LL.configure.settings.TITLE()}
</button> </button>
</fieldset> </fieldset>
<div class="save"> <div class="save">
<button class="primary" onclick={downloadBackup} <button class="primary" on:click={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button ><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
> >
<label class="button" <label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon" ><input on:input={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span >settings_backup_restore</span
>{$LL.backup.RESTORE()}</label >{$LL.backup.RESTORE()}</label
> >

View File

@@ -1,5 +1,5 @@
<script> <script>
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
</script> </script>
<dialog open> <dialog open>

View File

@@ -1,11 +1,8 @@
<script lang="ts"> <script>
import { page } from "$app/stores"; import { page } from "$app/stores";
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
import type { Snippet } from "svelte";
let { children }: { children?: Snippet } = $props(); $: paths = [
let paths = $derived([
{ {
href: "/config/chords/", href: "/config/chords/",
title: $LL.configure.chords.TITLE(), title: $LL.configure.chords.TITLE(),
@@ -21,7 +18,7 @@
title: $LL.configure.settings.TITLE(), title: $LL.configure.settings.TITLE(),
icon: "settings", icon: "settings",
}, },
]); ];
</script> </script>
<nav> <nav>
@@ -33,9 +30,7 @@
{/each} {/each}
</nav> </nav>
{#if children} <slot />
{@render children()}
{/if}
<style lang="scss"> <style lang="scss">
nav { nav {

View File

@@ -3,7 +3,7 @@
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import { slide, fade } from "svelte/transition"; import { slide, fade } from "svelte/transition";
import { preference } from "$lib/preferences"; import { preference } from "$lib/preferences";
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
import { downloadBackup } from "$lib/backup/backup"; import { downloadBackup } from "$lib/backup/backup";
function reboot() { function reboot() {
@@ -34,9 +34,13 @@
} }
} }
let rebootInfo = $derived($serialPort !== undefined); let rebootInfo = false;
let terminal = $state(false); let terminal = false;
let powerDialog = $state(false); let powerDialog = false;
$: if ($serialPort) {
rebootInfo = false;
}
</script> </script>
<section> <section>
@@ -113,7 +117,7 @@
{#if $serialPort} {#if $serialPort}
<button <button
class="secondary" class="secondary"
onclick={() => { on:click={() => {
$serialPort?.forget(); $serialPort?.forget();
$serialPort = undefined; $serialPort = undefined;
}} }}
@@ -121,7 +125,7 @@
>{$LL.deviceManager.DISCONNECT()}</button >{$LL.deviceManager.DISCONNECT()}</button
> >
{:else} {:else}
<button class="error" onclick={connect} <button class="error" on:click={connect}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button ><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
> >
{/if} {/if}
@@ -131,13 +135,13 @@
title={$LL.deviceManager.TERMINAL()} title={$LL.deviceManager.TERMINAL()}
class="icon" class="icon"
class:disabled={$serialPort === undefined} class:disabled={$serialPort === undefined}
onclick={() => (terminal = !terminal)}>terminal</a on:click={() => (terminal = !terminal)}>terminal</a
> >
<button <button
class="icon" class="icon"
title={$LL.deviceManager.bootMenu.TITLE()} title={$LL.deviceManager.bootMenu.TITLE()}
disabled={$serialPort === undefined} disabled={$serialPort === undefined}
onclick={() => (powerDialog = !powerDialog)}>settings_power</button on:click={() => (powerDialog = !powerDialog)}>settings_power</button
> >
</div> </div>
</div> </div>
@@ -147,18 +151,18 @@
role="button" role="button"
tabindex="-1" tabindex="-1"
transition:fade={{ duration: 250 }} transition:fade={{ duration: 250 }}
onclick={() => (powerDialog = !powerDialog)} on:click={() => (powerDialog = !powerDialog)}
onkeypress={(event) => { on:keypress={(event) => {
if (event.key === "Enter") powerDialog = !powerDialog; if (event.key === "Enter") powerDialog = !powerDialog;
}} }}
></div> />
<dialog open transition:slide={{ duration: 250 }}> <dialog open transition:slide={{ duration: 250 }}>
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3> <h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
<button onclick={reboot} <button on:click={reboot}
><span class="icon">restart_alt</span ><span class="icon">restart_alt</span
>{$LL.deviceManager.bootMenu.REBOOT()}</button >{$LL.deviceManager.bootMenu.REBOOT()}</button
> >
<button onclick={bootloader} <button on:click={bootloader}
><span class="icon">rule_settings</span ><span class="icon">rule_settings</span
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button >{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
> >

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
import { import {
changes, changes,
ChangeType, ChangeType,
@@ -25,7 +25,7 @@
if (event.shiftKey) { if (event.shiftKey) {
changes.set([]); changes.set([]);
} else { } else {
redoQueue.unshift($changes.pop()!); redoQueue = [$changes.pop()!, ...redoQueue];
changes.update((it) => it); changes.update((it) => it);
} }
} }
@@ -39,7 +39,7 @@
}); });
} }
} }
let redoQueue: Change[] = $state([]); let redoQueue: Change[] = [];
async function save() { async function save() {
try { try {
@@ -138,19 +138,19 @@
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }} use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
class="icon" class="icon"
disabled={$changes.length === 0} disabled={$changes.length === 0}
onclick={undo}>undo</button on:click={undo}>undo</button
> >
<button <button
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }} use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
class="icon" class="icon"
disabled={redoQueue.length === 0} disabled={redoQueue.length === 0}
onclick={redo}>redo</button on:click={redo}>redo</button
> >
{#if $changes.length !== 0} {#if $changes.length !== 0}
<button <button
transition:fly={{ x: 10 }} transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }} use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
onclick={save} on:click={save}
class="click-me" class="click-me"
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button ><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
> >

View File

@@ -1,25 +1,23 @@
<script lang="ts"> <script lang="ts">
import { browser, version } from "$app/environment"; import { browser, version } from "$app/environment";
import { action } from "$lib/title"; import { action } from "$lib/title";
import LL, { setLocale } from "$i18n/i18n-svelte"; import LL, { setLocale } from "../i18n/i18n-svelte";
import { theme } from "$lib/preferences.js"; import { theme } from "$lib/preferences.js";
import type { Locales } from "$i18n/i18n-types"; import type { Locales } from "../i18n/i18n-types";
import { detectLocale, locales } from "$i18n/i18n-util"; import { detectLocale, locales } from "../i18n/i18n-util";
import { loadLocaleAsync } from "$i18n/i18n-util.async"; import { loadLocaleAsync } from "../i18n/i18n-util.async";
import { tick } from "svelte"; import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte"; import SyncOverlay from "./SyncOverlay.svelte";
import { serialPort } from "$lib/serial/connection"; import { serialPort } from "$lib/serial/connection";
let locale = $state( let locale =
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(), (browser && (localStorage.getItem("locale") as Locales)) || detectLocale();
); $: if (browser)
$effect(() => { (async () => {
if (!browser) return;
localStorage.setItem("locale", locale); localStorage.setItem("locale", locale);
loadLocaleAsync(locale).then(() => { await loadLocaleAsync(locale);
setLocale(locale); setLocale(locale);
}); })();
});
function switchTheme() { function switchTheme() {
const mode = $theme.mode === "light" ? "dark" : "light"; const mode = $theme.mode === "light" ? "dark" : "light";
@@ -39,6 +37,7 @@
<footer> <footer>
<ul> <ul>
<li> <li>
<!-- svelte-ignore not-defined -->
<a <a
href={import.meta.env.VITE_HOMEPAGE_URL} href={import.meta.env.VITE_HOMEPAGE_URL}
rel="noreferrer" rel="noreferrer"
@@ -87,7 +86,7 @@
<button <button
use:action={{ title: $LL.profile.theme.DARK_MODE() }} use:action={{ title: $LL.profile.theme.DARK_MODE() }}
class="icon" class="icon"
onclick={switchTheme} on:click={switchTheme}
> >
dark_mode dark_mode
</button> </button>
@@ -95,27 +94,25 @@
<button <button
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }} use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
class="icon" class="icon"
onclick={switchTheme} on:click={switchTheme}
> >
light_mode light_mode
</button> </button>
{/if} {/if}
</li> </li>
<li> <li>
<div <button
role="button"
class="icon" class="icon"
use:action={{ title: $LL.profile.LANGUAGE() }} use:action={{ title: $LL.profile.LANGUAGE() }}
onclick={() => languageSelect.click()} on:click={() => languageSelect.click()}
> >translate
translate
<select bind:value={locale} bind:this={languageSelect}> <select bind:value={locale} bind:this={languageSelect}>
{#each locales as code} {#each locales as code}
<option value={code}>{code}</option> <option value={code}>{code}</option>
{/each} {/each}
</select> </select>
</div> </button>
</li> </li>
</ul> </ul>
</footer> </footer>

View File

@@ -1,10 +1,25 @@
<script lang="ts"> <script lang="ts">
import { fly } from "svelte/transition"; import { serialPort, syncStatus } from "$lib/serial/connection";
import { slide, fly } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share"; import { canShare, triggerShare } from "$lib/share";
import { popup } from "$lib/popup";
import BackupPopup from "./BackupPopup.svelte";
import ConnectionPopup from "./ConnectionPopup.svelte";
import { browser } from "$app/environment";
import { userPreferences } from "$lib/preferences";
import { action } from "$lib/title"; import { action } from "$lib/title";
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
import ConfigTabs from "./ConfigTabs.svelte"; import ConfigTabs from "./ConfigTabs.svelte";
import EditActions from "./EditActions.svelte"; import EditActions from "./EditActions.svelte";
import { onMount } from "svelte";
onMount(async () => {
if (browser && !$userPreferences.autoConnect) {
connectButton.click();
}
});
let connectButton: HTMLButtonElement;
</script> </script>
<nav> <nav>
@@ -20,24 +35,52 @@
use:action={{ title: $LL.share.TITLE() }} use:action={{ title: $LL.share.TITLE() }}
transition:fly={{ x: -8 }} transition:fly={{ x: -8 }}
class="icon" class="icon"
onclick={triggerShare}>share</button on:click={triggerShare}>share</button
> >
<button <button
use:action={{ title: $LL.print.TITLE() }} use:action={{ title: $LL.print.TITLE() }}
transition:fly={{ x: -8 }} transition:fly={{ x: -8 }}
class="icon" class="icon"
onclick={() => print()}>print</button on:click={() => print()}>print</button
> >
<div transition:slide class="separator" />
{/if} {/if}
{#if import.meta.env.TAURI_FAMILY === undefined} {#if import.meta.env.TAURI_FAMILY === undefined}
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }} {#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
<PwaStatus /> <PwaStatus />
{/await} {/await}
{/if} {/if}
<button
use:action={{ title: $LL.backup.TITLE() }}
use:popup={BackupPopup}
class="icon {$syncStatus}"
>
{#if $userPreferences.backup}
history
{:else}
history_toggle_off
{/if}
</button>
<button
bind:this={connectButton}
use:action={{ title: $LL.deviceManager.TITLE() }}
use:popup={ConnectionPopup}
class="icon connect"
class:error={$serialPort === undefined}
>
cable
</button>
</div> </div>
</nav> </nav>
<style lang="scss"> <style lang="scss">
.separator {
width: 1px;
height: 24px;
margin-inline: 4px;
background: var(--md-sys-color-outline-variant);
}
nav { nav {
display: grid; display: grid;
grid-template-columns: 1fr auto 1fr; grid-template-columns: 1fr auto 1fr;

View File

@@ -2,16 +2,13 @@
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation"; import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing"; import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props(); let inDirection = 0;
let outDirection = 0;
let inDirection = $state(0); let outroEnd: undefined | (() => void) = undefined;
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>; let animationDone: Promise<void>;
let isNavigating = $state(false); let isNavigating = false;
const routeOrder = [ const routeOrder = [
"/config/chords/", "/config/chords/",
@@ -51,8 +48,8 @@
<main <main
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }} in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }} out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd} on:outroend={outroEnd}
> >
{@render children()} <slot />
</main> </main>
{/if} {/if}

View File

@@ -5,7 +5,7 @@
syncStatus, syncStatus,
sync, sync,
} from "$lib/serial/connection"; } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte"; import LL from "../i18n/i18n-svelte";
import { slide } from "svelte/transition"; import { slide } from "svelte/transition";
</script> </script>
@@ -23,7 +23,7 @@
{/if} {/if}
</div> </div>
{:else if $serialPort} {:else if $serialPort}
<button transition:slide onclick={sync} <button transition:slide on:click={sync}
><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button ><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button
> >
{/if} {/if}

View File

@@ -0,0 +1,6 @@
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load = (() => {
throw redirect(302, "/config/layout/");
}) satisfies PageLoad;

View File

@@ -1,5 +1,5 @@
<script> <script>
import LL from "$i18n/i18n-svelte"; import LL from "../../i18n/i18n-svelte";
</script> </script>
{$LL.share.URL_COPIED()} {$LL.share.URL_COPIED()}

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes"; import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch"; import FlexSearch from "flexsearch";
import LL from "$i18n/i18n-svelte"; import LL from "../../../i18n/i18n-svelte";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { onDestroy, onMount, setContext, tick } from "svelte"; import { onDestroy, onMount, setContext, tick } from "svelte";
import { changes, ChangeType, chords } from "$lib/undo-redo"; import { changes, ChangeType, chords } from "$lib/undo-redo";
@@ -21,7 +21,7 @@
let resizeObserver: ResizeObserver; let resizeObserver: ResizeObserver;
let abortIndexing: (() => void) | undefined; let abortIndexing: (() => void) | undefined;
let progress = $state(0); let progress = 0;
onMount(() => { onMount(() => {
resizeObserver = new ResizeObserver(() => { resizeObserver = new ResizeObserver(() => {
@@ -37,11 +37,11 @@
let index = new FlexSearch.Index(); let index = new FlexSearch.Index();
let searchIndex = writable<FlexSearch.Index | undefined>(undefined); let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
$effect(() => { $: {
abortIndexing?.(); abortIndexing?.();
progress = 0; progress = 0;
buildIndex($chords, $osLayout).then(searchIndex.set); buildIndex($chords, $osLayout).then(searchIndex.set);
}); }
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) { function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
const plainPhrase: string[] = [""]; const plainPhrase: string[] = [""];
@@ -144,6 +144,7 @@
progress = i; progress = i;
if ("phrase" in chord) { if ("phrase" in chord) {
console.log(encodeChord(chord, osLayout));
await index.addAsync(i, encodeChord(chord, osLayout)); await index.addAsync(i, encodeChord(chord, osLayout));
} }
} }
@@ -210,7 +211,7 @@
setContext("cursor-crossfade", crossfade({})); setContext("cursor-crossfade", crossfade({}));
let page = $state(0); let page = 0;
</script> </script>
<svelte:head> <svelte:head>
@@ -222,7 +223,7 @@
<input <input
type="search" type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)} placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
oninput={(event) => $searchIndex && search($searchIndex, event)} on:input={(event) => $searchIndex && search($searchIndex, event)}
class:loading={progress !== $chords.length - 1} class:loading={progress !== $chords.length - 1}
/> />
<div class="paginator"> <div class="paginator">
@@ -234,12 +235,12 @@
</div> </div>
<button <button
class="icon" class="icon"
onclick={() => (page = Math.max(page - 1, 0))} on:click={() => (page = Math.max(page - 1, 0))}
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
> >
<button <button
class="icon" class="icon"
onclick={() => (page = Math.min(page + 1, $lastPage))} on:click={() => (page = Math.min(page + 1, $lastPage))}
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
> >
</div> </div>
@@ -250,24 +251,22 @@
<div class="results"> <div class="results">
<table transition:fly={{ y: 48, easing: expoOut }}> <table transition:fly={{ y: 48, easing: expoOut }}>
{#if $lastPage !== -1} {#if $lastPage !== -1}
<tbody>
{#if page === 0} {#if page === 0}
<tr <tr
><th class="new-chord" ><th class="new-chord"
><ChordActionEdit ><ChordActionEdit
onsubmit={(action) => insertChord(action)} on:submit={({ detail }) => insertChord(detail)}
/></th /></th
><td></td><td></td></tr ><td /><td /></tr
> >
{/if} {/if}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))} {#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
{#if chord} {#if chord}
<tr> <tr>
<ChordEdit {chord} onduplicate={() => (page = 0)} /> <ChordEdit {chord} on:duplicate={() => (page = 0)} />
</tr> </tr>
{/if} {/if}
{/each}</tbody {/each}
>
{:else} {:else}
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption> <caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
{/if} {/if}
@@ -279,7 +278,7 @@
"\n\nDid you know? " + "\n\nDid you know? " +
randomTips[Math.floor(randomTips.length * Math.random())]} randomTips[Math.floor(randomTips.length * Math.random())]}
></textarea> ></textarea>
<button onclick={downloadVocabulary} <button on:click={downloadVocabulary}
><span class="icon">download</span> ><span class="icon">download</span>
{$LL.configure.chords.VOCABULARY()}</button {$LL.configure.chords.VOCABULARY()}</button
> >

View File

@@ -1,44 +1,41 @@
<script lang="ts"> <script lang="ts">
import type { ChordInfo } from "$lib/undo-redo"; import type { ChordInfo } from "$lib/undo-redo";
import { SvelteSet } from "svelte/reactivity"; import { changes, ChangeType } from "$lib/undo-redo";
import { changes, chordHashes, ChangeType } from "$lib/undo-redo"; import { createEventDispatcher } from "svelte";
import LL from "$i18n/i18n-svelte"; import LL from "../../../i18n/i18n-svelte";
import ActionString from "$lib/components/ActionString.svelte"; import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector"; import { selectAction } from "./action-selector";
import { serialPort } from "$lib/serial/connection"; import { serialPort } from "$lib/serial/connection";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { inputToAction } from "./input-converter"; import { inputToAction } from "./input-converter";
import { hashChord, type Chord } from "$lib/serial/chord";
let { export let chord: ChordInfo | undefined = undefined;
chord = undefined,
onsubmit,
}: { chord?: ChordInfo; onsubmit: (actions: number[]) => void } = $props();
let pressedKeys = new SvelteSet<number>(); const dispatch = createEventDispatcher();
let editing = $state(false);
let pressedKeys = new Set<number>();
let editing = false;
function compare(a: number, b: number) { function compare(a: number, b: number) {
return a - b; return a - b;
} }
function makeChordInput(...actions: number[]) { function makeChordInput(...actions: number[]) {
const compound = compoundInputs[0] const compound = compoundIndices ?? [];
? hashChord(compoundInputs[0].actions)
: 0;
return [ return [
...compound,
...Array.from( ...Array.from(
{ {
length: 12 - actions.length, length: 12 - (compound.length + actions.length + 1),
}, },
(_, i) => (compound >> (i * 10)) & 0x3ff, () => 0,
), ),
...actions.toSorted(compare), ...actions.toSorted(compare),
]; ];
} }
function edit() { function edit() {
pressedKeys.clear(); pressedKeys = new Set();
editing = true; editing = true;
} }
@@ -53,13 +50,14 @@
return; return;
} }
pressedKeys.add(input); pressedKeys.add(input);
pressedKeys = pressedKeys;
} }
function keyup() { function keyup() {
if (!editing) return; if (!editing) return;
editing = false; editing = false;
if (pressedKeys.size < 1) return; if (pressedKeys.size < 1) return;
if (!chord) return onsubmit(makeChordInput(...pressedKeys)); if (!chord) return dispatch("submit", makeChordInput(...pressedKeys));
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push({
type: ChangeType.Chord, type: ChangeType.Chord,
@@ -73,9 +71,9 @@
} }
function addSpecial(event: MouseEvent) { function addSpecial(event: MouseEvent) {
event.stopPropagation();
selectAction(event, (action) => { selectAction(event, (action) => {
changes.update((changes) => { changes.update((changes) => {
console.log(compoundIndices, chordActions, action);
changes.push({ changes.push({
type: ChangeType.Chord, type: ChangeType.Chord,
id: chord!.id, id: chord!.id,
@@ -87,30 +85,10 @@
}); });
} }
function* resolveCompound(chord?: ChordInfo) { $: chordActions = chord?.actions
if (!chord) return; .slice(chord.actions.lastIndexOf(0) + 1)
let current: Chord = chord; .toSorted(compare);
for (let i = 0; i < 10; i++) { $: compoundIndices = chord?.actions.slice(0, chord.actions.indexOf(0));
if (current.actions[3] !== 0) return;
const compound = current.actions
.slice(0, 3)
.reduce((a, b, i) => a | (b << (i * 10)));
if (compound === 0) return;
const next = $chordHashes.get(compound);
if (!next) {
return null;
}
current = next;
yield next;
}
return;
}
let chordActions = $derived(
chord?.actions.slice(chord.actions.lastIndexOf(0) + 1).toSorted(compare),
);
let compoundInputs = $derived([...resolveCompound(chord)].reverse());
</script> </script>
<button <button
@@ -121,10 +99,10 @@
(chordActions.length < 2 || (chordActions.length < 2 ||
chordActions.some((it, i) => chordActions[i] !== it))} chordActions.some((it, i) => chordActions[i] !== it))}
class="chord" class="chord"
onclick={edit} on:click={edit}
onkeydown={keydown} on:keydown={keydown}
onkeyup={keyup} on:keyup={keyup}
onblur={keyup} on:blur={keyup}
> >
{#if editing && pressedKeys.size === 0} {#if editing && pressedKeys.size === 0}
<span>{$LL.configure.chords.HOLD_KEYS()}</span> <span>{$LL.configure.chords.HOLD_KEYS()}</span>
@@ -132,22 +110,21 @@
<span>{$LL.configure.chords.NEW_CHORD()}</span> <span>{$LL.configure.chords.NEW_CHORD()}</span>
{/if} {/if}
{#if !editing} {#if !editing}
{#each compoundInputs as compound} {#each compoundIndices ?? [] as index}
<sub <sub>{index}</sub>
><ActionString
display="keys"
actions={compound.actions.slice(compound.actions.lastIndexOf(0) + 1)}
></ActionString>
</sub>
<span>&rarr;</span>
{/each} {/each}
{#if compoundIndices?.length}
<span>&rarr;</span>
{/if}
{/if} {/if}
<ActionString <ActionString
display="keys" display="keys"
actions={editing ? [...pressedKeys].sort(compare) : (chordActions ?? [])} actions={editing ? [...pressedKeys].sort(compare) : chordActions ?? []}
/> />
<sup></sup> <sup></sup>
<div role="button" class="icon add" onclick={addSpecial}>add_circle</div> <button class="icon add" on:click|stopPropagation={addSpecial}
>add_circle</button
>
</button> </button>
<style lang="scss"> <style lang="scss">

View File

@@ -8,10 +8,11 @@
import { charaFileToUriComponent } from "$lib/share/share-url"; import { charaFileToUriComponent } from "$lib/share/share-url";
import SharePopup from "../SharePopup.svelte"; import SharePopup from "../SharePopup.svelte";
import tippy from "tippy.js"; import tippy from "tippy.js";
import { mount, unmount } from "svelte"; import { createEventDispatcher } from "svelte";
let { chord, onduplicate }: { chord: ChordInfo; onduplicate: () => void } = export let chord: ChordInfo;
$props();
const dispatch = createEventDispatcher<{ duplicate: void }>();
function remove() { function remove() {
changes.update((changes) => { changes.update((changes) => {
@@ -46,7 +47,7 @@
id.splice(id.indexOf(0), 1); id.splice(id.indexOf(0), 1);
id.push(0); id.push(0);
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) { while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
id[id.length - 1]!++; id[id.length - 1]++;
} }
changes.update((changes) => { changes.update((changes) => {
@@ -59,7 +60,7 @@
return changes; return changes;
}); });
onduplicate(); dispatch("duplicate");
} }
async function share(event: Event) { async function share(event: Event) {
@@ -73,48 +74,48 @@
}), }),
); );
await navigator.clipboard.writeText(url.toString()); await navigator.clipboard.writeText(url.toString());
let shareComponent = {}; let shareComponent: SharePopup;
tippy(event.target as HTMLElement, { tippy(event.target as HTMLElement, {
onCreate(instance) { onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!; const target = instance.popper.querySelector(".tippy-content")!;
shareComponent = mount(SharePopup, { target }); shareComponent = new SharePopup({ target });
}, },
onHidden(instance) { onHidden(instance) {
instance.destroy(); instance.destroy();
}, },
onDestroy(_instance) { onDestroy(_instance) {
unmount(shareComponent); shareComponent.$destroy();
}, },
}).show(); }).show();
} }
</script> </script>
<th> <th>
<ChordActionEdit {chord} onsubmit={() => {}} /> <ChordActionEdit {chord} />
</th> </th>
<td> <td>
<ChordPhraseEdit {chord} /> <ChordPhraseEdit {chord} />
</td> </td>
<td class="table-buttons"> <td class="table-buttons">
{#if !chord.deleted} {#if !chord.deleted}
<button transition:slide class="icon compact" onclick={remove} <button transition:slide class="icon compact" on:click={remove}
>delete</button >delete</button
> >
{:else} {:else}
<button transition:slide class="icon compact" onclick={restore} <button transition:slide class="icon compact" on:click={restore}
>restore_from_trash</button >restore_from_trash</button
> >
{/if} {/if}
<button disabled={chord.deleted} class="icon compact" onclick={duplicate} <button disabled={chord.deleted} class="icon compact" on:click={duplicate}
>content_copy</button >content_copy</button
> >
<button <button
class="icon compact" class="icon compact"
class:disabled={chord.isApplied} class:disabled={chord.isApplied}
onclick={restore}>undo</button on:click={restore}>undo</button
> >
<div class="separator"></div> <div class="separator" />
<button class="icon compact" onclick={share}>share</button> <button class="icon compact" on:click={share}>share</button>
</td> </td>
<style lang="scss"> <style lang="scss">

View File

@@ -9,11 +9,11 @@
import { serialPort } from "$lib/serial/connection"; import { serialPort } from "$lib/serial/connection";
import { get } from "svelte/store"; import { get } from "svelte/store";
let { chord }: { chord: ChordInfo } = $props(); export let chord: ChordInfo;
onMount(() => { onMount(() => {
if (chord.phrase.length === 0) { if (chord.phrase.length === 0) {
box?.focus(); box.focus();
} }
}); });
@@ -40,7 +40,6 @@
} }
function moveCursor(to: number) { function moveCursor(to: number) {
if (!box) return;
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length)); cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
const item = box.children.item(cursorPosition) as HTMLElement; const item = box.children.item(cursorPosition) as HTMLElement;
cursorOffset = item.offsetLeft + item.offsetWidth; cursorOffset = item.offsetLeft + item.offsetWidth;
@@ -72,7 +71,7 @@
} }
function clickCursor(event: MouseEvent) { function clickCursor(event: MouseEvent) {
if (box === undefined || event.target === button) return; if (event.target === button) return;
const distance = (event as unknown as { layerX: number }).layerX; const distance = (event as unknown as { layerX: number }).layerX;
let i = 0; let i = 0;
@@ -94,36 +93,37 @@
insertAction(cursorPosition, action); insertAction(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1)); tick().then(() => moveCursor(cursorPosition + 1));
}, },
() => box?.focus(), () => box.focus(),
); );
} }
let button: HTMLButtonElement | undefined = $state(); let button: HTMLButtonElement;
let box: HTMLDivElement | undefined = $state(); let box: HTMLDivElement;
let cursorPosition = 0; let cursorPosition = 0;
let cursorOffset = $state(0); let cursorOffset = 0;
let hasFocus = $state(false); let hasFocus = false;
</script> </script>
<!-- svelte-ignore a11y-autofocus -->
<div <div
onkeydown={keypress} on:keydown={keypress}
onmousedown={clickCursor} on:mousedown={clickCursor}
role="textbox" role="textbox"
tabindex="0" tabindex="0"
bind:this={box} bind:this={box}
class:edited={!chord.deleted && chord.phraseChanged} class:edited={!chord.deleted && chord.phraseChanged}
onfocusin={() => (hasFocus = true)} on:focusin={() => (hasFocus = true)}
onfocusout={(event) => { on:focusout={(event) => {
if (event.relatedTarget !== button) hasFocus = false; if (event.relatedTarget !== button) hasFocus = false;
}} }}
> >
{#if hasFocus} {#if hasFocus}
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0"> <div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} onclick={addSpecial}>add</button> <button class="icon" bind:this={button} on:click={addSpecial}>add</button>
</div> </div>
{:else} {:else}
<div></div> <div />
<!-- placeholder for cursor placement --> <!-- placeholder for cursor placement -->
{/if} {/if}
<ActionString actions={chord.phrase} /> <ActionString actions={chord.phrase} />

View File

@@ -1,21 +1,12 @@
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"; import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { mount, unmount, tick } from "svelte"; import { tick } from "svelte";
export function selectAction( export function selectAction(
event: MouseEvent | KeyboardEvent, event: MouseEvent | KeyboardEvent,
select: (action: number) => void, select: (action: number) => void,
dismissed?: () => void, dismissed?: () => void,
) { ) {
const component = mount(ActionSelector, { const component = new ActionSelector({ target: document.body });
target: document.body,
props: {
onclose: () => closed(),
onselect: (action: number) => {
select(action);
closed();
},
},
});
const dialog = document.querySelector("dialog > div") as HTMLDivElement; const dialog = document.querySelector("dialog > div") as HTMLDivElement;
const backdrop = document.querySelector("dialog") as HTMLDialogElement; const backdrop = document.querySelector("dialog") as HTMLDialogElement;
const dialogRect = dialog.getBoundingClientRect(); const dialogRect = dialog.getBoundingClientRect();
@@ -49,8 +40,14 @@ export function selectAction(
await dialogAnimation.finished; await dialogAnimation.finished;
unmount(component); component.$destroy();
await tick(); await tick();
dismissed?.(); dismissed?.();
} }
component.$on("close", closed);
component.$on("select", ({ detail }) => {
select(detail);
closed();
});
} }

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { share } from "$lib/share"; import { share } from "$lib/share";
import tippy from "tippy.js"; import tippy from "tippy.js";
import { mount, setContext, unmount } from "svelte"; import { setContext } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte"; import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url"; import { charaFileToUriComponent } from "$lib/share/share-url";
import SharePopup from "../SharePopup.svelte"; import SharePopup from "../SharePopup.svelte";
@@ -25,17 +25,17 @@
}), }),
); );
await navigator.clipboard.writeText(url.toString()); await navigator.clipboard.writeText(url.toString());
let shareComponent: {}; let shareComponent: SharePopup;
tippy(event.target as HTMLElement, { tippy(event.target as HTMLElement, {
onCreate(instance) { onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!; const target = instance.popper.querySelector(".tippy-content")!;
shareComponent = mount(SharePopup, { target }); shareComponent = new SharePopup({ target });
}, },
onHidden(instance) { onHidden(instance) {
instance.destroy(); instance.destroy();
}, },
onDestroy() { onDestroy() {
unmount(shareComponent); shareComponent.$destroy();
}, },
}).show(); }).show();
} }

View File

@@ -229,6 +229,12 @@
/>ms</span />ms</span
></label ></label
> >
<label
>Compound Chording<input
type="checkbox"
use:setting={{ id: 0x61 }}
/></label
>
</fieldset> </fieldset>
<fieldset> <fieldset>

View File

@@ -1,18 +1,20 @@
<script lang="ts"> <script lang="ts">
import { serialPort } from "$lib/serial/connection"; import { serialPort } from "$lib/serial/connection";
import { createEventDispatcher } from "svelte";
let { challenge, onconfirm }: { challenge: string; onconfirm: () => void } = export let challenge: string;
$props();
let challengeInput = $state(""); let challengeInput = "";
let challengeString = $derived(`${challenge} ${$serialPort!.device}`); $: challengeString = `${challenge} ${$serialPort!.device}`;
let isValid = $derived(challengeInput === challengeString); $: isValid = challengeInput === challengeString;
const dispatch = createEventDispatcher();
</script> </script>
<h3>Type the following to confirm the action</h3> <h3>Type the following to confirm the action</h3>
<p>{challengeString}</p> <p>{challengeString}</p>
<!-- svelte-ignore a11y_autofocus --> <!-- svelte-ignore a11y-autofocus -->
<input <input
autofocus autofocus
type="text" type="text"
@@ -20,7 +22,9 @@
placeholder={challengeString} placeholder={challengeString}
/> />
<button disabled={!isValid} onclick={onconfirm}>Confirm {challenge}</button> <button disabled={!isValid} on:click={() => dispatch("confirm")}
>Confirm {challenge}</button
>
<style lang="scss"> <style lang="scss">
input[type="text"] { input[type="text"] {

View File

@@ -0,0 +1,196 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { onMount } from "svelte";
import { basicSetup, EditorView } from "codemirror";
import { javascript, javascriptLanguage } from "@codemirror/lang-javascript";
import { defaultKeymap } from "@codemirror/commands";
import { keymap } from "@codemirror/view";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import LL from "../../i18n/i18n-svelte";
import type { CompletionContext } from "@codemirror/autocomplete";
import { serialPort } from "$lib/serial/connection";
import type { CharaDevice } from "$lib/serial/device";
import examplePlugin from "./example-plugin.js?raw";
let theme = EditorView.baseTheme({
".cm-editor .cm-content": {
fontFamily: '"Noto Sans Mono", monospace',
},
".cm-FoldPlaceholder": {
backgroundColor: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
},
".cm-gutters": {
backgroundColor: "var(--md-sys-color-surface-variant)",
color: "var(--md-sys-color-on-surface-variant)",
borderColor: "var(--md-sys-color-outline)",
},
".cm-activeLineGutter": {
backgroundColor: "var(--md-sys-color-tertiary)",
color: "var(--md-sys-color-on-tertiary)",
},
".cm-activeLine": {
backgroundColor: "transparent",
},
".cm-cursor": {
borderColor: "var(--md-sys-color-on-background)",
},
".cm-selectionBackground": {
background: "transparent !important",
backdropFilter: "invert(0.3)",
},
});
const highlightStyle = HighlightStyle.define(
[
{ tag: tags.keyword, color: "var(--md-sys-color-primary)" },
{ tag: tags.number, color: "var(--md-sys-color-secondary)" },
{ tag: tags.string, color: "var(--md-sys-color-tertiary)" },
{
tag: tags.comment,
color: "var(--md-sys-color-on-background)",
opacity: 0.6,
},
],
{
all: { fontFamily: '"Noto Sans Mono", monospace', fontSize: "14px" },
},
);
const completion = javascriptLanguage.data.of({
autocomplete: function completeGlobals(context: CompletionContext) {
if (context.matchBefore(/Chara\./)) {
// TODO
}
},
});
onMount(() => {
editorView = new EditorView({
extensions: [
basicSetup,
javascript(),
keymap.of(defaultKeymap),
theme,
syntaxHighlighting(highlightStyle),
completion,
],
parent: editor,
doc: examplePlugin,
});
});
const charaMethods = [
"reboot",
"bootloader",
"getRamBytesAvailable",
"getSetting",
"setSetting",
"getLayoutKey",
"setLayoutKey",
"deleteChord",
"setChord",
"getChordPhrase",
"getChordCount",
"getChord",
"send",
] satisfies Array<keyof CharaDevice>;
$: channels = $serialPort
? ({
getVersion: async (..._args: unknown[]) => $serialPort.version,
getDevice: async (..._args: unknown[]) => $serialPort.device,
commit: async (..._args: unknown[]) => {
if (
confirm(
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
"Click OK to perform the commit anyways.",
)
) {
return $serialPort.commit();
}
},
...Object.fromEntries(
charaMethods.map(
(it) => [it, $serialPort[it].bind($serialPort)] as const,
),
),
} satisfies Record<string, Function>)
: ({} as any);
async function onMessage(event: MessageEvent) {
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
const [channel, params] = event.data;
const response = channels[channel as keyof typeof channels](...params);
frame.contentWindow!.postMessage({ response: await response }, "*");
}
function runPlugin() {
frame.contentWindow?.postMessage(
{
actionCodes: KEYMAP_CODES,
script: editorView.state.doc.toString(),
charaChannels: Object.keys(channels),
},
"*",
);
}
let frame: HTMLIFrameElement;
let editor: HTMLDivElement;
let editorView: EditorView;
</script>
<svelte:window on:message={onMessage} />
<section>
<button on:click={runPlugin}
><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button
>
<div class="editor-root" bind:this={editor} />
</section>
<iframe
aria-hidden="true"
title="code sandbox"
bind:this={frame}
src="/sandbox/"
sandbox="allow-scripts"
/>
<style lang="scss">
section {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
iframe {
display: none;
}
button {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: min-content;
padding-inline-start: 0;
padding-inline-end: 8px;
font-size: 14px;
font-weight: bold;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border: none;
border-radius: 4px;
}
.editor-root {
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,19 +1,15 @@
<script lang="ts"> <script>
import type { ChannelEventData } from "../(app)/plugin/plugin-types"; // @ts-nocheck
let ongoingRequest;
let ongoingRequest: Promise<unknown> | undefined = undefined; let resolveRequest;
let resolveRequest: ((data: unknown) => void) | undefined = undefined; let source;
let source: MessageEventSource | undefined = undefined; async function post(channel, args) {
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
async function post(channel: string, args: unknown[]) {
while (ongoingRequest) { while (ongoingRequest) {
await ongoingRequest; await ongoingRequest;
} }
ongoingRequest = new Promise((resolve) => { ongoingRequest = new Promise((resolve) => {
resolveRequest = resolve; resolveRequest = resolve;
source?.postMessage([channel, args], { targetOrigin: "*" }); source.postMessage([channel, args], "*");
}); });
ongoingRequest.then(() => { ongoingRequest.then(() => {
ongoingRequest = undefined; ongoingRequest = undefined;
@@ -21,13 +17,13 @@
return ongoingRequest; return ongoingRequest;
} }
function onMessage(event: MessageEvent<ChannelEventData>) { window.addEventListener("message", (event) => {
if ("response" in event.data) { if ("response" in event.data) {
resolveRequest?.(event.data.response); resolveRequest(event.data.response);
} else { } else {
source = event.source ?? undefined; source = event.source;
const Action = event.data.actionCodes; var Action = event.data.actionCodes;
Object.assign( Object.assign(
Action, Action,
Object.fromEntries( Object.fromEntries(
@@ -37,20 +33,12 @@
), ),
); );
const Chara = Object.fromEntries( var Chara = {};
event.data.charaChannels.map((name) => [ for (const fn of event.data.charaChannels) {
name, Chara[fn] = (...args) => post(fn, args);
(...args: unknown[]) => post(name, args), }
]),
);
AsyncFunction( eval(`(async function(){${event.data.script}})()`);
"Action",
"Chara",
'"use strict"\n' + event.data.script,
)(Action, Chara);
}
} }
});
</script> </script>
<svelte:window on:message={onMessage} />

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import { LL } from "../../i18n/i18n-svelte";
</script>
<h1>{$LL.update.TITLE()}</h1>
<a
href="https://github.com/CharaChorder/CCOS-firmware/blob/main/CHANGELOG.md"
target="_blank">Changelog</a
>

View File

@@ -0,0 +1,174 @@
<script lang="ts">
import { chords } from "$lib/undo-redo";
import Action from "$lib/components/Action.svelte";
import { onDestroy, onMount } from "svelte";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { fly } from "svelte/transition";
import type { Chord } from "$lib/serial/chord";
const speedRating = [
[400, "+100", "excited", true],
[700, "+50", "satisfied", true],
[1400, "+25", "neutral", true],
[3000, "0", "dissatisfied", false],
[Infinity, "-50", "sad", false],
] as const;
const accuracyRating = [
[2, "+100", "calm", true],
[3, "+50", "content", false],
[5, "+25", "stressed", false],
[7, "0", "frustrated", false],
[14, "-25", "very_dissatisfied", false],
[Infinity, "-50", "extremely_dissatisfied", false],
] as const;
let next: Chord[] = [];
let nextHandle: number;
let took: number | undefined;
let delta = 0;
let speed: readonly [number, string, string, boolean] | undefined;
let accuracy: readonly [number, string, string, boolean] | undefined;
let progress = 0;
let attempts = 0;
let userInput = "";
onMount(() => {
runTest();
});
function runTest() {
if (took === undefined) {
took = performance.now();
delta = 0;
attempts = 0;
userInput = "";
if (next.length === 0) {
next = Array.from(
{ length: 5 },
() => $chords[Math.floor(Math.random() * $chords.length)]!,
);
} else {
next.shift();
next.push($chords[Math.floor(Math.random() * $chords.length)]!);
next = next;
}
}
if (
userInput ===
next[0]!.phrase
.map((it) => (it === 32 ? " " : KEYMAP_CODES.get(it)!.id))
.join("") +
" "
) {
took = undefined;
speed = speedRating.find(([max]) => delta <= max);
accuracy = accuracyRating.find(([max]) => attempts <= max);
progress++;
} else {
delta = performance.now() - took;
}
nextHandle = requestAnimationFrame(runTest);
}
let debounceTimer = 0;
function backspace(event: KeyboardEvent) {
if (event.code === "Backspace") {
userInput = userInput.slice(0, -1);
}
}
function input(event: KeyboardEvent) {
const stamp = performance.now();
if (stamp - debounceTimer > 50) {
attempts++;
}
debounceTimer = stamp;
userInput += event.key;
}
onDestroy(() => {
if (nextHandle) {
cancelAnimationFrame(nextHandle);
}
});
</script>
<svelte:window on:keydown={backspace} on:keypress={input} />
<h1>Vocabulary Trainer</h1>
{#if next[0]}
<div class="row">
{#key progress}
<div
in:fly={{ duration: 300, x: -48 }}
out:fly={{ duration: 1000, x: 128 }}
class="rating"
>
{#if speed}
<span class="rating-item">
<span
style:color="var(--md-sys-color-{speed[3] ? `primary` : `error`})"
class="icon">timer</span
>
{speed[1]}
<span class="icon">sentiment_{speed[2]}</span>
</span>
{/if}
{#if accuracy}
<span class="rating-item">
<span
style:color="var(--md-sys-color-{accuracy[3]
? `primary`
: `error`})"
class="icon">target</span
>
{accuracy[1]}
<span class="icon">sentiment_{accuracy[2]}</span>
</span>
{/if}
</div>
{/key}
</div>
<div class="hint" style:opacity={delta > 3000 ? 1 : 0}>
{#each next[0].actions as action}
<Action {action} display="keys" />
{/each}
</div>
<div>
{userInput}
</div>
{#each next as chord, i}
<div class="words" style:opacity={1 - i / next.length}>
{#each chord.phrase as action}
<Action {action} />
{/each}
</div>
{/each}
{:else}
<p>You don't have any chords</p>
{/if}
<style lang="scss">
.row {
position: relative;
height: 48px;
}
.rating-item {
display: flex;
gap: 8px;
justify-content: flex-start;
}
.rating {
position: absolute;
left: -48px;
width: max-content;
}
</style>

View File

@@ -1,60 +0,0 @@
<script>
/** @type {Promise<unknown> | undefined} */
let ongoingRequest = undefined;
/** @type {(data: unknown) => void | undefined} */
let resolveRequest = undefined;
/** @type {MessageEventSource | undefined} */
let source = undefined;
/**
* @param {string} channel
* @param {unknown} args
* @returns {Promise<unknown>}
*/
async function post(channel, args) {
while (ongoingRequest) {
await ongoingRequest;
}
ongoingRequest = new Promise((resolve) => {
resolveRequest = resolve;
source?.postMessage([channel, args], { targetOrigin: "*" });
});
ongoingRequest.then(() => {
ongoingRequest = undefined;
});
return ongoingRequest;
}
/**
* @param {MessageEvent<import('../../src/routes/plugin/plugin-types').ChannelEventData>} event
*/
function onMessage(event) {
if ("response" in event.data) {
resolveRequest?.(event.data.response);
} else {
source = event.source ?? undefined;
const Action = event.data.actionCodes;
Object.assign(
Action,
Object.fromEntries(
Object.values(event.data.actionCodes)
.filter((it) => !!it.id)
.map((it) => [it.id, it]),
),
);
new Function("Action", "Chara", event.data.script)(
Action,
Object.fromEntries(
event.data.charaChannels.map((name) => [
name,
(...args) => post(name, args),
]),
),
);
}
}
window.addEventListener("message", onMessage);
</script>

Some files were not shown because too many files have changed in this diff Show More