1 Commits

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

View File

@@ -21,22 +21,18 @@ jobs:
- name: ⏬ Install Python dependencies
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: "pnpm"
cache: "npm"
- name: ⏬ Install Node dependencies
run: pnpm install
run: npm ci
- name: 🔥 Optimize icon font
run: pnpm minify-icons
run: npm run minify-icons
- name: 🔨 Build site
run: pnpm build
run: npm run 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,11 +5,29 @@
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1689068808,
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github"
},
"original": {
@@ -20,11 +38,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1722415718,
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=",
"lastModified": 1689752456,
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce",
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github"
},
"original": {
@@ -36,11 +54,11 @@
},
"nixpkgs_2": {
"locked": {
"lastModified": 1718428119,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
"lastModified": 1681358109,
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github"
},
"original": {
@@ -59,14 +77,15 @@
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1722391647,
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=",
"lastModified": 1690942540,
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8",
"rev": "aa3994f054038262df55122dfa552b9eab71a994",
"type": "github"
},
"original": {
@@ -89,6 +108,21 @@
"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,75 +4,56 @@
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
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
dbus
openssl_3
glib
gtk3
libsoup
webkitgtk
librsvg
];
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
'';
};
}
);
# serial plugin
udev
]);
in {
devShell = pkgs.mkShell {
buildInputs = packages;
shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
'';
};
});
}

View File

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

11684
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,8 @@
{
"name": "charachorder-device-manager",
"version": "1.5.2",
"version": "1.5.1",
"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"
@@ -34,58 +30,52 @@
"typesafe-i18n": "typesafe-i18n"
},
"devDependencies": {
"@codemirror/autocomplete": "^6.17.0",
"@codemirror/commands": "^6.6.0",
"@codemirror/autocomplete": "^6.15.0",
"@codemirror/commands": "^6.3.3",
"@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.10.2",
"@codemirror/language": "^6.10.1",
"@codemirror/state": "^6.4.1",
"@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",
"@fontsource-variable/material-symbols-rounded": "^5.0.27",
"@fontsource-variable/noto-sans-mono": "^5.0.19",
"@material/material-color-utilities": "^0.2.7",
"@modyfi/vite-plugin-yaml": "^1.1.0",
"@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",
"@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",
"@types/flexsearch": "^0.7.6",
"@types/w3c-web-serial": "^1.0.6",
"@types/w3c-web-usb": "^1.0.10",
"@vite-pwa/sveltekit": "^0.6.0",
"@vite-pwa/sveltekit": "^0.2.10",
"autoprefixer": "^10.4.19",
"codemirror": "^6.0.1",
"cypress": "^13.13.2",
"d3": "^7.9.0",
"cypress": "^13.7.2",
"flexsearch": "^0.7.43",
"fontkit": "^2.0.2",
"glob": "^11.0.0",
"jsdom": "^24.1.1",
"glob": "^10.3.12",
"jsdom": "^22.1.0",
"npm-run-all": "^4.1.5",
"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",
"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",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"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",
"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",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.5.4",
"vite": "^5.3.5",
"typescript": "^5.4.4",
"vite": "^4.5.3",
"vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^0.20.1",
"vitest": "^2.0.5",
"workbox-window": "^7.1.0"
"vite-plugin-pwa": "^0.17.5",
"vitest": "^0.34.6"
},
"type": "module"
}

