mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-02 15:22:39 +00:00
Compare commits
22 Commits
v2.0.2
...
9266702cbb
| Author | SHA1 | Date | |
|---|---|---|---|
|
9266702cbb
|
|||
|
77e2d2b20e
|
|||
|
7819f546a6
|
|||
|
e37b38085d
|
|||
|
a3bf9ac32b
|
|||
|
|
5bd3245084 | ||
|
1cd2ec318a
|
|||
|
6c8bfa0272
|
|||
|
f69be14b5e
|
|||
|
dce554fc66
|
|||
|
f152dbdcf5
|
|||
|
6a29e6a2fc
|
|||
|
9bf3801fef
|
|||
|
d2accfb838
|
|||
|
b8a376b93b
|
|||
|
588719df91
|
|||
|
6a0dad9dad
|
|||
|
f3704e4051
|
|||
|
3e6298717e
|
|||
|
aced0bbbb7
|
|||
|
|
36874c59e3 | ||
|
9dc61a3482
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 9
|
||||||
- name: 🐉 Use Node.js 22.4.x
|
- name: 🐉 Use Node.js 22.4.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
|
|||||||
12
flake.nix
12
flake.nix
@@ -14,7 +14,13 @@
|
|||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [
|
||||||
|
(import rust-overlay)
|
||||||
|
(final: prev: {
|
||||||
|
nodejs = prev.nodejs_22;
|
||||||
|
corepack = prev.corepack_22;
|
||||||
|
})
|
||||||
|
];
|
||||||
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 = [
|
||||||
@@ -46,8 +52,8 @@
|
|||||||
];
|
];
|
||||||
packages =
|
packages =
|
||||||
(with pkgs; [
|
(with pkgs; [
|
||||||
nodejs_22
|
nodejs
|
||||||
nodePackages.pnpm
|
pnpm
|
||||||
rust-bin
|
rust-bin
|
||||||
fontMin
|
fontMin
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ const config = {
|
|||||||
"experiment",
|
"experiment",
|
||||||
"code",
|
"code",
|
||||||
"dictionary",
|
"dictionary",
|
||||||
|
"developer_board",
|
||||||
|
"developer_board_off",
|
||||||
|
"memory",
|
||||||
],
|
],
|
||||||
codePoints: {
|
codePoints: {
|
||||||
speed: "e9e4",
|
speed: "e9e4",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "2.0.2",
|
"version": "2.2.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.16",
|
"node": ">=22.4",
|
||||||
"pnpm": ">=8.6"
|
"pnpm": ">=9.4"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"cypress": "^13.13.2",
|
"cypress": "^13.13.2",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
|
"esptool-js": "^0.4.7",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
"glob": "^11.0.0",
|
"glob": "^11.0.0",
|
||||||
@@ -89,6 +90,7 @@
|
|||||||
"vite-plugin-mkcert": "^1.17.6",
|
"vite-plugin-mkcert": "^1.17.6",
|
||||||
"vite-plugin-pwa": "^0.20.5",
|
"vite-plugin-pwa": "^0.20.5",
|
||||||
"vitest": "^2.1.4",
|
"vitest": "^2.1.4",
|
||||||
|
"web-serial-polyfill": "^1.0.15",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
|
|||||||
30
pnpm-lock.yaml
generated
30
pnpm-lock.yaml
generated
@@ -92,6 +92,9 @@ importers:
|
|||||||
d3:
|
d3:
|
||||||
specifier: ^7.9.0
|
specifier: ^7.9.0
|
||||||
version: 7.9.0
|
version: 7.9.0
|
||||||
|
esptool-js:
|
||||||
|
specifier: ^0.4.7
|
||||||
|
version: 0.4.7
|
||||||
flexsearch:
|
flexsearch:
|
||||||
specifier: ^0.7.43
|
specifier: ^0.7.43
|
||||||
version: 0.7.43
|
version: 0.7.43
|
||||||
@@ -173,6 +176,9 @@ importers:
|
|||||||
vitest:
|
vitest:
|
||||||
specifier: ^2.1.4
|
specifier: ^2.1.4
|
||||||
version: 2.1.4(@types/node@20.14.10)(jsdom@25.0.1)(sass@1.80.6)(terser@5.31.1)
|
version: 2.1.4(@types/node@20.14.10)(jsdom@25.0.1)(sass@1.80.6)(terser@5.31.1)
|
||||||
|
web-serial-polyfill:
|
||||||
|
specifier: ^1.0.15
|
||||||
|
version: 1.0.15
|
||||||
workbox-window:
|
workbox-window:
|
||||||
specifier: ^7.3.0
|
specifier: ^7.3.0
|
||||||
version: 7.3.0
|
version: 7.3.0
|
||||||
@@ -1645,6 +1651,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
|
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
|
||||||
engines: {node: '>= 4.0.0'}
|
engines: {node: '>= 4.0.0'}
|
||||||
|
|
||||||
|
atob-lite@2.0.0:
|
||||||
|
resolution: {integrity: sha512-LEeSAWeh2Gfa2FtlQE1shxQ8zi5F9GHarrGKz08TMdODD5T4eH6BMsvtnhbWZ+XQn+Gb6om/917ucvRu7l7ukw==}
|
||||||
|
|
||||||
autoprefixer@10.4.20:
|
autoprefixer@10.4.20:
|
||||||
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
|
resolution: {integrity: sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==}
|
||||||
engines: {node: ^10 || ^12 || >=14}
|
engines: {node: ^10 || ^12 || >=14}
|
||||||
@@ -2250,6 +2259,9 @@ packages:
|
|||||||
esm-env@1.0.0:
|
esm-env@1.0.0:
|
||||||
resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==}
|
resolution: {integrity: sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==}
|
||||||
|
|
||||||
|
esptool-js@0.4.7:
|
||||||
|
resolution: {integrity: sha512-xVwtSVDRsvjXSEvNFrorgJfB71RFFkZkL+hs7O7gW5hgPrKGywZxo2U5LJddzkJ6eE31QinNVyywc0OaSntZCw==}
|
||||||
|
|
||||||
esrap@1.2.2:
|
esrap@1.2.2:
|
||||||
resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==}
|
resolution: {integrity: sha512-F2pSJklxx1BlQIQgooczXCPHmcWpn6EsP5oo73LQfonG9fIlIENQ8vMmfGXeojP9MrkzUNAfyU5vdFlR9shHAw==}
|
||||||
|
|
||||||
@@ -3084,6 +3096,9 @@ packages:
|
|||||||
pako@0.2.9:
|
pako@0.2.9:
|
||||||
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
|
||||||
|
|
||||||
|
pako@2.1.0:
|
||||||
|
resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -4060,6 +4075,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
web-serial-polyfill@1.0.15:
|
||||||
|
resolution: {integrity: sha512-usZN7kGRkEWr8DzRWxW+og55L1fHo4hNIwxCSCfWKpM+i0L+2AwzupMvkDFxnJNqUFOhLaD3PlgAOJxUOUrAoA==}
|
||||||
|
|
||||||
webidl-conversions@4.0.2:
|
webidl-conversions@4.0.2:
|
||||||
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
|
||||||
|
|
||||||
@@ -5796,6 +5814,8 @@ snapshots:
|
|||||||
|
|
||||||
at-least-node@1.0.0: {}
|
at-least-node@1.0.0: {}
|
||||||
|
|
||||||
|
atob-lite@2.0.0: {}
|
||||||
|
|
||||||
autoprefixer@10.4.20(postcss@8.4.39):
|
autoprefixer@10.4.20(postcss@8.4.39):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.24.2
|
browserslist: 4.24.2
|
||||||
@@ -6519,6 +6539,12 @@ snapshots:
|
|||||||
|
|
||||||
esm-env@1.0.0: {}
|
esm-env@1.0.0: {}
|
||||||
|
|
||||||
|
esptool-js@0.4.7:
|
||||||
|
dependencies:
|
||||||
|
atob-lite: 2.0.0
|
||||||
|
pako: 2.1.0
|
||||||
|
tslib: 2.6.3
|
||||||
|
|
||||||
esrap@1.2.2:
|
esrap@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@jridgewell/sourcemap-codec': 1.5.0
|
'@jridgewell/sourcemap-codec': 1.5.0
|
||||||
@@ -7336,6 +7362,8 @@ snapshots:
|
|||||||
|
|
||||||
pako@0.2.9: {}
|
pako@0.2.9: {}
|
||||||
|
|
||||||
|
pako@2.1.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -8336,6 +8364,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
|
web-serial-polyfill@1.0.15: {}
|
||||||
|
|
||||||
webidl-conversions@4.0.2: {}
|
webidl-conversions@4.0.2: {}
|
||||||
|
|
||||||
webidl-conversions@7.0.0: {}
|
webidl-conversions@7.0.0: {}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "2.0.2"
|
version = "2.2.3"
|
||||||
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": "2.0.2" },
|
"package": { "productName": "amacc1ng", "version": "2.2.3" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -3,35 +3,35 @@ col:
|
|||||||
# Ring / Middle
|
# Ring / Middle
|
||||||
- offset: [2, 0]
|
- offset: [2, 0]
|
||||||
row:
|
row:
|
||||||
- switch: { d: 25, e: 26, n: 27, w: 28, s: 29 }
|
- switch: { e: 26, n: 27, w: 28, s: 29 }
|
||||||
- switch: { d: 20, e: 21, n: 22, w: 23, s: 24 }
|
- switch: { e: 21, n: 22, w: 23, s: 24 }
|
||||||
- offset: [4, 0]
|
- offset: [4, 0]
|
||||||
switch: { d: 65, w: 66, n: 67, e: 68, s: 69 }
|
switch: { w: 66, n: 67, e: 68, s: 69 }
|
||||||
- switch: { d: 70, w: 71, n: 72, e: 73, s: 74 }
|
- switch: { w: 71, n: 72, e: 73, s: 74 }
|
||||||
- offset: [2, 0]
|
- offset: [2, 0]
|
||||||
row:
|
row:
|
||||||
- switch: { d: 40, e: 41, n: 42, w: 43, s: 44 }
|
- switch: { e: 41, n: 42, w: 43, s: 44 }
|
||||||
- switch: { d: 35, e: 36, n: 37, w: 38, s: 39 }
|
- switch: { e: 36, n: 37, w: 38, s: 39 }
|
||||||
- offset: [4, 0]
|
- offset: [4, 0]
|
||||||
switch: { d: 80, w: 81, n: 82, e: 83, s: 84 }
|
switch: { w: 81, n: 82, e: 83, s: 84 }
|
||||||
- switch: { d: 85, w: 86, n: 87, e: 88, s: 89 }
|
- switch: { w: 86, n: 87, e: 88, s: 89 }
|
||||||
# Pinkie / Index
|
# Pinkie / Index
|
||||||
- offset: [0, -3]
|
- offset: [0, -3]
|
||||||
row:
|
row:
|
||||||
- switch: { d: 30, e: 31, n: 32, w: 33, s: 34 }
|
- switch: { e: 31, n: 32, w: 33, s: 34 }
|
||||||
- offset: [4, 0]
|
- offset: [4, 0]
|
||||||
switch: { d: 15, e: 16, n: 17, w: 18, s: 19 }
|
switch: { e: 16, n: 17, w: 18, s: 19 }
|
||||||
- switch: { d: 60, w: 61, n: 62, e: 63, s: 64 }
|
- switch: { w: 61, n: 62, e: 63, s: 64 }
|
||||||
- offset: [4, 0]
|
- offset: [4, 0]
|
||||||
switch: { d: 75, w: 76, n: 77, e: 78, s: 79 }
|
switch: { w: 76, n: 77, e: 78, s: 79 }
|
||||||
# Thumbs
|
# Thumbs
|
||||||
- row:
|
- row:
|
||||||
- offset: [5.5, 0.5]
|
- offset: [5.5, 0.5]
|
||||||
switch: { d: 10, e: 11, n: 12, w: 13, s: 14 }
|
switch: { e: 11, n: 12, w: 13, s: 14 }
|
||||||
- offset: [1, 0.5]
|
- offset: [1, 0.5]
|
||||||
switch: { d: 55, w: 56, n: 57, e: 58, s: 59 }
|
switch: { w: 56, n: 57, e: 58, s: 59 }
|
||||||
- row:
|
- row:
|
||||||
- offset: [4.5, -0.25]
|
- offset: [4.5, -0.25]
|
||||||
switch: { d: 5, e: 6, n: 7, w: 8, s: 9 }
|
switch: { e: 6, n: 7, w: 8, s: 9 }
|
||||||
- offset: [3, -0.25]
|
- offset: [3, -0.25]
|
||||||
switch: { d: 50, w: 51, n: 52, e: 53, s: 54 }
|
switch: { w: 51, n: 52, e: 53, s: 54 }
|
||||||
|
|||||||
37
src/lib/assets/layouts/m4gr.yml
Normal file
37
src/lib/assets/layouts/m4gr.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: M4G
|
||||||
|
col:
|
||||||
|
# Ring / Middle
|
||||||
|
- offset: [2, 0]
|
||||||
|
row:
|
||||||
|
- switch: { e: 26, n: 27, w: 28, s: 29 }
|
||||||
|
- switch: { e: 21, n: 22, w: 23, s: 24 }
|
||||||
|
- offset: [4, 0]
|
||||||
|
switch: { w: 66, n: 67, e: 68, s: 69 }
|
||||||
|
- switch: { w: 71, n: 72, e: 73, s: 74 }
|
||||||
|
- offset: [2, 0]
|
||||||
|
row:
|
||||||
|
- switch: { e: 41, n: 42, w: 43, s: 44 }
|
||||||
|
- switch: { e: 36, n: 37, w: 38, s: 39 }
|
||||||
|
- offset: [4, 0]
|
||||||
|
switch: { w: 81, n: 82, e: 83, s: 84 }
|
||||||
|
- switch: { w: 86, n: 87, e: 88, s: 89 }
|
||||||
|
# Pinkie / Index
|
||||||
|
- offset: [0, -3]
|
||||||
|
row:
|
||||||
|
- switch: { e: 31, n: 32, w: 33, s: 34 }
|
||||||
|
- offset: [4, 0]
|
||||||
|
switch: { e: 16, n: 17, w: 18, s: 19 }
|
||||||
|
- switch: { w: 61, n: 62, e: 63, s: 64 }
|
||||||
|
- offset: [4, 0]
|
||||||
|
switch: { w: 76, n: 77, e: 78, s: 79 }
|
||||||
|
# Thumbs
|
||||||
|
- row:
|
||||||
|
- offset: [5.5, 0.5]
|
||||||
|
switch: { e: 11, n: 12, w: 13, s: 14 }
|
||||||
|
- offset: [1, 0.5]
|
||||||
|
switch: { w: 56, n: 57, e: 58, s: 59 }
|
||||||
|
- row:
|
||||||
|
- offset: [4.5, -0.25]
|
||||||
|
switch: { e: 6, n: 7, w: 8, s: 9 }
|
||||||
|
- offset: [3, -0.25]
|
||||||
|
switch: { w: 51, n: 52, e: 53, s: 54 }
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
||||||
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
||||||
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
||||||
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
|
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
|
||||||
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
||||||
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
||||||
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
||||||
|
|||||||
20
src/lib/charrecorder/TrackText.svelte
Normal file
20
src/lib/charrecorder/TrackText.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { ReplayPlayer } from "./core/player";
|
||||||
|
import { TextPlugin } from "./core/plugins/text";
|
||||||
|
|
||||||
|
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||||
|
|
||||||
|
let { text = $bindable("") } = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!player.player) return;
|
||||||
|
const tracker = new TextPlugin();
|
||||||
|
tracker.register(player.player);
|
||||||
|
const unsubscribe = tracker.subscribe((value) => {
|
||||||
|
text = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { WpmReplayPlugin } from "./core/plugins/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 WpmReplayPlugin();
|
||||||
|
tracker.register(player.player);
|
||||||
|
const unsubscribe = tracker.subscribe((value) => {
|
||||||
|
wpm = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(this.tokens);
|
|
||||||
|
|
||||||
clearTimeout(this.timeout);
|
clearTimeout(this.timeout);
|
||||||
if (replay.stepper.held.size === 0) {
|
if (replay.stepper.held.size === 0) {
|
||||||
|
|||||||
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReplayPlayer } from "../player";
|
||||||
|
import type { ReplayPlugin, StoreContract } from "../types";
|
||||||
|
|
||||||
|
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
|
||||||
|
private subscribers = new Set<(value: string) => void>();
|
||||||
|
|
||||||
|
register(replay: ReplayPlayer) {
|
||||||
|
replay.subscribe(() => {
|
||||||
|
if (this.subscribers.size === 0) return;
|
||||||
|
const text = replay.stepper.text
|
||||||
|
.filter((it) => it.source !== "ghost")
|
||||||
|
.map((it) => it.text)
|
||||||
|
.join("");
|
||||||
|
for (const subscription of this.subscribers) {
|
||||||
|
subscription(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
subscribe(subscription: (value: string) => void) {
|
||||||
|
this.subscribers.add(subscription);
|
||||||
|
return () => this.subscribers.delete(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ export class TextRenderer {
|
|||||||
);
|
);
|
||||||
this.cursorNode.setAttribute("x", "0");
|
this.cursorNode.setAttribute("x", "0");
|
||||||
this.cursorNode.setAttribute("y", "0");
|
this.cursorNode.setAttribute("y", "0");
|
||||||
|
this.cursorNode.setAttribute("class", "cursor");
|
||||||
this.svg.appendChild(this.cursorNode);
|
this.svg.appendChild(this.cursorNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||||
|
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
||||||
|
"highlight-action",
|
||||||
|
);
|
||||||
|
|
||||||
let {
|
let {
|
||||||
key,
|
key,
|
||||||
@@ -47,6 +50,7 @@
|
|||||||
]}
|
]}
|
||||||
{@const hasIcon = !dynamicMapping && !!icon}
|
{@const hasIcon = !dynamicMapping && !!icon}
|
||||||
<text
|
<text
|
||||||
|
class:hidden={$currentAction?.has(actionId) === false}
|
||||||
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
||||||
font-weight={isApplied ? "" : "bold"}
|
font-weight={isApplied ? "" : "bold"}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
@@ -96,4 +100,8 @@
|
|||||||
text:focus-within {
|
text:focus-within {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text.hidden {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,11 +8,14 @@
|
|||||||
KeyboardEventHandler,
|
KeyboardEventHandler,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
} from "svelte/elements";
|
} from "svelte/elements";
|
||||||
|
import { type Writable } from "svelte/store";
|
||||||
|
|
||||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||||
"visual-layout-config",
|
"visual-layout-config",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
|
||||||
|
|
||||||
let {
|
let {
|
||||||
i,
|
i,
|
||||||
key,
|
key,
|
||||||
@@ -35,6 +38,8 @@
|
|||||||
|
|
||||||
<g
|
<g
|
||||||
class="key-group"
|
class="key-group"
|
||||||
|
class:highlight={$highlight?.has(key.id) === true}
|
||||||
|
class:faded={$highlight?.has(key.id) === false}
|
||||||
{onclick}
|
{onclick}
|
||||||
{onkeypress}
|
{onkeypress}
|
||||||
{onfocusin}
|
{onfocusin}
|
||||||
@@ -131,12 +136,14 @@
|
|||||||
stroke-opacity: 0.3;
|
stroke-opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.faded,
|
||||||
g:hover {
|
g:hover {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity #{$transition} ease;
|
transition: opacity #{$transition} ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.highlight,
|
||||||
g:focus-within {
|
g:focus-within {
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -37,6 +37,10 @@
|
|||||||
import("$lib/assets/layouts/m4g.yml").then(
|
import("$lib/assets/layouts/m4g.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
),
|
),
|
||||||
|
M4GR: () =>
|
||||||
|
import("$lib/assets/layouts/m4gr.yml").then(
|
||||||
|
(it) => it.default as VisualLayout,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -70,6 +74,7 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
max-height: 20cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const KEY_COUNTS = {
|
|||||||
LITE: 67,
|
LITE: 67,
|
||||||
X: 256,
|
X: 256,
|
||||||
M4G: 90,
|
M4G: 90,
|
||||||
|
M4GR: 90,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -36,6 +37,13 @@ if (
|
|||||||
await import("./tauri-serial");
|
await import("./tauri-serial");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
|
||||||
|
// @ts-expect-error polyfill
|
||||||
|
navigator.serial = await import("web-serial-polyfill").then(
|
||||||
|
({ serial }) => serial,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getViablePorts(): Promise<SerialPort[]> {
|
export async function getViablePorts(): Promise<SerialPort[]> {
|
||||||
return navigator.serial.getPorts().then((ports) =>
|
return navigator.serial.getPorts().then((ports) =>
|
||||||
ports.filter((it) => {
|
ports.filter((it) => {
|
||||||
@@ -126,9 +134,9 @@ export class CharaDevice {
|
|||||||
await this.port.close();
|
await this.port.close();
|
||||||
|
|
||||||
this.version = new SemVer(
|
this.version = new SemVer(
|
||||||
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 typeof this.company;
|
||||||
this.device = device as typeof this.device;
|
this.device = device as typeof this.device;
|
||||||
this.chipset = chipset as typeof this.chipset;
|
this.chipset = chipset as typeof this.chipset;
|
||||||
@@ -177,9 +185,12 @@ export class CharaDevice {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async internalRead() {
|
private async internalRead(timeoutMs: number | undefined) {
|
||||||
try {
|
try {
|
||||||
const { value } = await timeout(this.reader.read(), 5000);
|
const { value } =
|
||||||
|
timeoutMs !== undefined
|
||||||
|
? await timeout(this.reader.read(), timeoutMs)
|
||||||
|
: await this.reader.read();
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "output",
|
type: "output",
|
||||||
@@ -270,14 +281,15 @@ export class CharaDevice {
|
|||||||
*/
|
*/
|
||||||
async send<T extends number>(
|
async send<T extends number>(
|
||||||
expectedLength: T,
|
expectedLength: T,
|
||||||
...command: string[]
|
command: string[],
|
||||||
|
timeout: number | undefined = 5000,
|
||||||
): Promise<LengthArray<string, T>> {
|
): Promise<LengthArray<string, T>> {
|
||||||
return this.runWith(async (send, read) => {
|
return this.runWith(async (send, read) => {
|
||||||
await send(...command);
|
await send(...command);
|
||||||
const commandString = command
|
const commandString = command
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||||
const readResult = await read();
|
const readResult = await read(timeout);
|
||||||
if (readResult === undefined) {
|
if (readResult === undefined) {
|
||||||
console.error("No response");
|
console.error("No response");
|
||||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||||
@@ -299,7 +311,7 @@ export class CharaDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChordCount(): Promise<number> {
|
async getChordCount(): Promise<number> {
|
||||||
const [count] = await this.send(1, "CML C0");
|
const [count] = await this.send(1, ["CML", "C0"]);
|
||||||
return Number.parseInt(count);
|
return Number.parseInt(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +319,11 @@ export class CharaDevice {
|
|||||||
* Retrieves a chord by index
|
* Retrieves a chord by index
|
||||||
*/
|
*/
|
||||||
async getChord(index: number | number[]): Promise<Chord> {
|
async getChord(index: number | number[]): Promise<Chord> {
|
||||||
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
|
const [actions, phrase] = await this.send(2, [
|
||||||
|
"CML",
|
||||||
|
"C1",
|
||||||
|
index.toString(),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
actions: parseChordActions(actions),
|
actions: parseChordActions(actions),
|
||||||
phrase: parsePhrase(phrase),
|
phrase: parsePhrase(phrase),
|
||||||
@@ -318,29 +334,30 @@ export class CharaDevice {
|
|||||||
* Retrieves the phrase for a set of actions
|
* Retrieves the phrase for a set of actions
|
||||||
*/
|
*/
|
||||||
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
||||||
const [phrase] = await this.send(
|
const [phrase] = await this.send(1, [
|
||||||
1,
|
"CML",
|
||||||
`CML C2 ${stringifyChordActions(actions)}`,
|
"C2",
|
||||||
);
|
stringifyChordActions(actions),
|
||||||
|
]);
|
||||||
return phrase === "2" ? undefined : parsePhrase(phrase);
|
return phrase === "2" ? undefined : parsePhrase(phrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChord(chord: Chord) {
|
async setChord(chord: Chord) {
|
||||||
const [status] = await this.send(
|
const [status] = await this.send(1, [
|
||||||
1,
|
|
||||||
"CML",
|
"CML",
|
||||||
"C3",
|
"C3",
|
||||||
stringifyChordActions(chord.actions),
|
stringifyChordActions(chord.actions),
|
||||||
stringifyPhrase(chord.phrase),
|
stringifyPhrase(chord.phrase),
|
||||||
);
|
]);
|
||||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
if (status !== "0") console.error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||||
const status = await this.send(
|
const status = await this.send(1, [
|
||||||
1,
|
"CML",
|
||||||
`CML C4 ${stringifyChordActions(chord.actions)}`,
|
"C4",
|
||||||
);
|
stringifyChordActions(chord.actions),
|
||||||
|
]);
|
||||||
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
||||||
throw new Error(`Failed with status ${status}`);
|
throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
@@ -352,7 +369,13 @@ export class CharaDevice {
|
|||||||
* @param action the assigned action id
|
* @param action the assigned action id
|
||||||
*/
|
*/
|
||||||
async setLayoutKey(layer: number, id: number, action: number) {
|
async setLayoutKey(layer: number, id: number, action: number) {
|
||||||
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
|
const [status] = await this.send(1, [
|
||||||
|
"VAR",
|
||||||
|
"B4",
|
||||||
|
`A${layer}`,
|
||||||
|
id.toString(),
|
||||||
|
action.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +386,12 @@ export class CharaDevice {
|
|||||||
* @returns the assigned action id
|
* @returns the assigned action id
|
||||||
*/
|
*/
|
||||||
async getLayoutKey(layer: number, id: number) {
|
async getLayoutKey(layer: number, id: number) {
|
||||||
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
|
const [position, status] = await this.send(2, [
|
||||||
|
"VAR",
|
||||||
|
"B3",
|
||||||
|
`A${layer}`,
|
||||||
|
id.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
return Number(position);
|
return Number(position);
|
||||||
}
|
}
|
||||||
@@ -376,7 +404,7 @@ export class CharaDevice {
|
|||||||
* **This does not need to be called for chords**
|
* **This does not need to be called for chords**
|
||||||
*/
|
*/
|
||||||
async commit() {
|
async commit() {
|
||||||
const [status] = await this.send(1, "VAR B0");
|
const [status] = await this.send(1, ["VAR", "B0"]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,10 +415,12 @@ export class CharaDevice {
|
|||||||
* To permanently store the settings, you *must* call commit.
|
* To permanently store the settings, you *must* call commit.
|
||||||
*/
|
*/
|
||||||
async setSetting(id: number, value: number) {
|
async setSetting(id: number, value: number) {
|
||||||
const [status] = await this.send(
|
const [status] = await this.send(1, [
|
||||||
1,
|
"VAR",
|
||||||
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
|
"B2",
|
||||||
);
|
id.toString(16).toUpperCase(),
|
||||||
|
value.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,10 +428,11 @@ export class CharaDevice {
|
|||||||
* Retrieves a setting from the device
|
* Retrieves a setting from the device
|
||||||
*/
|
*/
|
||||||
async getSetting(id: number): Promise<number> {
|
async getSetting(id: number): Promise<number> {
|
||||||
const [value, status] = await this.send(
|
const [value, status] = await this.send(2, [
|
||||||
2,
|
"VAR",
|
||||||
`VAR B1 ${id.toString(16).toUpperCase()}`,
|
"B1",
|
||||||
);
|
id.toString(16).toUpperCase(),
|
||||||
|
]);
|
||||||
if (status !== "0")
|
if (status !== "0")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
||||||
@@ -413,14 +444,14 @@ export class CharaDevice {
|
|||||||
* Reboots the device
|
* Reboots the device
|
||||||
*/
|
*/
|
||||||
async reboot() {
|
async reboot() {
|
||||||
await this.send(0, "RST");
|
await this.send(0, ["RST"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reboots the device to the bootloader
|
* Reboots the device to the bootloader
|
||||||
*/
|
*/
|
||||||
async bootloader() {
|
async bootloader() {
|
||||||
await this.send(0, "RST BOOTLOADER");
|
await this.send(0, ["RST", "BOOTLOADER"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -429,7 +460,12 @@ export class CharaDevice {
|
|||||||
async reset(
|
async reset(
|
||||||
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
||||||
) {
|
) {
|
||||||
await this.send(0, `RST ${type}`);
|
await this.send(0, ["RST", type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryKey(): Promise<number> {
|
||||||
|
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
|
||||||
|
return Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -438,7 +474,7 @@ export class CharaDevice {
|
|||||||
* This is useful for debugging when there is a suspected heap or stack issue.
|
* This is useful for debugging when there is a suspected heap or stack issue.
|
||||||
*/
|
*/
|
||||||
async getRamBytesAvailable(): Promise<number> {
|
async getRamBytesAvailable(): Promise<number> {
|
||||||
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
|
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateFirmware(file: File | Blob): Promise<void> {
|
async updateFirmware(file: File | Blob): Promise<void> {
|
||||||
|
|||||||
15
src/lib/util/shuffle.ts
Normal file
15
src/lib/util/shuffle.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
|
||||||
|
*/
|
||||||
|
export function shuffleInPlace<T>(array: 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]!];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffle<T>(array: T[]): T[] {
|
||||||
|
const result = [...array];
|
||||||
|
shuffleInPlace(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -27,9 +27,9 @@
|
|||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
||||||
|
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true },
|
||||||
],
|
],
|
||||||
/*[
|
/*[
|
||||||
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
|
|
||||||
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
||||||
],*/
|
],*/
|
||||||
] satisfies {
|
] satisfies {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { downloadBackup } from "$lib/backup/backup";
|
import { downloadBackup } from "$lib/backup/backup";
|
||||||
import { initSerial, serialPort } from "$lib/serial/connection";
|
import { initSerial, serialPort } from "$lib/serial/connection";
|
||||||
import { fade, slide } from "svelte/transition";
|
import { fade, slide } from "svelte/transition";
|
||||||
|
import type { LoaderOptions, ESPLoader } from "esptool-js";
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
|
|
||||||
@@ -9,7 +10,12 @@
|
|||||||
let success = $state(false);
|
let success = $state(false);
|
||||||
let error = $state<Error | undefined>(undefined);
|
let error = $state<Error | undefined>(undefined);
|
||||||
|
|
||||||
|
let terminalOutput = $state("");
|
||||||
|
|
||||||
let step = $state(0);
|
let step = $state(0);
|
||||||
|
let eraseAll = $state(false);
|
||||||
|
|
||||||
|
let espLoader;
|
||||||
|
|
||||||
async function update() {
|
async function update() {
|
||||||
working = true;
|
working = true;
|
||||||
@@ -18,7 +24,9 @@
|
|||||||
const port = $serialPort!;
|
const port = $serialPort!;
|
||||||
$serialPort = undefined;
|
$serialPort = undefined;
|
||||||
try {
|
try {
|
||||||
const file = await fetch(otaUrl!).then((it) => it.blob());
|
const file = await fetch(
|
||||||
|
`${data.meta.path}/${data.meta.update.ota?.name}`,
|
||||||
|
).then((it) => it.blob());
|
||||||
|
|
||||||
await port.updateFirmware(file);
|
await port.updateFirmware(file);
|
||||||
|
|
||||||
@@ -36,18 +44,7 @@
|
|||||||
: undefined,
|
: undefined,
|
||||||
);
|
);
|
||||||
let isCorrectDevice = $derived(
|
let isCorrectDevice = $derived(
|
||||||
currentDevice ? currentDevice === data.device : undefined,
|
currentDevice ? currentDevice === data.meta.target : undefined,
|
||||||
);
|
|
||||||
|
|
||||||
let uf2Url = $derived(
|
|
||||||
data.uf2
|
|
||||||
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.uf2.name}`
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
let otaUrl = $derived(
|
|
||||||
data.ota
|
|
||||||
? `${import.meta.env.VITE_FIRMWARE_URL}${data.device}/${data.version}/${data.ota.name}`
|
|
||||||
: undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -84,10 +81,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getFileSystem() {
|
async function getFileSystem() {
|
||||||
if (!uf2Url) return;
|
if (!data.meta.update.uf2) return;
|
||||||
const uf2Promise = fetch(uf2Url).then((it) => it.blob());
|
const uf2Promise = fetch(
|
||||||
|
`${data.meta.path}/${data.meta.update.uf2.name}`,
|
||||||
|
).then((it) => it.blob());
|
||||||
const handle = await window.showSaveFilePicker({
|
const handle = await window.showSaveFilePicker({
|
||||||
id: `${data.device}-update`,
|
id: `${data.meta.target}-update`,
|
||||||
suggestedName: "CURRENT.UF2",
|
suggestedName: "CURRENT.UF2",
|
||||||
excludeAcceptAllOption: true,
|
excludeAcceptAllOption: true,
|
||||||
types: [
|
types: [
|
||||||
@@ -102,21 +101,104 @@
|
|||||||
await uf2.stream().pipeTo(writable);
|
await uf2.stream().pipeTo(writable);
|
||||||
step = 4;
|
step = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function espBootloader() {
|
||||||
|
$serialPort?.forget();
|
||||||
|
const port = await navigator.serial.requestPort();
|
||||||
|
port.open({ baudRate: 1200 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectEsp(port: SerialPort): Promise<ESPLoader> {
|
||||||
|
const esptool = data.meta.update.esptool!;
|
||||||
|
const { Transport, ESPLoader } = await import("esptool-js");
|
||||||
|
const espLoader = new ESPLoader({
|
||||||
|
transport: new Transport(port),
|
||||||
|
baudrate: 9600, // Number(esptool.baud),
|
||||||
|
romBaudrate: 9600, // Number(esptool.baud),
|
||||||
|
debugLogging: true,
|
||||||
|
terminal: {
|
||||||
|
clean: () => {
|
||||||
|
terminalOutput = "";
|
||||||
|
},
|
||||||
|
writeLine: (data) => {
|
||||||
|
terminalOutput += data + "\n";
|
||||||
|
},
|
||||||
|
write: (data) => {
|
||||||
|
terminalOutput += data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies LoaderOptions);
|
||||||
|
await espLoader.detectChip(esptool.before);
|
||||||
|
if (!espLoader.IS_STUB) {
|
||||||
|
await espLoader.runStub();
|
||||||
|
}
|
||||||
|
|
||||||
|
return espLoader;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flashImages() {
|
||||||
|
const port = await navigator.serial.requestPort();
|
||||||
|
try {
|
||||||
|
const esptool = data.meta.update.esptool!;
|
||||||
|
espLoader = await connectEsp(port);
|
||||||
|
const fileArray = await Promise.all(
|
||||||
|
Object.entries(esptool.files).map(([offset, name]) =>
|
||||||
|
fetch(`${data.meta.path}/${name}`)
|
||||||
|
.then((it) => it.blob())
|
||||||
|
.then((it) => it.text())
|
||||||
|
.then((it) => ({
|
||||||
|
address: Number(offset),
|
||||||
|
data: it,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await espLoader.writeFlash({
|
||||||
|
flashSize: esptool.flash_size,
|
||||||
|
flashMode: esptool.flash_mode,
|
||||||
|
flashFreq: esptool.flash_freq,
|
||||||
|
compress: true,
|
||||||
|
eraseAll,
|
||||||
|
fileArray,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
port.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eraseSPI() {
|
||||||
|
const port = await navigator.serial.requestPort();
|
||||||
|
try {
|
||||||
|
console.log(data.meta);
|
||||||
|
const spiFlash = data.meta.spi_flash!;
|
||||||
|
espLoader = await connectEsp(port);
|
||||||
|
|
||||||
|
/*espLoader.flashSpiAttach(
|
||||||
|
(spiFlash.connection.clk << 0) |
|
||||||
|
(spiFlash.connection.q << 8) |
|
||||||
|
(spiFlash.connection.d << 16) |
|
||||||
|
(spiFlash.connection.cs << 24),
|
||||||
|
);
|
||||||
|
espLoader.flashId();*/
|
||||||
|
} finally {
|
||||||
|
port.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h2>
|
<h2>
|
||||||
<a class="inline-link" href="/ccos">CCOS</a> /
|
<a class="inline-link" href="/ccos">CCOS</a> /
|
||||||
<a
|
<a
|
||||||
href="/ccos/{data.device}"
|
href="/ccos/{data.meta.target}"
|
||||||
class="device inline-link"
|
class="device inline-link"
|
||||||
class:correct-device={isCorrectDevice === true}
|
class:correct-device={isCorrectDevice === true}
|
||||||
class:incorrect-device={isCorrectDevice === false}>{data.device}</a
|
class:incorrect-device={isCorrectDevice === false}>{data.meta.target}</a
|
||||||
>
|
>
|
||||||
/ <em class="version">{data.version}</em>
|
/ <em class="version">{data.meta.version}</em>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
{#if data.ota && !data.device.endsWith("m0")}
|
{#if data.meta.update.ota && !data.meta.target.endsWith("m0")}
|
||||||
{@const buttonError = error || (!success && isCorrectDevice === false)}
|
{@const buttonError = error || (!success && isCorrectDevice === false)}
|
||||||
<section>
|
<section>
|
||||||
<button
|
<button
|
||||||
@@ -136,7 +218,7 @@
|
|||||||
{$serialPort.chipset}</b
|
{$serialPort.chipset}</b
|
||||||
>
|
>
|
||||||
will be updated from <b class="version">{$serialPort.version}</b> to
|
will be updated from <b class="version">{$serialPort.version}</b> to
|
||||||
<b class="version">{data.version}</b>
|
<b class="version">{data.meta.version}</b>
|
||||||
</div>
|
</div>
|
||||||
{:else if $serialPort && isCorrectDevice === false}
|
{:else if $serialPort && isCorrectDevice === false}
|
||||||
<div class="error" transition:slide>
|
<div class="error" transition:slide>
|
||||||
@@ -158,27 +240,6 @@
|
|||||||
<h3>Manual Update</h3>
|
<h3>Manual Update</h3>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<ul class="files">
|
|
||||||
{#if data.uf2}
|
|
||||||
<li>
|
|
||||||
<a target="_blank" download href={uf2Url}
|
|
||||||
>{data.uf2.name} <span class="icon">download</span><span class="size"
|
|
||||||
>{toByteUnit(data.uf2.size)}</span
|
|
||||||
></a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if data.ota}
|
|
||||||
<li>
|
|
||||||
<a target="_blank" download href={otaUrl}
|
|
||||||
>{data.ota.name} <span class="icon">download</span><span class="size"
|
|
||||||
>{toByteUnit(data.uf2.size)}</span
|
|
||||||
></a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{#if isCorrectDevice === false}
|
{#if isCorrectDevice === false}
|
||||||
<div transition:slide class="incorrect-device">
|
<div transition:slide class="incorrect-device">
|
||||||
These files are incompatible with your device
|
These files are incompatible with your device
|
||||||
@@ -186,7 +247,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h4>UF2 Instructions</h4>
|
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
<button class="inline-button" onclick={connect}
|
<button class="inline-button" onclick={connect}
|
||||||
@@ -227,6 +287,37 @@
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{#if data.meta.update.esptool}
|
||||||
|
<section>
|
||||||
|
<h3>Factory Flash (WIP)</h3>
|
||||||
|
<p>
|
||||||
|
If everything else fails, you can go through the same process that is
|
||||||
|
being used in the factory.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
This will temporarily brick your device if the process is not done
|
||||||
|
completely or incorrectly.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="esp-buttons">
|
||||||
|
<button onclick={espBootloader}
|
||||||
|
><span class="icon">memory</span>ESP Bootloader</button
|
||||||
|
>
|
||||||
|
<button onclick={flashImages}
|
||||||
|
><span class="icon">developer_board</span>Flash Images</button
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase All</label
|
||||||
|
>
|
||||||
|
<button onclick={eraseSPI}
|
||||||
|
><span class="icon">developer_board</span>Erase SPI Flash</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pre>{terminalOutput}</pre>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -239,6 +330,10 @@
|
|||||||
margin-block-start: 4em;
|
margin-block-start: 4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
}
|
}
|
||||||
@@ -249,6 +344,7 @@
|
|||||||
|
|
||||||
.container {
|
.container {
|
||||||
width: calc(min(100%, 16cm));
|
width: calc(min(100%, 16cm));
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
@@ -402,4 +498,8 @@
|
|||||||
.incorrect-device {
|
.incorrect-device {
|
||||||
color: var(--md-sys-color-error);
|
color: var(--md-sys-color-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.esp-buttons {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,50 @@
|
|||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
import type { FileListing, Listing } from "../../listing";
|
import type { FileListing, Listing } from "../../listing";
|
||||||
|
import type { VersionMeta } from "./meta";
|
||||||
|
|
||||||
export const load = (async ({ fetch, params }) => {
|
export const load = (async ({ fetch, params }) => {
|
||||||
const result = await fetch(
|
const result = await fetch(
|
||||||
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
|
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
|
||||||
);
|
);
|
||||||
const data: Listing[] = await result.json();
|
const data: Listing[] = await result.json();
|
||||||
|
const meta: VersionMeta | undefined = data.some(
|
||||||
|
(entry) => entry.type === "file" && entry.name === "meta.json",
|
||||||
|
)
|
||||||
|
? await fetch(
|
||||||
|
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/meta.json`,
|
||||||
|
).then((res) => res.json())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uf2: data.find(
|
meta: {
|
||||||
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
|
version: meta?.version ?? params.version,
|
||||||
) as FileListing,
|
target: meta?.target ?? params.device,
|
||||||
ota: data.find(
|
path: `${import.meta.env.VITE_FIRMWARE_URL}${params.device}/${params.version}`,
|
||||||
(entry) => entry.type === "file" && entry.name === "firmware.bin",
|
git_commit: meta?.git_commit ?? "",
|
||||||
),
|
git_is_dirty: meta?.git_is_dirty ?? false,
|
||||||
version: params.version,
|
git_date: meta?.git_date ?? data[0]?.mtime ?? "",
|
||||||
device: params.device,
|
public_build: meta?.public_build ?? !params.version.startsWith("."),
|
||||||
|
development_mode: meta?.development_mode ?? 0,
|
||||||
|
update: {
|
||||||
|
uf2:
|
||||||
|
(data.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "file" &&
|
||||||
|
entry.name === (meta?.update?.uf2 ?? "CURRENT.UF2"),
|
||||||
|
) as FileListing) ?? undefined,
|
||||||
|
ota:
|
||||||
|
data.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "file" &&
|
||||||
|
entry.name === (meta?.update?.ota ?? "firmware.bin"),
|
||||||
|
) ?? undefined,
|
||||||
|
esptool: meta?.update?.esptool ?? undefined,
|
||||||
|
},
|
||||||
|
files: data.filter(
|
||||||
|
(entry) =>
|
||||||
|
entry.type === "file" && (!meta?.files || entry.name in meta.files),
|
||||||
|
) as FileListing[],
|
||||||
|
spi_flash: meta?.spi_flash ?? undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}) satisfies PageLoad;
|
}) satisfies PageLoad;
|
||||||
|
|||||||
41
src/routes/(app)/ccos/[device]/[version]/meta.ts
Normal file
41
src/routes/(app)/ccos/[device]/[version]/meta.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface VersionMeta {
|
||||||
|
version: string;
|
||||||
|
target: string;
|
||||||
|
git_commit: string;
|
||||||
|
git_is_dirty: boolean;
|
||||||
|
git_date: string;
|
||||||
|
public_build: boolean;
|
||||||
|
development_mode: number;
|
||||||
|
update: {
|
||||||
|
ota: string | null;
|
||||||
|
uf2: string | null;
|
||||||
|
esptool: EspToolData | null;
|
||||||
|
};
|
||||||
|
files: string[];
|
||||||
|
spi_flash: SPIFlashInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SPIFlashInfo {
|
||||||
|
type: string;
|
||||||
|
size: string;
|
||||||
|
connection: SPIConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SPIConnection {
|
||||||
|
clk: number;
|
||||||
|
q: number;
|
||||||
|
d: number;
|
||||||
|
hd: number;
|
||||||
|
cs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EspToolData {
|
||||||
|
chip: string;
|
||||||
|
baud: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
flash_mode: string;
|
||||||
|
flash_freq: string;
|
||||||
|
flash_size: string;
|
||||||
|
files: Record<string, string>;
|
||||||
|
}
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
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>, onlyPhrase: boolean = false) {
|
||||||
const plainPhrase: string[] = [""];
|
const plainPhrase: string[] = [""];
|
||||||
const extraActions: string[] = [];
|
const extraActions: string[] = [];
|
||||||
const extraCodes: string[] = [];
|
const extraCodes: string[] = [];
|
||||||
@@ -103,6 +103,10 @@
|
|||||||
return result ?? `0x${it.toString(16)}`;
|
return result ?? `0x${it.toString(16)}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (onlyPhrase) {
|
||||||
|
return plainPhrase.join();
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...plainPhrase,
|
...plainPhrase,
|
||||||
`+${input.join("+")}`,
|
`+${input.join("+")}`,
|
||||||
@@ -182,7 +186,7 @@
|
|||||||
function downloadVocabulary() {
|
function downloadVocabulary() {
|
||||||
const vocabulary = new Set(
|
const vocabulary = new Set(
|
||||||
$chords.map((it) =>
|
$chords.map((it) =>
|
||||||
"phrase" in it ? plainPhrase(it.phrase, $osLayout).trim() : "",
|
"phrase" in it ? encodeChord(it, $osLayout, true).trim() : "",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
vocabulary.delete("");
|
vocabulary.delete("");
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import ConfirmChallenge from "./ConfirmChallenge.svelte";
|
import ConfirmChallenge from "./ConfirmChallenge.svelte";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
export const confirmChallenge: Action<
|
export const confirmChallenge: Action<
|
||||||
HTMLElement,
|
HTMLElement,
|
||||||
{ onConfirm: () => void; challenge: string }
|
{ onConfirm: () => void; challenge: string }
|
||||||
> = (node, { onConfirm, challenge }) => {
|
> = (node, { onConfirm, challenge }) => {
|
||||||
let component: ConfirmChallenge | undefined;
|
let component: {} | undefined;
|
||||||
let target: HTMLElement | undefined;
|
let target: HTMLElement | undefined;
|
||||||
const edit = tippy(node, {
|
const edit = tippy(node, {
|
||||||
interactive: true,
|
interactive: true,
|
||||||
@@ -15,15 +16,22 @@ export const confirmChallenge: Action<
|
|||||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||||
target.classList.add("active");
|
target.classList.add("active");
|
||||||
if (component === undefined) {
|
if (component === undefined) {
|
||||||
component = new ConfirmChallenge({ target, props: { challenge } });
|
component = mount(ConfirmChallenge, {
|
||||||
component.$on("confirm", () => {
|
target,
|
||||||
edit.hide();
|
props: {
|
||||||
onConfirm();
|
challenge,
|
||||||
|
onconfirm() {
|
||||||
|
edit.hide();
|
||||||
|
onConfirm();
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onHidden() {
|
onHidden() {
|
||||||
component?.$destroy();
|
if (component) {
|
||||||
|
unmount(component);
|
||||||
|
}
|
||||||
target?.classList.remove("active");
|
target?.classList.remove("active");
|
||||||
component = undefined;
|
component = undefined;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,231 +1,24 @@
|
|||||||
<script lang="ts">
|
<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>
|
</script>
|
||||||
|
|
||||||
<h2>WIP</h2>
|
<ul>
|
||||||
|
<li><a href="/learn/layout/">Layout</a></li>
|
||||||
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
<li><a href="/learn/chords/">Chords</a></li>
|
||||||
|
<li><a href="/learn/sentence/">Sentences</a></li>
|
||||||
{#key $nextWord}
|
</ul>
|
||||||
<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">
|
<style lang="scss">
|
||||||
@use "sass:math";
|
ul {
|
||||||
|
margin: 16px;
|
||||||
input {
|
|
||||||
background: none;
|
|
||||||
font: inherit;
|
|
||||||
color: inherit;
|
|
||||||
border: none;
|
|
||||||
width: 5ch;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
min-width: 20ch;
|
|
||||||
padding: 1ch;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
gap: 16px;
|
||||||
align-items: center;
|
list-style-type: none;
|
||||||
justify-content: center;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stats {
|
a {
|
||||||
display: flex;
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
gap: 3em;
|
width: 128px;
|
||||||
}
|
height: 128px;
|
||||||
|
|
||||||
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>
|
</style>
|
||||||
|
|||||||
231
src/routes/(app)/learn/chords/+page.svelte
Normal file
231
src/routes/(app)/learn/chords/+page.svelte
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||||
|
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
import {
|
||||||
|
words,
|
||||||
|
nextWord,
|
||||||
|
scores,
|
||||||
|
learnConfigDefault,
|
||||||
|
learnConfig,
|
||||||
|
learnConfigStored,
|
||||||
|
} from "$lib/learn/chords";
|
||||||
|
import { blur, fade } from "svelte/transition";
|
||||||
|
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
|
||||||
|
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||||
|
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||||
|
|
||||||
|
let recorder = $derived(new ReplayRecorder($nextWord));
|
||||||
|
let start = performance.now();
|
||||||
|
$effect(() => {
|
||||||
|
start = recorder && performance.now();
|
||||||
|
});
|
||||||
|
|
||||||
|
let chords: InferredChord[] = $state([]);
|
||||||
|
|
||||||
|
function onkeyboard(event: KeyboardEvent) {
|
||||||
|
recorder.next(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lerp(a: number, b: number, t: number) {
|
||||||
|
return a + (b - a) * t;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inspect(chords);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const [chord] = chords;
|
||||||
|
if (!chord) return;
|
||||||
|
|
||||||
|
console.log(chord);
|
||||||
|
|
||||||
|
if (chord.output.trim() === $nextWord) {
|
||||||
|
scores.update((scores) => {
|
||||||
|
const score = Math.max(
|
||||||
|
$learnConfig.minScore,
|
||||||
|
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!scores[$nextWord]) {
|
||||||
|
scores[$nextWord] = {
|
||||||
|
score,
|
||||||
|
lastTyped: performance.now(),
|
||||||
|
total: 1,
|
||||||
|
};
|
||||||
|
return scores;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldScore = scores[$nextWord].score;
|
||||||
|
scores[$nextWord].score = lerp(
|
||||||
|
score,
|
||||||
|
oldScore,
|
||||||
|
$learnConfig.scoreBlend,
|
||||||
|
);
|
||||||
|
scores[$nextWord].lastTyped = performance.now();
|
||||||
|
scores[$nextWord].total += 1;
|
||||||
|
|
||||||
|
return scores;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
button?.blur();
|
||||||
|
scores.update((scores) => {
|
||||||
|
return scores;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let button = $state<HTMLButtonElement>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2>WIP</h2>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
||||||
|
|
||||||
|
{#key $nextWord}
|
||||||
|
<h3>
|
||||||
|
{$nextWord}
|
||||||
|
{#if $scores[$nextWord!] === undefined}
|
||||||
|
<sup class="new-word">new</sup>
|
||||||
|
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
||||||
|
<sup class="weak">weak</sup>
|
||||||
|
{/if}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="chord" in:fade>
|
||||||
|
<CharRecorder replay={recorder.player} cursor={true}>
|
||||||
|
<TrackChords bind:chords />
|
||||||
|
</CharRecorder>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
{#key $nextWord}
|
||||||
|
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
||||||
|
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
<button onclick={skip} bind:this={button}>skip</button>
|
||||||
|
|
||||||
|
<section class="stats">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Weak</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($scores)
|
||||||
|
.sort(([, a], [, b]) => a.score - b.score)
|
||||||
|
.splice(0, 10) as [word, score]}
|
||||||
|
<tr class="decay">
|
||||||
|
<td>{word}</td>
|
||||||
|
<td><i>{score.score.toFixed(2)}</i></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Strong</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($scores)
|
||||||
|
.sort(([, a], [, b]) => b.score - a.score)
|
||||||
|
.splice(0, 10) as [word, score]}
|
||||||
|
<tr class="decay">
|
||||||
|
<td>{word}</td>
|
||||||
|
<td><i>{score.score.toFixed(2)}</i></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Rehearse</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($scores)
|
||||||
|
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
||||||
|
.splice(0, 10) as [word, score]}
|
||||||
|
<tr class="decay">
|
||||||
|
<td>{word}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Settings</summary>
|
||||||
|
<button onclick={() => ($scores = {})}>Reset</button>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries(learnConfigDefault) as [key, value]}
|
||||||
|
<tr>
|
||||||
|
<th>{key}</th>
|
||||||
|
<td
|
||||||
|
><input
|
||||||
|
type="number"
|
||||||
|
value={$learnConfig[key] ?? value}
|
||||||
|
step="0.1"
|
||||||
|
oninput={(event) =>
|
||||||
|
($learnConfigStored[key] = event.target.value)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
disabled={!$learnConfigStored[key]}
|
||||||
|
onclick={() => ($learnConfigStored[key] = undefined)}>⟲</button
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass:math";
|
||||||
|
|
||||||
|
input {
|
||||||
|
background: none;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
width: 5ch;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
min-width: 20ch;
|
||||||
|
padding: 1ch;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
sup {
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8em;
|
||||||
|
|
||||||
|
&.new-word {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
&.weak {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 1 through 10 {
|
||||||
|
tr.decay:nth-child(#{$i}) {
|
||||||
|
opacity: 1 - math.div($i, 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
132
src/routes/(app)/learn/layout/+page.svelte
Normal file
132
src/routes/(app)/learn/layout/+page.svelte
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { share } from "$lib/share";
|
||||||
|
import tippy from "tippy.js";
|
||||||
|
import { mount, setContext, unmount } from "svelte";
|
||||||
|
import Layout from "$lib/components/layout/Layout.svelte";
|
||||||
|
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||||
|
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||||
|
import { writable, derived } from "svelte/store";
|
||||||
|
import { layout } from "$lib/undo-redo";
|
||||||
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import { serialPort } from "$lib/serial/connection";
|
||||||
|
|
||||||
|
let hasStarted = $state(false);
|
||||||
|
|
||||||
|
setContext<VisualLayoutConfig>("visual-layout-config", {
|
||||||
|
scale: 50,
|
||||||
|
inactiveScale: 0.5,
|
||||||
|
inactiveOpacity: 0.4,
|
||||||
|
strokeWidth: 1,
|
||||||
|
margin: 5,
|
||||||
|
fontSize: 9,
|
||||||
|
iconFontSize: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actions = derived(layout, (layout) => {
|
||||||
|
const result = new Set<number>();
|
||||||
|
for (const layer of layout) {
|
||||||
|
for (const key of layer) {
|
||||||
|
result.add(key.action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...result];
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentAction = writable(0);
|
||||||
|
|
||||||
|
const expected = derived(
|
||||||
|
[layout, currentAction],
|
||||||
|
([layout, currentAction]) => {
|
||||||
|
const result: Array<{ layer: number; key: number }> = [];
|
||||||
|
for (let layer = 0; layer <= layout.length; layer++) {
|
||||||
|
if (layout[layer] === undefined) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let key = 0; key <= layout[layer].length; key++) {
|
||||||
|
if (layout[layer][key]?.action === currentAction) {
|
||||||
|
result.push({ layer, key });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlight = derived(
|
||||||
|
expected,
|
||||||
|
(expected) => new Set(expected.map(({ key }) => key)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const highlightAction = derived(
|
||||||
|
currentAction,
|
||||||
|
(currentAction) => new Set([currentAction]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentLayer = writable(0);
|
||||||
|
|
||||||
|
setContext("highlight", highlight);
|
||||||
|
|
||||||
|
setContext("highlight-action", highlightAction);
|
||||||
|
|
||||||
|
setContext("active-layer", currentLayer);
|
||||||
|
|
||||||
|
async function next() {
|
||||||
|
console.log("Next");
|
||||||
|
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
|
||||||
|
if (nextAction !== undefined) {
|
||||||
|
currentAction.set(nextAction);
|
||||||
|
currentLayer.set($expected[0]?.layer ?? 0);
|
||||||
|
const key = await $serialPort?.queryKey();
|
||||||
|
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
|
||||||
|
console.log("Correct", key);
|
||||||
|
} else {
|
||||||
|
console.log("Incorrect", key);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
|
||||||
|
hasStarted = true;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="challenge">
|
||||||
|
<Action display="inline-keys" action={$currentAction}></Action>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Layout />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.challenge {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 100px;
|
||||||
|
border: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
580
src/routes/(app)/learn/sentence/+page.svelte
Normal file
580
src/routes/(app)/learn/sentence/+page.svelte
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/stores";
|
||||||
|
import { SvelteMap } from "svelte/reactivity";
|
||||||
|
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||||
|
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
import { shuffleInPlace } from "$lib/util/shuffle";
|
||||||
|
import { fade, fly, slide } from "svelte/transition";
|
||||||
|
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||||
|
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
|
||||||
|
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import TrackText from "$lib/charrecorder/TrackText.svelte";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { expoIn, expoOut } from "svelte/easing";
|
||||||
|
|
||||||
|
function viaLocalStorage<T>(key: string, initial: T) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(key) ?? "");
|
||||||
|
} catch {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
|
||||||
|
viaLocalStorage("mastery-thresholds", [
|
||||||
|
[1500, 1050, "Words"],
|
||||||
|
[3000, 2500, "Pairs"],
|
||||||
|
[5000, 3500, "Trios"],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const avgWordLength = 5;
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
localStorage.removeItem("mastery-thresholds");
|
||||||
|
localStorage.removeItem("idle-timeout");
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
let inputSentence = $derived(
|
||||||
|
(browser && $page.url.searchParams.get("sentence")) || "Hello World",
|
||||||
|
);
|
||||||
|
let wpmTarget = $derived(
|
||||||
|
(browser && Number($page.url.searchParams.get("wpm"))) || 250,
|
||||||
|
);
|
||||||
|
let devTools = $derived(
|
||||||
|
browser && $page.url.searchParams.get("dev") === "true",
|
||||||
|
);
|
||||||
|
let sentenceWords = $derived(inputSentence.split(" "));
|
||||||
|
let msPerChar = $derived((1 / ((wpmTarget / 60) * avgWordLength)) * 1000);
|
||||||
|
let totalMs = $derived(inputSentence.length * msPerChar);
|
||||||
|
let msPerWord = $derived(
|
||||||
|
(inputSentence.length * msPerChar) / inputSentence.split(" ").length,
|
||||||
|
);
|
||||||
|
let currentWord = $state("");
|
||||||
|
let wordStats = new SvelteMap<string, number[]>();
|
||||||
|
let wordMastery = new SvelteMap<string, number>();
|
||||||
|
let text = $state("");
|
||||||
|
let level = $state(0);
|
||||||
|
let bestWPM = $state(0);
|
||||||
|
let wpm = $state(0);
|
||||||
|
let chords: InferredChord[] = $state([]);
|
||||||
|
let recorder = $state(new ReplayRecorder());
|
||||||
|
let idle = $state(true);
|
||||||
|
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
|
||||||
|
|
||||||
|
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
let cooldown = $state(false);
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
selectNextWord();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (wpm > bestWPM) {
|
||||||
|
bestWPM = wpm;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localStorage.setItem("idle-timeout", idleTime.toString());
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
localStorage.setItem(
|
||||||
|
"mastery-thresholds",
|
||||||
|
JSON.stringify(masteryThresholds),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let words = $derived.by(() => {
|
||||||
|
const words = inputSentence.trim().split(" ");
|
||||||
|
switch (level) {
|
||||||
|
case 0: {
|
||||||
|
shuffleInPlace(words);
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
const pairs = [];
|
||||||
|
for (let i = 0; i < words.length - 1; i++) {
|
||||||
|
pairs.push(`${words[i]} ${words[i + 1]}`);
|
||||||
|
}
|
||||||
|
shuffleInPlace(pairs);
|
||||||
|
return pairs;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
const trios = [];
|
||||||
|
for (let i = 0; i < words.length - 2; i++) {
|
||||||
|
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
||||||
|
}
|
||||||
|
shuffleInPlace(trios);
|
||||||
|
return trios;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return [inputSentence];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
for (const [word, speeds] of wordStats.entries()) {
|
||||||
|
const level = word.split(" ").length - 1;
|
||||||
|
const masteryThreshold = masteryThresholds[level];
|
||||||
|
if (masteryThreshold === undefined) continue;
|
||||||
|
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
|
||||||
|
wordMastery.set(
|
||||||
|
word,
|
||||||
|
1 -
|
||||||
|
Math.min(
|
||||||
|
1,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
(averageSpeed - masteryThreshold[1]) /
|
||||||
|
(masteryThreshold[0] - masteryThreshold[1]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let progress = $derived(
|
||||||
|
level === masteryThresholds.length
|
||||||
|
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
|
||||||
|
: words.length > 0
|
||||||
|
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
|
||||||
|
words.length
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
let mastered = $derived(
|
||||||
|
words.length > 0
|
||||||
|
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (progress === 1 && level < masteryThresholds.length) {
|
||||||
|
level++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectNextWord() {
|
||||||
|
const unmasteredWords = words
|
||||||
|
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
|
||||||
|
.filter(([, it]) => it !== 1);
|
||||||
|
unmasteredWords.sort(([, a], [, b]) => a - b);
|
||||||
|
let nextWord = unmasteredWords[0]?.[0] ?? words[0] ?? "ERROR";
|
||||||
|
for (const [word] of unmasteredWords) {
|
||||||
|
if (word === currentWord || Math.random() > 0.5) continue;
|
||||||
|
nextWord = word;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
currentWord = nextWord;
|
||||||
|
recorder = new ReplayRecorder(nextWord);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkInput() {
|
||||||
|
if (recorder.player.stepper.challenge.length === 0) return;
|
||||||
|
const replay = recorder.finish(false);
|
||||||
|
const elapsed = replay.finish - replay.start! - idleTime;
|
||||||
|
if (elapsed < masteryThresholds[level]![0]) {
|
||||||
|
const prevStats = wordStats.get(currentWord) ?? [];
|
||||||
|
prevStats.push(elapsed);
|
||||||
|
wordStats.set(currentWord, prevStats.slice(-10));
|
||||||
|
}
|
||||||
|
|
||||||
|
text = "";
|
||||||
|
setTimeout(() => {
|
||||||
|
selectNextWord();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!idle || !text) return;
|
||||||
|
if (text.trim() !== currentWord.trim()) return;
|
||||||
|
if (level === masteryThresholds.length) {
|
||||||
|
const replay = recorder.finish();
|
||||||
|
const elapsed = replay.finish - replay.start!;
|
||||||
|
text = "";
|
||||||
|
recorder = new ReplayRecorder(currentWord);
|
||||||
|
console.log(elapsed, totalMs);
|
||||||
|
wpm = (totalMs / elapsed) * wpmTarget;
|
||||||
|
} else {
|
||||||
|
checkInput();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function onkey(event: KeyboardEvent) {
|
||||||
|
if (idleTimeout) {
|
||||||
|
clearTimeout(idleTimeout);
|
||||||
|
}
|
||||||
|
idle = false;
|
||||||
|
recorder.next(event);
|
||||||
|
idleTimeout = setTimeout(() => {
|
||||||
|
idle = true;
|
||||||
|
}, idleTime);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Sentence Trainer</h1>
|
||||||
|
|
||||||
|
<div class="levels">
|
||||||
|
{#each masteryThresholds as [, , title], i}
|
||||||
|
<button
|
||||||
|
class:active={level === i}
|
||||||
|
class:mastered={i < level || progress === 1}
|
||||||
|
class="threshold"
|
||||||
|
onclick={() => {
|
||||||
|
level = i;
|
||||||
|
selectNextWord();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
class:active={level === masteryThresholds.length}
|
||||||
|
class:mastered={masteryThresholds.length < level || progress === 1}
|
||||||
|
class="threshold"
|
||||||
|
onclick={() => {
|
||||||
|
level = masteryThresholds.length;
|
||||||
|
selectNextWord();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{wpmTarget} WPM
|
||||||
|
</button>
|
||||||
|
{#each masteryThresholds as _, i}
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style:--progress="{-100 *
|
||||||
|
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
|
||||||
|
style:--mastered="{-100 *
|
||||||
|
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
|
||||||
|
class:active={level === i}
|
||||||
|
></div>
|
||||||
|
{/each}
|
||||||
|
<div
|
||||||
|
class="progress"
|
||||||
|
style:--progress="-100%"
|
||||||
|
style:--mastered="{-100 *
|
||||||
|
(1 -
|
||||||
|
(level === masteryThresholds.length
|
||||||
|
? progress
|
||||||
|
: masteryThresholds.length < level
|
||||||
|
? 1
|
||||||
|
: 0))}%"
|
||||||
|
class:active={level === masteryThresholds.length}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="sentence">
|
||||||
|
{#each sentenceWords as _, i}
|
||||||
|
{#if i !== sentenceWords.length - 1}
|
||||||
|
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
|
||||||
|
{@const mastery = wordMastery.get(word) ?? 0}
|
||||||
|
<div
|
||||||
|
class="arch"
|
||||||
|
class:mastered={mastery === 1}
|
||||||
|
style:opacity={mastery}
|
||||||
|
style:grid-row={(i % 2) + 1}
|
||||||
|
style:grid-column="{i + 1} / span 2"
|
||||||
|
style:border-bottom="none"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{#each sentenceWords as word, i}
|
||||||
|
{@const mastery = wordMastery.get(word)}
|
||||||
|
<div
|
||||||
|
class="word"
|
||||||
|
class:mastered={mastery === 1}
|
||||||
|
style:opacity={mastery ?? 0}
|
||||||
|
style:grid-row={3}
|
||||||
|
style:grid-column={i + 1}
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{#each sentenceWords as _, i}
|
||||||
|
{#if i < sentenceWords.length - 2}
|
||||||
|
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
|
||||||
|
{@const mastery = wordMastery.get(word) ?? 0}
|
||||||
|
<div
|
||||||
|
class="arch"
|
||||||
|
class:mastered={mastery === 1}
|
||||||
|
style:opacity={mastery}
|
||||||
|
style:grid-row={(i % 3) + 4}
|
||||||
|
style:grid-column="{i + 1} / span 3"
|
||||||
|
style:border-top="none"
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if level === masteryThresholds.length}
|
||||||
|
{@const maxDigits = 4}
|
||||||
|
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
|
||||||
|
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
|
||||||
|
<div class="finish" transition:slide>
|
||||||
|
<div
|
||||||
|
class="wpm"
|
||||||
|
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
|
||||||
|
style:opacity={progress}
|
||||||
|
style:font-size="3rem"
|
||||||
|
style:color="var(--md-sys-color-{progress === 1
|
||||||
|
? 'primary'
|
||||||
|
: 'on-background'})"
|
||||||
|
style:scale={(progress + 0.5) / 2}
|
||||||
|
>
|
||||||
|
{#each indices as i}
|
||||||
|
{@const char = wpmString[i]}
|
||||||
|
{#key char}
|
||||||
|
<div
|
||||||
|
style:grid-column={i + 1}
|
||||||
|
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
|
||||||
|
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
{/each}
|
||||||
|
<div style:grid-column={maxDigits + 3} style:justify-self="start">
|
||||||
|
WPM
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="wpm"
|
||||||
|
style:grid-template-columns="4ch 1ch auto"
|
||||||
|
style:font-size="1.5rem"
|
||||||
|
>
|
||||||
|
{#key wpm}
|
||||||
|
<div
|
||||||
|
style:grid-column={1}
|
||||||
|
style:justify-self="end"
|
||||||
|
transition:fade={{ duration: 200 }}
|
||||||
|
>
|
||||||
|
{Math.floor(wpm)}
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
<div style:grid-column={3} style:justify-self="start">WPM</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<ChordHud {chords} />
|
||||||
|
<div class="container">
|
||||||
|
<div
|
||||||
|
class="input-section"
|
||||||
|
onkeydown={onkey}
|
||||||
|
onkeyup={onkey}
|
||||||
|
tabindex="0"
|
||||||
|
role="textbox"
|
||||||
|
>
|
||||||
|
{#key recorder}
|
||||||
|
<div class="input" transition:fade={{ duration: 200 }}>
|
||||||
|
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
||||||
|
<TrackText bind:text />
|
||||||
|
<TrackChords bind:chords />
|
||||||
|
</CharRecorder>
|
||||||
|
</div>
|
||||||
|
{/key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if devTools}
|
||||||
|
<div>Dev Tools</div>
|
||||||
|
<button onclick={reset}>Reset</button>
|
||||||
|
<label>Idle Time <input bind:value={idleTime} /></label>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Total</th>
|
||||||
|
<td
|
||||||
|
><span style:color="var(--md-sys-color-tertiary)"
|
||||||
|
>{Math.round(totalMs)}</span
|
||||||
|
>ms</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Char</th>
|
||||||
|
<td
|
||||||
|
><span style:color="var(--md-sys-color-tertiary)"
|
||||||
|
>{Math.round(msPerChar)}</span
|
||||||
|
>ms</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Word</th>
|
||||||
|
<td
|
||||||
|
><span style:color="var(--md-sys-color-tertiary)"
|
||||||
|
>{Math.round(msPerWord)}</span
|
||||||
|
>ms</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each masteryThresholds as _, i}
|
||||||
|
<tr>
|
||||||
|
<th>L{i + 1}</th>
|
||||||
|
<td><input bind:value={masteryThresholds[i]![0]} /></td>
|
||||||
|
<td><input bind:value={masteryThresholds[i]![1]} /></td>
|
||||||
|
<td><input bind:value={masteryThresholds[i]![2]} /></td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
{#each wordStats.entries() as [word, stats]}
|
||||||
|
{@const mastery = wordMastery.get(word) ?? 0}
|
||||||
|
<tr>
|
||||||
|
<th>{word}</th>
|
||||||
|
<td
|
||||||
|
style:color="var(--md-sys-color-{mastery === 1
|
||||||
|
? 'primary'
|
||||||
|
: 'tertiary'})">{Math.round(mastery * 100)}%</td
|
||||||
|
>
|
||||||
|
{#each stats as stat}
|
||||||
|
<td>{stat}</td>
|
||||||
|
{/each}
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.levels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wpm {
|
||||||
|
width: min-content;
|
||||||
|
display: grid;
|
||||||
|
transition: scale 0.2s ease;
|
||||||
|
|
||||||
|
* {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.finish {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(2, 1fr);
|
||||||
|
font-weight: bold;
|
||||||
|
justify-items: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sentence {
|
||||||
|
display: grid;
|
||||||
|
width: min-content;
|
||||||
|
gap: 4px 1ch;
|
||||||
|
grid-template-rows: repeat(4, auto);
|
||||||
|
margin-block: 1rem;
|
||||||
|
|
||||||
|
.word,
|
||||||
|
.arch {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
|
||||||
|
&.mastered {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
border-color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.arch {
|
||||||
|
border: 2px solid var(--md-sys-color-outline);
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: relative;
|
||||||
|
height: 1rem;
|
||||||
|
width: auto;
|
||||||
|
background: var(--md-sys-color-outline-variant);
|
||||||
|
border: none;
|
||||||
|
overflow: hidden;
|
||||||
|
grid-row: 2;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
background: var(--md-sys-color-outline);
|
||||||
|
transform: translateX(var(--progress));
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
transform: translateX(var(--mastered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.threshold {
|
||||||
|
width: auto;
|
||||||
|
justify-self: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
grid-row: 1;
|
||||||
|
|
||||||
|
&.mastered,
|
||||||
|
&.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mastered {
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section {
|
||||||
|
display: grid;
|
||||||
|
cursor: text;
|
||||||
|
|
||||||
|
:global(.cursor) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
display: flex;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
max-width: 16cm;
|
||||||
|
outline: 2px dashed transparent;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
margin-block: 1rem;
|
||||||
|
transition:
|
||||||
|
outline 0.2s ease,
|
||||||
|
border-radius 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-section:focus-within {
|
||||||
|
outline: none;
|
||||||
|
.input {
|
||||||
|
outline-color: var(--md-sys-color-primary);
|
||||||
|
border-radius: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.cursor) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -58,6 +58,7 @@ export default defineConfig({
|
|||||||
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
||||||
"prerendered/**/*.html",
|
"prerendered/**/*.html",
|
||||||
],
|
],
|
||||||
|
globIgnores: ["prerendered/pages/ccos/**/*"],
|
||||||
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
|
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
|
||||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user