20 Commits

Author SHA1 Message Date
John de St. Germain
d2934fc2ca Fix typo 2024-09-09 15:52:50 -05:00
26c43b1966 feat: learn 2024-08-21 18:20:04 +02:00
8b2bfee099 feat: multi-purpose site
feat: editor
feat: plugin editor
2024-08-01 01:31:04 +02:00
b8b903c5e1 refactor: update to Svelte 5 preview
feat: add charrecorder
feat: dynamic os layouts for CC1
2024-08-01 00:28:38 +02:00
6201cf5b0c feat: update dynamic library description 2024-07-24 19:19:20 +02:00
aaafadf732 fix: pid/vid wrong 2024-07-24 19:07:18 +02:00
fe80867ce4 feat: M4G support 2024-07-24 18:28:47 +02:00
72a8e084ce fix: plugins can't execute plugins 2024-07-16 15:21:34 +02:00
989e844190 fix: compound order 2024-07-11 13:40:31 +02:00
500221f39a feat: experimental support for compounds 2024-07-11 13:38:19 +02:00
Raymond Li
d91273d27b Update CONTRIBUTING.md 2024-07-10 00:22:40 +02:00
888df6dd66 1.5.2 2024-07-09 16:43:06 +02:00
7ad9612037 fix: add pnpm to pipeline 2024-07-09 16:39:21 +02:00
3f9674b399 fix: pwa prevents layout share url from being loaded 2024-07-09 16:29:28 +02:00
92ba5bcb24 fix: build 2024-07-09 16:28:42 +02:00
2163a63a7c fix: release build pipeline 2024-07-08 18:51:09 +02:00
65a5a2517e feat: improvements 2024-07-08 18:43:06 +02:00
21e8c291b0 fix: compatibility issues 2024-07-08 09:26:51 +02:00
4106a80d53 feat: improve device support 2024-06-08 17:34:18 +02:00
John de St Germain
01fb61d27c Fix misspelling 2024-05-13 21:39:14 +02:00
105 changed files with 11452 additions and 12727 deletions

View File

@@ -21,18 +21,22 @@ jobs:
- name: ⏬ Install Python dependencies
run: pip install -r requirements.txt
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
- name: 🐉 Use Node.js 18.16.x
uses: actions/setup-node@v3
with:
node-version: 18.16.x
cache: "npm"
cache: "pnpm"
- name: ⏬ Install Node dependencies
run: npm ci
run: pnpm install
- name: 🔥 Optimize icon font
run: npm run minify-icons
run: pnpm minify-icons
- name: 🔨 Build site
run: npm run build
run: pnpm build
- name: 📦 Upload build artifacts
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
- 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
way to subset variable woff2 fonts with ligatures.

58
flake.lock generated
View File

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

117
flake.nix
View File

@@ -4,56 +4,75 @@
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
rust-overlay,
}:
flake-utils.lib.eachDefaultSystem (system: let
overlays = [(import rust-overlay)];
pkgs = import nixpkgs {inherit system overlays;};
rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
};
fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
tauriPkgs = nixpkgs.legacyPackages.${system};
libraries = with tauriPkgs; [
webkitgtk
gtk3
cairo
gdk-pixbuf
glib
dbus
openssl_3
librsvg
];
packages =
(with pkgs; [
nodejs_18
rust-bin
fontMin
])
++ (with tauriPkgs; [
curl
wget
pkg-config
outputs =
{
self,
nixpkgs,
flake-utils,
rust-overlay,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs { inherit system overlays; };
rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [
"rust-src"
"rust-std"
"clippy"
"rust-analyzer"
];
};
fontMin = pkgs.python311.withPackages (
ps:
with ps;
[
brotli
fonttools
]
++ (with fonttools.optional-dependencies; [ woff ])
);
tauriPkgs = nixpkgs.legacyPackages.${system};
libraries = with tauriPkgs; [
webkitgtk
gtk3
cairo
gdk-pixbuf
glib
dbus
openssl_3
glib
gtk3
libsoup
webkitgtk
librsvg
# serial plugin
udev
]);
in {
devShell = pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
'';
};
});
];
packages =
(with pkgs; [
nodejs_22
nodePackages.pnpm
rust-bin
fontMin
])
++ (with tauriPkgs; [
curl
wget
pkg-config
dbus
openssl_3
glib
gtk3
libsoup
webkitgtk
librsvg
# serial plugin
udev
]);
in
{
devShell = pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
'';
};
}
);
}