8259
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "1.5.2"
version = "1.5.1"
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.2" },
"package": { "productName": "amacc1ng", "version": "1.5.1" },
"tauri": {
"allowlist": { "all": false },
"bundle": {

View File

@@ -9,193 +9,145 @@ 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,9 +70,6 @@ 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
@@ -83,9 +80,6 @@ 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
@@ -96,31 +90,11 @@ 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 Right Arrow
title: Keypad 6 and Rigth 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: MacOS only
description: Not required to be supported by any OS. Possibly deprecated.
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"
icon: language
title: Media Browser
title: Media www
description: Not required to be supported by any OS. Possibly deprecated.
497:
id: "KSC_F1"
keyCode: "BrowserBack"
title: Media Browser Back
title: Media Back
description: Not required to be supported by any OS. Possibly deprecated.
498:
id: "KSC_F2"
keyCode: "BrowserForward"
title: Media Browser Forward
title: Media Forward
description: Not required to be supported by any OS. Possibly deprecated.
499:
id: "KSC_F3"
keyCode: "BrowserStop"
title: Media Browser Stop
description: Not supported on MacOS
title: Media Stop
description: Not required to be supported by any OS. Possibly deprecated.
500:
id: "KSC_F4"
icon: search
keyCode: "BrowserSearch"
title: Media Browser Search
title: Media Find
description: Not required to be supported by any OS. Possibly deprecated.
501:
id: "KSC_F5"
icon: brightness_high
title: Media Brightness Up
title: Media Scroll Up
description: Not required to be supported by any OS. Possibly deprecated.
502:
id: "KSC_F6"
icon: brightness_low
title: Media Brightness Down
title: Media Scroll Down
description: Not required to be supported by any OS. Possibly deprecated.
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 System Sleep
title: Media Sleep
description: Not required to be supported by any OS. Possibly deprecated.
505:
id: "KSC_F9"
icon: routine
keyCode: "WakeUp"
title: Media System Wake
description: Not supported on Windows
title: Media Coffee
description: Not required to be supported by any OS. Possibly deprecated.
506:
id: "KSC_FA"
keyCode: "BrowserRefresh"
title: Media Browser Refresh
title: Media Refresh
description: Not required to be supported by any OS. Possibly deprecated.
507:
id: "KSC_FB"
title: Media Calculator
description: Not supported on MacOS
title: Media Calc
description: Not required to be supported by any OS. Possibly deprecated.
508:
id: "KSC_FC"
description: Not required to be supported by any OS.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,8 @@
import Action from "$lib/components/Action.svelte";
import type { KeyInfo } from "$lib/serial/keymap-codes";
let {
actions,
display = "inline-keys",
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
$props();
export let actions: Array<number | KeyInfo>;
export let display: "keys" | "inline-keys" = "inline-keys";
</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" onclick={() => updateServiceWorker(true)}
<button title="Update ready" on:click={() => 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 = $state("");
let value: string;
let io: HTMLDivElement;
</script>
<form onsubmit={submit}>
<form on:submit={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>
<div class="anchor" />
</div>
<fieldset>
<input onsubmit={submit} bind:value />
<input on:submit={submit} bind:value />
</fieldset>
</form>

View File

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

View File

@@ -5,22 +5,13 @@
KEYMAP_IDS,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { onMount } from "svelte";
import { createEventDispatcher, 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";
let {
currentAction = undefined,
nextAction = undefined,
onselect,
onclose,
}: {
currentAction?: number;
nextAction?: number;
onselect: (id: number) => void;
onclose: () => void;
} = $props();
export let currentAction: number | undefined = undefined;
export let nextAction: number | undefined = undefined;
onMount(() => {
searchBox.focus();
@@ -48,13 +39,13 @@
function select(id?: number) {
if (id !== undefined) {
onselect(id);
dispatch("select", id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
onselect(exact);
if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ??
@@ -76,45 +67,40 @@
event.preventDefault();
}
let results: number[] = $state([]);
let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN);
let results: number[] = [];
let exact: number | undefined = undefined;
let code: number = Number.NaN;
const dispatch = createEventDispatcher();
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter = $state(new Set<number>());
let filter: Set<number>;
</script>
<svelte:window on:keydown={keyboardNavigation} />
<!-- 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();
}}
>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
<dialog open on:click|self={() => dispatch("close")}>
<div class="content">
<div class="search-row">
<input
type="search"
bind:this={searchBox}
oninput={search}
onkeypress={(event) => {
on:input={search}
on:keypress={(event) => {
if (event.key === "Enter") {
select(exact);
}
}}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
<button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button
>
<button
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon"
onclick={onclose}>close</button
on:click={() => dispatch("close")}>close</button
>
</div>
<fieldset class="filters">
@@ -154,12 +140,12 @@
{#if exact !== undefined}
<li class="exact">
<i>Exact match</i>
<ActionListItem id={exact} onclick={() => select(exact)} />
<ActionListItem id={exact} on:click={() => select(exact)} />
</li>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<li><button onclick={() => select(code)}>USE CODE</button></li>
<li><button on:click={() => select(code)}>USE CODE</button></li>
{:else}
<li>Action code is out of range</li>
{/if}
@@ -170,7 +156,7 @@
? Array.from(KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li>
<li><ActionListItem {id} on:click={() => select(id)} /></li>
{/each}
{/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, mount, unmount } from "svelte";
import { getContext } 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");
}
let { visualLayout }: { visualLayout: VisualLayout } = $props();
let layoutInfo = $state(compileLayout(visualLayout));
export let visualLayout: VisualLayout;
$: layoutInfo = 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,26 +127,11 @@
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 = mount(ActionSelector, {
const component = new 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;
@@ -182,8 +167,22 @@
await dialogAnimation.finished;
unmount(component);
component.$destroy();
}
component.$on("close", closed);
component.$on("select", ({ detail }) => {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action: detail,
});
return changes;
});
closed();
});
}
let focusKey: CompiledLayoutKey;
@@ -202,9 +201,9 @@
<KeyboardKey
{i}
{key}
onfocusin={() => (focusKey = key)}
onclick={() => edit(i)}
onkeypress={({ key }) => {
on:focusin={() => (focusKey = key)}
on:click={() => edit(i)}
on:keypress={({ key }) => {
if (key === "Enter") {
edit(i);
}

View File

@@ -12,21 +12,14 @@
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
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();
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]];
</script>
{#each positions as position, layer}

View File

@@ -3,41 +3,24 @@
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;
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);
$: posX = key.pos[0] * scale;
$: posY = key.pos[1] * scale;
$: sizeX = key.size[0] * scale;
$: sizeY = key.size[1] * scale;
</script>
<g
class="key-group"
{onclick}
{onkeypress}
{onfocusin}
on:click
on:keypress
on:focusin
role="button"
tabindex={i + 1}
>

View File

@@ -7,7 +7,7 @@
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade } from "svelte/transition";
let device = $derived($serialPort?.device);
$: device = $serialPort?.device;
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
@@ -21,10 +21,6 @@
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,
@@ -33,10 +29,6 @@
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>
@@ -48,7 +40,7 @@
<button
class="icon"
use:action={{ title, shortcut: `alt+${value + 1}` }}
onclick={() => ($activeLayer = value)}
on:click={() => ($activeLayer = value)}
class:active={$activeLayer === value}
>
{icon}

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,25 @@
import tippy from "tippy.js";
import type { Action } from "svelte/action";
import { unmount, mount, type Component } from "svelte";
import type { ComponentType, SvelteComponent } from "svelte";
export const popup: Action<HTMLButtonElement, Component> = (
export const popup: Action<HTMLButtonElement, ComponentType> = (
node,
Component,
) => {
let component: {} | undefined;
let component: SvelteComponent | 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 ??= mount(Component, { target });
component ??= new Component({ target });
},
onHidden() {
if (component) {
unmount(component);
component = undefined;
}
component?.$destroy();
target?.classList.remove("active");
component = undefined;
},
});

View File

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

View File

@@ -55,19 +55,3 @@ 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,19 +12,15 @@ 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 (
@@ -90,9 +86,9 @@ export class CharaDevice {
private suspendDebounceId?: number;
version!: SemVer;
company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
chipset!: "M0" | "S2" | "S3";
company!: "CHARACHORDER";
device!: "ONE" | "LITE" | "X";
chipset!: "M0" | "S2";
keyCount!: 90 | 67 | 256;
get portInfo() {
@@ -128,9 +124,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 typeof this.company;
this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
this.company = company as "CHARACHORDER";
this.device = device as "ONE" | "LITE" | "X";
this.chipset = chipset as "M0" | "S2";
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
alert(e);

View File

@@ -72,6 +72,7 @@ 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

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

View File

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

View File

@@ -1,35 +1,10 @@
@import "./reset";
@import "./form/button";
@import "./form/toggle";
@import "./form/checkbox";
@import "./kbd";
@import "./print";
@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;
* {
box-sizing: border-box;
appearance: none;
}

View File

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

View File

@@ -1,6 +1,6 @@
import { persistentWritable } from "$lib/storage";
import { derived } from "svelte/store";
import { hashChord, type Chord } from "$lib/serial/chord";
import type { Chord } from "$lib/serial/chord";
import {
deviceChords,
deviceLayout,
@@ -158,9 +158,3 @@ 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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,13 +4,13 @@
import "$lib/style/scrollbar.scss";
import "$lib/style/tippy.scss";
import "$lib/style/theme.scss";
import Sidebar from "./Sidebar.svelte";
import { onDestroy, onMount, type Snippet } from "svelte";
import { onDestroy, onMount } 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 @@
});
}
let { data, children }: { data: LayoutData; children: Snippet } = $props();
export let data: LayoutData;
onMount(async () => {
theme.subscribe((it) => {
@@ -79,7 +79,7 @@
stopLayoutDetection?.();
});
let webManifestLink = $state("");
let webManifestLink = "";
function handleHotkey(event: KeyboardEvent) {
let key = $osLayout.get(event.code);
@@ -108,42 +108,57 @@
</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 onkeydown={handleHotkey} />
<svelte:window on:keydown={handleHotkey} />
<div class="layout">
<Sidebar />
<Navigation />
<!-- <PickChangesDialog /> -->
<!-- <PickChangesDialog /> -->
<PageTransition>
{#if children}
{@render children()}
{/if}
</PageTransition>
<PageTransition>
<slot />
</PageTransition>
<Footer />
<Footer />
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
<BrowserWarning />
{/if}
</div>
{#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;
<style lang="scss">
.layout {
width: 100vw;
height: 100vh;
margin: 0;
display: grid;
grid-template-areas:
"sidebar main"
"sidebar footer";
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
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);
}
</style>

View File

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

View File

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

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 onclick={() => downloadFile(createChordBackup())}>
<button on:click={() => downloadFile(createChordBackup())}>
<span class="icon">piano</span>
{$LL.configure.chords.TITLE()}
</button>
<button onclick={() => downloadFile(createLayoutBackup())}>
<button on:click={() => downloadFile(createLayoutBackup())}>
<span class="icon">keyboard</span>
{$LL.configure.layout.TITLE()}
</button>
<button onclick={() => downloadFile(createSettingsBackup())}>
<button on:click={() => downloadFile(createSettingsBackup())}>
<span class="icon">settings</span>
{$LL.configure.settings.TITLE()}
</button>
</fieldset>
<div class="save">
<button class="primary" onclick={downloadBackup}
<button class="primary" on:click={downloadBackup}
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
>
<label class="button"
><input oninput={restoreBackup} type="file" /><span class="icon"
><input on:input={restoreBackup} type="file" /><span class="icon"
>settings_backup_restore</span
>{$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

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

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

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.unshift($changes.pop()!);
redoQueue = [$changes.pop()!, ...redoQueue];
changes.update((it) => it);
}
}
@@ -39,7 +39,7 @@
});
}
}
let redoQueue: Change[] = $state([]);
let redoQueue: Change[] = [];
async function save() {
try {
@@ -138,19 +138,19 @@
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
class="icon"
disabled={$changes.length === 0}
onclick={undo}>undo</button
on:click={undo}>undo</button
>
<button
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
class="icon"
disabled={redoQueue.length === 0}
onclick={redo}>redo</button
on:click={redo}>redo</button
>
{#if $changes.length !== 0}
<button
transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
onclick={save}
on:click={save}
class="click-me"
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
>

View File

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

View File

@@ -1,10 +1,25 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { serialPort, syncStatus } from "$lib/serial/connection";
import { slide, fly } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share";
import { 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>
@@ -20,24 +35,52 @@
use:action={{ title: $LL.share.TITLE() }}
transition:fly={{ x: -8 }}
class="icon"
onclick={triggerShare}>share</button
on:click={triggerShare}>share</button
>
<button
use:action={{ title: $LL.print.TITLE() }}
transition:fly={{ x: -8 }}
class="icon"
onclick={() => print()}>print</button
on:click={() => 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

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

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 onclick={sync}
<button transition:slide on:click={sync}
><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button
>
{/if}

View File

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

View File

@@ -1,5 +1,5 @@
<script>
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_CODES } from "$lib/serial/keymap-codes";
import { KEYMAP_CATEGORIES, 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 = $state(0);
let progress = 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,6 +144,7 @@
progress = i;
if ("phrase" in chord) {
console.log(encodeChord(chord, osLayout));
await index.addAsync(i, encodeChord(chord, osLayout));
}
}
@@ -210,7 +211,7 @@
setContext("cursor-crossfade", crossfade({}));
let page = $state(0);
let page = 0;
</script>
<svelte:head>
@@ -222,7 +223,7 @@
<input
type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
oninput={(event) => $searchIndex && search($searchIndex, event)}
on:input={(event) => $searchIndex && search($searchIndex, event)}
class:loading={progress !== $chords.length - 1}
/>
<div class="paginator">
@@ -234,12 +235,12 @@
</div>
<button
class="icon"
onclick={() => (page = Math.max(page - 1, 0))}
on:click={() => (page = Math.max(page - 1, 0))}
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
>
<button
class="icon"
onclick={() => (page = Math.min(page + 1, $lastPage))}
on:click={() => (page = Math.min(page + 1, $lastPage))}
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
>
</div>
@@ -250,24 +251,22 @@
<div class="results">
<table transition:fly={{ y: 48, easing: expoOut }}>
{#if $lastPage !== -1}
<tbody>
{#if page === 0}
<tr
><th class="new-chord"
><ChordActionEdit
onsubmit={(action) => insertChord(action)}
/></th
><td></td><td></td></tr
>
{#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>
{/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} onduplicate={() => (page = 0)} />
</tr>
{/if}
{/each}</tbody
>
{/each}
{:else}
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
{/if}
@@ -279,7 +278,7 @@
"\n\nDid you know? " +
randomTips[Math.floor(randomTips.length * Math.random())]}
></textarea>
<button onclick={downloadVocabulary}
<button on:click={downloadVocabulary}
><span class="icon">download</span>
{$LL.configure.chords.VOCABULARY()}</button
>

View File

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

View File

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

View File

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

View File

@@ -1,21 +1,12 @@
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { mount, unmount, tick } from "svelte";
import { tick } from "svelte";
export function selectAction(
event: MouseEvent | KeyboardEvent,
select: (action: number) => void,
dismissed?: () => void,
) {
const component = mount(ActionSelector, {
target: document.body,
props: {
onclose: () => closed(),
onselect: (action: number) => {
select(action);
closed();
},
},
});
const component = new ActionSelector({ target: document.body });
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
const dialogRect = dialog.getBoundingClientRect();
@@ -49,8 +40,14 @@ export function selectAction(
await dialogAnimation.finished;
unmount(component);
component.$destroy();
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 { mount, setContext, unmount } from "svelte";
import { setContext } 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: {};
let shareComponent: SharePopup;
tippy(event.target as HTMLElement, {
onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!;
shareComponent = mount(SharePopup, { target });
shareComponent = new SharePopup({ target });
},
onHidden(instance) {
instance.destroy();
},
onDestroy() {
unmount(shareComponent);
shareComponent.$destroy();
},
}).show();
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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