mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-16 06:02:41 +00:00
Compare commits
1 Commits
fix-typo-n
...
fix-typo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29756834f8 |
12
.github/workflows/build.yml
vendored
12
.github/workflows/build.yml
vendored
@@ -21,22 +21,18 @@ jobs:
|
|||||||
- name: ⏬ Install Python dependencies
|
- name: ⏬ Install Python dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
- name: Install pnpm
|
|
||||||
uses: pnpm/action-setup@v4
|
|
||||||
with:
|
|
||||||
version: 8
|
|
||||||
- name: 🐉 Use Node.js 18.16.x
|
- name: 🐉 Use Node.js 18.16.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18.16.x
|
node-version: 18.16.x
|
||||||
cache: "pnpm"
|
cache: "npm"
|
||||||
- name: ⏬ Install Node dependencies
|
- name: ⏬ Install Node dependencies
|
||||||
run: pnpm install
|
run: npm ci
|
||||||
|
|
||||||
- name: 🔥 Optimize icon font
|
- name: 🔥 Optimize icon font
|
||||||
run: pnpm minify-icons
|
run: npm run minify-icons
|
||||||
- name: 🔨 Build site
|
- name: 🔨 Build site
|
||||||
run: pnpm build
|
run: npm run build
|
||||||
|
|
||||||
- name: 📦 Upload build artifacts
|
- name: 📦 Upload build artifacts
|
||||||
uses: actions/upload-artifact@v3.1.2
|
uses: actions/upload-artifact@v3.1.2
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ You may need to run through some additional setup to get Rust running inside Int
|
|||||||
- Python >=3.10
|
- Python >=3.10
|
||||||
- Rust Stable (For Tauri Development)
|
- Rust Stable (For Tauri Development)
|
||||||
|
|
||||||
I know, python in JS projects is extremely annoying. Unfortunately,
|
I know, python in JS projects is extremely annoying, unfortunately,
|
||||||
it seems to be the only platform that offers a functional
|
it seems to be the only platform that offers a functional
|
||||||
way to subset variable woff2 fonts with ligatures.
|
way to subset variable woff2 fonts with ligatures.
|
||||||
|
|
||||||
|
|||||||
58
flake.lock
generated
58
flake.lock
generated
@@ -5,11 +5,29 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1710146030,
|
"lastModified": 1689068808,
|
||||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681202837,
|
||||||
|
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -20,11 +38,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1722415718,
|
"lastModified": 1689752456,
|
||||||
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=",
|
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce",
|
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -36,11 +54,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1718428119,
|
"lastModified": 1681358109,
|
||||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -59,14 +77,15 @@
|
|||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils_2",
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1722391647,
|
"lastModified": 1690942540,
|
||||||
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=",
|
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8",
|
"rev": "aa3994f054038262df55122dfa552b9eab71a994",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -89,6 +108,21 @@
|
|||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
33
flake.nix
33
flake.nix
@@ -4,35 +4,19 @@
|
|||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
outputs =
|
outputs = {
|
||||||
{
|
|
||||||
self,
|
self,
|
||||||
nixpkgs,
|
nixpkgs,
|
||||||
flake-utils,
|
flake-utils,
|
||||||
rust-overlay,
|
rust-overlay,
|
||||||
}:
|
}:
|
||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (system: let
|
||||||
system:
|
|
||||||
let
|
|
||||||
overlays = [(import rust-overlay)];
|
overlays = [(import rust-overlay)];
|
||||||
pkgs = import nixpkgs {inherit system overlays;};
|
pkgs = import nixpkgs {inherit system overlays;};
|
||||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [
|
extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
|
||||||
"rust-src"
|
|
||||||
"rust-std"
|
|
||||||
"clippy"
|
|
||||||
"rust-analyzer"
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
fontMin = pkgs.python311.withPackages (
|
fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
|
||||||
ps:
|
|
||||||
with ps;
|
|
||||||
[
|
|
||||||
brotli
|
|
||||||
fonttools
|
|
||||||
]
|
|
||||||
++ (with fonttools.optional-dependencies; [ woff ])
|
|
||||||
);
|
|
||||||
tauriPkgs = nixpkgs.legacyPackages.${system};
|
tauriPkgs = nixpkgs.legacyPackages.${system};
|
||||||
libraries = with tauriPkgs; [
|
libraries = with tauriPkgs; [
|
||||||
webkitgtk
|
webkitgtk
|
||||||
@@ -46,8 +30,7 @@
|
|||||||
];
|
];
|
||||||
packages =
|
packages =
|
||||||
(with pkgs; [
|
(with pkgs; [
|
||||||
nodejs_22
|
nodejs_18
|
||||||
nodePackages.pnpm
|
|
||||||
rust-bin
|
rust-bin
|
||||||
fontMin
|
fontMin
|
||||||
])
|
])
|
||||||
@@ -65,14 +48,12 @@
|
|||||||
# serial plugin
|
# serial plugin
|
||||||
udev
|
udev
|
||||||
]);
|
]);
|
||||||
in
|
in {
|
||||||
{
|
|
||||||
devShell = pkgs.mkShell {
|
devShell = pkgs.mkShell {
|
||||||
buildInputs = packages;
|
buildInputs = packages;
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,13 +94,6 @@ const config = {
|
|||||||
"description",
|
"description",
|
||||||
"add_circle",
|
"add_circle",
|
||||||
"refresh",
|
"refresh",
|
||||||
"tune",
|
|
||||||
"edit_document",
|
|
||||||
"chat",
|
|
||||||
"account_circle",
|
|
||||||
"experiment",
|
|
||||||
"code",
|
|
||||||
"dictionary",
|
|
||||||
],
|
],
|
||||||
codePoints: {
|
codePoints: {
|
||||||
speed: "e9e4",
|
speed: "e9e4",
|
||||||
@@ -119,9 +112,6 @@ const config = {
|
|||||||
upload_2: "ff52",
|
upload_2: "ff52",
|
||||||
stat_minus_2: "e69c",
|
stat_minus_2: "e69c",
|
||||||
stat_2: "e699",
|
stat_2: "e699",
|
||||||
routine: "e20c",
|
|
||||||
experiment: "e686",
|
|
||||||
dictionary: "f539",
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
11684
package-lock.json
generated
Normal file
11684
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@@ -1,12 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "1.5.2",
|
"version": "1.5.1",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
|
||||||
"node": ">=18.16",
|
|
||||||
"pnpm": ">=8.6"
|
|
||||||
},
|
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/CharaChorder/DeviceManager.git"
|
"url": "https://github.com/CharaChorder/DeviceManager.git"
|
||||||
@@ -34,58 +30,52 @@
|
|||||||
"typesafe-i18n": "typesafe-i18n"
|
"typesafe-i18n": "typesafe-i18n"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.17.0",
|
"@codemirror/autocomplete": "^6.15.0",
|
||||||
"@codemirror/commands": "^6.6.0",
|
"@codemirror/commands": "^6.3.3",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.2",
|
||||||
"@codemirror/language": "^6.10.2",
|
"@codemirror/language": "^6.10.1",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.4.1",
|
||||||
"@codemirror/view": "^6.29.1",
|
"@fontsource-variable/material-symbols-rounded": "^5.0.27",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.0.36",
|
"@fontsource-variable/noto-sans-mono": "^5.0.19",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
"@material/material-color-utilities": "^0.2.7",
|
||||||
"@lezer/highlight": "^1.2.0",
|
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
|
||||||
"@melt-ui/pp": "^0.3.2",
|
|
||||||
"@melt-ui/svelte": "^0.83.0",
|
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.2",
|
"@sveltejs/adapter-static": "^2.0.3",
|
||||||
"@sveltejs/kit": "^2.5.18",
|
"@sveltejs/kit": "^1.30.4",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
"@sveltejs/vite-plugin-svelte": "^2.5.3",
|
||||||
"@tauri-apps/api": "^1.6.0",
|
"@tauri-apps/api": "^1.5.3",
|
||||||
"@tauri-apps/cli": "^1.6.0",
|
"@tauri-apps/cli": "^1.5.11",
|
||||||
"@types/dom-view-transitions": "^1.0.5",
|
"@types/dom-view-transitions": "^1.0.4",
|
||||||
"@types/flexsearch": "^0.7.6",
|
"@types/flexsearch": "^0.7.6",
|
||||||
"@types/w3c-web-serial": "^1.0.6",
|
"@types/w3c-web-serial": "^1.0.6",
|
||||||
"@types/w3c-web-usb": "^1.0.10",
|
"@types/w3c-web-usb": "^1.0.10",
|
||||||
"@vite-pwa/sveltekit": "^0.6.0",
|
"@vite-pwa/sveltekit": "^0.2.10",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"cypress": "^13.13.2",
|
"cypress": "^13.7.2",
|
||||||
"d3": "^7.9.0",
|
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"fontkit": "^2.0.2",
|
"fontkit": "^2.0.2",
|
||||||
"glob": "^11.0.0",
|
"glob": "^10.3.12",
|
||||||
"jsdom": "^24.1.1",
|
"jsdom": "^22.1.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.2",
|
||||||
"sass": "^1.77.8",
|
"sass": "^1.74.1",
|
||||||
"stylelint": "^16.8.1",
|
"stylelint": "^15.11.0",
|
||||||
"stylelint-config-clean-order": "^6.1.0",
|
"stylelint-config-clean-order": "^5.4.2",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-recommended-scss": "^14.1.0",
|
"stylelint-config-recommended-scss": "^13.1.0",
|
||||||
"stylelint-config-standard-scss": "^13.1.0",
|
"stylelint-config-standard-scss": "^11.1.0",
|
||||||
"svelte": "5.0.0-next.221",
|
"svelte": "^4.2.12",
|
||||||
"svelte-check": "^3.8.5",
|
"svelte-check": "^3.6.9",
|
||||||
"svelte-preprocess": "^6.0.2",
|
"svelte-preprocess": "^5.1.3",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.4.4",
|
||||||
"vite": "^5.3.5",
|
"vite": "^4.5.3",
|
||||||
"vite-plugin-mkcert": "^1.17.5",
|
"vite-plugin-mkcert": "^1.17.5",
|
||||||
"vite-plugin-pwa": "^0.20.1",
|
"vite-plugin-pwa": "^0.17.5",
|
||||||
"vitest": "^2.0.5",
|
"vitest": "^0.34.6"
|
||||||
"workbox-window": "^7.1.0"
|
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
8259
pnpm-lock.yaml
generated
8259
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "1.5.2"
|
version = "1.5.1"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||||
license = "AGPL-3"
|
license = "AGPL-3"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devPath": "http://localhost:5173",
|
"devPath": "http://localhost:5173",
|
||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": { "productName": "amacc1ng", "version": "1.5.2" },
|
"package": { "productName": "amacc1ng", "version": "1.5.1" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -9,193 +9,145 @@ actions:
|
|||||||
This action is unique in this way. Technically it is "printable", but it is not visible.
|
This action is unique in this way. Technically it is "printable", but it is not visible.
|
||||||
39:
|
39:
|
||||||
id: "'"
|
id: "'"
|
||||||
keyCode: Quote
|
|
||||||
title: Single Quote
|
title: Single Quote
|
||||||
44:
|
44:
|
||||||
id: ","
|
id: ","
|
||||||
keyCode: Comma
|
|
||||||
title: Comma
|
title: Comma
|
||||||
45:
|
45:
|
||||||
id: "-"
|
id: "-"
|
||||||
keyCode: Minus
|
|
||||||
title: Minus
|
title: Minus
|
||||||
46:
|
46:
|
||||||
id: "."
|
id: "."
|
||||||
keyCode: Period
|
|
||||||
title: Period
|
title: Period
|
||||||
47:
|
47:
|
||||||
id: "/"
|
id: "/"
|
||||||
keyCode: Slash
|
|
||||||
title: Forward Slash
|
title: Forward Slash
|
||||||
48:
|
48:
|
||||||
id: "0"
|
id: "0"
|
||||||
keyCode: Digit0
|
|
||||||
title: Zero
|
title: Zero
|
||||||
49:
|
49:
|
||||||
id: "1"
|
id: "1"
|
||||||
keyCode: Digit1
|
|
||||||
title: One
|
title: One
|
||||||
50:
|
50:
|
||||||
id: "2"
|
id: "2"
|
||||||
keyCode: Digit2
|
|
||||||
title: Two
|
title: Two
|
||||||
51:
|
51:
|
||||||
id: "3"
|
id: "3"
|
||||||
keyCode: Digit3
|
|
||||||
title: Three
|
title: Three
|
||||||
52:
|
52:
|
||||||
id: "4"
|
id: "4"
|
||||||
keyCode: Digit4
|
|
||||||
title: Four
|
title: Four
|
||||||
53:
|
53:
|
||||||
id: "5"
|
id: "5"
|
||||||
keyCode: Digit5
|
|
||||||
title: Five
|
title: Five
|
||||||
54:
|
54:
|
||||||
id: "6"
|
id: "6"
|
||||||
keyCode: Digit6
|
|
||||||
title: Six
|
title: Six
|
||||||
55:
|
55:
|
||||||
id: "7"
|
id: "7"
|
||||||
keyCode: Digit7
|
|
||||||
title: Seven
|
title: Seven
|
||||||
56:
|
56:
|
||||||
id: "8"
|
id: "8"
|
||||||
keyCode: Digit8
|
|
||||||
title: Eight
|
title: Eight
|
||||||
57:
|
57:
|
||||||
id: "9"
|
id: "9"
|
||||||
keyCode: Digit9
|
|
||||||
title: Nine
|
title: Nine
|
||||||
59:
|
59:
|
||||||
id: ";"
|
id: ";"
|
||||||
keyCode: Semicolon
|
|
||||||
title: Semicolon
|
title: Semicolon
|
||||||
61:
|
61:
|
||||||
id: "="
|
id: "="
|
||||||
keyCode: Equal
|
|
||||||
title: Equals
|
title: Equals
|
||||||
91:
|
91:
|
||||||
id: "["
|
id: "["
|
||||||
keyCode: BracketLeft
|
|
||||||
title: Left Bracket
|
title: Left Bracket
|
||||||
92:
|
92:
|
||||||
id: "\\"
|
id: "\\"
|
||||||
keyCode: Backslash
|
|
||||||
title: Backslash
|
title: Backslash
|
||||||
93:
|
93:
|
||||||
id: "]"
|
id: "]"
|
||||||
keyCode: BracketRight
|
|
||||||
title: Right Bracket
|
title: Right Bracket
|
||||||
96:
|
96:
|
||||||
id: "`"
|
id: "`"
|
||||||
keyCode: Backquote
|
|
||||||
title: Backtick
|
title: Backtick
|
||||||
97:
|
97:
|
||||||
id: "a"
|
id: "a"
|
||||||
keyCode: KeyA
|
|
||||||
title: Lowercase a
|
title: Lowercase a
|
||||||
98:
|
98:
|
||||||
id: "b"
|
id: "b"
|
||||||
keyCode: KeyB
|
|
||||||
title: Lowercase b
|
title: Lowercase b
|
||||||
99:
|
99:
|
||||||
id: "c"
|
id: "c"
|
||||||
keyCode: KeyC
|
|
||||||
title: Lowercase c
|
title: Lowercase c
|
||||||
100:
|
100:
|
||||||
id: "d"
|
id: "d"
|
||||||
keyCode: KeyD
|
|
||||||
title: Lowercase d
|
title: Lowercase d
|
||||||
101:
|
101:
|
||||||
id: "e"
|
id: "e"
|
||||||
keyCode: KeyE
|
|
||||||
title: Lowercase e
|
title: Lowercase e
|
||||||
102:
|
102:
|
||||||
id: "f"
|
id: "f"
|
||||||
keyCode: KeyF
|
|
||||||
title: Lowercase f
|
title: Lowercase f
|
||||||
103:
|
103:
|
||||||
id: "g"
|
id: "g"
|
||||||
keyCode: KeyG
|
|
||||||
title: Lowercase g
|
title: Lowercase g
|
||||||
104:
|
104:
|
||||||
id: "h"
|
id: "h"
|
||||||
keyCode: KeyH
|
|
||||||
title: Lowercase h
|
title: Lowercase h
|
||||||
105:
|
105:
|
||||||
id: "i"
|
id: "i"
|
||||||
keyCode: KeyI
|
|
||||||
title: Lowercase i
|
title: Lowercase i
|
||||||
106:
|
106:
|
||||||
id: "j"
|
id: "j"
|
||||||
keyCode: KeyJ
|
|
||||||
title: Lowercase j
|
title: Lowercase j
|
||||||
107:
|
107:
|
||||||
id: "k"
|
id: "k"
|
||||||
keyCode: KeyK
|
|
||||||
title: Lowercase k
|
title: Lowercase k
|
||||||
108:
|
108:
|
||||||
id: "l"
|
id: "l"
|
||||||
keyCode: KeyL
|
|
||||||
title: Lowercase l
|
title: Lowercase l
|
||||||
109:
|
109:
|
||||||
id: "m"
|
id: "m"
|
||||||
keyCode: KeyM
|
|
||||||
title: Lowercase m
|
title: Lowercase m
|
||||||
110:
|
110:
|
||||||
id: "n"
|
id: "n"
|
||||||
keyCode: KeyN
|
|
||||||
title: Lowercase n
|
title: Lowercase n
|
||||||
111:
|
111:
|
||||||
id: "o"
|
id: "o"
|
||||||
keyCode: KeyO
|
|
||||||
title: Lowercase o
|
title: Lowercase o
|
||||||
112:
|
112:
|
||||||
id: "p"
|
id: "p"
|
||||||
keyCode: KeyP
|
|
||||||
title: Lowercase p
|
title: Lowercase p
|
||||||
113:
|
113:
|
||||||
id: "q"
|
id: "q"
|
||||||
keyCode: KeyQ
|
|
||||||
title: Lowercase q
|
title: Lowercase q
|
||||||
114:
|
114:
|
||||||
id: "r"
|
id: "r"
|
||||||
keyCode: KeyR
|
|
||||||
title: Lowercase r
|
title: Lowercase r
|
||||||
115:
|
115:
|
||||||
id: "s"
|
id: "s"
|
||||||
keyCode: KeyS
|
|
||||||
title: Lowercase s
|
title: Lowercase s
|
||||||
116:
|
116:
|
||||||
id: "t"
|
id: "t"
|
||||||
keyCode: KeyT
|
|
||||||
title: Lowercase t
|
title: Lowercase t
|
||||||
117:
|
117:
|
||||||
id: "u"
|
id: "u"
|
||||||
keyCode: KeyU
|
|
||||||
title: Lowercase u
|
title: Lowercase u
|
||||||
118:
|
118:
|
||||||
id: "v"
|
id: "v"
|
||||||
keyCode: KeyV
|
|
||||||
title: Lowercase v
|
title: Lowercase v
|
||||||
119:
|
119:
|
||||||
id: "w"
|
id: "w"
|
||||||
KeyCode: KeyW
|
|
||||||
title: Lowercase w
|
title: Lowercase w
|
||||||
120:
|
120:
|
||||||
id: "x"
|
id: "x"
|
||||||
keyCode: KeyX
|
|
||||||
title: Lowercase x
|
title: Lowercase x
|
||||||
121:
|
121:
|
||||||
id: "y"
|
id: "y"
|
||||||
keyCode: KeyY
|
|
||||||
title: Lowercase y
|
title: Lowercase y
|
||||||
122:
|
122:
|
||||||
id: "z"
|
id: "z"
|
||||||
keyCode: KeyZ
|
|
||||||
title: Lowercase z
|
title: Lowercase z
|
||||||
127:
|
127:
|
||||||
id: "DEL"
|
id: "DEL"
|
||||||
keyCode: Delete
|
|
||||||
title: Delete
|
title: Delete
|
||||||
|
|||||||
@@ -70,9 +70,6 @@ actions:
|
|||||||
title: Primary Keymap
|
title: Primary Keymap
|
||||||
icon: counter_1
|
icon: counter_1
|
||||||
variant: left
|
variant: left
|
||||||
description: |
|
|
||||||
Acts as a toggle if the same action is not assigned
|
|
||||||
to the target layer
|
|
||||||
549:
|
549:
|
||||||
variantOf: 548
|
variantOf: 548
|
||||||
<<: *primary_keymap
|
<<: *primary_keymap
|
||||||
@@ -83,9 +80,6 @@ actions:
|
|||||||
title: Numeric Layer
|
title: Numeric Layer
|
||||||
icon: counter_2
|
icon: counter_2
|
||||||
variant: left
|
variant: left
|
||||||
description: |
|
|
||||||
Acts as a toggle if the same action is not assigned
|
|
||||||
to the target layer
|
|
||||||
551:
|
551:
|
||||||
variantOf: 550
|
variantOf: 550
|
||||||
<<: *secondary_keymap
|
<<: *secondary_keymap
|
||||||
@@ -96,31 +90,11 @@ actions:
|
|||||||
title: Function Layer
|
title: Function Layer
|
||||||
icon: counter_3
|
icon: counter_3
|
||||||
variant: left
|
variant: left
|
||||||
description: |
|
|
||||||
Acts as a toggle if the same action is not assigned
|
|
||||||
to the target layer
|
|
||||||
553:
|
553:
|
||||||
variationOf: 552
|
variationOf: 552
|
||||||
<<: *tertiary_keymap
|
<<: *tertiary_keymap
|
||||||
id: "KM_3_R"
|
id: "KM_3_R"
|
||||||
variant: right
|
variant: right
|
||||||
558:
|
|
||||||
id: HOLD_COMPOUND
|
|
||||||
title: Dynamic Library
|
|
||||||
icon: layers
|
|
||||||
description: |
|
|
||||||
Allows for the activation & creation of dynamic chord libraries.
|
|
||||||
When included as part of a chord output,
|
|
||||||
that chord's input becomes the seed for a dynamic chord library,
|
|
||||||
and that library is activated.
|
|
||||||
Any new chords created while a dynamic library is active are established one level above its seed.
|
|
||||||
559:
|
|
||||||
id: RELEASE_COMPOUND
|
|
||||||
title: Base Library
|
|
||||||
icon: layers_clear
|
|
||||||
description: |
|
|
||||||
Re-activates your base chord library,
|
|
||||||
and deactivates any currently active dynamic chord library.
|
|
||||||
576:
|
576:
|
||||||
id: ACTION_DELAY_1000
|
id: ACTION_DELAY_1000
|
||||||
icon: clock_loader_90
|
icon: clock_loader_90
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ actions:
|
|||||||
350:
|
350:
|
||||||
id: "KP_6"
|
id: "KP_6"
|
||||||
keyCode: "Numpad6"
|
keyCode: "Numpad6"
|
||||||
title: Keypad 6 and Right Arrow
|
title: Keypad 6 and Rigth Arrow
|
||||||
351:
|
351:
|
||||||
id: "KP_7"
|
id: "KP_7"
|
||||||
keyCode: "Numpad7"
|
keyCode: "Numpad7"
|
||||||
@@ -422,8 +422,8 @@ actions:
|
|||||||
title: Keyboard Non-US \ and | (US English)
|
title: Keyboard Non-US \ and | (US English)
|
||||||
357:
|
357:
|
||||||
id: "COMPOSE"
|
id: "COMPOSE"
|
||||||
icon: menu
|
|
||||||
title: Keyboard Application
|
title: Keyboard Application
|
||||||
|
description: Officially supported by Win, Unix, and Boot
|
||||||
358:
|
358:
|
||||||
id: "POWER"
|
id: "POWER"
|
||||||
keyCode: "Power"
|
keyCode: "Power"
|
||||||
@@ -944,99 +944,99 @@ actions:
|
|||||||
title: Keyboard Right GUI
|
title: Keyboard Right GUI
|
||||||
488:
|
488:
|
||||||
id: "KSC_E8"
|
id: "KSC_E8"
|
||||||
icon: play_pause
|
|
||||||
keyCode: "MediaPlayPause"
|
keyCode: "MediaPlayPause"
|
||||||
title: Media Play Pause
|
title: Media Play Pause
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
489:
|
489:
|
||||||
id: "KSC_E9"
|
id: "KSC_E9"
|
||||||
icon: stop
|
|
||||||
keyCode: "MediaStop"
|
keyCode: "MediaStop"
|
||||||
title: Media Stop CD
|
title: Media Stop CD
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
490:
|
490:
|
||||||
id: "KSC_EA"
|
id: "KSC_EA"
|
||||||
icon: skip_previous
|
|
||||||
keyCode: "MediaTrackPrevious"
|
keyCode: "MediaTrackPrevious"
|
||||||
title: Media Previous Song
|
title: Media Previous Song
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
491:
|
491:
|
||||||
id: "KSC_EB"
|
id: "KSC_EB"
|
||||||
icon: skip_next
|
|
||||||
keyCode: "MediaTrackNext"
|
keyCode: "MediaTrackNext"
|
||||||
title: Media Next Song
|
title: Media Next Song
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
492:
|
492:
|
||||||
id: "KSC_EC"
|
id: "KSC_EC"
|
||||||
icon: eject
|
|
||||||
keyCode: "Eject"
|
keyCode: "Eject"
|
||||||
title: Media Eject CD
|
title: Media Eject CD
|
||||||
description: MacOS only
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
493:
|
493:
|
||||||
id: "KSC_ED"
|
id: "KSC_ED"
|
||||||
icon: volume_up
|
|
||||||
keyCode: "AudioVolumeUp"
|
keyCode: "AudioVolumeUp"
|
||||||
title: Media Volume Up
|
title: Media Volume Up
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
494:
|
494:
|
||||||
id: "KSC_EE"
|
id: "KSC_EE"
|
||||||
icon: volume_down
|
|
||||||
keyCode: "AudioVolumeDown"
|
keyCode: "AudioVolumeDown"
|
||||||
title: Media Volume Down
|
title: Media Volume Down
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
495:
|
495:
|
||||||
id: "KSC_EF"
|
id: "KSC_EF"
|
||||||
icon: volume_off
|
|
||||||
keyCode: "AudioVolumeMute"
|
keyCode: "AudioVolumeMute"
|
||||||
title: Media Mute
|
title: Media Mute
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
496:
|
496:
|
||||||
id: "KSC_F0"
|
id: "KSC_F0"
|
||||||
icon: language
|
title: Media www
|
||||||
title: Media Browser
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
497:
|
497:
|
||||||
id: "KSC_F1"
|
id: "KSC_F1"
|
||||||
keyCode: "BrowserBack"
|
keyCode: "BrowserBack"
|
||||||
title: Media Browser Back
|
title: Media Back
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
498:
|
498:
|
||||||
id: "KSC_F2"
|
id: "KSC_F2"
|
||||||
keyCode: "BrowserForward"
|
keyCode: "BrowserForward"
|
||||||
title: Media Browser Forward
|
title: Media Forward
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
499:
|
499:
|
||||||
id: "KSC_F3"
|
id: "KSC_F3"
|
||||||
keyCode: "BrowserStop"
|
keyCode: "BrowserStop"
|
||||||
title: Media Browser Stop
|
title: Media Stop
|
||||||
description: Not supported on MacOS
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
500:
|
500:
|
||||||
id: "KSC_F4"
|
id: "KSC_F4"
|
||||||
icon: search
|
|
||||||
keyCode: "BrowserSearch"
|
keyCode: "BrowserSearch"
|
||||||
title: Media Browser Search
|
title: Media Find
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
501:
|
501:
|
||||||
id: "KSC_F5"
|
id: "KSC_F5"
|
||||||
icon: brightness_high
|
title: Media Scroll Up
|
||||||
title: Media Brightness Up
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
502:
|
502:
|
||||||
id: "KSC_F6"
|
id: "KSC_F6"
|
||||||
icon: brightness_low
|
title: Media Scroll Down
|
||||||
title: Media Brightness Down
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
503:
|
503:
|
||||||
id: "KSC_F7"
|
id: "KSC_F7"
|
||||||
title: Media Edit
|
title: Media Edit
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
504:
|
504:
|
||||||
id: "KSC_F8"
|
id: "KSC_F8"
|
||||||
icon: bedtime
|
|
||||||
keyCode: "Sleep"
|
keyCode: "Sleep"
|
||||||
title: Media System Sleep
|
title: Media Sleep
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
505:
|
505:
|
||||||
id: "KSC_F9"
|
id: "KSC_F9"
|
||||||
icon: routine
|
|
||||||
keyCode: "WakeUp"
|
keyCode: "WakeUp"
|
||||||
title: Media System Wake
|
title: Media Coffee
|
||||||
description: Not supported on Windows
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
506:
|
506:
|
||||||
id: "KSC_FA"
|
id: "KSC_FA"
|
||||||
keyCode: "BrowserRefresh"
|
keyCode: "BrowserRefresh"
|
||||||
title: Media Browser Refresh
|
title: Media Refresh
|
||||||
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
507:
|
507:
|
||||||
id: "KSC_FB"
|
id: "KSC_FB"
|
||||||
title: Media Calculator
|
title: Media Calc
|
||||||
description: Not supported on MacOS
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
508:
|
508:
|
||||||
id: "KSC_FC"
|
id: "KSC_FC"
|
||||||
description: Not required to be supported by any OS.
|
description: Not required to be supported by any OS.
|
||||||
|
|||||||
@@ -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 }
|
|
||||||
@@ -96,12 +96,7 @@ export function restoreFromFile(
|
|||||||
case "backup": {
|
case "backup": {
|
||||||
const recent = file.history[0];
|
const recent = file.history[0];
|
||||||
if (!recent) return;
|
if (!recent) return;
|
||||||
let backupDevice = recent[1].device;
|
if (recent[1].device !== get(serialPort)?.device) {
|
||||||
if (backupDevice === "TWO") backupDevice = "ONE";
|
|
||||||
let currentDevice = get(serialPort)?.device;
|
|
||||||
if (currentDevice === "TWO") currentDevice = "ONE";
|
|
||||||
|
|
||||||
if (backupDevice !== currentDevice) {
|
|
||||||
alert("Backup is incompatible with this device");
|
alert("Backup is incompatible with this device");
|
||||||
throw new Error("Backup is incompatible with this device");
|
throw new Error("Backup is incompatible with this device");
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/lib/backup/compat/legacy-chords.sample.json
Normal file
26
src/lib/backup/compat/legacy-chords.sample.json
Normal 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
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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"],
|
|
||||||
]);
|
|
||||||
@@ -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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,53 +3,47 @@
|
|||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import { action as title } from "$lib/title";
|
import { action as title } from "$lib/title";
|
||||||
import { osLayout } from "$lib/os-layout";
|
import { osLayout } from "$lib/os-layout";
|
||||||
|
import LL from "../../i18n/i18n-svelte";
|
||||||
|
|
||||||
let {
|
export let action: number | KeyInfo;
|
||||||
action,
|
export let display: "inline-keys" | "keys" = "inline-keys";
|
||||||
display,
|
|
||||||
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
|
|
||||||
|
|
||||||
let info = $derived(
|
$: info =
|
||||||
typeof action === "number"
|
typeof action === "number"
|
||||||
? (KEYMAP_CODES.get(action) ?? { code: action })
|
? KEYMAP_CODES.get(action) ?? { code: action }
|
||||||
: action,
|
: action;
|
||||||
);
|
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
|
||||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
|
||||||
|
|
||||||
let tooltip = $derived(
|
$: tooltip =
|
||||||
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
||||||
(info.title ?? "") +
|
(info.title ?? "") +
|
||||||
(info.variant === "left"
|
(info.variant === "left"
|
||||||
? " (left)"
|
? " (left)"
|
||||||
: info.variant === "right"
|
: info.variant === "right"
|
||||||
? " (right)"
|
? " (right)"
|
||||||
: ""),
|
: "");
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if display === "keys"}
|
{#if dynamicMapping}
|
||||||
|
<span
|
||||||
|
use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
|
||||||
|
class="dynamic"
|
||||||
|
class:left={info.variant === "left"}
|
||||||
|
class:right={info.variant === "right"}
|
||||||
|
class:inline={display === "inline-keys"}>{dynamicMapping}</span
|
||||||
|
>
|
||||||
|
{:else if display === "keys"}
|
||||||
<kbd
|
<kbd
|
||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
use:title={{ title: tooltip }}
|
use:title={{ title: tooltip }}
|
||||||
>
|
>
|
||||||
{dynamicMapping ??
|
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
|
||||||
info.icon ??
|
|
||||||
info.display ??
|
|
||||||
info.id ??
|
|
||||||
`0x${info.code.toString(16)}`}
|
|
||||||
</kbd>
|
</kbd>
|
||||||
{:else if display === "inline-keys"}
|
{:else if display === "inline-keys"}
|
||||||
{#if !info.icon && dynamicMapping?.length === 1}
|
{#if !info.icon && info.id?.length === 1}
|
||||||
<span
|
<span
|
||||||
use:title={{ title: tooltip }}
|
|
||||||
class:left={info.variant === "left"}
|
|
||||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
|
||||||
>
|
|
||||||
{:else if !info.icon && info.id?.length === 1}
|
|
||||||
<span
|
|
||||||
use:title={{ title: tooltip }}
|
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{info.id}</span
|
class:right={info.variant === "right"}>{info.id}</span
|
||||||
>
|
>
|
||||||
@@ -61,8 +55,7 @@
|
|||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
use:title={{ title: tooltip }}
|
use:title={{ title: tooltip }}
|
||||||
>
|
>
|
||||||
{dynamicMapping ??
|
{info.icon ??
|
||||||
info.icon ??
|
|
||||||
info.display ??
|
info.display ??
|
||||||
info.id ??
|
info.id ??
|
||||||
`0x${info.code.toString(16)}`}</kbd
|
`0x${info.code.toString(16)}`}</kbd
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../../i18n/i18n-svelte";
|
||||||
import Action from "$lib/components/Action.svelte";
|
import Action from "$lib/components/Action.svelte";
|
||||||
import type { MouseEventHandler } from "svelte/elements";
|
|
||||||
|
|
||||||
let {
|
export let id: number | KeyInfo;
|
||||||
id,
|
|
||||||
onclick,
|
|
||||||
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
|
|
||||||
$props();
|
|
||||||
|
|
||||||
let key = $derived(
|
$: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
||||||
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
|
||||||
| number
|
| number
|
||||||
| KeyInfo,
|
| KeyInfo;
|
||||||
);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button {onclick}>
|
<button on:click>
|
||||||
{#if typeof key === "object"}
|
{#if typeof key === "object"}
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<b>
|
<b>
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
import Action from "$lib/components/Action.svelte";
|
import Action from "$lib/components/Action.svelte";
|
||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
|
|
||||||
let {
|
export let actions: Array<number | KeyInfo>;
|
||||||
actions,
|
export let display: "keys" | "inline-keys" = "inline-keys";
|
||||||
display = "inline-keys",
|
|
||||||
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
|
|
||||||
$props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}
|
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $needRefresh}
|
{#if $needRefresh}
|
||||||
<button title="Update ready" onclick={() => updateServiceWorker(true)}
|
<button title="Update ready" on:click={() => updateServiceWorker(true)}
|
||||||
>Update <span class="icon">update</span></button
|
>Update <span class="icon">update</span></button
|
||||||
>
|
>
|
||||||
{:else if $offlineReady}
|
{:else if $offlineReady}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
io.scrollTo({ top: io.scrollHeight });
|
io.scrollTo({ top: io.scrollHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
let value: string = $state("");
|
let value: string;
|
||||||
let io: HTMLDivElement;
|
let io: HTMLDivElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form onsubmit={submit}>
|
<form on:submit={submit}>
|
||||||
<div bind:this={io} class="io">
|
<div bind:this={io} class="io">
|
||||||
{#each $serialLog as { type, value }}
|
{#each $serialLog as { type, value }}
|
||||||
{#if type === "input"}
|
{#if type === "input"}
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
<p transition:slide>{value}</p>
|
<p transition:slide>{value}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<div class="anchor"></div>
|
<div class="anchor" />
|
||||||
</div>
|
</div>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<input onsubmit={submit} bind:value />
|
<input on:submit={submit} bind:value />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
|
export let title: string | undefined;
|
||||||
|
export let shortcut: string | undefined;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if title}
|
{#if title}
|
||||||
|
|||||||
@@ -5,22 +5,13 @@
|
|||||||
KEYMAP_IDS,
|
KEYMAP_IDS,
|
||||||
} from "$lib/serial/keymap-codes";
|
} from "$lib/serial/keymap-codes";
|
||||||
import FlexSearch from "flexsearch";
|
import FlexSearch from "flexsearch";
|
||||||
import { onMount } from "svelte";
|
import { createEventDispatcher, onMount } from "svelte";
|
||||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../../../i18n/i18n-svelte";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
|
|
||||||
let {
|
export let currentAction: number | undefined = undefined;
|
||||||
currentAction = undefined,
|
export let nextAction: number | undefined = undefined;
|
||||||
nextAction = undefined,
|
|
||||||
onselect,
|
|
||||||
onclose,
|
|
||||||
}: {
|
|
||||||
currentAction?: number;
|
|
||||||
nextAction?: number;
|
|
||||||
onselect: (id: number) => void;
|
|
||||||
onclose: () => void;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
searchBox.focus();
|
searchBox.focus();
|
||||||
@@ -48,13 +39,13 @@
|
|||||||
|
|
||||||
function select(id?: number) {
|
function select(id?: number) {
|
||||||
if (id !== undefined) {
|
if (id !== undefined) {
|
||||||
onselect(id);
|
dispatch("select", id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyboardNavigation(event: KeyboardEvent) {
|
function keyboardNavigation(event: KeyboardEvent) {
|
||||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
if (event.shiftKey && event.key === "Enter") {
|
||||||
onselect(exact);
|
dispatch("select", exact);
|
||||||
} else if (event.key === "ArrowDown") {
|
} else if (event.key === "ArrowDown") {
|
||||||
const element =
|
const element =
|
||||||
resultList.querySelector("li:focus-within")?.nextSibling ??
|
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||||
@@ -76,45 +67,40 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
let results: number[] = $state([]);
|
let results: number[] = [];
|
||||||
let exact: number | undefined = $state(undefined);
|
let exact: number | undefined = undefined;
|
||||||
let code: number = $state(Number.NaN);
|
let code: number = Number.NaN;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
let searchBox: HTMLInputElement;
|
let searchBox: HTMLInputElement;
|
||||||
let resultList: HTMLUListElement;
|
let resultList: HTMLUListElement;
|
||||||
let filter = $state(new Set<number>());
|
let filter: Set<number>;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={keyboardNavigation} />
|
<svelte:window on:keydown={keyboardNavigation} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
<dialog open on:click|self={() => dispatch("close")}>
|
||||||
<dialog
|
|
||||||
open
|
|
||||||
onclick={(event) => {
|
|
||||||
if (event.target === event.currentTarget) onclose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
bind:this={searchBox}
|
bind:this={searchBox}
|
||||||
oninput={search}
|
on:input={search}
|
||||||
onkeypress={(event) => {
|
on:keypress={(event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
select(exact);
|
select(exact);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||||
/>
|
/>
|
||||||
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
<button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
||||||
>{$LL.actionSearch.DELETE()}</button
|
>{$LL.actionSearch.DELETE()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={onclose}>close</button
|
on:click={() => dispatch("close")}>close</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="filters">
|
<fieldset class="filters">
|
||||||
@@ -154,12 +140,12 @@
|
|||||||
{#if exact !== undefined}
|
{#if exact !== undefined}
|
||||||
<li class="exact">
|
<li class="exact">
|
||||||
<i>Exact match</i>
|
<i>Exact match</i>
|
||||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
<ActionListItem id={exact} on:click={() => select(exact)} />
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !exact && code}
|
{#if !exact && code}
|
||||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
<li><button on:click={() => select(code)}>USE CODE</button></li>
|
||||||
{:else}
|
{:else}
|
||||||
<li>Action code is out of range</li>
|
<li>Action code is out of range</li>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -170,7 +156,7 @@
|
|||||||
? Array.from(KEYMAP_CODES, ([it]) => it)
|
? Array.from(KEYMAP_CODES, ([it]) => it)
|
||||||
: results}
|
: results}
|
||||||
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
||||||
<li><ActionListItem {id} onclick={() => select(id)} /></li>
|
<li><ActionListItem {id} on:click={() => select(id)} /></li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||||
import { getContext, mount, unmount } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
||||||
}
|
}
|
||||||
|
|
||||||
let { visualLayout }: { visualLayout: VisualLayout } = $props();
|
export let visualLayout: VisualLayout;
|
||||||
let layoutInfo = $state(compileLayout(visualLayout));
|
$: layoutInfo = compileLayout(visualLayout);
|
||||||
|
|
||||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||||
@@ -127,26 +127,11 @@
|
|||||||
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||||
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
||||||
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
||||||
const component = mount(ActionSelector, {
|
const component = new ActionSelector({
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: {
|
||||||
currentAction,
|
currentAction,
|
||||||
nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
|
nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
|
||||||
onclose() {
|
|
||||||
closed();
|
|
||||||
},
|
|
||||||
onselect(action) {
|
|
||||||
changes.update((changes) => {
|
|
||||||
changes.push({
|
|
||||||
type: ChangeType.Layout,
|
|
||||||
id: keyInfo.id,
|
|
||||||
layer: get(activeLayer),
|
|
||||||
action,
|
|
||||||
});
|
|
||||||
return changes;
|
|
||||||
});
|
|
||||||
closed();
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||||
@@ -182,8 +167,22 @@
|
|||||||
|
|
||||||
await dialogAnimation.finished;
|
await dialogAnimation.finished;
|
||||||
|
|
||||||
unmount(component);
|
component.$destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
component.$on("close", closed);
|
||||||
|
component.$on("select", ({ detail }) => {
|
||||||
|
changes.update((changes) => {
|
||||||
|
changes.push({
|
||||||
|
type: ChangeType.Layout,
|
||||||
|
id: keyInfo.id,
|
||||||
|
layer: get(activeLayer),
|
||||||
|
action: detail,
|
||||||
|
});
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
closed();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusKey: CompiledLayoutKey;
|
let focusKey: CompiledLayoutKey;
|
||||||
@@ -202,9 +201,9 @@
|
|||||||
<KeyboardKey
|
<KeyboardKey
|
||||||
{i}
|
{i}
|
||||||
{key}
|
{key}
|
||||||
onfocusin={() => (focusKey = key)}
|
on:focusin={() => (focusKey = key)}
|
||||||
onclick={() => edit(i)}
|
on:click={() => edit(i)}
|
||||||
onkeypress={({ key }) => {
|
on:keypress={({ key }) => {
|
||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
edit(i);
|
edit(i);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,14 @@
|
|||||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||||
|
|
||||||
let {
|
export let key: CompiledLayoutKey;
|
||||||
key,
|
export let fontSizeMultiplier = 1;
|
||||||
fontSizeMultiplier = 1,
|
|
||||||
middle,
|
export let middle: [number, number];
|
||||||
pos,
|
export let pos: [number, number];
|
||||||
rotate,
|
export let rotate: number;
|
||||||
positions,
|
|
||||||
}: {
|
export let positions: [[number, number], [number, number], [number, number]];
|
||||||
key: CompiledLayoutKey;
|
|
||||||
fontSizeMultiplier?: number;
|
|
||||||
middle: [number, number];
|
|
||||||
pos: [number, number];
|
|
||||||
rotate: number;
|
|
||||||
positions: [[number, number], [number, number], [number, number]];
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each positions as position, layer}
|
{#each positions as position, layer}
|
||||||
|
|||||||
@@ -3,41 +3,24 @@
|
|||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import KeyText from "$lib/components/layout/KeyText.svelte";
|
import KeyText from "$lib/components/layout/KeyText.svelte";
|
||||||
import type {
|
|
||||||
FocusEventHandler,
|
|
||||||
KeyboardEventHandler,
|
|
||||||
MouseEventHandler,
|
|
||||||
} from "svelte/elements";
|
|
||||||
|
|
||||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||||
"visual-layout-config",
|
"visual-layout-config",
|
||||||
);
|
);
|
||||||
|
export let i: number;
|
||||||
|
export let key: CompiledLayoutKey;
|
||||||
|
|
||||||
let {
|
$: posX = key.pos[0] * scale;
|
||||||
i,
|
$: posY = key.pos[1] * scale;
|
||||||
key,
|
$: sizeX = key.size[0] * scale;
|
||||||
onclick,
|
$: sizeY = key.size[1] * scale;
|
||||||
onkeypress,
|
|
||||||
onfocusin,
|
|
||||||
}: {
|
|
||||||
i: number;
|
|
||||||
key: CompiledLayoutKey;
|
|
||||||
onclick: MouseEventHandler<SVGGElement>;
|
|
||||||
onkeypress: KeyboardEventHandler<SVGGElement>;
|
|
||||||
onfocusin: FocusEventHandler<SVGGElement>;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let posX = $derived(key.pos[0] * scale);
|
|
||||||
let posY = $derived(key.pos[1] * scale);
|
|
||||||
let sizeX = $derived(key.size[0] * scale);
|
|
||||||
let sizeY = $derived(key.size[1] * scale);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<g
|
<g
|
||||||
class="key-group"
|
class="key-group"
|
||||||
{onclick}
|
on:click
|
||||||
{onkeypress}
|
on:keypress
|
||||||
{onfocusin}
|
on:focusin
|
||||||
role="button"
|
role="button"
|
||||||
tabindex={i + 1}
|
tabindex={i + 1}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
|
|
||||||
let device = $derived($serialPort?.device);
|
$: device = $serialPort?.device;
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||||
|
|
||||||
const layers = [
|
const layers = [
|
||||||
@@ -21,10 +21,6 @@
|
|||||||
import("$lib/assets/layouts/one.yml").then(
|
import("$lib/assets/layouts/one.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
),
|
),
|
||||||
TWO: () =>
|
|
||||||
import("$lib/assets/layouts/one.yml").then(
|
|
||||||
(it) => it.default as VisualLayout,
|
|
||||||
),
|
|
||||||
LITE: () =>
|
LITE: () =>
|
||||||
import("$lib/assets/layouts/lite.yml").then(
|
import("$lib/assets/layouts/lite.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
@@ -33,10 +29,6 @@
|
|||||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
),
|
),
|
||||||
M4G: () =>
|
|
||||||
import("$lib/assets/layouts/m4g.yml").then(
|
|
||||||
(it) => it.default as VisualLayout,
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,7 +40,7 @@
|
|||||||
<button
|
<button
|
||||||
class="icon"
|
class="icon"
|
||||||
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
||||||
onclick={() => ($activeLayer = value)}
|
on:click={() => ($activeLayer = value)}
|
||||||
class:active={$activeLayer === value}
|
class:active={$activeLayer === value}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||||
import ActionString from "$lib/components/ActionString.svelte";
|
import ActionString from "$lib/components/ActionString.svelte";
|
||||||
|
|
||||||
let {
|
export let title: string;
|
||||||
title,
|
export let message: string | undefined;
|
||||||
message,
|
export let abortTitle: string;
|
||||||
abortTitle,
|
export let confirmTitle: string;
|
||||||
confirmTitle,
|
|
||||||
actions = [],
|
export let actions: number[] = [];
|
||||||
onabort,
|
|
||||||
onconfirm,
|
const dispatch = createEventDispatcher();
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
message?: string;
|
|
||||||
abortTitle: string;
|
|
||||||
confirmTitle: string;
|
|
||||||
actions: number[];
|
|
||||||
onabort: () => void;
|
|
||||||
onconfirm: () => void;
|
|
||||||
} = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
@@ -28,8 +20,10 @@
|
|||||||
{/if}
|
{/if}
|
||||||
<p><ActionString {actions} /></p>
|
<p><ActionString {actions} /></p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button onclick={onabort}>{abortTitle}</button>
|
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
|
||||||
<button class="primary" onclick={onconfirm}>{confirmTitle}</button>
|
<button class="primary" on:click={() => dispatch("confirm")}
|
||||||
|
>{confirmTitle}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, type Snippet } from "svelte";
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
@@ -11,7 +9,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog bind:this={modal}>
|
<dialog bind:this={modal}>
|
||||||
{@render children()}
|
<slot />
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
} from "$lib/undo-redo";
|
} from "$lib/undo-redo";
|
||||||
import { ChangeType, chords } from "$lib/undo-redo";
|
import { ChangeType, chords } from "$lib/undo-redo";
|
||||||
import ActionString from "$lib/components/ActionString.svelte";
|
import ActionString from "$lib/components/ActionString.svelte";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../../i18n/i18n-svelte";
|
||||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||||
|
|
||||||
export let changes: Change[] = [
|
export let changes: Change[] = [
|
||||||
|
|||||||
@@ -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;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,28 +1,25 @@
|
|||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import { unmount, mount, type Component } from "svelte";
|
import type { ComponentType, SvelteComponent } from "svelte";
|
||||||
|
|
||||||
export const popup: Action<HTMLButtonElement, Component> = (
|
export const popup: Action<HTMLButtonElement, ComponentType> = (
|
||||||
node,
|
node,
|
||||||
Component,
|
Component,
|
||||||
) => {
|
) => {
|
||||||
let component: {} | undefined;
|
let component: SvelteComponent | undefined;
|
||||||
let target: HTMLElement | undefined;
|
let target: HTMLElement | undefined;
|
||||||
const edit = tippy(node, {
|
const edit = tippy(node, {
|
||||||
interactive: true,
|
interactive: true,
|
||||||
placement: "right",
|
|
||||||
trigger: "click",
|
trigger: "click",
|
||||||
onShow(instance) {
|
onShow(instance) {
|
||||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||||
target.classList.add("active");
|
target.classList.add("active");
|
||||||
component ??= mount(Component, { target });
|
component ??= new Component({ target });
|
||||||
},
|
},
|
||||||
onHidden() {
|
onHidden() {
|
||||||
if (component) {
|
component?.$destroy();
|
||||||
unmount(component);
|
|
||||||
component = undefined;
|
|
||||||
}
|
|
||||||
target?.classList.remove("active");
|
target?.classList.remove("active");
|
||||||
|
component = undefined;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let {
|
import { createEventDispatcher } from "svelte";
|
||||||
ports,
|
|
||||||
onconfirm,
|
export let ports: SerialPort[];
|
||||||
}: {
|
const dispatch = createEventDispatcher<{ confirm: SerialPort | undefined }>();
|
||||||
ports: SerialPort[];
|
let selected = ports[0]?.getInfo().name;
|
||||||
onconfirm: (port: SerialPort | undefined) => void;
|
|
||||||
} = $props();
|
|
||||||
let selected = $state(ports[0]?.getInfo().name);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog>
|
<dialog>
|
||||||
@@ -22,9 +19,12 @@
|
|||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<button onclick={() => onconfirm(undefined)}>Cancel</button>
|
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
onclick={() =>
|
on:click={() =>
|
||||||
onconfirm(ports.find((it) => it.getInfo().name === selected))}>Ok</button
|
dispatch(
|
||||||
|
"confirm",
|
||||||
|
ports.find((it) => it.getInfo().name === selected),
|
||||||
|
)}>Ok</button
|
||||||
>
|
>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -55,19 +55,3 @@ export function deserializeActions(native: bigint): number[] {
|
|||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hashes a chord input the same way as CCOS
|
|
||||||
*/
|
|
||||||
export function hashChord(actions: number[]) {
|
|
||||||
const chord = new Uint8Array(16);
|
|
||||||
const view = new DataView(chord.buffer);
|
|
||||||
const serialized = serializeActions(actions);
|
|
||||||
view.setBigUint64(0, serialized & 0xffff_ffff_ffff_ffffn, true);
|
|
||||||
view.setBigUint64(8, serialized >> 64n, true);
|
|
||||||
let hash = 2166136261;
|
|
||||||
for (let i = 0; i < 16; i++) {
|
|
||||||
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
|
|
||||||
}
|
|
||||||
return hash & 0x3fff_ffff;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,19 +12,15 @@ import { browser } from "$app/environment";
|
|||||||
|
|
||||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||||
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
|
|
||||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
||||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
||||||
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const KEY_COUNTS = {
|
const KEY_COUNTS = {
|
||||||
ONE: 90,
|
ONE: 90,
|
||||||
TWO: 90,
|
|
||||||
LITE: 67,
|
LITE: 67,
|
||||||
X: 256,
|
X: 256,
|
||||||
M4G: 90,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -90,9 +86,9 @@ export class CharaDevice {
|
|||||||
private suspendDebounceId?: number;
|
private suspendDebounceId?: number;
|
||||||
|
|
||||||
version!: SemVer;
|
version!: SemVer;
|
||||||
company!: "CHARACHORDER" | "FORGE";
|
company!: "CHARACHORDER";
|
||||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
|
device!: "ONE" | "LITE" | "X";
|
||||||
chipset!: "M0" | "S2" | "S3";
|
chipset!: "M0" | "S2";
|
||||||
keyCount!: 90 | 67 | 256;
|
keyCount!: 90 | 67 | 256;
|
||||||
|
|
||||||
get portInfo() {
|
get portInfo() {
|
||||||
@@ -128,9 +124,9 @@ export class CharaDevice {
|
|||||||
await this.send(1, "VERSION").then(([version]) => version),
|
await this.send(1, "VERSION").then(([version]) => version),
|
||||||
);
|
);
|
||||||
const [company, device, chipset] = await this.send(3, "ID");
|
const [company, device, chipset] = await this.send(3, "ID");
|
||||||
this.company = company as typeof this.company;
|
this.company = company as "CHARACHORDER";
|
||||||
this.device = device as typeof this.device;
|
this.device = device as "ONE" | "LITE" | "X";
|
||||||
this.chipset = chipset as typeof this.chipset;
|
this.chipset = chipset as "M0" | "S2";
|
||||||
this.keyCount = KEY_COUNTS[this.device];
|
this.keyCount = KEY_COUNTS[this.device];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
alert(e);
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export async function charaFileFromUriComponent<T extends CharaFiles>(
|
|||||||
.stream()
|
.stream()
|
||||||
.pipeThrough(new DecompressionStream("deflate"));
|
.pipeThrough(new DecompressionStream("deflate"));
|
||||||
const actions = new Uint8Array(await new Response(stream).arrayBuffer());
|
const actions = new Uint8Array(await new Response(stream).arrayBuffer());
|
||||||
|
console.log(actions);
|
||||||
file[key] = deserializeActionArray(actions);
|
file[key] = deserializeActionArray(actions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
h1 {
|
|
||||||
margin-block-start: 0;
|
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--md-sys-color-secondary);
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,10 @@
|
|||||||
@import "./reset";
|
|
||||||
|
|
||||||
@import "./form/button";
|
@import "./form/button";
|
||||||
@import "./form/toggle";
|
@import "./form/toggle";
|
||||||
@import "./form/checkbox";
|
@import "./form/checkbox";
|
||||||
|
|
||||||
@import "./kbd";
|
@import "./kbd";
|
||||||
@import "./print";
|
@import "./print";
|
||||||
|
|
||||||
@import "./elements/h1";
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
body {
|
appearance: none;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
|
||||||
color: var(--md-sys-color-on-background);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
contain: strict;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
padding-inline: 16px;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import { mount, unmount, type SvelteComponent } from "svelte";
|
import type { SvelteComponent } from "svelte";
|
||||||
import Tooltip from "$lib/components/Tooltip.svelte";
|
import Tooltip from "$lib/components/Tooltip.svelte";
|
||||||
|
|
||||||
export const hotkeys = new Map<string, HTMLElement>();
|
export const hotkeys = new Map<string, HTMLElement>();
|
||||||
@@ -9,22 +9,20 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
|
|||||||
node: Element,
|
node: Element,
|
||||||
{ title, shortcut },
|
{ title, shortcut },
|
||||||
) => {
|
) => {
|
||||||
let component: {} | undefined;
|
let component: SvelteComponent | undefined;
|
||||||
const tooltip = tippy(node, {
|
const tooltip = tippy(node, {
|
||||||
arrow: false,
|
arrow: false,
|
||||||
theme: "tooltip",
|
theme: "tooltip",
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
onShow(instance) {
|
onShow(instance) {
|
||||||
component ??= mount(Tooltip, {
|
component ??= new Tooltip({
|
||||||
target: instance.popper.querySelector(".tippy-content") as Element,
|
target: instance.popper.querySelector(".tippy-content") as Element,
|
||||||
props: { title, shortcut },
|
props: { title, shortcut },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onHidden() {
|
onHidden() {
|
||||||
if (component) {
|
component?.$destroy();
|
||||||
unmount(component);
|
|
||||||
component = undefined;
|
component = undefined;
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { persistentWritable } from "$lib/storage";
|
import { persistentWritable } from "$lib/storage";
|
||||||
import { derived } from "svelte/store";
|
import { derived } from "svelte/store";
|
||||||
import { hashChord, type Chord } from "$lib/serial/chord";
|
import type { Chord } from "$lib/serial/chord";
|
||||||
import {
|
import {
|
||||||
deviceChords,
|
deviceChords,
|
||||||
deviceLayout,
|
deviceLayout,
|
||||||
@@ -158,9 +158,3 @@ export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
|
|||||||
a.localeCompare(b),
|
a.localeCompare(b),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const chordHashes = derived(
|
|
||||||
chords,
|
|
||||||
(chords) =>
|
|
||||||
new Map(chords.map((chord) => [hashChord(chord.actions), chord] as const)),
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<h2>WIP</h2>
|
|
||||||
@@ -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}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
@@ -4,13 +4,13 @@
|
|||||||
import "$lib/style/scrollbar.scss";
|
import "$lib/style/scrollbar.scss";
|
||||||
import "$lib/style/tippy.scss";
|
import "$lib/style/tippy.scss";
|
||||||
import "$lib/style/theme.scss";
|
import "$lib/style/theme.scss";
|
||||||
import Sidebar from "./Sidebar.svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { onDestroy, onMount, type Snippet } from "svelte";
|
|
||||||
import {
|
import {
|
||||||
applyTheme,
|
applyTheme,
|
||||||
argbFromHex,
|
argbFromHex,
|
||||||
themeFromSourceColor,
|
themeFromSourceColor,
|
||||||
} from "@material/material-color-utilities";
|
} from "@material/material-color-utilities";
|
||||||
|
import Navigation from "./Navigation.svelte";
|
||||||
import { canAutoConnect } from "$lib/serial/device";
|
import { canAutoConnect } from "$lib/serial/device";
|
||||||
import { initSerial } from "$lib/serial/connection";
|
import { initSerial } from "$lib/serial/connection";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
@@ -20,10 +20,10 @@
|
|||||||
import "tippy.js/dist/tippy.css";
|
import "tippy.js/dist/tippy.css";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import { theme, userPreferences } from "$lib/preferences.js";
|
import { theme, userPreferences } from "$lib/preferences.js";
|
||||||
import { LL, setLocale } from "$i18n/i18n-svelte";
|
import { LL, setLocale } from "../i18n/i18n-svelte";
|
||||||
import { loadLocale } from "$i18n/i18n-util.sync";
|
import { loadLocale } from "../i18n/i18n-util.sync";
|
||||||
import { detectLocale } from "$i18n/i18n-util";
|
import { detectLocale } from "../i18n/i18n-util";
|
||||||
import type { Locales } from "$i18n/i18n-types";
|
import type { Locales } from "../i18n/i18n-types";
|
||||||
import Footer from "./Footer.svelte";
|
import Footer from "./Footer.svelte";
|
||||||
import { osLayout, runLayoutDetection } from "$lib/os-layout.js";
|
import { osLayout, runLayoutDetection } from "$lib/os-layout.js";
|
||||||
import PageTransition from "./PageTransition.svelte";
|
import PageTransition from "./PageTransition.svelte";
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
export let data: LayoutData;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
theme.subscribe((it) => {
|
theme.subscribe((it) => {
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
stopLayoutDetection?.();
|
stopLayoutDetection?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
let webManifestLink = $state("");
|
let webManifestLink = "";
|
||||||
|
|
||||||
function handleHotkey(event: KeyboardEvent) {
|
function handleHotkey(event: KeyboardEvent) {
|
||||||
let key = $osLayout.get(event.code);
|
let key = $osLayout.get(event.code);
|
||||||
@@ -108,23 +108,20 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<!--{@html webManifestLink}-->
|
{@html webManifestLink}
|
||||||
<title>{$LL.TITLE()}</title>
|
<title>{$LL.TITLE()}</title>
|
||||||
<meta name="description" content={$LL.DESCRIPTION()} />
|
<meta name="description" content={$LL.DESCRIPTION()} />
|
||||||
<meta name="theme-color" content={data.themeColor} />
|
<meta name="theme-color" content={data.themeColor} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleHotkey} />
|
<svelte:window on:keydown={handleHotkey} />
|
||||||
|
|
||||||
<div class="layout">
|
<Navigation />
|
||||||
<Sidebar />
|
|
||||||
|
|
||||||
<!-- <PickChangesDialog /> -->
|
<!-- <PickChangesDialog /> -->
|
||||||
|
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
{#if children}
|
<slot />
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
</PageTransition>
|
</PageTransition>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
@@ -132,18 +129,36 @@
|
|||||||
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
||||||
<BrowserWarning />
|
<BrowserWarning />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" global>
|
||||||
.layout {
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
display: grid;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
grid-template-areas:
|
color: var(--md-sys-color-on-background);
|
||||||
"sidebar main"
|
|
||||||
"sidebar footer";
|
background: var(--md-sys-color-background);
|
||||||
grid-template-columns: auto 1fr;
|
}
|
||||||
grid-template-rows: 1fr;
|
|
||||||
|
main {
|
||||||
|
contain: strict;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-block-start: 0;
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--md-sys-color-secondary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,2 +1,16 @@
|
|||||||
|
import type { LayoutLoad } from "./$types";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { charaFileFromUriComponent } from "$lib/share/share-url";
|
||||||
|
|
||||||
export const prerender = true;
|
export const prerender = true;
|
||||||
export const trailingSlash = "always";
|
export const trailingSlash = "always";
|
||||||
|
|
||||||
|
export const load = (async ({ url, data, fetch }) => {
|
||||||
|
const importFile = browser && new URLSearchParams(url.search).get("import");
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
importFile: importFile
|
||||||
|
? await charaFileFromUriComponent(importFile, fetch)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}) satisfies LayoutLoad;
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { redirect } from "@sveltejs/kit";
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load = (() => {
|
export const load = (() => {
|
||||||
redirect(302, "/config/layout/");
|
throw redirect(302, "/config/");
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { preference } from "$lib/preferences";
|
import { preference } from "$lib/preferences";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
import {
|
import {
|
||||||
createChordBackup,
|
createChordBackup,
|
||||||
createLayoutBackup,
|
createLayoutBackup,
|
||||||
@@ -25,25 +25,25 @@
|
|||||||
</p>
|
</p>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{$LL.backup.INDIVIDUAL()}</legend>
|
<legend>{$LL.backup.INDIVIDUAL()}</legend>
|
||||||
<button onclick={() => downloadFile(createChordBackup())}>
|
<button on:click={() => downloadFile(createChordBackup())}>
|
||||||
<span class="icon">piano</span>
|
<span class="icon">piano</span>
|
||||||
{$LL.configure.chords.TITLE()}
|
{$LL.configure.chords.TITLE()}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
<button on:click={() => downloadFile(createLayoutBackup())}>
|
||||||
<span class="icon">keyboard</span>
|
<span class="icon">keyboard</span>
|
||||||
{$LL.configure.layout.TITLE()}
|
{$LL.configure.layout.TITLE()}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
<button on:click={() => downloadFile(createSettingsBackup())}>
|
||||||
<span class="icon">settings</span>
|
<span class="icon">settings</span>
|
||||||
{$LL.configure.settings.TITLE()}
|
{$LL.configure.settings.TITLE()}
|
||||||
</button>
|
</button>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div class="save">
|
<div class="save">
|
||||||
<button class="primary" onclick={downloadBackup}
|
<button class="primary" on:click={downloadBackup}
|
||||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||||
>
|
>
|
||||||
<label class="button"
|
<label class="button"
|
||||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
><input on:input={restoreBackup} type="file" /><span class="icon"
|
||||||
>settings_backup_restore</span
|
>settings_backup_restore</span
|
||||||
>{$LL.backup.RESTORE()}</label
|
>{$LL.backup.RESTORE()}</label
|
||||||
>
|
>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog open>
|
<dialog open>
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
import { page } from "$app/stores";
|
import { page } from "$app/stores";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
let { children }: { children?: Snippet } = $props();
|
$: paths = [
|
||||||
|
|
||||||
let paths = $derived([
|
|
||||||
{
|
{
|
||||||
href: "/config/chords/",
|
href: "/config/chords/",
|
||||||
title: $LL.configure.chords.TITLE(),
|
title: $LL.configure.chords.TITLE(),
|
||||||
@@ -21,7 +18,7 @@
|
|||||||
title: $LL.configure.settings.TITLE(),
|
title: $LL.configure.settings.TITLE(),
|
||||||
icon: "settings",
|
icon: "settings",
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
@@ -33,9 +30,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{#if children}
|
<slot />
|
||||||
{@render children()}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
nav {
|
nav {
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { slide, fade } from "svelte/transition";
|
import { slide, fade } from "svelte/transition";
|
||||||
import { preference } from "$lib/preferences";
|
import { preference } from "$lib/preferences";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
import { downloadBackup } from "$lib/backup/backup";
|
import { downloadBackup } from "$lib/backup/backup";
|
||||||
|
|
||||||
function reboot() {
|
function reboot() {
|
||||||
@@ -34,9 +34,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let rebootInfo = $derived($serialPort !== undefined);
|
let rebootInfo = false;
|
||||||
let terminal = $state(false);
|
let terminal = false;
|
||||||
let powerDialog = $state(false);
|
let powerDialog = false;
|
||||||
|
|
||||||
|
$: if ($serialPort) {
|
||||||
|
rebootInfo = false;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -113,7 +117,7 @@
|
|||||||
{#if $serialPort}
|
{#if $serialPort}
|
||||||
<button
|
<button
|
||||||
class="secondary"
|
class="secondary"
|
||||||
onclick={() => {
|
on:click={() => {
|
||||||
$serialPort?.forget();
|
$serialPort?.forget();
|
||||||
$serialPort = undefined;
|
$serialPort = undefined;
|
||||||
}}
|
}}
|
||||||
@@ -121,7 +125,7 @@
|
|||||||
>{$LL.deviceManager.DISCONNECT()}</button
|
>{$LL.deviceManager.DISCONNECT()}</button
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="error" onclick={connect}
|
<button class="error" on:click={connect}
|
||||||
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -131,13 +135,13 @@
|
|||||||
title={$LL.deviceManager.TERMINAL()}
|
title={$LL.deviceManager.TERMINAL()}
|
||||||
class="icon"
|
class="icon"
|
||||||
class:disabled={$serialPort === undefined}
|
class:disabled={$serialPort === undefined}
|
||||||
onclick={() => (terminal = !terminal)}>terminal</a
|
on:click={() => (terminal = !terminal)}>terminal</a
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="icon"
|
class="icon"
|
||||||
title={$LL.deviceManager.bootMenu.TITLE()}
|
title={$LL.deviceManager.bootMenu.TITLE()}
|
||||||
disabled={$serialPort === undefined}
|
disabled={$serialPort === undefined}
|
||||||
onclick={() => (powerDialog = !powerDialog)}>settings_power</button
|
on:click={() => (powerDialog = !powerDialog)}>settings_power</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,18 +151,18 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
transition:fade={{ duration: 250 }}
|
transition:fade={{ duration: 250 }}
|
||||||
onclick={() => (powerDialog = !powerDialog)}
|
on:click={() => (powerDialog = !powerDialog)}
|
||||||
onkeypress={(event) => {
|
on:keypress={(event) => {
|
||||||
if (event.key === "Enter") powerDialog = !powerDialog;
|
if (event.key === "Enter") powerDialog = !powerDialog;
|
||||||
}}
|
}}
|
||||||
></div>
|
/>
|
||||||
<dialog open transition:slide={{ duration: 250 }}>
|
<dialog open transition:slide={{ duration: 250 }}>
|
||||||
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
|
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
|
||||||
<button onclick={reboot}
|
<button on:click={reboot}
|
||||||
><span class="icon">restart_alt</span
|
><span class="icon">restart_alt</span
|
||||||
>{$LL.deviceManager.bootMenu.REBOOT()}</button
|
>{$LL.deviceManager.bootMenu.REBOOT()}</button
|
||||||
>
|
>
|
||||||
<button onclick={bootloader}
|
<button on:click={bootloader}
|
||||||
><span class="icon">rule_settings</span
|
><span class="icon">rule_settings</span
|
||||||
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
|
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
|
||||||
>
|
>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
import {
|
import {
|
||||||
changes,
|
changes,
|
||||||
ChangeType,
|
ChangeType,
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
if (event.shiftKey) {
|
if (event.shiftKey) {
|
||||||
changes.set([]);
|
changes.set([]);
|
||||||
} else {
|
} else {
|
||||||
redoQueue.unshift($changes.pop()!);
|
redoQueue = [$changes.pop()!, ...redoQueue];
|
||||||
changes.update((it) => it);
|
changes.update((it) => it);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let redoQueue: Change[] = $state([]);
|
let redoQueue: Change[] = [];
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
try {
|
try {
|
||||||
@@ -138,19 +138,19 @@
|
|||||||
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
|
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
|
||||||
class="icon"
|
class="icon"
|
||||||
disabled={$changes.length === 0}
|
disabled={$changes.length === 0}
|
||||||
onclick={undo}>undo</button
|
on:click={undo}>undo</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
|
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
|
||||||
class="icon"
|
class="icon"
|
||||||
disabled={redoQueue.length === 0}
|
disabled={redoQueue.length === 0}
|
||||||
onclick={redo}>redo</button
|
on:click={redo}>redo</button
|
||||||
>
|
>
|
||||||
{#if $changes.length !== 0}
|
{#if $changes.length !== 0}
|
||||||
<button
|
<button
|
||||||
transition:fly={{ x: 10 }}
|
transition:fly={{ x: 10 }}
|
||||||
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
|
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
|
||||||
onclick={save}
|
on:click={save}
|
||||||
class="click-me"
|
class="click-me"
|
||||||
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
|
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
|
||||||
>
|
>
|
||||||
@@ -1,25 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser, version } from "$app/environment";
|
import { browser, version } from "$app/environment";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
import LL, { setLocale } from "$i18n/i18n-svelte";
|
import LL, { setLocale } from "../i18n/i18n-svelte";
|
||||||
import { theme } from "$lib/preferences.js";
|
import { theme } from "$lib/preferences.js";
|
||||||
import type { Locales } from "$i18n/i18n-types";
|
import type { Locales } from "../i18n/i18n-types";
|
||||||
import { detectLocale, locales } from "$i18n/i18n-util";
|
import { detectLocale, locales } from "../i18n/i18n-util";
|
||||||
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
import { loadLocaleAsync } from "../i18n/i18n-util.async";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import SyncOverlay from "./SyncOverlay.svelte";
|
import SyncOverlay from "./SyncOverlay.svelte";
|
||||||
import { serialPort } from "$lib/serial/connection";
|
import { serialPort } from "$lib/serial/connection";
|
||||||
|
|
||||||
let locale = $state(
|
let locale =
|
||||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale();
|
||||||
);
|
$: if (browser)
|
||||||
$effect(() => {
|
(async () => {
|
||||||
if (!browser) return;
|
|
||||||
localStorage.setItem("locale", locale);
|
localStorage.setItem("locale", locale);
|
||||||
loadLocaleAsync(locale).then(() => {
|
await loadLocaleAsync(locale);
|
||||||
setLocale(locale);
|
setLocale(locale);
|
||||||
});
|
})();
|
||||||
});
|
|
||||||
|
|
||||||
function switchTheme() {
|
function switchTheme() {
|
||||||
const mode = $theme.mode === "light" ? "dark" : "light";
|
const mode = $theme.mode === "light" ? "dark" : "light";
|
||||||
@@ -39,6 +37,7 @@
|
|||||||
<footer>
|
<footer>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
|
<!-- svelte-ignore not-defined -->
|
||||||
<a
|
<a
|
||||||
href={import.meta.env.VITE_HOMEPAGE_URL}
|
href={import.meta.env.VITE_HOMEPAGE_URL}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
@@ -87,7 +86,7 @@
|
|||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
|
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={switchTheme}
|
on:click={switchTheme}
|
||||||
>
|
>
|
||||||
dark_mode
|
dark_mode
|
||||||
</button>
|
</button>
|
||||||
@@ -95,27 +94,25 @@
|
|||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
|
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={switchTheme}
|
on:click={switchTheme}
|
||||||
>
|
>
|
||||||
light_mode
|
light_mode
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<div
|
<button
|
||||||
role="button"
|
|
||||||
class="icon"
|
class="icon"
|
||||||
use:action={{ title: $LL.profile.LANGUAGE() }}
|
use:action={{ title: $LL.profile.LANGUAGE() }}
|
||||||
onclick={() => languageSelect.click()}
|
on:click={() => languageSelect.click()}
|
||||||
>
|
>translate
|
||||||
translate
|
|
||||||
|
|
||||||
<select bind:value={locale} bind:this={languageSelect}>
|
<select bind:value={locale} bind:this={languageSelect}>
|
||||||
{#each locales as code}
|
{#each locales as code}
|
||||||
<option value={code}>{code}</option>
|
<option value={code}>{code}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fly } from "svelte/transition";
|
import { serialPort, syncStatus } from "$lib/serial/connection";
|
||||||
|
import { slide, fly } from "svelte/transition";
|
||||||
import { canShare, triggerShare } from "$lib/share";
|
import { canShare, triggerShare } from "$lib/share";
|
||||||
|
import { popup } from "$lib/popup";
|
||||||
|
import BackupPopup from "./BackupPopup.svelte";
|
||||||
|
import ConnectionPopup from "./ConnectionPopup.svelte";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { userPreferences } from "$lib/preferences";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
import ConfigTabs from "./ConfigTabs.svelte";
|
import ConfigTabs from "./ConfigTabs.svelte";
|
||||||
import EditActions from "./EditActions.svelte";
|
import EditActions from "./EditActions.svelte";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (browser && !$userPreferences.autoConnect) {
|
||||||
|
connectButton.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let connectButton: HTMLButtonElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav>
|
<nav>
|
||||||
@@ -20,24 +35,52 @@
|
|||||||
use:action={{ title: $LL.share.TITLE() }}
|
use:action={{ title: $LL.share.TITLE() }}
|
||||||
transition:fly={{ x: -8 }}
|
transition:fly={{ x: -8 }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={triggerShare}>share</button
|
on:click={triggerShare}>share</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.print.TITLE() }}
|
use:action={{ title: $LL.print.TITLE() }}
|
||||||
transition:fly={{ x: -8 }}
|
transition:fly={{ x: -8 }}
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={() => print()}>print</button
|
on:click={() => print()}>print</button
|
||||||
>
|
>
|
||||||
|
<div transition:slide class="separator" />
|
||||||
{/if}
|
{/if}
|
||||||
{#if import.meta.env.TAURI_FAMILY === undefined}
|
{#if import.meta.env.TAURI_FAMILY === undefined}
|
||||||
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
||||||
<PwaStatus />
|
<PwaStatus />
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
use:action={{ title: $LL.backup.TITLE() }}
|
||||||
|
use:popup={BackupPopup}
|
||||||
|
class="icon {$syncStatus}"
|
||||||
|
>
|
||||||
|
{#if $userPreferences.backup}
|
||||||
|
history
|
||||||
|
{:else}
|
||||||
|
history_toggle_off
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
bind:this={connectButton}
|
||||||
|
use:action={{ title: $LL.deviceManager.TITLE() }}
|
||||||
|
use:popup={ConnectionPopup}
|
||||||
|
class="icon connect"
|
||||||
|
class:error={$serialPort === undefined}
|
||||||
|
>
|
||||||
|
cable
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.separator {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
margin-inline: 4px;
|
||||||
|
background: var(--md-sys-color-outline-variant);
|
||||||
|
}
|
||||||
|
|
||||||
nav {
|
nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
@@ -2,16 +2,13 @@
|
|||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
||||||
import { expoIn, expoOut } from "svelte/easing";
|
import { expoIn, expoOut } from "svelte/easing";
|
||||||
import type { Snippet } from "svelte";
|
|
||||||
|
|
||||||
let { children }: { children: Snippet } = $props();
|
let inDirection = 0;
|
||||||
|
let outDirection = 0;
|
||||||
let inDirection = $state(0);
|
let outroEnd: undefined | (() => void) = undefined;
|
||||||
let outDirection = $state(0);
|
|
||||||
let outroEnd: undefined | (() => void) = $state(undefined);
|
|
||||||
let animationDone: Promise<void>;
|
let animationDone: Promise<void>;
|
||||||
|
|
||||||
let isNavigating = $state(false);
|
let isNavigating = false;
|
||||||
|
|
||||||
const routeOrder = [
|
const routeOrder = [
|
||||||
"/config/chords/",
|
"/config/chords/",
|
||||||
@@ -51,8 +48,8 @@
|
|||||||
<main
|
<main
|
||||||
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
|
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
|
||||||
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
|
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
|
||||||
onoutroend={outroEnd}
|
on:outroend={outroEnd}
|
||||||
>
|
>
|
||||||
{@render children()}
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
syncStatus,
|
syncStatus,
|
||||||
sync,
|
sync,
|
||||||
} from "$lib/serial/connection";
|
} from "$lib/serial/connection";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../i18n/i18n-svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if $serialPort}
|
{:else if $serialPort}
|
||||||
<button transition:slide onclick={sync}
|
<button transition:slide on:click={sync}
|
||||||
><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button
|
><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
6
src/routes/config/+page.ts
Normal file
6
src/routes/config/+page.ts
Normal 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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../../i18n/i18n-svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{$LL.share.URL_COPIED()}
|
{$LL.share.URL_COPIED()}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
import FlexSearch from "flexsearch";
|
import FlexSearch from "flexsearch";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../../../i18n/i18n-svelte";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
import { onDestroy, onMount, setContext, tick } from "svelte";
|
import { onDestroy, onMount, setContext, tick } from "svelte";
|
||||||
import { changes, ChangeType, chords } from "$lib/undo-redo";
|
import { changes, ChangeType, chords } from "$lib/undo-redo";
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
let resizeObserver: ResizeObserver;
|
let resizeObserver: ResizeObserver;
|
||||||
|
|
||||||
let abortIndexing: (() => void) | undefined;
|
let abortIndexing: (() => void) | undefined;
|
||||||
let progress = $state(0);
|
let progress = 0;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
@@ -37,11 +37,11 @@
|
|||||||
|
|
||||||
let index = new FlexSearch.Index();
|
let index = new FlexSearch.Index();
|
||||||
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
|
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
|
||||||
$effect(() => {
|
$: {
|
||||||
abortIndexing?.();
|
abortIndexing?.();
|
||||||
progress = 0;
|
progress = 0;
|
||||||
buildIndex($chords, $osLayout).then(searchIndex.set);
|
buildIndex($chords, $osLayout).then(searchIndex.set);
|
||||||
});
|
}
|
||||||
|
|
||||||
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
|
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
|
||||||
const plainPhrase: string[] = [""];
|
const plainPhrase: string[] = [""];
|
||||||
@@ -144,6 +144,7 @@
|
|||||||
progress = i;
|
progress = i;
|
||||||
|
|
||||||
if ("phrase" in chord) {
|
if ("phrase" in chord) {
|
||||||
|
console.log(encodeChord(chord, osLayout));
|
||||||
await index.addAsync(i, encodeChord(chord, osLayout));
|
await index.addAsync(i, encodeChord(chord, osLayout));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +211,7 @@
|
|||||||
|
|
||||||
setContext("cursor-crossfade", crossfade({}));
|
setContext("cursor-crossfade", crossfade({}));
|
||||||
|
|
||||||
let page = $state(0);
|
let page = 0;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -222,7 +223,7 @@
|
|||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
|
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
|
||||||
oninput={(event) => $searchIndex && search($searchIndex, event)}
|
on:input={(event) => $searchIndex && search($searchIndex, event)}
|
||||||
class:loading={progress !== $chords.length - 1}
|
class:loading={progress !== $chords.length - 1}
|
||||||
/>
|
/>
|
||||||
<div class="paginator">
|
<div class="paginator">
|
||||||
@@ -234,12 +235,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={() => (page = Math.max(page - 1, 0))}
|
on:click={() => (page = Math.max(page - 1, 0))}
|
||||||
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
|
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="icon"
|
class="icon"
|
||||||
onclick={() => (page = Math.min(page + 1, $lastPage))}
|
on:click={() => (page = Math.min(page + 1, $lastPage))}
|
||||||
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
|
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -250,24 +251,22 @@
|
|||||||
<div class="results">
|
<div class="results">
|
||||||
<table transition:fly={{ y: 48, easing: expoOut }}>
|
<table transition:fly={{ y: 48, easing: expoOut }}>
|
||||||
{#if $lastPage !== -1}
|
{#if $lastPage !== -1}
|
||||||
<tbody>
|
|
||||||
{#if page === 0}
|
{#if page === 0}
|
||||||
<tr
|
<tr
|
||||||
><th class="new-chord"
|
><th class="new-chord"
|
||||||
><ChordActionEdit
|
><ChordActionEdit
|
||||||
onsubmit={(action) => insertChord(action)}
|
on:submit={({ detail }) => insertChord(detail)}
|
||||||
/></th
|
/></th
|
||||||
><td></td><td></td></tr
|
><td /><td /></tr
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
||||||
{#if chord}
|
{#if chord}
|
||||||
<tr>
|
<tr>
|
||||||
<ChordEdit {chord} onduplicate={() => (page = 0)} />
|
<ChordEdit {chord} on:duplicate={() => (page = 0)} />
|
||||||
</tr>
|
</tr>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}</tbody
|
{/each}
|
||||||
>
|
|
||||||
{:else}
|
{:else}
|
||||||
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
|
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -279,7 +278,7 @@
|
|||||||
"\n\nDid you know? " +
|
"\n\nDid you know? " +
|
||||||
randomTips[Math.floor(randomTips.length * Math.random())]}
|
randomTips[Math.floor(randomTips.length * Math.random())]}
|
||||||
></textarea>
|
></textarea>
|
||||||
<button onclick={downloadVocabulary}
|
<button on:click={downloadVocabulary}
|
||||||
><span class="icon">download</span>
|
><span class="icon">download</span>
|
||||||
{$LL.configure.chords.VOCABULARY()}</button
|
{$LL.configure.chords.VOCABULARY()}</button
|
||||||
>
|
>
|
||||||
@@ -1,44 +1,41 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { ChordInfo } from "$lib/undo-redo";
|
import type { ChordInfo } from "$lib/undo-redo";
|
||||||
import { SvelteSet } from "svelte/reactivity";
|
import { changes, ChangeType } from "$lib/undo-redo";
|
||||||
import { changes, chordHashes, ChangeType } from "$lib/undo-redo";
|
import { createEventDispatcher } from "svelte";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "../../../i18n/i18n-svelte";
|
||||||
import ActionString from "$lib/components/ActionString.svelte";
|
import ActionString from "$lib/components/ActionString.svelte";
|
||||||
import { selectAction } from "./action-selector";
|
import { selectAction } from "./action-selector";
|
||||||
import { serialPort } from "$lib/serial/connection";
|
import { serialPort } from "$lib/serial/connection";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { inputToAction } from "./input-converter";
|
import { inputToAction } from "./input-converter";
|
||||||
import { hashChord, type Chord } from "$lib/serial/chord";
|
|
||||||
|
|
||||||
let {
|
export let chord: ChordInfo | undefined = undefined;
|
||||||
chord = undefined,
|
|
||||||
onsubmit,
|
|
||||||
}: { chord?: ChordInfo; onsubmit: (actions: number[]) => void } = $props();
|
|
||||||
|
|
||||||
let pressedKeys = new SvelteSet<number>();
|
const dispatch = createEventDispatcher();
|
||||||
let editing = $state(false);
|
|
||||||
|
let pressedKeys = new Set<number>();
|
||||||
|
let editing = false;
|
||||||
|
|
||||||
function compare(a: number, b: number) {
|
function compare(a: number, b: number) {
|
||||||
return a - b;
|
return a - b;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeChordInput(...actions: number[]) {
|
function makeChordInput(...actions: number[]) {
|
||||||
const compound = compoundInputs[0]
|
const compound = compoundIndices ?? [];
|
||||||
? hashChord(compoundInputs[0].actions)
|
|
||||||
: 0;
|
|
||||||
return [
|
return [
|
||||||
|
...compound,
|
||||||
...Array.from(
|
...Array.from(
|
||||||
{
|
{
|
||||||
length: 12 - actions.length,
|
length: 12 - (compound.length + actions.length + 1),
|
||||||
},
|
},
|
||||||
(_, i) => (compound >> (i * 10)) & 0x3ff,
|
() => 0,
|
||||||
),
|
),
|
||||||
...actions.toSorted(compare),
|
...actions.toSorted(compare),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit() {
|
function edit() {
|
||||||
pressedKeys.clear();
|
pressedKeys = new Set();
|
||||||
editing = true;
|
editing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,13 +50,14 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pressedKeys.add(input);
|
pressedKeys.add(input);
|
||||||
|
pressedKeys = pressedKeys;
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyup() {
|
function keyup() {
|
||||||
if (!editing) return;
|
if (!editing) return;
|
||||||
editing = false;
|
editing = false;
|
||||||
if (pressedKeys.size < 1) return;
|
if (pressedKeys.size < 1) return;
|
||||||
if (!chord) return onsubmit(makeChordInput(...pressedKeys));
|
if (!chord) return dispatch("submit", makeChordInput(...pressedKeys));
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Chord,
|
type: ChangeType.Chord,
|
||||||
@@ -73,9 +71,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addSpecial(event: MouseEvent) {
|
function addSpecial(event: MouseEvent) {
|
||||||
event.stopPropagation();
|
|
||||||
selectAction(event, (action) => {
|
selectAction(event, (action) => {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
|
console.log(compoundIndices, chordActions, action);
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Chord,
|
type: ChangeType.Chord,
|
||||||
id: chord!.id,
|
id: chord!.id,
|
||||||
@@ -87,30 +85,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function* resolveCompound(chord?: ChordInfo) {
|
$: chordActions = chord?.actions
|
||||||
if (!chord) return;
|
.slice(chord.actions.lastIndexOf(0) + 1)
|
||||||
let current: Chord = chord;
|
.toSorted(compare);
|
||||||
for (let i = 0; i < 10; i++) {
|
$: compoundIndices = chord?.actions.slice(0, chord.actions.indexOf(0));
|
||||||
if (current.actions[3] !== 0) return;
|
|
||||||
const compound = current.actions
|
|
||||||
.slice(0, 3)
|
|
||||||
.reduce((a, b, i) => a | (b << (i * 10)));
|
|
||||||
if (compound === 0) return;
|
|
||||||
const next = $chordHashes.get(compound);
|
|
||||||
if (!next) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = next;
|
|
||||||
yield next;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let chordActions = $derived(
|
|
||||||
chord?.actions.slice(chord.actions.lastIndexOf(0) + 1).toSorted(compare),
|
|
||||||
);
|
|
||||||
let compoundInputs = $derived([...resolveCompound(chord)].reverse());
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -121,10 +99,10 @@
|
|||||||
(chordActions.length < 2 ||
|
(chordActions.length < 2 ||
|
||||||
chordActions.some((it, i) => chordActions[i] !== it))}
|
chordActions.some((it, i) => chordActions[i] !== it))}
|
||||||
class="chord"
|
class="chord"
|
||||||
onclick={edit}
|
on:click={edit}
|
||||||
onkeydown={keydown}
|
on:keydown={keydown}
|
||||||
onkeyup={keyup}
|
on:keyup={keyup}
|
||||||
onblur={keyup}
|
on:blur={keyup}
|
||||||
>
|
>
|
||||||
{#if editing && pressedKeys.size === 0}
|
{#if editing && pressedKeys.size === 0}
|
||||||
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
|
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
|
||||||
@@ -132,22 +110,21 @@
|
|||||||
<span>{$LL.configure.chords.NEW_CHORD()}</span>
|
<span>{$LL.configure.chords.NEW_CHORD()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !editing}
|
{#if !editing}
|
||||||
{#each compoundInputs as compound}
|
{#each compoundIndices ?? [] as index}
|
||||||
<sub
|
<sub>{index}</sub>
|
||||||
><ActionString
|
|
||||||
display="keys"
|
|
||||||
actions={compound.actions.slice(compound.actions.lastIndexOf(0) + 1)}
|
|
||||||
></ActionString>
|
|
||||||
</sub>
|
|
||||||
<span>→</span>
|
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if compoundIndices?.length}
|
||||||
|
<span>→</span>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
<ActionString
|
<ActionString
|
||||||
display="keys"
|
display="keys"
|
||||||
actions={editing ? [...pressedKeys].sort(compare) : (chordActions ?? [])}
|
actions={editing ? [...pressedKeys].sort(compare) : chordActions ?? []}
|
||||||
/>
|
/>
|
||||||
<sup>•</sup>
|
<sup>•</sup>
|
||||||
<div role="button" class="icon add" onclick={addSpecial}>add_circle</div>
|
<button class="icon add" on:click|stopPropagation={addSpecial}
|
||||||
|
>add_circle</button
|
||||||
|
>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
import { charaFileToUriComponent } from "$lib/share/share-url";
|
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||||
import SharePopup from "../SharePopup.svelte";
|
import SharePopup from "../SharePopup.svelte";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import { mount, unmount } from "svelte";
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
let { chord, onduplicate }: { chord: ChordInfo; onduplicate: () => void } =
|
export let chord: ChordInfo;
|
||||||
$props();
|
|
||||||
|
const dispatch = createEventDispatcher<{ duplicate: void }>();
|
||||||
|
|
||||||
function remove() {
|
function remove() {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
id.splice(id.indexOf(0), 1);
|
id.splice(id.indexOf(0), 1);
|
||||||
id.push(0);
|
id.push(0);
|
||||||
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
|
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
|
||||||
id[id.length - 1]!++;
|
id[id.length - 1]++;
|
||||||
}
|
}
|
||||||
|
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
@@ -59,7 +60,7 @@
|
|||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
|
|
||||||
onduplicate();
|
dispatch("duplicate");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function share(event: Event) {
|
async function share(event: Event) {
|
||||||
@@ -73,48 +74,48 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await navigator.clipboard.writeText(url.toString());
|
await navigator.clipboard.writeText(url.toString());
|
||||||
let shareComponent = {};
|
let shareComponent: SharePopup;
|
||||||
tippy(event.target as HTMLElement, {
|
tippy(event.target as HTMLElement, {
|
||||||
onCreate(instance) {
|
onCreate(instance) {
|
||||||
const target = instance.popper.querySelector(".tippy-content")!;
|
const target = instance.popper.querySelector(".tippy-content")!;
|
||||||
shareComponent = mount(SharePopup, { target });
|
shareComponent = new SharePopup({ target });
|
||||||
},
|
},
|
||||||
onHidden(instance) {
|
onHidden(instance) {
|
||||||
instance.destroy();
|
instance.destroy();
|
||||||
},
|
},
|
||||||
onDestroy(_instance) {
|
onDestroy(_instance) {
|
||||||
unmount(shareComponent);
|
shareComponent.$destroy();
|
||||||
},
|
},
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<th>
|
<th>
|
||||||
<ChordActionEdit {chord} onsubmit={() => {}} />
|
<ChordActionEdit {chord} />
|
||||||
</th>
|
</th>
|
||||||
<td>
|
<td>
|
||||||
<ChordPhraseEdit {chord} />
|
<ChordPhraseEdit {chord} />
|
||||||
</td>
|
</td>
|
||||||
<td class="table-buttons">
|
<td class="table-buttons">
|
||||||
{#if !chord.deleted}
|
{#if !chord.deleted}
|
||||||
<button transition:slide class="icon compact" onclick={remove}
|
<button transition:slide class="icon compact" on:click={remove}
|
||||||
>delete</button
|
>delete</button
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<button transition:slide class="icon compact" onclick={restore}
|
<button transition:slide class="icon compact" on:click={restore}
|
||||||
>restore_from_trash</button
|
>restore_from_trash</button
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
<button disabled={chord.deleted} class="icon compact" onclick={duplicate}
|
<button disabled={chord.deleted} class="icon compact" on:click={duplicate}
|
||||||
>content_copy</button
|
>content_copy</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="icon compact"
|
class="icon compact"
|
||||||
class:disabled={chord.isApplied}
|
class:disabled={chord.isApplied}
|
||||||
onclick={restore}>undo</button
|
on:click={restore}>undo</button
|
||||||
>
|
>
|
||||||
<div class="separator"></div>
|
<div class="separator" />
|
||||||
<button class="icon compact" onclick={share}>share</button>
|
<button class="icon compact" on:click={share}>share</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
import { serialPort } from "$lib/serial/connection";
|
import { serialPort } from "$lib/serial/connection";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
let { chord }: { chord: ChordInfo } = $props();
|
export let chord: ChordInfo;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (chord.phrase.length === 0) {
|
if (chord.phrase.length === 0) {
|
||||||
box?.focus();
|
box.focus();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -40,7 +40,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function moveCursor(to: number) {
|
function moveCursor(to: number) {
|
||||||
if (!box) return;
|
|
||||||
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
|
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
|
||||||
const item = box.children.item(cursorPosition) as HTMLElement;
|
const item = box.children.item(cursorPosition) as HTMLElement;
|
||||||
cursorOffset = item.offsetLeft + item.offsetWidth;
|
cursorOffset = item.offsetLeft + item.offsetWidth;
|
||||||
@@ -72,7 +71,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clickCursor(event: MouseEvent) {
|
function clickCursor(event: MouseEvent) {
|
||||||
if (box === undefined || event.target === button) return;
|
if (event.target === button) return;
|
||||||
const distance = (event as unknown as { layerX: number }).layerX;
|
const distance = (event as unknown as { layerX: number }).layerX;
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
@@ -94,36 +93,37 @@
|
|||||||
insertAction(cursorPosition, action);
|
insertAction(cursorPosition, action);
|
||||||
tick().then(() => moveCursor(cursorPosition + 1));
|
tick().then(() => moveCursor(cursorPosition + 1));
|
||||||
},
|
},
|
||||||
() => box?.focus(),
|
() => box.focus(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let button: HTMLButtonElement | undefined = $state();
|
let button: HTMLButtonElement;
|
||||||
let box: HTMLDivElement | undefined = $state();
|
let box: HTMLDivElement;
|
||||||
let cursorPosition = 0;
|
let cursorPosition = 0;
|
||||||
let cursorOffset = $state(0);
|
let cursorOffset = 0;
|
||||||
|
|
||||||
let hasFocus = $state(false);
|
let hasFocus = false;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
<div
|
<div
|
||||||
onkeydown={keypress}
|
on:keydown={keypress}
|
||||||
onmousedown={clickCursor}
|
on:mousedown={clickCursor}
|
||||||
role="textbox"
|
role="textbox"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
bind:this={box}
|
bind:this={box}
|
||||||
class:edited={!chord.deleted && chord.phraseChanged}
|
class:edited={!chord.deleted && chord.phraseChanged}
|
||||||
onfocusin={() => (hasFocus = true)}
|
on:focusin={() => (hasFocus = true)}
|
||||||
onfocusout={(event) => {
|
on:focusout={(event) => {
|
||||||
if (event.relatedTarget !== button) hasFocus = false;
|
if (event.relatedTarget !== button) hasFocus = false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{#if hasFocus}
|
{#if hasFocus}
|
||||||
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
|
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
|
||||||
<button class="icon" bind:this={button} onclick={addSpecial}>add</button>
|
<button class="icon" bind:this={button} on:click={addSpecial}>add</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div></div>
|
<div />
|
||||||
<!-- placeholder for cursor placement -->
|
<!-- placeholder for cursor placement -->
|
||||||
{/if}
|
{/if}
|
||||||
<ActionString actions={chord.phrase} />
|
<ActionString actions={chord.phrase} />
|
||||||
@@ -1,21 +1,12 @@
|
|||||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||||
import { mount, unmount, tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
|
|
||||||
export function selectAction(
|
export function selectAction(
|
||||||
event: MouseEvent | KeyboardEvent,
|
event: MouseEvent | KeyboardEvent,
|
||||||
select: (action: number) => void,
|
select: (action: number) => void,
|
||||||
dismissed?: () => void,
|
dismissed?: () => void,
|
||||||
) {
|
) {
|
||||||
const component = mount(ActionSelector, {
|
const component = new ActionSelector({ target: document.body });
|
||||||
target: document.body,
|
|
||||||
props: {
|
|
||||||
onclose: () => closed(),
|
|
||||||
onselect: (action: number) => {
|
|
||||||
select(action);
|
|
||||||
closed();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||||
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
|
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
|
||||||
const dialogRect = dialog.getBoundingClientRect();
|
const dialogRect = dialog.getBoundingClientRect();
|
||||||
@@ -49,8 +40,14 @@ export function selectAction(
|
|||||||
|
|
||||||
await dialogAnimation.finished;
|
await dialogAnimation.finished;
|
||||||
|
|
||||||
unmount(component);
|
component.$destroy();
|
||||||
await tick();
|
await tick();
|
||||||
dismissed?.();
|
dismissed?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
component.$on("close", closed);
|
||||||
|
component.$on("select", ({ detail }) => {
|
||||||
|
select(detail);
|
||||||
|
closed();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { share } from "$lib/share";
|
import { share } from "$lib/share";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import { mount, setContext, unmount } from "svelte";
|
import { setContext } from "svelte";
|
||||||
import Layout from "$lib/components/layout/Layout.svelte";
|
import Layout from "$lib/components/layout/Layout.svelte";
|
||||||
import { charaFileToUriComponent } from "$lib/share/share-url";
|
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||||
import SharePopup from "../SharePopup.svelte";
|
import SharePopup from "../SharePopup.svelte";
|
||||||
@@ -25,17 +25,17 @@
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await navigator.clipboard.writeText(url.toString());
|
await navigator.clipboard.writeText(url.toString());
|
||||||
let shareComponent: {};
|
let shareComponent: SharePopup;
|
||||||
tippy(event.target as HTMLElement, {
|
tippy(event.target as HTMLElement, {
|
||||||
onCreate(instance) {
|
onCreate(instance) {
|
||||||
const target = instance.popper.querySelector(".tippy-content")!;
|
const target = instance.popper.querySelector(".tippy-content")!;
|
||||||
shareComponent = mount(SharePopup, { target });
|
shareComponent = new SharePopup({ target });
|
||||||
},
|
},
|
||||||
onHidden(instance) {
|
onHidden(instance) {
|
||||||
instance.destroy();
|
instance.destroy();
|
||||||
},
|
},
|
||||||
onDestroy() {
|
onDestroy() {
|
||||||
unmount(shareComponent);
|
shareComponent.$destroy();
|
||||||
},
|
},
|
||||||
}).show();
|
}).show();
|
||||||
}
|
}
|
||||||
@@ -229,6 +229,12 @@
|
|||||||
/>ms</span
|
/>ms</span
|
||||||
></label
|
></label
|
||||||
>
|
>
|
||||||
|
<label
|
||||||
|
>Compound Chording<input
|
||||||
|
type="checkbox"
|
||||||
|
use:setting={{ id: 0x61 }}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { serialPort } from "$lib/serial/connection";
|
import { serialPort } from "$lib/serial/connection";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
let { challenge, onconfirm }: { challenge: string; onconfirm: () => void } =
|
export let challenge: string;
|
||||||
$props();
|
|
||||||
|
|
||||||
let challengeInput = $state("");
|
let challengeInput = "";
|
||||||
let challengeString = $derived(`${challenge} ${$serialPort!.device}`);
|
$: challengeString = `${challenge} ${$serialPort!.device}`;
|
||||||
let isValid = $derived(challengeInput === challengeString);
|
$: isValid = challengeInput === challengeString;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h3>Type the following to confirm the action</h3>
|
<h3>Type the following to confirm the action</h3>
|
||||||
|
|
||||||
<p>{challengeString}</p>
|
<p>{challengeString}</p>
|
||||||
<!-- svelte-ignore a11y_autofocus -->
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
<input
|
<input
|
||||||
autofocus
|
autofocus
|
||||||
type="text"
|
type="text"
|
||||||
@@ -20,7 +22,9 @@
|
|||||||
placeholder={challengeString}
|
placeholder={challengeString}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button disabled={!isValid} onclick={onconfirm}>Confirm {challenge}</button>
|
<button disabled={!isValid} on:click={() => dispatch("confirm")}
|
||||||
|
>Confirm {challenge}</button
|
||||||
|
>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
input[type="text"] {
|
input[type="text"] {
|
||||||
196
src/routes/plugin/+page.svelte
Normal file
196
src/routes/plugin/+page.svelte
Normal 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>
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
<script lang="ts">
|
<script>
|
||||||
import type { ChannelEventData } from "../(app)/plugin/plugin-types";
|
// @ts-nocheck
|
||||||
|
let ongoingRequest;
|
||||||
let ongoingRequest: Promise<unknown> | undefined = undefined;
|
let resolveRequest;
|
||||||
let resolveRequest: ((data: unknown) => void) | undefined = undefined;
|
let source;
|
||||||
let source: MessageEventSource | undefined = undefined;
|
async function post(channel, args) {
|
||||||
|
|
||||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
|
||||||
|
|
||||||
async function post(channel: string, args: unknown[]) {
|
|
||||||
while (ongoingRequest) {
|
while (ongoingRequest) {
|
||||||
await ongoingRequest;
|
await ongoingRequest;
|
||||||
}
|
}
|
||||||
ongoingRequest = new Promise((resolve) => {
|
ongoingRequest = new Promise((resolve) => {
|
||||||
resolveRequest = resolve;
|
resolveRequest = resolve;
|
||||||
source?.postMessage([channel, args], { targetOrigin: "*" });
|
source.postMessage([channel, args], "*");
|
||||||
});
|
});
|
||||||
ongoingRequest.then(() => {
|
ongoingRequest.then(() => {
|
||||||
ongoingRequest = undefined;
|
ongoingRequest = undefined;
|
||||||
@@ -21,13 +17,13 @@
|
|||||||
return ongoingRequest;
|
return ongoingRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMessage(event: MessageEvent<ChannelEventData>) {
|
window.addEventListener("message", (event) => {
|
||||||
if ("response" in event.data) {
|
if ("response" in event.data) {
|
||||||
resolveRequest?.(event.data.response);
|
resolveRequest(event.data.response);
|
||||||
} else {
|
} else {
|
||||||
source = event.source ?? undefined;
|
source = event.source;
|
||||||
|
|
||||||
const Action = event.data.actionCodes;
|
var Action = event.data.actionCodes;
|
||||||
Object.assign(
|
Object.assign(
|
||||||
Action,
|
Action,
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
@@ -37,20 +33,12 @@
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const Chara = Object.fromEntries(
|
var Chara = {};
|
||||||
event.data.charaChannels.map((name) => [
|
for (const fn of event.data.charaChannels) {
|
||||||
name,
|
Chara[fn] = (...args) => post(fn, args);
|
||||||
(...args: unknown[]) => post(name, args),
|
}
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
AsyncFunction(
|
eval(`(async function(){${event.data.script}})()`);
|
||||||
"Action",
|
|
||||||
"Chara",
|
|
||||||
'"use strict"\n' + event.data.script,
|
|
||||||
)(Action, Chara);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:message={onMessage} />
|
|
||||||
|
|||||||
10
src/routes/update-guide/+page.svelte
Normal file
10
src/routes/update-guide/+page.svelte
Normal 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
|
||||||
|
>
|
||||||
174
src/routes/vocabulary/+page.svelte
Normal file
174
src/routes/vocabulary/+page.svelte
Normal 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>
|
||||||
@@ -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
Reference in New Issue
Block a user