View File

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

11684
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

8259
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -70,6 +70,9 @@ actions:
title: Primary Keymap
icon: counter_1
variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
549:
variantOf: 548
<<: *primary_keymap
@@ -80,6 +83,9 @@ actions:
title: Numeric Layer
icon: counter_2
variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
551:
variantOf: 550
<<: *secondary_keymap
@@ -90,11 +96,31 @@ actions:
title: Function Layer
icon: counter_3
variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
553:
variationOf: 552
<<: *tertiary_keymap
id: "KM_3_R"
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:
id: ACTION_DELAY_1000
icon: clock_loader_90

View File

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

View File

@@ -0,0 +1,37 @@
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

@@ -17,7 +17,7 @@
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
"Spurring is a chording only mode which is more advanced, but can greatly imporve typing speed when mastered",
"Spurring is a chording only mode which is more advanced, but can greatly improve typing speed when mastered",
"The forced chord phenomenon is when typing a word character by character starts to feel unnatural",
"Don't be afraid to delete chords you keep getting wrong",
"Most people find it easier to start their chord library from scratch rather than learning someone else's",

View File

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

View File

@@ -1,26 +0,0 @@
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

@@ -0,0 +1,137 @@
<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

@@ -0,0 +1,130 @@
<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

@@ -0,0 +1,30 @@
<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

@@ -0,0 +1,20 @@
<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

@@ -0,0 +1,134 @@
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

@@ -0,0 +1,112 @@
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

@@ -0,0 +1,71 @@
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

@@ -0,0 +1,48 @@
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

@@ -0,0 +1,26 @@
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

@@ -0,0 +1,67 @@
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

@@ -0,0 +1,132 @@
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

@@ -0,0 +1,58 @@
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

@@ -0,0 +1,96 @@
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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
} from "$lib/undo-redo";
import { ChangeType, chords } from "$lib/undo-redo";
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";
export let changes: Change[] = [

101
src/lib/learn/chords.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,35 @@
@import "./reset";
@import "./form/button";
@import "./form/toggle";
@import "./form/checkbox";
@import "./kbd";
@import "./print";
* {
box-sizing: border-box;
appearance: none;
@import "./elements/h1";
body {
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 tippy from "tippy.js";
import type { SvelteComponent } from "svelte";
import { mount, unmount, type SvelteComponent } from "svelte";
import Tooltip from "$lib/components/Tooltip.svelte";
export const hotkeys = new Map<string, HTMLElement>();
@@ -9,20 +9,22 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element,
{ title, shortcut },
) => {
let component: SvelteComponent | undefined;
let component: {} | undefined;
const tooltip = tippy(node, {
arrow: false,
theme: "tooltip",
animation: "fade",
onShow(instance) {
component ??= new Tooltip({
component ??= mount(Tooltip, {
target: instance.popper.querySelector(".tippy-content") as Element,
props: { title, shortcut },
});
},
onHidden() {
component?.$destroy();
component = undefined;
if (component) {
unmount(component);
component = undefined;
}
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,134 @@
<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

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

View File

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

View File

@@ -0,0 +1,12 @@
<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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,125 @@
<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

@@ -0,0 +1,231 @@
<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

@@ -0,0 +1,263 @@
<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

@@ -0,0 +1,30 @@
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

@@ -1,16 +1,2 @@
import type { LayoutLoad } from "./$types";
import { browser } from "$app/environment";
import { charaFileFromUriComponent } from "$lib/share/share-url";
export const prerender = true;
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

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

View File

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

View File

@@ -1,10 +0,0 @@
<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

@@ -1,174 +0,0 @@
<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>

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