mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2025-12-27 13:16:15 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6a29e6a2fc
|
|||
|
9bf3801fef
|
|||
|
d2accfb838
|
|||
|
b8a376b93b
|
|||
|
588719df91
|
|||
|
6a0dad9dad
|
|||
|
f3704e4051
|
|||
|
3e6298717e
|
|||
|
aced0bbbb7
|
|||
|
|
36874c59e3 | ||
|
9dc61a3482
|
|||
|
d9183f952a
|
|||
|
913a833824
|
|||
|
0d6ef4d011
|
|||
|
232045964c
|
|||
|
3659b80e41
|
|||
|
3a02caeb6d
|
|||
|
259fd3a989
|
|||
|
dcf1d89fa0
|
|||
|
c79237ce22
|
|||
|
d68f1b19fa
|
|||
|
9cb36662b3
|
|||
|
b4605fe84d
|
|||
|
06d122b5d6
|
|||
|
3d25b030c6
|
|||
|
bf490ba823
|
|||
|
397f4bb6a9
|
|||
|
1f4604bcbc
|
|||
|
68faf57a22
|
|||
|
1d976947e1
|
|||
|
ca8bfac3bc
|
|||
|
2f0d8f2e1d
|
|||
|
236e23086c
|
|||
|
d1fefb88a1
|
|||
|
26c43b1966
|
|||
|
8b2bfee099
|
|||
|
b8b903c5e1
|
|||
|
6201cf5b0c
|
|||
|
aaafadf732
|
|||
|
fe80867ce4
|
|||
|
72a8e084ce
|
|||
|
989e844190
|
|||
|
500221f39a
|
|||
|
|
d91273d27b | ||
|
888df6dd66
|
|||
|
7ad9612037
|
|||
|
3f9674b399
|
|||
|
92ba5bcb24
|
|||
|
2163a63a7c
|
|||
|
65a5a2517e
|
|||
|
21e8c291b0
|
|||
|
4106a80d53
|
|||
|
|
01fb61d27c | ||
|
3dd91a1cea
|
|||
|
cbcf705f71
|
|||
|
4007810c7b
|
|||
|
f322435c41
|
|||
|
587375e654
|
|||
|
0500a723de
|
|||
|
26dcc56aca
|
|||
|
20b65813bf
|
|||
|
87b23c04b1
|
|||
|
8b2bc6d109
|
|||
|
19cf0b26b3
|
|||
|
3e72dd3cb8
|
|||
|
a40daefbad
|
|||
|
77d4a90519
|
|||
|
c9a031a1fd
|
|||
|
254a0c1aec
|
|||
|
bd75012cf1
|
|||
|
4b738bb340
|
|||
|
3af65106bf
|
|||
|
8087d10d5a
|
|||
|
2782966505
|
|||
|
5b6d369101
|
|||
|
b423d1c661
|
|||
|
92a3c6012f
|
|||
|
8ec11c7ec9
|
|||
|
5c8eb1d19f
|
|||
|
91a044bbba
|
|||
|
1a6c85a361
|
|||
|
ecef11ac2d
|
|||
|
a23af9ba9d
|
|||
|
93849f250f
|
|||
|
33890b0aa8
|
|||
|
6f925de1af
|
|||
|
d45fe43f17
|
|||
|
59788f059d
|
|||
|
2808973ad0
|
|||
|
bef51d2a7d
|
|||
|
854ab6d3be
|
|||
|
86ec8651b6
|
|||
|
4e4bff02a0
|
|||
|
5d4dbc7e2a
|
|||
|
dfd1c0bcbd
|
|||
|
6ac2cd1993
|
|||
|
7256dc50d4
|
|||
|
f0ad19e6c2
|
|||
|
9022a09b4c
|
|||
|
7e3e61afd7
|
|||
|
08f594d164
|
|||
|
046595b51f
|
|||
|
fbc5303690
|
|||
|
ad41d39bfb
|
|||
|
6faaa18b3b
|
|||
|
6ab6959129
|
52
.github/workflows/build.yml
vendored
52
.github/workflows/build.yml
vendored
@@ -1,13 +1,9 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
CI:
|
||||
build:
|
||||
name: 🔨🚀 Build and deploy
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -21,30 +17,34 @@ jobs:
|
||||
- name: ⏬ Install Python dependencies
|
||||
run: pip install -r requirements.txt
|
||||
|
||||
- name: 🐉 Use Node.js 18.16.x
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- name: 🐉 Use Node.js 22.4.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18.16.x
|
||||
cache: "npm"
|
||||
node-version: 22.4.x
|
||||
cache: "pnpm"
|
||||
- name: ⏬ Install Node dependencies
|
||||
run: npm ci
|
||||
run: pnpm install
|
||||
|
||||
- name: 🔥 Optimize icon font
|
||||
run: npm run minify-icons
|
||||
run: pnpm minify-icons
|
||||
- name: 🔨 Build site
|
||||
run: npm run build
|
||||
run: pnpm build
|
||||
|
||||
- name: 📦 Upload build artifacts
|
||||
uses: actions/upload-artifact@v3.1.2
|
||||
with:
|
||||
name: build
|
||||
path: build
|
||||
- name: Disable jekyll
|
||||
run: touch build/.nojekyll
|
||||
- name: Custom domain
|
||||
run: echo 'manager.charachorder.com' > build/CNAME
|
||||
- run: git config user.name github-actions
|
||||
- run: git config user.email github-actions@github.com
|
||||
- run: git --work-tree build add --all
|
||||
- run: git commit -m "Automatic Deploy action run by github-actions"
|
||||
- run: git push origin HEAD:gh-pages --force
|
||||
- name: Setup SSH
|
||||
run: |
|
||||
install -m 600 -D /dev/null ~/.ssh/id_rsa
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||
|
||||
- name: Publish Stable
|
||||
if: ${{ github.ref == 'refs/tags/v*' }}
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
|
||||
|
||||
- name: Publish Branch
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
|
||||
- name: Publish Commit
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}
|
||||
|
||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
...require("@theaninova/prettier-config"),
|
||||
plugins: ["prettier-plugin-svelte"],
|
||||
pluginSearchDirs: ["."],
|
||||
overrides: [{files: "*.svelte", options: {parser: "svelte"}}],
|
||||
}
|
||||
@@ -29,7 +29,7 @@ You may need to run through some additional setup to get Rust running inside Int
|
||||
- Python >=3.10
|
||||
- Rust Stable (For Tauri Development)
|
||||
|
||||
I know, python in JS projects is extremely annoying, unfortunately,
|
||||
I know, python in JS projects is extremely annoying. Unfortunately,
|
||||
it seems to be the only platform that offers a functional
|
||||
way to subset variable woff2 fonts with ligatures.
|
||||
|
||||
|
||||
58
flake.lock
generated
58
flake.lock
generated
@@ -5,29 +5,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1689068808,
|
||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"flake-utils_2": {
|
||||
"inputs": {
|
||||
"systems": "systems_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1681202837,
|
||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -38,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1689752456,
|
||||
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
||||
"lastModified": 1722415718,
|
||||
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
||||
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -54,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681358109,
|
||||
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -77,15 +59,14 @@
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils_2",
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1690942540,
|
||||
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
|
||||
"lastModified": 1722391647,
|
||||
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "aa3994f054038262df55122dfa552b9eab71a994",
|
||||
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -108,21 +89,6 @@
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems_2": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
|
||||
117
flake.nix
117
flake.nix
@@ -4,56 +4,75 @@
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
rust-overlay,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
overlays = [(import rust-overlay)];
|
||||
pkgs = import nixpkgs {inherit system overlays;};
|
||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
|
||||
};
|
||||
fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
|
||||
tauriPkgs = nixpkgs.legacyPackages.${system};
|
||||
libraries = with tauriPkgs; [
|
||||
webkitgtk
|
||||
gtk3
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
dbus
|
||||
openssl_3
|
||||
librsvg
|
||||
];
|
||||
packages =
|
||||
(with pkgs; [
|
||||
nodejs_18
|
||||
rust-bin
|
||||
fontMin
|
||||
])
|
||||
++ (with tauriPkgs; [
|
||||
curl
|
||||
wget
|
||||
pkg-config
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
rust-overlay,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
"rust-src"
|
||||
"rust-std"
|
||||
"clippy"
|
||||
"rust-analyzer"
|
||||
];
|
||||
};
|
||||
fontMin = pkgs.python311.withPackages (
|
||||
ps:
|
||||
with ps;
|
||||
[
|
||||
brotli
|
||||
fonttools
|
||||
]
|
||||
++ (with fonttools.optional-dependencies; [ woff ])
|
||||
);
|
||||
tauriPkgs = nixpkgs.legacyPackages.${system};
|
||||
libraries = with tauriPkgs; [
|
||||
webkitgtk
|
||||
gtk3
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
dbus
|
||||
openssl_3
|
||||
glib
|
||||
gtk3
|
||||
libsoup
|
||||
webkitgtk
|
||||
librsvg
|
||||
# serial plugin
|
||||
udev
|
||||
]);
|
||||
in {
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = packages;
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||
'';
|
||||
};
|
||||
});
|
||||
];
|
||||
packages =
|
||||
(with pkgs; [
|
||||
nodejs_22
|
||||
nodePackages.pnpm
|
||||
rust-bin
|
||||
fontMin
|
||||
])
|
||||
++ (with tauriPkgs; [
|
||||
curl
|
||||
wget
|
||||
pkg-config
|
||||
dbus
|
||||
openssl_3
|
||||
glib
|
||||
gtk3
|
||||
libsoup
|
||||
webkitgtk
|
||||
librsvg
|
||||
# serial plugin
|
||||
udev
|
||||
]);
|
||||
in
|
||||
{
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = packages;
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const config = {
|
||||
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
|
||||
outputPath: "src/lib/assets/icons.min.woff2",
|
||||
icons: [
|
||||
"deployed_code_update",
|
||||
"adjust",
|
||||
"add",
|
||||
"piano",
|
||||
@@ -18,6 +19,8 @@ const config = {
|
||||
"update",
|
||||
"offline_pin",
|
||||
"warning",
|
||||
"dangerous",
|
||||
"check",
|
||||
"cable",
|
||||
"person",
|
||||
"sync",
|
||||
@@ -41,6 +44,7 @@ const config = {
|
||||
"save",
|
||||
"settings_backup_restore",
|
||||
"sort",
|
||||
"shopping_bag",
|
||||
"filter_list",
|
||||
"settings_power",
|
||||
"link",
|
||||
@@ -64,6 +68,8 @@ const config = {
|
||||
"bolt",
|
||||
"undo",
|
||||
"redo",
|
||||
"replay",
|
||||
"reply",
|
||||
"navigate_before",
|
||||
"navigate_next",
|
||||
"print",
|
||||
@@ -90,9 +96,23 @@ const config = {
|
||||
"upload_2",
|
||||
"stat_minus_2",
|
||||
"stat_2",
|
||||
"send",
|
||||
"more_horiz",
|
||||
"add_reaction",
|
||||
"stop",
|
||||
"description",
|
||||
"add_circle",
|
||||
"refresh",
|
||||
"tune",
|
||||
"edit_document",
|
||||
"chat",
|
||||
"account_circle",
|
||||
"experiment",
|
||||
"code",
|
||||
"dictionary",
|
||||
"developer_board",
|
||||
"developer_board_off",
|
||||
"memory",
|
||||
],
|
||||
codePoints: {
|
||||
speed: "e9e4",
|
||||
@@ -111,7 +131,10 @@ const config = {
|
||||
upload_2: "ff52",
|
||||
stat_minus_2: "e69c",
|
||||
stat_2: "e699",
|
||||
routine: "e20c",
|
||||
experiment: "e686",
|
||||
dictionary: "f539",
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default config
|
||||
export default config;
|
||||
|
||||
11743
package-lock.json
generated
11743
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
109
package.json
109
package.json
@@ -1,8 +1,12 @@
|
||||
{
|
||||
"name": "charachorder-device-manager",
|
||||
"version": "1.3.1",
|
||||
"version": "2.2.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18.16",
|
||||
"pnpm": ">=8.6"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CharaChorder/DeviceManager.git"
|
||||
@@ -13,72 +17,81 @@
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm-run-all --parallel vite typesafe-i18n",
|
||||
"dev:external": "npm-run-all --parallel vite:external typesafe-i18n",
|
||||
"dev:tauri": "tauri dev",
|
||||
"vite": "vite dev",
|
||||
"vite:external": "vite --host",
|
||||
"build": "typesafe-i18n --no-watch && vite build",
|
||||
"build:tauri": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run --coverage",
|
||||
"preview": "vite preview",
|
||||
"postinstall": "patch-package",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"minify-icons": "node src/tools/minify-icon-font.js",
|
||||
"version": "node src/tools/version.js && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
|
||||
"lint": "prettier --plugin-search-dir . --check .",
|
||||
"format": "prettier --plugin-search-dir . --write .",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write .",
|
||||
"typesafe-i18n": "typesafe-i18n"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.9.0",
|
||||
"@codemirror/commands": "^6.2.5",
|
||||
"@codemirror/lang-javascript": "^6.2.1",
|
||||
"@codemirror/language": "^6.9.0",
|
||||
"@codemirror/state": "^6.2.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.0.16",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.0.17",
|
||||
"@material/material-color-utilities": "^0.2.7",
|
||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||
"@sveltejs/adapter-static": "^2.0.3",
|
||||
"@sveltejs/kit": "^1.24.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^2.4.5",
|
||||
"@tauri-apps/api": "^1.4.0",
|
||||
"@tauri-apps/cli": "^1.4.0",
|
||||
"@theaninova/prettier-config": "^1.0.0",
|
||||
"@types/dom-view-transitions": "^1.0.1",
|
||||
"@types/flexsearch": "^0.7.3",
|
||||
"@types/w3c-web-serial": "^1.0.3",
|
||||
"@codemirror/autocomplete": "^6.18.2",
|
||||
"@codemirror/commands": "^6.7.1",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/language": "^6.10.3",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.1.3",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.1.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.0",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.7.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@tauri-apps/api": "^1.6.0",
|
||||
"@tauri-apps/cli": "^1.6.0",
|
||||
"@types/dom-view-transitions": "^1.0.5",
|
||||
"@types/flexsearch": "^0.7.6",
|
||||
"@types/w3c-web-serial": "^1.0.7",
|
||||
"@types/w3c-web-usb": "^1.0.10",
|
||||
"@vite-pwa/sveltekit": "^0.2.7",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"codemirror": "^6.0.1",
|
||||
"cypress": "^13.1.0",
|
||||
"flexsearch": "^0.7.31",
|
||||
"fontkit": "^2.0.2",
|
||||
"glob": "^10.3.4",
|
||||
"hotkeys-js": "^3.12.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"cypress": "^13.13.2",
|
||||
"d3": "^7.9.0",
|
||||
"esptool-js": "^0.4.7",
|
||||
"flexsearch": "^0.7.43",
|
||||
"fontkit": "^2.0.4",
|
||||
"glob": "^11.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"matrix-js-sdk": "^34.9.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"patch-package": "^8.0.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-svelte": "^3.0.3",
|
||||
"sass": "^1.66.1",
|
||||
"stylelint": "^15.10.3",
|
||||
"stylelint-config-clean-order": "^5.2.0",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"rxjs": "^7.8.1",
|
||||
"sass": "^1.80.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stylelint": "^16.10.0",
|
||||
"stylelint-config-clean-order": "^6.1.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-recommended-scss": "^13.0.0",
|
||||
"stylelint-config-standard-scss": "^11.0.0",
|
||||
"svelte": "^4.2.0",
|
||||
"svelte-check": "^3.5.1",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"stylelint-config-recommended-scss": "^14.1.0",
|
||||
"stylelint-config-standard-scss": "^13.1.0",
|
||||
"svelte": "5.1.9",
|
||||
"svelte-check": "^4.0.5",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.4.9",
|
||||
"vite-plugin-mkcert": "^1.16.0",
|
||||
"vite-plugin-pwa": "^0.17.4",
|
||||
"vitest": "^0.34.4"
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-mkcert": "^1.17.6",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.1.4",
|
||||
"web-serial-polyfill": "^1.0.15",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
diff --git a/node_modules/@types/flexsearch/index.d.ts b/node_modules/@types/flexsearch/index.d.ts
|
||||
index ecde8e7..64a5f1e 100755
|
||||
--- a/node_modules/@types/flexsearch/index.d.ts
|
||||
+++ b/node_modules/@types/flexsearch/index.d.ts
|
||||
@@ -6,7 +6,6 @@
|
||||
/************************************/
|
||||
/* Utils */
|
||||
/************************************/
|
||||
-export type Id = number | string;
|
||||
export type Limit = number;
|
||||
export type ExportHandler<T> = (id: string | number, value: T) => void;
|
||||
export type AsyncCallback<T = undefined> = T extends undefined ? () => void : (result: T) => void;
|
||||
@@ -165,7 +164,7 @@ export type IndexSearchResult = Id[];
|
||||
* * Usage: https://github.com/nextapps-de/flexsearch#usage
|
||||
*/
|
||||
|
||||
-export class Index {
|
||||
+export default class Index<ID extends number | string = number> {
|
||||
constructor(x?: Preset | IndexOptions<string>);
|
||||
add(id: Id, item: string): this;
|
||||
append(id: Id, item: string): this;
|
||||
@@ -1,153 +0,0 @@
|
||||
diff --git a/node_modules/flexsearch/index.d.ts b/node_modules/flexsearch/index.d.ts
|
||||
deleted file mode 100644
|
||||
index 9f39f41..0000000
|
||||
--- a/node_modules/flexsearch/index.d.ts
|
||||
+++ /dev/null
|
||||
@@ -1,147 +0,0 @@
|
||||
-declare module "flexsearch" {
|
||||
- export interface Index<T> {
|
||||
- readonly id: string;
|
||||
- readonly index: string;
|
||||
- readonly length: number;
|
||||
-
|
||||
- init(options?: CreateOptions): this;
|
||||
- info(): {
|
||||
- id: any;
|
||||
- items: any;
|
||||
- cache: any;
|
||||
- matcher: number;
|
||||
- worker: any;
|
||||
- threshold: any;
|
||||
- depth: any;
|
||||
- resolution: any;
|
||||
- contextual: boolean;
|
||||
- };
|
||||
- add(o: T): this;
|
||||
- add(id: number, o: string): this;
|
||||
-
|
||||
- // Result without pagination -> T[]
|
||||
- search(
|
||||
- query: string,
|
||||
- options: number | SearchOptions,
|
||||
- callback: (results: T[]) => void
|
||||
- ): void;
|
||||
- search(query: string, options?: number | SearchOptions): Promise<T[]>;
|
||||
- search(
|
||||
- options: SearchOptions & { query: string },
|
||||
- callback: (results: T[]) => void
|
||||
- ): void;
|
||||
- search(options: SearchOptions & { query: string }): Promise<T[]>;
|
||||
-
|
||||
- // Result with pagination -> SearchResults<T>
|
||||
- search(
|
||||
- query: string,
|
||||
- options: number | (SearchOptions & { page?: boolean | Cursor }),
|
||||
- callback: (results: SearchResults<T>) => void
|
||||
- ): void;
|
||||
- search(
|
||||
- query: string,
|
||||
- options?: number | (SearchOptions & { page?: boolean | Cursor })
|
||||
- ): Promise<SearchResults<T>>;
|
||||
- search(
|
||||
- options: SearchOptions & { query: string; page?: boolean | Cursor },
|
||||
- callback: (results: SearchResults<T>) => void
|
||||
- ): void;
|
||||
- search(
|
||||
- options: SearchOptions & { query: string; page?: boolean | Cursor }
|
||||
- ): Promise<SearchResults<T>>;
|
||||
-
|
||||
- update(id: number, o: T): this;
|
||||
- remove(id: number): this;
|
||||
- clear(): this;
|
||||
- destroy(): this;
|
||||
- addMatcher(matcher: Matcher): this;
|
||||
-
|
||||
- where(whereObj: { [key: string]: string } | ((o: T) => boolean)): T[];
|
||||
- encode(str: string): string;
|
||||
- export(
|
||||
- callback: (key: string, data: any) => any,
|
||||
- self?: this,
|
||||
- field?: string,
|
||||
- index_doc?: Number,
|
||||
- index?: Number
|
||||
- ): Promise<boolean>;
|
||||
- import(exported: string): this;
|
||||
- }
|
||||
-
|
||||
- interface SearchOptions {
|
||||
- limit?: number;
|
||||
- suggest?: boolean;
|
||||
- where?: { [key: string]: string };
|
||||
- field?: string | string[];
|
||||
- bool?: "and" | "or" | "not";
|
||||
- //TODO: Sorting
|
||||
- }
|
||||
-
|
||||
- interface SearchResults<T> {
|
||||
- page?: Cursor;
|
||||
- next?: Cursor;
|
||||
- result: T[];
|
||||
- }
|
||||
-
|
||||
- interface Document {
|
||||
- id: string;
|
||||
- field: any;
|
||||
- }
|
||||
-
|
||||
- export type CreateOptions = {
|
||||
- profile?: IndexProfile;
|
||||
- tokenize?: DefaultTokenizer | TokenizerFn;
|
||||
- split?: RegExp;
|
||||
- encode?: DefaultEncoder | EncoderFn | false;
|
||||
- cache?: boolean | number;
|
||||
- async?: boolean;
|
||||
- worker?: false | number;
|
||||
- depth?: false | number;
|
||||
- threshold?: false | number;
|
||||
- resolution?: number;
|
||||
- stemmer?: Stemmer | string | false;
|
||||
- filter?: FilterFn | string | false;
|
||||
- rtl?: boolean;
|
||||
- doc?: Document;
|
||||
- };
|
||||
-
|
||||
- // limit number Sets the limit of results.
|
||||
- // suggest true, false Enables suggestions in results.
|
||||
- // where object Use a where-clause for non-indexed fields.
|
||||
- // field string, Array<string> Sets the document fields which should be searched. When no field is set, all fields will be searched. Custom options per field are also supported.
|
||||
- // bool "and", "or" Sets the used logical operator when searching through multiple fields.
|
||||
- // page true, false, cursor Enables paginated results.
|
||||
-
|
||||
- type IndexProfile =
|
||||
- | "memory"
|
||||
- | "speed"
|
||||
- | "match"
|
||||
- | "score"
|
||||
- | "balance"
|
||||
- | "fast";
|
||||
- type DefaultTokenizer = "strict" | "forward" | "reverse" | "full";
|
||||
- type TokenizerFn = (str: string) => string[];
|
||||
- type DefaultEncoder = "icase" | "simple" | "advanced" | "extra" | "balance";
|
||||
- type EncoderFn = (str: string) => string;
|
||||
- type Stemmer = { [key: string]: string };
|
||||
- type Matcher = { [key: string]: string };
|
||||
- type FilterFn = (str: string) => boolean;
|
||||
- type Cursor = string;
|
||||
-
|
||||
- export default class FlexSearch {
|
||||
- static create<T>(options?: CreateOptions): Index<T>;
|
||||
- static registerMatcher(matcher: Matcher): typeof FlexSearch;
|
||||
- static registerEncoder(name: string, encoder: EncoderFn): typeof FlexSearch;
|
||||
- static registerLanguage(
|
||||
- lang: string,
|
||||
- options: { stemmer?: Stemmer; filter?: string[] }
|
||||
- ): typeof FlexSearch;
|
||||
- static encode(name: string, str: string): string;
|
||||
- }
|
||||
-}
|
||||
-
|
||||
-// FlexSearch.create(<options>)
|
||||
-// FlexSearch.registerMatcher({KEY: VALUE})
|
||||
-// FlexSearch.registerEncoder(name, encoder)
|
||||
-// FlexSearch.registerLanguage(lang, {stemmer:{}, filter:[]})
|
||||
-// FlexSearch.encode(name, string)
|
||||
8581
pnpm-lock.yaml
generated
Normal file
8581
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "1.3.1"
|
||||
version = "2.2.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||
license = "AGPL-3"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": { "productName": "amacc1ng", "version": "1.3.1" },
|
||||
"package": { "productName": "amacc1ng", "version": "2.2.0" },
|
||||
"tauri": {
|
||||
"allowlist": { "all": false },
|
||||
"bundle": {
|
||||
|
||||
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
@@ -11,4 +11,4 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
export {};
|
||||
|
||||
26
src/env.d.ts
vendored
26
src/env.d.ts
vendored
@@ -1,19 +1,23 @@
|
||||
/// <references types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly TAURI_FAMILY?: string
|
||||
readonly TAURI_PLATFORM_VERSION?: string
|
||||
readonly TAURI_TARGET_TRIPLE?: string
|
||||
readonly TAURI_ARCH?: string
|
||||
readonly TAURI_DEBUG?: boolean
|
||||
readonly TAURI_PLATFORM_TYPE?: string
|
||||
readonly TAURI_FAMILY?: string;
|
||||
readonly TAURI_PLATFORM_VERSION?: string;
|
||||
readonly TAURI_TARGET_TRIPLE?: string;
|
||||
readonly TAURI_ARCH?: string;
|
||||
readonly TAURI_DEBUG?: boolean;
|
||||
readonly TAURI_PLATFORM_TYPE?: string;
|
||||
|
||||
readonly VITE_HOMEPAGE_URL: string
|
||||
readonly VITE_BUGS_URL: string
|
||||
readonly VITE_DOCS_URL: string
|
||||
readonly VIET_LEARN_URL: string
|
||||
readonly VITE_HOMEPAGE_URL: string;
|
||||
readonly VITE_BUGS_URL: string;
|
||||
readonly VITE_DOCS_URL: string;
|
||||
readonly VITE_LEARN_URL: string;
|
||||
readonly VITE_LATEST_FIRMWARE: string;
|
||||
readonly VITE_STORE_URL: string;
|
||||
readonly VITE_MATRIX_URL: string;
|
||||
readonly VITE_FIRMWARE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {Translation} from "../i18n-types"
|
||||
import type { Translation } from "../i18n-types";
|
||||
|
||||
const de = {
|
||||
TITLE: "CharaChorder Gerätemanager",
|
||||
@@ -17,11 +17,11 @@ const de = {
|
||||
RELOAD: "Neu laden",
|
||||
},
|
||||
backup: {
|
||||
TITLE: "Verlauf speichern",
|
||||
INDIVIDUAL: "Einzeldateien",
|
||||
TITLE: "Backup",
|
||||
AUTO_BACKUP: "Auto-backup",
|
||||
DISCLAIMER:
|
||||
"Der Verlauf wird als Backup in diesem Browser gespeichert. Der Verlauf bleibt auf diesem Computer.",
|
||||
DOWNLOAD: "Alles herunterladen",
|
||||
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
||||
DOWNLOAD: "Alles",
|
||||
RESTORE: "Wiederherstellen",
|
||||
},
|
||||
modal: {
|
||||
@@ -35,7 +35,8 @@ const de = {
|
||||
filter: {
|
||||
ALL: "Alle",
|
||||
},
|
||||
LIVE_LAYOUT_INFO: "Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
|
||||
LIVE_LAYOUT_INFO:
|
||||
"Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
|
||||
SHIFT_WARNING: "Diese Aktion hält <kbd class='icon'>shift</kbd>",
|
||||
ALT_CODE_WARNING: "Dieses Alt-Code Makro funktioniert nur unter Windows",
|
||||
},
|
||||
@@ -66,12 +67,13 @@ const de = {
|
||||
APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
|
||||
NO_DEVICE: "Kein Gerät verbunden",
|
||||
LINUX_PERMISSIONS:
|
||||
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden.",
|
||||
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden. Flatpak und Snap Anwendungen benötigen zusätzliche Berechtigungen oder funktionieren möglicherweise gar nicht.",
|
||||
bootMenu: {
|
||||
TITLE: "Bootmenü",
|
||||
REBOOT: "Neustarten",
|
||||
BOOTLOADER: "Bootloader",
|
||||
POWER_WARNING: "Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
|
||||
POWER_WARNING:
|
||||
"Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
|
||||
},
|
||||
},
|
||||
browserWarning: {
|
||||
@@ -83,7 +85,8 @@ const de = {
|
||||
INFO_BROWSER_PREFIX:
|
||||
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
|
||||
INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
|
||||
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
|
||||
INFO_BROWSER_SUFFIX:
|
||||
" sich bewusst dazu entschieden die API zu deaktivieren.",
|
||||
DOWNLOAD_APP:
|
||||
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
|
||||
},
|
||||
@@ -106,7 +109,7 @@ const de = {
|
||||
},
|
||||
configure: {
|
||||
chords: {
|
||||
TITLE: "Akkorde",
|
||||
TITLE: "Bibliothek",
|
||||
HOLD_KEYS: "Akkord halten",
|
||||
NEW_CHORD: "Neuer Akkord",
|
||||
DUPLICATE: "Akkord existiert bereits",
|
||||
@@ -117,17 +120,18 @@ const de = {
|
||||
conflict: {
|
||||
TITLE: "Akkordkonflikt",
|
||||
DESCRIPTION:
|
||||
"Der Akkord {0} würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
|
||||
"Der Akkord würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
|
||||
CONFIRM: "Überschreiben",
|
||||
ABORT: "Überspringen",
|
||||
},
|
||||
VOCABULARY: "Vokabelliste",
|
||||
TRY_TYPING: "Versuche hier zu tippen",
|
||||
},
|
||||
layout: {
|
||||
TITLE: "Layout",
|
||||
},
|
||||
settings: {
|
||||
TITLE: "Einstellungen",
|
||||
TITLE: "Gerät",
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
@@ -135,6 +139,6 @@ const de = {
|
||||
RUN: "Ausführen",
|
||||
},
|
||||
},
|
||||
} satisfies Translation
|
||||
} satisfies Translation;
|
||||
|
||||
export default de
|
||||
export default de;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type {BaseTranslation} from "../i18n-types"
|
||||
import type { BaseTranslation } from "../i18n-types";
|
||||
|
||||
const en = {
|
||||
TITLE: "CharaChorder Device Manager",
|
||||
DESCRIPTION: "The device manager and configuration tool for CharaChorder devices.",
|
||||
DESCRIPTION:
|
||||
"The device manager and configuration tool for CharaChorder devices.",
|
||||
saveActions: {
|
||||
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
||||
REDO: "Redo",
|
||||
@@ -12,10 +13,11 @@ const en = {
|
||||
TITLE: "Update your device",
|
||||
},
|
||||
backup: {
|
||||
TITLE: "Store History",
|
||||
INDIVIDUAL: "Individual backups",
|
||||
DISCLAIMER: "Your history is stored as a backup in this browser. The history remains on your computer.",
|
||||
DOWNLOAD: "Download Everything",
|
||||
TITLE: "Backup",
|
||||
AUTO_BACKUP: "Auto-backup",
|
||||
DISCLAIMER:
|
||||
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
|
||||
DOWNLOAD: "Everything",
|
||||
RESTORE: "Restore",
|
||||
},
|
||||
sync: {
|
||||
@@ -64,24 +66,28 @@ const en = {
|
||||
TERMINAL: "Terminal",
|
||||
APPLY_SETTINGS: "Flash changes to device",
|
||||
NO_DEVICE: "No device connected",
|
||||
LINUX_PERMISSIONS: "Most Linux based systems need adjusted permissions in order to connect your device.",
|
||||
LINUX_PERMISSIONS:
|
||||
"Most Linux based systems need adjusted permissions in order to connect your device. Flatpak or Snap versions in particular might need additional permissions or may not work at all.",
|
||||
bootMenu: {
|
||||
TITLE: "Boot Menu",
|
||||
REBOOT: "Reboot",
|
||||
BOOTLOADER: "Bootloader",
|
||||
POWER_WARNING: "To reboot from bootloader you need to physically reconnect your device.",
|
||||
POWER_WARNING:
|
||||
"To reboot from bootloader you need to physically reconnect your device.",
|
||||
},
|
||||
},
|
||||
browserWarning: {
|
||||
TITLE: "Warning",
|
||||
INFO_SERIAL_PREFIX: "Your current browser is not supported due to this site's unique requirement for ",
|
||||
INFO_SERIAL_PREFIX:
|
||||
"Your current browser is not supported due to this site's unique requirement for ",
|
||||
INFO_SERIAL_INFIX: "serial connections",
|
||||
INFO_SERIAL_SUFFIX: ".",
|
||||
INFO_BROWSER_PREFIX:
|
||||
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
|
||||
INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
|
||||
INFO_BROWSER_SUFFIX: ".",
|
||||
DOWNLOAD_APP: "Chrome or Edge are officially supported, but other browsers might work as well.",
|
||||
DOWNLOAD_APP:
|
||||
"Chrome or Edge are officially supported, but other browsers might work as well.",
|
||||
},
|
||||
changes: {
|
||||
TITLE: "Import changes",
|
||||
@@ -102,7 +108,7 @@ const en = {
|
||||
},
|
||||
configure: {
|
||||
chords: {
|
||||
TITLE: "Chords",
|
||||
TITLE: "Library",
|
||||
HOLD_KEYS: "Hold chord",
|
||||
NEW_CHORD: "New chord",
|
||||
DUPLICATE: "Chord already exists",
|
||||
@@ -113,17 +119,18 @@ const en = {
|
||||
conflict: {
|
||||
TITLE: "Chord conflict",
|
||||
DESCRIPTION:
|
||||
"Your chord {0} conflicts with an existing chord. Are you sure you want to overwrite this chord?",
|
||||
"Your chord conflicts with an existing chord. Are you sure you want to overwrite this chord?",
|
||||
CONFIRM: "Overwrite",
|
||||
ABORT: "Skip",
|
||||
},
|
||||
VOCABULARY: "Vocabulary",
|
||||
TRY_TYPING: "Try typing here",
|
||||
},
|
||||
layout: {
|
||||
TITLE: "Layout",
|
||||
},
|
||||
settings: {
|
||||
TITLE: "Settings",
|
||||
TITLE: "Device",
|
||||
},
|
||||
},
|
||||
plugin: {
|
||||
@@ -131,6 +138,6 @@ const en = {
|
||||
RUN: "Run",
|
||||
},
|
||||
},
|
||||
} satisfies BaseTranslation
|
||||
} satisfies BaseTranslation;
|
||||
|
||||
export default en
|
||||
export default en;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type {FormattersInitializer} from "typesafe-i18n"
|
||||
import type {Locales, Formatters} from "./i18n-types"
|
||||
import type { FormattersInitializer } from "typesafe-i18n";
|
||||
import type { Locales, Formatters } from "./i18n-types";
|
||||
|
||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
|
||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (
|
||||
_locale: Locales,
|
||||
) => {
|
||||
const formatters: Formatters = {
|
||||
// add your formatter functions here
|
||||
}
|
||||
};
|
||||
|
||||
return formatters
|
||||
}
|
||||
return formatters;
|
||||
};
|
||||
|
||||
55
src/lib/PageTransition.svelte
Normal file
55
src/lib/PageTransition.svelte
Normal file
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
||||
import { expoIn, expoOut } from "svelte/easing";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
|
||||
$props();
|
||||
|
||||
let inDirection = $state(0);
|
||||
let outDirection = $state(0);
|
||||
let outroEnd: undefined | (() => void) = $state(undefined);
|
||||
let animationDone: Promise<void>;
|
||||
|
||||
let isNavigating = $state(false);
|
||||
|
||||
function routeIndex(route: string | undefined): number {
|
||||
return routeOrder.findIndex((it) => route?.startsWith(it));
|
||||
}
|
||||
|
||||
beforeNavigate((navigation) => {
|
||||
const from = routeIndex(navigation.from?.url.pathname);
|
||||
const to = routeIndex(navigation.to?.url.pathname);
|
||||
if (from === -1 || to === -1 || from === to) return;
|
||||
isNavigating = true;
|
||||
|
||||
inDirection = from > to ? -1 : 1;
|
||||
outDirection = from > to ? 1 : -1;
|
||||
|
||||
animationDone = new Promise((resolve) => {
|
||||
outroEnd = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
afterNavigate(async () => {
|
||||
await animationDone;
|
||||
isNavigating = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !isNavigating}
|
||||
<main
|
||||
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
|
||||
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
|
||||
onoutroend={outroEnd}
|
||||
>
|
||||
{@render children()}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
main {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -28,6 +28,9 @@ actions:
|
||||
42:
|
||||
id: "*"
|
||||
title: Asterisk
|
||||
43:
|
||||
id: "+"
|
||||
title: Plus
|
||||
58:
|
||||
id: ":"
|
||||
title: Colon
|
||||
|
||||
@@ -9,145 +9,193 @@ actions:
|
||||
This action is unique in this way. Technically it is "printable", but it is not visible.
|
||||
39:
|
||||
id: "'"
|
||||
keyCode: Quote
|
||||
title: Single Quote
|
||||
44:
|
||||
id: ","
|
||||
keyCode: Comma
|
||||
title: Comma
|
||||
45:
|
||||
id: "-"
|
||||
keyCode: Minus
|
||||
title: Minus
|
||||
46:
|
||||
id: "."
|
||||
keyCode: Period
|
||||
title: Period
|
||||
47:
|
||||
id: "/"
|
||||
keyCode: Slash
|
||||
title: Forward Slash
|
||||
48:
|
||||
id: "0"
|
||||
keyCode: Digit0
|
||||
title: Zero
|
||||
49:
|
||||
id: "1"
|
||||
keyCode: Digit1
|
||||
title: One
|
||||
50:
|
||||
id: "2"
|
||||
keyCode: Digit2
|
||||
title: Two
|
||||
51:
|
||||
id: "3"
|
||||
keyCode: Digit3
|
||||
title: Three
|
||||
52:
|
||||
id: "4"
|
||||
keyCode: Digit4
|
||||
title: Four
|
||||
53:
|
||||
id: "5"
|
||||
keyCode: Digit5
|
||||
title: Five
|
||||
54:
|
||||
id: "6"
|
||||
keyCode: Digit6
|
||||
title: Six
|
||||
55:
|
||||
id: "7"
|
||||
keyCode: Digit7
|
||||
title: Seven
|
||||
56:
|
||||
id: "8"
|
||||
keyCode: Digit8
|
||||
title: Eight
|
||||
57:
|
||||
id: "9"
|
||||
keyCode: Digit9
|
||||
title: Nine
|
||||
59:
|
||||
id: ";"
|
||||
keyCode: Semicolon
|
||||
title: Semicolon
|
||||
61:
|
||||
id: "="
|
||||
keyCode: Equal
|
||||
title: Equals
|
||||
91:
|
||||
id: "["
|
||||
keyCode: BracketLeft
|
||||
title: Left Bracket
|
||||
92:
|
||||
id: "\\"
|
||||
keyCode: Backslash
|
||||
title: Backslash
|
||||
93:
|
||||
id: "]"
|
||||
keyCode: BracketRight
|
||||
title: Right Bracket
|
||||
96:
|
||||
id: "`"
|
||||
keyCode: Backquote
|
||||
title: Backtick
|
||||
97:
|
||||
id: "a"
|
||||
keyCode: KeyA
|
||||
title: Lowercase a
|
||||
98:
|
||||
id: "b"
|
||||
keyCode: KeyB
|
||||
title: Lowercase b
|
||||
99:
|
||||
id: "c"
|
||||
keyCode: KeyC
|
||||
title: Lowercase c
|
||||
100:
|
||||
id: "d"
|
||||
keyCode: KeyD
|
||||
title: Lowercase d
|
||||
101:
|
||||
id: "e"
|
||||
keyCode: KeyE
|
||||
title: Lowercase e
|
||||
102:
|
||||
id: "f"
|
||||
keyCode: KeyF
|
||||
title: Lowercase f
|
||||
103:
|
||||
id: "g"
|
||||
keyCode: KeyG
|
||||
title: Lowercase g
|
||||
104:
|
||||
id: "h"
|
||||
keyCode: KeyH
|
||||
title: Lowercase h
|
||||
105:
|
||||
id: "i"
|
||||
keyCode: KeyI
|
||||
title: Lowercase i
|
||||
106:
|
||||
id: "j"
|
||||
keyCode: KeyJ
|
||||
title: Lowercase j
|
||||
107:
|
||||
id: "k"
|
||||
keyCode: KeyK
|
||||
title: Lowercase k
|
||||
108:
|
||||
id: "l"
|
||||
keyCode: KeyL
|
||||
title: Lowercase l
|
||||
109:
|
||||
id: "m"
|
||||
keyCode: KeyM
|
||||
title: Lowercase m
|
||||
110:
|
||||
id: "n"
|
||||
keyCode: KeyN
|
||||
title: Lowercase n
|
||||
111:
|
||||
id: "o"
|
||||
keyCode: KeyO
|
||||
title: Lowercase o
|
||||
112:
|
||||
id: "p"
|
||||
keyCode: KeyP
|
||||
title: Lowercase p
|
||||
113:
|
||||
id: "q"
|
||||
keyCode: KeyQ
|
||||
title: Lowercase q
|
||||
114:
|
||||
id: "r"
|
||||
keyCode: KeyR
|
||||
title: Lowercase r
|
||||
115:
|
||||
id: "s"
|
||||
keyCode: KeyS
|
||||
title: Lowercase s
|
||||
116:
|
||||
id: "t"
|
||||
keyCode: KeyT
|
||||
title: Lowercase t
|
||||
117:
|
||||
id: "u"
|
||||
keyCode: KeyU
|
||||
title: Lowercase u
|
||||
118:
|
||||
id: "v"
|
||||
keyCode: KeyV
|
||||
title: Lowercase v
|
||||
119:
|
||||
id: "w"
|
||||
KeyCode: KeyW
|
||||
title: Lowercase w
|
||||
120:
|
||||
id: "x"
|
||||
keyCode: KeyX
|
||||
title: Lowercase x
|
||||
121:
|
||||
id: "y"
|
||||
keyCode: KeyY
|
||||
title: Lowercase y
|
||||
122:
|
||||
id: "z"
|
||||
keyCode: KeyZ
|
||||
title: Lowercase z
|
||||
127:
|
||||
id: "DEL"
|
||||
keyCode: Delete
|
||||
title: Delete
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
name: CharaChorder
|
||||
description: CharaChorder specific actions
|
||||
actions:
|
||||
0:
|
||||
id: "NO_ACTION"
|
||||
display: "No Action"
|
||||
528:
|
||||
id: "RESTART"
|
||||
title: Restart Device
|
||||
@@ -58,6 +61,7 @@ actions:
|
||||
544:
|
||||
variantOf: 36
|
||||
id: "SPACERIGHT"
|
||||
display: " "
|
||||
title: Right Spacebar (eg CC Lite)
|
||||
icon: space_bar
|
||||
variant: right
|
||||
@@ -66,6 +70,9 @@ actions:
|
||||
title: Primary Keymap
|
||||
icon: counter_1
|
||||
variant: left
|
||||
description: |
|
||||
Acts as a toggle if the same action is not assigned
|
||||
to the target layer
|
||||
549:
|
||||
variantOf: 548
|
||||
<<: *primary_keymap
|
||||
@@ -76,6 +83,9 @@ actions:
|
||||
title: Numeric Layer
|
||||
icon: counter_2
|
||||
variant: left
|
||||
description: |
|
||||
Acts as a toggle if the same action is not assigned
|
||||
to the target layer
|
||||
551:
|
||||
variantOf: 550
|
||||
<<: *secondary_keymap
|
||||
@@ -86,8 +96,44 @@ actions:
|
||||
title: Function Layer
|
||||
icon: counter_3
|
||||
variant: left
|
||||
description: |
|
||||
Acts as a toggle if the same action is not assigned
|
||||
to the target layer
|
||||
553:
|
||||
variationOf: 552
|
||||
<<: *tertiary_keymap
|
||||
id: "KM_3_R"
|
||||
variant: right
|
||||
558:
|
||||
id: HOLD_COMPOUND
|
||||
title: Dynamic Library
|
||||
icon: layers
|
||||
description: |
|
||||
Allows for the activation & creation of dynamic chord libraries.
|
||||
When included as part of a chord output,
|
||||
that chord's input becomes the seed for a dynamic chord library,
|
||||
and that library is activated.
|
||||
Any new chords created while a dynamic library is active are established one level above its seed.
|
||||
559:
|
||||
id: RELEASE_COMPOUND
|
||||
title: Base Library
|
||||
icon: layers_clear
|
||||
description: |
|
||||
Re-activates your base chord library,
|
||||
and deactivates any currently active dynamic chord library.
|
||||
576:
|
||||
id: ACTION_DELAY_1000
|
||||
icon: clock_loader_90
|
||||
description: Wait for one second
|
||||
577:
|
||||
id: ACTION_DELAY_100
|
||||
icon: clock_loader_60
|
||||
description: Wait for 100 milliseconds
|
||||
578:
|
||||
id: ACTION_DELAY_10
|
||||
icon: clock_loader_40
|
||||
description: Wait for 10 milliseconds
|
||||
579:
|
||||
id: ACTION_DELAY_1
|
||||
icon: clock_loader_10
|
||||
description: Wait for one millisecond
|
||||
|
||||
@@ -6,41 +6,49 @@ actions:
|
||||
id: "LEFT_CTRL"
|
||||
display: CTRL
|
||||
title: Control Keyboard Modifier
|
||||
keyCode: ControlLeft
|
||||
variant: left
|
||||
513: &left_shift
|
||||
id: "LEFT_SHIFT"
|
||||
title: Shift Keyboard Modifier
|
||||
keyCode: ShiftLeft
|
||||
variant: left
|
||||
icon: shift
|
||||
514: &left_alt
|
||||
id: "LEFT_ALT"
|
||||
display: ALT
|
||||
title: Alt Keyboard Modifier
|
||||
keyCode: AltLeft
|
||||
variant: left
|
||||
515: &left_gui
|
||||
id: "LEFT_GUI"
|
||||
title: GUI Keyboard Modifier
|
||||
keyCode: MetaLeft
|
||||
icon: apps
|
||||
variant: left
|
||||
516:
|
||||
variationOf: 512
|
||||
<<: *left_ctrl
|
||||
id: "RIGHT_CTRL"
|
||||
keyCode: ControlRight
|
||||
variant: right
|
||||
517:
|
||||
variationOf: 513
|
||||
<<: *left_shift
|
||||
id: "RIGHT_SHIFT"
|
||||
keyCode: ShiftRight
|
||||
variant: right
|
||||
518:
|
||||
variationOf: 514
|
||||
<<: *left_alt
|
||||
id: "RIGHT_ALT"
|
||||
keyCode: AltRight
|
||||
variant: right
|
||||
519:
|
||||
variationOf: 515
|
||||
<<: *left_gui
|
||||
id: "RIGHT_GUI"
|
||||
keyCode: MetaRight
|
||||
variant: right
|
||||
520:
|
||||
id: "RELEASE_MOD"
|
||||
|
||||
28
src/lib/assets/keymaps/keymap.d.ts
vendored
28
src/lib/assets/keymaps/keymap.d.ts
vendored
@@ -1,19 +1,19 @@
|
||||
export interface KeymapCategory {
|
||||
name: string
|
||||
description: string
|
||||
icon?: string
|
||||
display?: string
|
||||
type?: "unassigned"
|
||||
actions: Record<number, Partial<ActionInfo>>
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
display?: string;
|
||||
type?: "unassigned";
|
||||
actions: Record<number, Partial<ActionInfo>>;
|
||||
}
|
||||
|
||||
export interface ActionInfo {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
display: string
|
||||
description: string
|
||||
variant: "left" | "right"
|
||||
variantOf: number
|
||||
keyCode: string
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
display: string;
|
||||
description: string;
|
||||
variant: "left" | "right";
|
||||
variantOf: number;
|
||||
keyCode: string;
|
||||
}
|
||||
|
||||
@@ -422,8 +422,8 @@ actions:
|
||||
title: Keyboard Non-US \ and | (US English)
|
||||
357:
|
||||
id: "COMPOSE"
|
||||
icon: menu
|
||||
title: Keyboard Application
|
||||
description: Officially supported by Win, Unix, and Boot
|
||||
358:
|
||||
id: "POWER"
|
||||
keyCode: "Power"
|
||||
@@ -920,131 +920,123 @@ actions:
|
||||
description: Not required to be supported by any OS
|
||||
480:
|
||||
id: "KSC_E0"
|
||||
keyCode: "ControlLeft"
|
||||
title: Keyboard Left Control
|
||||
481:
|
||||
id: "KSC_E1"
|
||||
keyCode: "ShiftLeft"
|
||||
title: Keyboard Left Shift
|
||||
482:
|
||||
id: "KSC_E2"
|
||||
keyCode: "AltLeft"
|
||||
title: Keyboard Left Alt
|
||||
483:
|
||||
id: "KSC_E3"
|
||||
keyCode: "MetaLeft"
|
||||
title: Keyboard Left GUI
|
||||
484:
|
||||
id: "KSC_E4"
|
||||
keyCode: "ControlRight"
|
||||
title: Keyboard Right Control
|
||||
485:
|
||||
id: "KSC_E5"
|
||||
keyCode: "ShiftRight"
|
||||
title: Keyboard Right Shift
|
||||
486:
|
||||
id: "KSC_E6"
|
||||
keyCode: "AltRight"
|
||||
title: Keyboard Right Alt
|
||||
487:
|
||||
id: "KSC_E7"
|
||||
keyCode: "MetaRight"
|
||||
title: Keyboard Right GUI
|
||||
488:
|
||||
id: "KSC_E8"
|
||||
icon: play_pause
|
||||
keyCode: "MediaPlayPause"
|
||||
title: Media Play Pause
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
489:
|
||||
id: "KSC_E9"
|
||||
icon: stop
|
||||
keyCode: "MediaStop"
|
||||
title: Media Stop CD
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
490:
|
||||
id: "KSC_EA"
|
||||
icon: skip_previous
|
||||
keyCode: "MediaTrackPrevious"
|
||||
title: Media Previous Song
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
491:
|
||||
id: "KSC_EB"
|
||||
icon: skip_next
|
||||
keyCode: "MediaTrackNext"
|
||||
title: Media Next Song
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
492:
|
||||
id: "KSC_EC"
|
||||
icon: eject
|
||||
keyCode: "Eject"
|
||||
title: Media Eject CD
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
description: MacOS only
|
||||
493:
|
||||
id: "KSC_ED"
|
||||
icon: volume_up
|
||||
keyCode: "AudioVolumeUp"
|
||||
title: Media Volume Up
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
494:
|
||||
id: "KSC_EE"
|
||||
icon: volume_down
|
||||
keyCode: "AudioVolumeDown"
|
||||
title: Media Volume Down
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
495:
|
||||
id: "KSC_EF"
|
||||
icon: volume_off
|
||||
keyCode: "AudioVolumeMute"
|
||||
title: Media Mute
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
496:
|
||||
id: "KSC_F0"
|
||||
title: Media www
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
icon: language
|
||||
title: Media Browser
|
||||
497:
|
||||
id: "KSC_F1"
|
||||
keyCode: "BrowserBack"
|
||||
title: Media Back
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media Browser Back
|
||||
498:
|
||||
id: "KSC_F2"
|
||||
keyCode: "BrowserForward"
|
||||
title: Media Forward
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media Browser Forward
|
||||
499:
|
||||
id: "KSC_F3"
|
||||
keyCode: "BrowserStop"
|
||||
title: Media Stop
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media Browser Stop
|
||||
description: Not supported on MacOS
|
||||
500:
|
||||
id: "KSC_F4"
|
||||
icon: search
|
||||
keyCode: "BrowserSearch"
|
||||
title: Media Find
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media Browser Search
|
||||
501:
|
||||
id: "KSC_F5"
|
||||
title: Media Scroll Up
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
icon: brightness_high
|
||||
title: Media Brightness Up
|
||||
502:
|
||||
id: "KSC_F6"
|
||||
title: Media Scroll Down
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
icon: brightness_low
|
||||
title: Media Brightness Down
|
||||
503:
|
||||
id: "KSC_F7"
|
||||
title: Media Edit
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
504:
|
||||
id: "KSC_F8"
|
||||
icon: bedtime
|
||||
keyCode: "Sleep"
|
||||
title: Media Sleep
|
||||
title: Media System Sleep
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
505:
|
||||
id: "KSC_F9"
|
||||
icon: routine
|
||||
keyCode: "WakeUp"
|
||||
title: Media Coffee
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media System Wake
|
||||
description: Not supported on Windows
|
||||
506:
|
||||
id: "KSC_FA"
|
||||
keyCode: "BrowserRefresh"
|
||||
title: Media Refresh
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media Browser Refresh
|
||||
507:
|
||||
id: "KSC_FB"
|
||||
title: Media Calc
|
||||
description: Not required to be supported by any OS. Possibly deprecated.
|
||||
title: Media Calculator
|
||||
description: Not supported on MacOS
|
||||
508:
|
||||
id: "KSC_FC"
|
||||
description: Not required to be supported by any OS.
|
||||
|
||||
@@ -15,10 +15,10 @@ col:
|
||||
- key: 64
|
||||
- key: 65
|
||||
- key: 66
|
||||
size: [ 2, 1 ]
|
||||
size: [2, 1]
|
||||
- row:
|
||||
- key: 39
|
||||
size: [ 1.5, 1 ]
|
||||
size: [1.5, 1]
|
||||
- key: 40
|
||||
- key: 41
|
||||
- key: 42
|
||||
@@ -32,10 +32,10 @@ col:
|
||||
- key: 50
|
||||
- key: 51
|
||||
- key: 52
|
||||
size: [ 1.5, 1 ]
|
||||
size: [1.5, 1]
|
||||
- row:
|
||||
- key: 26
|
||||
size: [ 1.75, 1 ]
|
||||
size: [1.75, 1]
|
||||
- key: 27
|
||||
- key: 28
|
||||
- key: 29
|
||||
@@ -48,10 +48,10 @@ col:
|
||||
- key: 36
|
||||
- key: 37
|
||||
- key: 38
|
||||
size: [ 2.25, 1 ]
|
||||
size: [2.25, 1]
|
||||
- row:
|
||||
- key: 12
|
||||
size: [ 2, 1 ]
|
||||
size: [2, 1]
|
||||
- key: 13
|
||||
- key: 14
|
||||
- key: 15
|
||||
@@ -68,20 +68,19 @@ col:
|
||||
- row:
|
||||
- key: 0
|
||||
- key: 1
|
||||
size: [ 1.25, 1 ]
|
||||
size: [1.25, 1]
|
||||
- key: 2
|
||||
size: [ 1.25, 1 ]
|
||||
size: [1.25, 1]
|
||||
- key: 3
|
||||
size: [ 2, 1 ]
|
||||
size: [2, 1]
|
||||
- key: 4
|
||||
- key: 5
|
||||
- key: 6
|
||||
size: [ 2, 1 ]
|
||||
size: [2, 1]
|
||||
- key: 7
|
||||
size: [ 1.25, 1 ]
|
||||
size: [1.25, 1]
|
||||
- key: 8
|
||||
size: [ 1.25, 1 ]
|
||||
size: [1.25, 1]
|
||||
- key: 9
|
||||
- key: 10
|
||||
- key: 11
|
||||
|
||||
|
||||
37
src/lib/assets/layouts/m4g.yml
Normal file
37
src/lib/assets/layouts/m4g.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 }
|
||||
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 }
|
||||
38
src/lib/assets/random-tips/en.json
Normal file
38
src/lib/assets/random-tips/en.json
Normal file
@@ -0,0 +1,38 @@
|
||||
[
|
||||
"You can use DUP+i to create chords on the fly in any text box",
|
||||
"This site is open source! Check out the full source code on GitHub in the bottom left",
|
||||
"Two letter chords can be activated accidentally in chentry. Be cautious around them",
|
||||
"More inputs in a chord increase the tolerance, making them easier to activate",
|
||||
"The maximum number of outputs in a chord is 256",
|
||||
"You can create backups of your device on the top right",
|
||||
"For programming you should set your auto-delete timeout to about 200ms",
|
||||
"Large parts of this site were written on a CC1",
|
||||
"I use VIM btw...",
|
||||
"I use NixOS btw...",
|
||||
"You can hold shift on the undo button to undo all changes",
|
||||
"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",
|
||||
"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",
|
||||
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
||||
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
||||
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
||||
"Spurring is a chording only mode which is more advanced, but can greatly improve typing speed when mastered",
|
||||
"The forced chord phenomenon is when typing a word character by character starts to feel unnatural",
|
||||
"Don't be afraid to delete chords you keep getting wrong",
|
||||
"Most people find it easier to start their chord library from scratch rather than learning someone else's",
|
||||
"A common techinque to deal with conflicts is to add DUP or the same key mirrored on the other hand",
|
||||
"A longer chord is not always more difficult",
|
||||
"Riley Keen made headlines when his Monkeytype score of 500WPM using a CC1 got him banned off the site",
|
||||
"A 3d press refers to pressing down into a 5-way switch",
|
||||
"The serial communication protocol used by CCOS is documented on docs.charachorder.com",
|
||||
"The 'CCOS is ready' message can be turned off in the settings",
|
||||
"Most people using the CC1 don't change the a-z key layout, as further modification provides very little benefit",
|
||||
"Using VIM on the default CC1 a-z layout is perfectly doable, it's just a matter of getting used to it",
|
||||
"You can use Nexus to track words you might want to add to your chord library",
|
||||
"The CC1 default layout was 80% science, 20% art",
|
||||
"There is little to no reason to use hjkl in VIM on a CC1 since the arrows keys are so close already",
|
||||
"The device manager automatically creates a backup for you when you reboot your device into the bootloader",
|
||||
"You can use \"compound\", \"macro\", \"suffix\" and \"cursor warp\" in the chord search to find specific types of chords",
|
||||
"You can search for chord inputs by using a leading \"+\", for example \"+a +DUP\" will show all chords with inputs that contain both a and DUP"
|
||||
]
|
||||
@@ -4,33 +4,45 @@ import type {
|
||||
CharaFile,
|
||||
CharaLayoutFile,
|
||||
CharaSettingsFile,
|
||||
} from "$lib/share/chara-file.js"
|
||||
import type {Change} from "$lib/undo-redo.js"
|
||||
import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js"
|
||||
import {get} from "svelte/store"
|
||||
import {serialPort} from "../serial/connection"
|
||||
import {csvLayoutToJson, isCsvLayout} from "$lib/backup/compat/legacy-layout"
|
||||
import {isCsvChords, csvChordsToJson} from "./compat/legacy-chords"
|
||||
} from "$lib/share/chara-file.js";
|
||||
import type { Change } from "$lib/undo-redo.js";
|
||||
import {
|
||||
changes,
|
||||
ChangeType,
|
||||
chords,
|
||||
layout,
|
||||
settings,
|
||||
} from "$lib/undo-redo.js";
|
||||
import { get } from "svelte/store";
|
||||
import { serialPort } from "../serial/connection";
|
||||
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
|
||||
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
|
||||
|
||||
export function downloadFile<T extends CharaFile<string>>(contents: T) {
|
||||
const downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(contents)], {type: "application/json"}))
|
||||
const element = document.createElement("a")
|
||||
const downloadUrl = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(contents)], { type: "application/json" }),
|
||||
);
|
||||
const element = document.createElement("a");
|
||||
element.setAttribute(
|
||||
"download",
|
||||
`${contents.type}-${get(serialPort)?.device}-${new Date().toISOString()}.json`,
|
||||
)
|
||||
element.href = downloadUrl
|
||||
element.setAttribute("target", "_blank")
|
||||
element.click()
|
||||
URL.revokeObjectURL(downloadUrl)
|
||||
`${contents.type}-${
|
||||
get(serialPort)?.device
|
||||
}-${new Date().toISOString()}.json`,
|
||||
);
|
||||
element.href = downloadUrl;
|
||||
element.setAttribute("target", "_blank");
|
||||
element.click();
|
||||
URL.revokeObjectURL(downloadUrl);
|
||||
}
|
||||
|
||||
export function downloadBackup() {
|
||||
downloadFile<CharaBackupFile>({
|
||||
charaVersion: 1,
|
||||
type: "backup",
|
||||
history: [[createChordBackup(), createLayoutBackup(), createSettingsBackup()]],
|
||||
})
|
||||
history: [
|
||||
[createChordBackup(), createLayoutBackup(), createSettingsBackup()],
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function createLayoutBackup(): CharaLayoutFile {
|
||||
@@ -38,127 +50,148 @@ export function createLayoutBackup(): CharaLayoutFile {
|
||||
charaVersion: 1,
|
||||
type: "layout",
|
||||
device: get(serialPort)?.device,
|
||||
layout: get(layout).map(it => it.map(it => it.action)) as [number[], number[], number[]],
|
||||
}
|
||||
layout: get(layout).map((it) => it.map((it) => it.action)) as [
|
||||
number[],
|
||||
number[],
|
||||
number[],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function createChordBackup(): CharaChordFile {
|
||||
return {charaVersion: 1, type: "chords", chords: get(chords).map(it => [it.actions, it.phrase])}
|
||||
return {
|
||||
charaVersion: 1,
|
||||
type: "chords",
|
||||
chords: get(chords).map((it) => [it.actions, it.phrase]),
|
||||
};
|
||||
}
|
||||
|
||||
export function createSettingsBackup(): CharaSettingsFile {
|
||||
return {charaVersion: 1, type: "settings", settings: get(settings).map(it => it.value)}
|
||||
return {
|
||||
charaVersion: 1,
|
||||
type: "settings",
|
||||
settings: get(settings).map((it) => it.value),
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreBackup(event: Event) {
|
||||
const input = (event.target as HTMLInputElement).files![0]
|
||||
if (!input) return
|
||||
const text = await input.text()
|
||||
const input = (event.target as HTMLInputElement).files![0];
|
||||
if (!input) return;
|
||||
const text = await input.text();
|
||||
if (input.name.endsWith(".json")) {
|
||||
restoreFromFile(JSON.parse(text))
|
||||
restoreFromFile(JSON.parse(text));
|
||||
} else if (isCsvLayout(text)) {
|
||||
restoreFromFile(csvLayoutToJson(text))
|
||||
restoreFromFile(csvLayoutToJson(text));
|
||||
} else if (isCsvChords(text)) {
|
||||
restoreFromFile(csvChordsToJson(text))
|
||||
restoreFromFile(csvChordsToJson(text));
|
||||
} else {
|
||||
alert("Unknown backup format")
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreFromFile(
|
||||
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
||||
) {
|
||||
if (file.charaVersion !== 1) throw new Error("Incompatible backup")
|
||||
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
||||
switch (file.type) {
|
||||
case "backup": {
|
||||
const recent = file.history[0]
|
||||
if (recent[1].device !== get(serialPort)?.device) {
|
||||
alert("Backup is incompatible with this device")
|
||||
throw new Error("Backup is incompatible with this device")
|
||||
const recent = file.history[0];
|
||||
if (!recent) return;
|
||||
let backupDevice = recent[1].device;
|
||||
if (backupDevice === "TWO") backupDevice = "ONE";
|
||||
let currentDevice = get(serialPort)?.device;
|
||||
if (currentDevice === "TWO") currentDevice = "ONE";
|
||||
|
||||
if (backupDevice !== currentDevice) {
|
||||
alert("Backup is incompatible with this device");
|
||||
throw new Error("Backup is incompatible with this device");
|
||||
}
|
||||
|
||||
changes.update(changes => {
|
||||
changes.update((changes) => {
|
||||
changes.push(
|
||||
...getChangesFromChordFile(recent[0]),
|
||||
...getChangesFromLayoutFile(recent[1]),
|
||||
...getChangesFromSettingsFile(recent[2]),
|
||||
)
|
||||
return changes
|
||||
})
|
||||
break
|
||||
);
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "chords": {
|
||||
changes.update(changes => {
|
||||
changes.push(...getChangesFromChordFile(file))
|
||||
return changes
|
||||
})
|
||||
break
|
||||
changes.update((changes) => {
|
||||
changes.push(...getChangesFromChordFile(file));
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "layout": {
|
||||
changes.update(changes => {
|
||||
changes.push(...getChangesFromLayoutFile(file))
|
||||
return changes
|
||||
})
|
||||
break
|
||||
changes.update((changes) => {
|
||||
changes.push(...getChangesFromLayoutFile(file));
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "settings": {
|
||||
changes.update(changes => {
|
||||
changes.push(...getChangesFromSettingsFile(file))
|
||||
return changes
|
||||
})
|
||||
break
|
||||
changes.update((changes) => {
|
||||
changes.push(...getChangesFromSettingsFile(file));
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown backup type "${(file as CharaFile<string>).type}"`)
|
||||
throw new Error(
|
||||
`Unknown backup type "${(file as CharaFile<string>).type}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getChangesFromChordFile(file: CharaChordFile) {
|
||||
const changes: Change[] = []
|
||||
const existingChords = new Set(get(chords).map(({phrase, actions}) => JSON.stringify([actions, phrase])))
|
||||
const changes: Change[] = [];
|
||||
const existingChords = new Set(
|
||||
get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])),
|
||||
);
|
||||
for (const [input, output] of file.chords) {
|
||||
if (existingChords.has(JSON.stringify([input, output]))) {
|
||||
continue
|
||||
continue;
|
||||
}
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
actions: input,
|
||||
phrase: output,
|
||||
id: input,
|
||||
})
|
||||
});
|
||||
}
|
||||
return changes
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
|
||||
const changes: Change[] = []
|
||||
const changes: Change[] = [];
|
||||
for (const [id, value] of file.settings.entries()) {
|
||||
const setting = get(settings)[id]
|
||||
const setting = get(settings)[id];
|
||||
if (setting !== undefined && setting.value !== value) {
|
||||
changes.push({
|
||||
type: ChangeType.Setting,
|
||||
id,
|
||||
setting: value,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
return changes
|
||||
return changes;
|
||||
}
|
||||
|
||||
export function getChangesFromLayoutFile(file: CharaLayoutFile) {
|
||||
const changes: Change[] = []
|
||||
const changes: Change[] = [];
|
||||
for (const [layer, keys] of file.layout.entries()) {
|
||||
for (const [id, action] of keys.entries()) {
|
||||
if (get(layout)[layer][id].action !== action) {
|
||||
if (get(layout)[layer]?.[id]?.action !== action) {
|
||||
changes.push({
|
||||
type: ChangeType.Layout,
|
||||
layer,
|
||||
id,
|
||||
action,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes
|
||||
return changes;
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
e + b + a,babe
|
||||
e + c + b,because
|
||||
f + e + c + a,face
|
||||
h + e + c + a,each
|
||||
i + d + ',I'd
|
||||
i + g + b,big
|
||||
i + g + e,give
|
||||
k + b + a,back
|
||||
k + e + a,take
|
||||
l + e + a,late
|
||||
l + e + d + a,lead
|
||||
l + f + e,feel
|
||||
l + g + e + a,large
|
||||
l + h + e,help
|
||||
l + i + a,Lia
|
||||
l + i + f,fill
|
||||
l + i + f + e,life
|
||||
l + i + g + b + a,gitlab
|
||||
l + k + i + e,like
|
||||
m + e + a,make
|
||||
m + i + ',I'm
|
||||
n + c + a,can
|
||||
n + d + a,and
|
||||
n + e + b,been
|
||||
n + e + b + a,enable
|
||||
n + e + d,end
|
||||
@@ -1,7 +1,7 @@
|
||||
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
|
||||
import type {CharaChordFile} from "$lib/share/chara-file"
|
||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
|
||||
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]])
|
||||
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]]);
|
||||
|
||||
export function csvChordsToJson(csv: string): CharaChordFile {
|
||||
return {
|
||||
@@ -10,22 +10,22 @@ export function csvChordsToJson(csv: string): CharaChordFile {
|
||||
chords: csv
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(line => {
|
||||
const [input, output] = line.split(/,(?=[^,]*$)/, 2)
|
||||
.map((line) => {
|
||||
const [input, output] = line.split(/,(?=[^,]*$)/, 2);
|
||||
return [
|
||||
input
|
||||
input!
|
||||
.split("+")
|
||||
.map(it => KEYMAP_IDS.get(it.trim())?.code ?? 0)
|
||||
.map((it) => KEYMAP_IDS.get(it.trim())?.code ?? 0)
|
||||
.sort((a, b) => a - b),
|
||||
output
|
||||
output!
|
||||
.trim()
|
||||
.split("")
|
||||
.map(it => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
|
||||
]
|
||||
.map((it) => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
|
||||
];
|
||||
}),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function isCsvChords(csv: string): boolean {
|
||||
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv)
|
||||
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv);
|
||||
}
|
||||
|
||||
@@ -4,21 +4,24 @@
|
||||
"device": "one",
|
||||
"layout": [
|
||||
[
|
||||
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262, 288, 277, 298, 307, 264, 287,
|
||||
268, 332, 311, 274, 286, 308, 329, 310, 280, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315,
|
||||
314, 317, 316, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263, 293, 260, 296,
|
||||
544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278, 357, 516, 519, 517, 518, 327, 336, 338, 335,
|
||||
337, 328, 325, 322, 323, 324
|
||||
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262,
|
||||
288, 277, 298, 307, 264, 287, 268, 332, 311, 274, 286, 308, 329, 310, 280,
|
||||
358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315, 314, 317, 316,
|
||||
312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263,
|
||||
293, 260, 296, 544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278,
|
||||
357, 516, 519, 517, 518, 327, 336, 338, 335, 337, 328, 325, 322, 323, 324
|
||||
],
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||
],
|
||||
[
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import {describe, expect, it} from "vitest"
|
||||
import legacyLayout from "./legacy-layout.sample.csv?raw"
|
||||
import legacyLayoutConverted from "./legacy-layout-converted.sample.json"
|
||||
import {csvLayoutToJson, isCsvLayout} from "./legacy-layout"
|
||||
import { describe, expect, it } from "vitest";
|
||||
import legacyLayout from "./legacy-layout.sample.csv?raw";
|
||||
import legacyLayoutConverted from "./legacy-layout-converted.sample.json";
|
||||
import { csvLayoutToJson, isCsvLayout } from "./legacy-layout";
|
||||
|
||||
describe("legacy layout", () => {
|
||||
it("should detect a legacy layout", () => {
|
||||
expect(isCsvLayout(legacyLayout)).to.be.true
|
||||
})
|
||||
expect(isCsvLayout(legacyLayout)).to.be.true;
|
||||
});
|
||||
|
||||
it("should not detect chord maps as layouts", () => {
|
||||
expect(isCsvLayout("e + h + t,the")).to.be.false
|
||||
})
|
||||
expect(isCsvLayout("e + h + t,the")).to.be.false;
|
||||
});
|
||||
|
||||
it("should convert legacy layouts", () => {
|
||||
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted)
|
||||
})
|
||||
})
|
||||
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import type {CharaLayoutFile} from "$lib/share/chara-file"
|
||||
import type { CharaLayoutFile } from "$lib/share/chara-file";
|
||||
|
||||
/**
|
||||
* Converts a legacy CSV-based layout to the modern JSON-based format
|
||||
*/
|
||||
export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] = "one"): CharaLayoutFile {
|
||||
export function csvLayoutToJson(
|
||||
csv: string,
|
||||
device: CharaLayoutFile["device"] = "one",
|
||||
): CharaLayoutFile {
|
||||
const layout: CharaLayoutFile = {
|
||||
charaVersion: 1,
|
||||
type: "layout",
|
||||
device,
|
||||
layout: [[], [], []],
|
||||
}
|
||||
};
|
||||
|
||||
for (const layer of csv.trim().split("\n")) {
|
||||
const [layerId, key, action] = layer.substring(1).split(",").map(Number)
|
||||
const [layerId, key, action] = layer.substring(1).split(",").map(Number);
|
||||
|
||||
layout.layout[Number(layerId) - 1][Number(key)] = Number(action)
|
||||
layout.layout[Number(layerId) - 1]![Number(key)] = Number(action);
|
||||
}
|
||||
|
||||
return layout
|
||||
return layout;
|
||||
}
|
||||
|
||||
export function isCsvLayout(csv: string): boolean {
|
||||
return /^(A[123],\d+,\d+\n?)+$/.test(csv)
|
||||
return /^(A[123],\d+,\d+\n?)+$/.test(csv);
|
||||
}
|
||||
|
||||
140
src/lib/charrecorder/CharRecorder.svelte
Normal file
140
src/lib/charrecorder/CharRecorder.svelte
Normal file
@@ -0,0 +1,140 @@
|
||||
<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,
|
||||
ondone,
|
||||
}: {
|
||||
replay: ReplayPlayer | Replay;
|
||||
cursor?: boolean;
|
||||
keys?: boolean;
|
||||
children?: Snippet;
|
||||
ondone?: () => void;
|
||||
} = $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.onDone = ondone;
|
||||
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>
|
||||
130
src/lib/charrecorder/ChordHud.svelte
Normal file
130
src/lib/charrecorder/ChordHud.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale } from "svelte/transition";
|
||||
import { KBD_ICONS } from "./renderer/kbd-icon.js";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import type { InferredChord } from "./core/types.js";
|
||||
|
||||
let { chords }: { chords: InferredChord[] } = $props();
|
||||
|
||||
function getPercent(
|
||||
deviation: number,
|
||||
inputCount: number,
|
||||
perfect: number,
|
||||
fail: number,
|
||||
) {
|
||||
const failAdjusted = fail * inputCount;
|
||||
const perfectAdjusted = perfect * inputCount;
|
||||
return Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
Math.max(0, deviation - perfectAdjusted) /
|
||||
(failAdjusted - perfectAdjusted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getColor(percent: number, alpha = 1) {
|
||||
return `hsl(${(1 - percent) * 120}deg 50% 50% / ${alpha})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
{#each chords as { input, id, deviation }, i (id)}
|
||||
{@const a = getPercent(deviation[0], input.length, 10, 25)}
|
||||
{@const b = getPercent(deviation[1], input.length, 10, 18)}
|
||||
{@const max = Math.max(a, b)}
|
||||
<div
|
||||
class="chord"
|
||||
out:fly={{ x: -100 }}
|
||||
style:translate="calc(-{(chords.length - i - 1) * 5}em - 50%) 0"
|
||||
style:scale={1 - (chords.length - i) / 6}
|
||||
style:opacity={1 - (chords.length - i - 1) / 6}
|
||||
title="Press: {deviation[0]}ms, Release: {deviation[1]}ms"
|
||||
>
|
||||
<div
|
||||
class="rating"
|
||||
style:color={getColor(max)}
|
||||
style:text-shadow="0 0 {Math.round((1 - max) * 10)}px {getColor(
|
||||
max,
|
||||
0.6,
|
||||
)}"
|
||||
in:scale={{
|
||||
start: 1.5 + 1.2 * (1 - max),
|
||||
easing: expoOut,
|
||||
duration: 1000,
|
||||
}}
|
||||
>
|
||||
{#if max === 1}
|
||||
Close
|
||||
{:else if max > 0.5}
|
||||
Okay
|
||||
{:else if max > 0}
|
||||
Good
|
||||
{:else}
|
||||
Perfect
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
in:fly={{ y: 20, easing: expoOut, duration: 1000 }}
|
||||
class="tile"
|
||||
style:background="linear-gradient(to right, {getColor(a)}, {getColor(
|
||||
b,
|
||||
)})"
|
||||
></div>
|
||||
<div in:fly={{ y: 60, easing: expoOut, duration: 1000 }}>
|
||||
{#each input as token}
|
||||
<kbd>{KBD_ICONS.get(token.code)}</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
position: relative;
|
||||
margin: 1em;
|
||||
margin-bottom: 0;
|
||||
display: grid;
|
||||
height: 3em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.rating {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: 100%;
|
||||
height: 0.2em;
|
||||
border-radius: 0.1em;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
kbd + kbd {
|
||||
margin-inline-start: 0.3em;
|
||||
}
|
||||
|
||||
.chord {
|
||||
will-change: transform, opacity, scale;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline-end: 1em;
|
||||
padding-inline: 0.1em;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
translate 0.3s ease,
|
||||
scale 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
30
src/lib/charrecorder/TrackChords.svelte
Normal file
30
src/lib/charrecorder/TrackChords.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import type { InferredChord } from "./core/types.js";
|
||||
import { ChordsReplayPlugin } from "./core/plugins/chords.js";
|
||||
import type { ReplayPlayer } from "./core/player.js";
|
||||
|
||||
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||
|
||||
let {
|
||||
chords = $bindable([]),
|
||||
count = 1,
|
||||
}: {
|
||||
chords: InferredChord[];
|
||||
count?: number;
|
||||
} = $props();
|
||||
|
||||
if (browser) {
|
||||
$effect(() => {
|
||||
if (!player.player) return;
|
||||
const tracker = new ChordsReplayPlugin();
|
||||
tracker.register(player.player);
|
||||
const unsubscribe = tracker.subscribe((value) => {
|
||||
chords = value.slice(-count);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
20
src/lib/charrecorder/TrackRollingWpm.svelte
Normal file
20
src/lib/charrecorder/TrackRollingWpm.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { RollingWpmReplayPlugin } from "./core/plugins/rolling-wpm";
|
||||
import type { ReplayPlayer } from "./core/player";
|
||||
|
||||
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||
|
||||
let { wpm = $bindable(0) } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!player.player) return;
|
||||
const tracker = new RollingWpmReplayPlugin();
|
||||
tracker.register(player.player);
|
||||
const unsubscribe = tracker.subscribe((value) => {
|
||||
wpm = value;
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
146
src/lib/charrecorder/core/player.ts
Normal file
146
src/lib/charrecorder/core/player.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
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>();
|
||||
|
||||
onDone?: () => 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
|
||||
) {
|
||||
if (this.onDone) {
|
||||
this.onDone();
|
||||
}
|
||||
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) {
|
||||
if (this.onDone) {
|
||||
this.onDone();
|
||||
}
|
||||
return this;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.startTime = performance.now();
|
||||
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||
}, delay);
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
112
src/lib/charrecorder/core/plugins/chords.ts
Normal file
112
src/lib/charrecorder/core/plugins/chords.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
|
||||
import type {
|
||||
StoreContract,
|
||||
ReplayPlugin,
|
||||
InferredChord,
|
||||
TextToken,
|
||||
} from "../types";
|
||||
|
||||
function isValid(human: TextToken[], robot: TextToken[]) {
|
||||
return human.length > 1 && human.length <= 10 && robot.length > 0;
|
||||
}
|
||||
|
||||
export class ChordsReplayPlugin
|
||||
implements StoreContract<InferredChord[]>, ReplayPlugin
|
||||
{
|
||||
private readonly subscribers = new Set<(value: InferredChord[]) => void>();
|
||||
|
||||
private readonly chords: InferredChord[] = [];
|
||||
|
||||
private tokens: TextToken[] = [];
|
||||
|
||||
private timeout: Parameters<typeof clearTimeout>[0] = NaN;
|
||||
|
||||
private infer(human: TextToken[], robo: TextToken[]) {
|
||||
const output = robo
|
||||
.filter((token) => token.text.length === 1)
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
this.chords.push({
|
||||
id: human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0),
|
||||
input: human,
|
||||
output,
|
||||
deviation: [
|
||||
human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0) -
|
||||
human.reduce((acc, curr) => Math.min(acc, curr.stamp), Infinity),
|
||||
human.reduce(
|
||||
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
|
||||
0,
|
||||
) -
|
||||
human.reduce(
|
||||
(acc, curr) => Math.min(acc, curr.stamp + (curr.duration ?? 0)),
|
||||
Infinity,
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(this.chords);
|
||||
}
|
||||
}
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe((token) => {
|
||||
if (token) {
|
||||
this.tokens.push(token);
|
||||
}
|
||||
|
||||
let last = NaN;
|
||||
let roboStart = NaN;
|
||||
let roboEnd = NaN;
|
||||
for (let i = 0; i < this.tokens.length; i++) {
|
||||
const token = this.tokens[i]!;
|
||||
if (!token.duration || !token.source) break;
|
||||
|
||||
if (
|
||||
Number.isNaN(roboStart) &&
|
||||
token.source === "human" &&
|
||||
token.stamp > last
|
||||
) {
|
||||
this.tokens = [];
|
||||
}
|
||||
|
||||
if (Number.isNaN(last) || token.stamp + token.duration > last) {
|
||||
last = token.stamp + token.duration;
|
||||
}
|
||||
|
||||
if (Number.isNaN(roboStart) && token.source === "robot") {
|
||||
roboStart = i;
|
||||
} else if (!Number.isNaN(roboStart) && token.source === "human") {
|
||||
roboEnd = i;
|
||||
const human = this.tokens.splice(0, roboStart);
|
||||
const robot = this.tokens.splice(0, roboEnd - roboStart);
|
||||
if (isValid(human, robot)) {
|
||||
this.infer(human, robot);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(this.tokens);
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
if (replay.stepper.held.size === 0) {
|
||||
this.timeout = setTimeout(() => {
|
||||
if (this.tokens.length > 0) {
|
||||
const human = this.tokens.splice(
|
||||
0,
|
||||
this.tokens.findIndex((it) => it.source === "robot"),
|
||||
);
|
||||
const robot = this.tokens.splice(0, this.tokens.length);
|
||||
if (isValid(human, robot)) {
|
||||
this.infer(human, robot);
|
||||
}
|
||||
}
|
||||
}, ROBOT_THRESHOLD);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscription: (value: InferredChord[]) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
71
src/lib/charrecorder/core/plugins/meta.ts
Normal file
71
src/lib/charrecorder/core/plugins/meta.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
|
||||
import type { GraphData, ReplayPlugin, StoreContract } from "../types";
|
||||
|
||||
export class MetaReplayPlugin
|
||||
implements StoreContract<GraphData>, ReplayPlugin
|
||||
{
|
||||
private subscribers = new Set<(value: GraphData) => void>();
|
||||
|
||||
private graphData: GraphData = { min: [0, 0], max: [0, 0], tokens: [] };
|
||||
|
||||
private liveHeldRoboFilter = new Set<string>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe((token) => {
|
||||
if (!token) return;
|
||||
const lastHeld = this.graphData.tokens
|
||||
.at(-1)
|
||||
?.reduce(
|
||||
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
|
||||
0,
|
||||
);
|
||||
if (
|
||||
lastHeld &&
|
||||
(lastHeld === -1 || lastHeld > token.stamp + (token.duration ?? 0))
|
||||
) {
|
||||
this.graphData.tokens.at(-1)!.push(token);
|
||||
} else {
|
||||
this.graphData.tokens.push([token]);
|
||||
}
|
||||
if (this.graphData.tokens.length === 1) {
|
||||
this.graphData.min = [token.stamp, 0];
|
||||
}
|
||||
this.graphData.max = [
|
||||
this.graphData.tokens
|
||||
.at(-1)!
|
||||
.reduce(
|
||||
(acc, { stamp, duration }) =>
|
||||
Math.max(acc, stamp + (duration ?? 0)),
|
||||
0,
|
||||
),
|
||||
Math.max(this.graphData.max[1], this.graphData.tokens.at(-1)!.length),
|
||||
];
|
||||
|
||||
this.liveHeldRoboFilter.add(token.code);
|
||||
|
||||
if (token.duration === undefined) {
|
||||
setTimeout(() => {
|
||||
if (this.liveHeldRoboFilter.has(token.code)) {
|
||||
token.source = "human";
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(this.graphData);
|
||||
}
|
||||
}
|
||||
}, ROBOT_THRESHOLD);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.liveHeldRoboFilter.delete(token.code);
|
||||
}, token.duration);
|
||||
}
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(this.graphData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscription: (value: GraphData) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
48
src/lib/charrecorder/core/plugins/rolling-wpm.ts
Normal file
48
src/lib/charrecorder/core/plugins/rolling-wpm.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReplayPlayer } from "../player";
|
||||
import type { ReplayPlugin, StoreContract } from "../types";
|
||||
import { avgWordLength } from "./wpm";
|
||||
|
||||
export class RollingWpmReplayPlugin
|
||||
implements StoreContract<number>, ReplayPlugin
|
||||
{
|
||||
subscribers = new Set<(value: number) => void>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe(() => {
|
||||
if (this.subscribers.size === 0) return;
|
||||
let i = 0;
|
||||
const index = Math.max(
|
||||
0,
|
||||
replay.stepper.text.findLastIndex((char) => {
|
||||
if (char.source === "ghost") return false;
|
||||
if (char.text === " " && i < 10) {
|
||||
i++;
|
||||
} else if (char.text === " ") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
const length =
|
||||
replay.stepper.text.length - replay.stepper.ghostCount - index;
|
||||
const msPerChar =
|
||||
((replay.stepper.text[
|
||||
replay.stepper.text.length - replay.stepper.ghostCount - 1
|
||||
]?.stamp ?? 0) -
|
||||
(replay.stepper.text[index]?.stamp ?? 0)) /
|
||||
length;
|
||||
|
||||
const value = 60_000 / (msPerChar * avgWordLength);
|
||||
if (Number.isFinite(value)) {
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscription: (value: number) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
26
src/lib/charrecorder/core/plugins/wpm.ts
Normal file
26
src/lib/charrecorder/core/plugins/wpm.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReplayPlayer } from "../player";
|
||||
import type { ReplayPlugin, StoreContract } from "../types";
|
||||
|
||||
export const avgWordLength = 5;
|
||||
|
||||
export class WpmReplayPlugin implements StoreContract<number>, ReplayPlugin {
|
||||
private subscribers = new Set<(value: number) => void>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe(() => {
|
||||
if (this.subscribers.size === 0) return;
|
||||
const msPerChar =
|
||||
((replay.stepper.text.at(-1)?.stamp ?? 0) - replay.startTime) /
|
||||
replay.stepper.text.length;
|
||||
|
||||
const value = 60_000 / (msPerChar * avgWordLength);
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
subscribe(subscription: (value: number) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
79
src/lib/charrecorder/core/recorder.ts
Normal file
79
src/lib/charrecorder/core/recorder.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { ReplayPlayer } from "./player.js";
|
||||
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
|
||||
|
||||
function maybeRound<T>(value: T, round: boolean): T {
|
||||
return typeof value === "number" && round ? (Math.round(value) as T) : value;
|
||||
}
|
||||
|
||||
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) ?? ["", 0];
|
||||
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, round = true) {
|
||||
return {
|
||||
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
|
||||
finish: maybeRound(
|
||||
trim
|
||||
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
||||
: performance.now(),
|
||||
round,
|
||||
),
|
||||
keys: this.replay
|
||||
.map(
|
||||
([key, code, at, duration]) =>
|
||||
[
|
||||
key,
|
||||
code,
|
||||
maybeRound(at, round),
|
||||
maybeRound(duration, round),
|
||||
] as const,
|
||||
)
|
||||
.sort((a, b) => a[2] - b[2]),
|
||||
};
|
||||
}
|
||||
}
|
||||
132
src/lib/charrecorder/core/step.ts
Normal file
132
src/lib/charrecorder/core/step.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ROBOT_THRESHOLD } from "./player";
|
||||
import type { LiveReplayEvent, ReplayEvent, TextToken } from "./types";
|
||||
|
||||
/**
|
||||
* This is the "heart" of the player logic
|
||||
*/
|
||||
export class ReplayStepper {
|
||||
held = new Map<string, boolean>();
|
||||
|
||||
text: TextToken[];
|
||||
|
||||
cursor = 0;
|
||||
|
||||
challenge: TextToken[];
|
||||
|
||||
ghostCount: number;
|
||||
|
||||
mistakeCount = 0;
|
||||
|
||||
constructor(initialReplay: ReplayEvent[] = [], challenge = "") {
|
||||
this.challenge = challenge.split("").map((text) => ({
|
||||
stamp: 0,
|
||||
duration: 0,
|
||||
code: "",
|
||||
text,
|
||||
source: "ghost",
|
||||
correct: true,
|
||||
}));
|
||||
this.text = [...this.challenge];
|
||||
this.ghostCount = this.challenge.length;
|
||||
for (const key of initialReplay) {
|
||||
this.step(...key);
|
||||
}
|
||||
}
|
||||
|
||||
step(
|
||||
...[output, code, at, duration]: ReplayEvent | LiveReplayEvent
|
||||
): TextToken | undefined {
|
||||
let token: TextToken | undefined = undefined;
|
||||
if (output === "Backspace") {
|
||||
if (this.held.has("ControlLeft") || this.held.has("ControlRight")) {
|
||||
let wordIndex = 0;
|
||||
for (let i = this.cursor - 1; i >= 0; i--) {
|
||||
if (/\w+/.test(/** @type {TextToken} */ this.text[i]!.text)) {
|
||||
wordIndex = i;
|
||||
} else if (wordIndex !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.text.splice(wordIndex, this.cursor - wordIndex);
|
||||
} else if (this.cursor !== 0) {
|
||||
this.text.splice(this.cursor - 1, 1);
|
||||
}
|
||||
this.cursor = Math.min(
|
||||
this.cursor,
|
||||
this.text.length - this.ghostCount + 1,
|
||||
);
|
||||
}
|
||||
if (output.length === 1) {
|
||||
token = {
|
||||
stamp: at,
|
||||
duration,
|
||||
code,
|
||||
text: output,
|
||||
source:
|
||||
duration === undefined
|
||||
? undefined
|
||||
: duration < ROBOT_THRESHOLD
|
||||
? "robot"
|
||||
: "human",
|
||||
correct: true,
|
||||
};
|
||||
this.text.splice(this.cursor, 0, token);
|
||||
}
|
||||
|
||||
if (code === "ArrowLeft" || code === "Backspace") {
|
||||
this.cursor = Math.max(this.cursor - 1, 0);
|
||||
}
|
||||
if (code === "ArrowRight" || output.length === 1) {
|
||||
this.cursor = Math.min(
|
||||
this.cursor + 1,
|
||||
this.text.length - this.ghostCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "Enter") {
|
||||
token = {
|
||||
stamp: at,
|
||||
code,
|
||||
duration,
|
||||
text: "\n",
|
||||
source:
|
||||
duration === undefined
|
||||
? undefined
|
||||
: duration < ROBOT_THRESHOLD
|
||||
? "robot"
|
||||
: "human",
|
||||
correct: true,
|
||||
};
|
||||
this.text.splice(this.cursor, 0, token);
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
if (this.challenge.length > 0) {
|
||||
let challengeIndex = 0;
|
||||
this.mistakeCount = 0;
|
||||
for (let i = 0; i < this.text.length - this.ghostCount; i++) {
|
||||
if (this.text[i]!.text === this.challenge[challengeIndex]?.text) {
|
||||
this.text[i]!.correct = true;
|
||||
} else {
|
||||
this.mistakeCount++;
|
||||
this.text[i]!.correct = false;
|
||||
}
|
||||
challengeIndex++;
|
||||
}
|
||||
|
||||
const currentGhostCount = this.ghostCount;
|
||||
this.ghostCount = Math.max(0, this.challenge.length - challengeIndex);
|
||||
|
||||
this.text.splice(
|
||||
this.text.length - currentGhostCount,
|
||||
Math.max(0, currentGhostCount - this.ghostCount),
|
||||
...this.challenge.slice(
|
||||
challengeIndex,
|
||||
challengeIndex + Math.max(0, this.ghostCount - currentGhostCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
58
src/lib/charrecorder/core/types.ts
Normal file
58
src/lib/charrecorder/core/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ReplayPlayer } from "./player.js";
|
||||
|
||||
export interface Replay {
|
||||
start: number;
|
||||
finish: number;
|
||||
keys: ReplayEvent[];
|
||||
challenge?: string;
|
||||
}
|
||||
export type LiveReplayEvent = readonly [
|
||||
output: string,
|
||||
code: string,
|
||||
at: number,
|
||||
];
|
||||
export type ReplayEvent = readonly [...LiveReplayEvent, duration: number];
|
||||
|
||||
export interface TextToken {
|
||||
stamp: number;
|
||||
duration?: number;
|
||||
text: string;
|
||||
code: string;
|
||||
source?: "human" | "robot" | "ghost";
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
min: [number, number];
|
||||
max: [number, number];
|
||||
tokens: TextToken[][];
|
||||
}
|
||||
|
||||
export interface ReplayStepResult {
|
||||
text: TextToken[];
|
||||
cursor: number;
|
||||
challengeCursor: number;
|
||||
token: TextToken | undefined;
|
||||
}
|
||||
|
||||
export type TransmittableKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"timeStamp" | "type" | "code" | "key"
|
||||
>;
|
||||
|
||||
export interface InferredChord {
|
||||
id: number;
|
||||
input: TextToken[];
|
||||
output: string;
|
||||
deviation: [number, number];
|
||||
}
|
||||
|
||||
export interface ReplayPlugin {
|
||||
register(replay: ReplayPlayer): void;
|
||||
}
|
||||
|
||||
export interface StoreContract<T> {
|
||||
subscribe(subscription: (value: T) => void): () => void;
|
||||
|
||||
set?: (value: T) => void;
|
||||
}
|
||||
96
src/lib/charrecorder/renderer/kbd-icon.ts
Normal file
96
src/lib/charrecorder/renderer/kbd-icon.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export const KBD_ICONS = new Map([
|
||||
["KeyA", "a"],
|
||||
["KeyB", "b"],
|
||||
["KeyC", "c"],
|
||||
["KeyD", "d"],
|
||||
["KeyE", "e"],
|
||||
["KeyF", "f"],
|
||||
["KeyG", "g"],
|
||||
["KeyH", "h"],
|
||||
["KeyI", "i"],
|
||||
["KeyJ", "j"],
|
||||
["KeyK", "k"],
|
||||
["KeyL", "l"],
|
||||
["KeyM", "m"],
|
||||
["KeyN", "n"],
|
||||
["KeyO", "o"],
|
||||
["KeyP", "p"],
|
||||
["KeyQ", "q"],
|
||||
["KeyR", "r"],
|
||||
["KeyS", "s"],
|
||||
["KeyT", "t"],
|
||||
["KeyU", "u"],
|
||||
["KeyV", "v"],
|
||||
["KeyW", "w"],
|
||||
["KeyX", "x"],
|
||||
["KeyY", "y"],
|
||||
["KeyZ", "z"],
|
||||
["Digit0", "0"],
|
||||
["Digit1", "1"],
|
||||
["Digit2", "2"],
|
||||
["Digit3", "3"],
|
||||
["Digit4", "4"],
|
||||
["Digit5", "5"],
|
||||
["Digit6", "6"],
|
||||
["Digit7", "7"],
|
||||
["Digit8", "8"],
|
||||
["Digit9", "9"],
|
||||
["Period", "."],
|
||||
["Comma", ","],
|
||||
["Semicolon", ";"],
|
||||
["Quote", "'"],
|
||||
["BracketLeft", "["],
|
||||
["BracketRight", "]"],
|
||||
["Backslash", "\\"],
|
||||
["Slash", "/"],
|
||||
["Minus", "-"],
|
||||
["Equal", "="],
|
||||
["Backquote", "`"],
|
||||
["IntlBackslash", "¦"],
|
||||
["IntlRo", "ろ"],
|
||||
["IntlYen", "¥"],
|
||||
["IntlHash", "#"],
|
||||
["BracketLeft", "["],
|
||||
["BracketRight", "]"],
|
||||
["NumLock", "⇭"],
|
||||
["ScrollLock", "⇳"],
|
||||
["Backspace", "⌫"],
|
||||
["Delete", "⌦"],
|
||||
["Enter", "↵"],
|
||||
["Space", "␣"],
|
||||
["Tab", "⇥"],
|
||||
["ArrowLeft", "←"],
|
||||
["ArrowRight", "→"],
|
||||
["ArrowUp", "↑"],
|
||||
["ArrowDown", "↓"],
|
||||
["ShiftLeft", "⇧"],
|
||||
["ShiftRight", "⇧"],
|
||||
["ControlLeft", "Ctrl"],
|
||||
["ControlRight", "Ctrl"],
|
||||
["AltLeft", "Alt"],
|
||||
["AltRight", "Alt"],
|
||||
["MetaLeft", "⌘"],
|
||||
["MetaRight", "⌘"],
|
||||
["CapsLock", "⇪"],
|
||||
["Escape", "Esc"],
|
||||
["F1", "F1"],
|
||||
["F2", "F2"],
|
||||
["F3", "F3"],
|
||||
["F4", "F4"],
|
||||
["F5", "F5"],
|
||||
["F6", "F6"],
|
||||
["F7", "F7"],
|
||||
["F8", "F8"],
|
||||
["F9", "F9"],
|
||||
["F10", "F10"],
|
||||
["F11", "F11"],
|
||||
["F12", "F12"],
|
||||
["PrintScreen", "PrtSc"],
|
||||
["Pause", "Pause"],
|
||||
["Insert", "Ins"],
|
||||
["Home", "Home"],
|
||||
["End", "End"],
|
||||
["PageUp", "PgUp"],
|
||||
["PageDown", "PgDn"],
|
||||
["ContextMenu", "Menu"],
|
||||
]);
|
||||
287
src/lib/charrecorder/renderer/renderer.ts
Normal file
287
src/lib/charrecorder/renderer/renderer.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { TextToken } from "../core/types";
|
||||
import { KBD_ICONS } from "./kbd-icon";
|
||||
|
||||
export class TextRenderer {
|
||||
shinyChords = true;
|
||||
|
||||
shiny: number[] | undefined;
|
||||
|
||||
readonly cursorNode: SVGRectElement;
|
||||
|
||||
private readonly nodes = new Map<TextToken, SVGTextElement>();
|
||||
|
||||
private readonly heldNodes = new Map<string, SVGTextElement>();
|
||||
|
||||
private readonly occupiedHeld: Array<boolean | undefined> = [];
|
||||
|
||||
private readonly occupied: number[] = [];
|
||||
|
||||
animationOptions: KeyframeAnimationOptions = {
|
||||
duration: 100,
|
||||
easing: "ease",
|
||||
};
|
||||
|
||||
heldKeySize = 0.8;
|
||||
|
||||
ghostText = "";
|
||||
|
||||
constructor(
|
||||
readonly node: HTMLElement,
|
||||
readonly svg: SVGSVGElement,
|
||||
readonly textNode: Text,
|
||||
) {
|
||||
this.cursorNode = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"rect",
|
||||
);
|
||||
this.cursorNode.setAttribute("x", "0");
|
||||
this.cursorNode.setAttribute("y", "0");
|
||||
this.svg.appendChild(this.cursorNode);
|
||||
}
|
||||
|
||||
set showCursor(value: boolean) {
|
||||
this.cursorNode.style.visibility = value ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
getAtRange(i: number): [number, number] {
|
||||
const range = document.createRange();
|
||||
const rangeIndex = Math.max(0, Math.min(i, this.textNode.length - 1));
|
||||
range.setStart(this.textNode, rangeIndex);
|
||||
range.setEnd(
|
||||
this.textNode,
|
||||
this.textNode.length === 0 ? 0 : rangeIndex + 1,
|
||||
);
|
||||
const charBounds = range.getBoundingClientRect();
|
||||
return [
|
||||
i > this.textNode.length - 1
|
||||
? charBounds.x + charBounds.width
|
||||
: charBounds.x,
|
||||
charBounds.y + charBounds.height / 2 + 1,
|
||||
];
|
||||
}
|
||||
|
||||
set held(keys: Map<string, boolean>) {
|
||||
const prev = new Set(this.heldNodes.keys());
|
||||
const fontSize = getComputedStyle(this.node).fontSize;
|
||||
|
||||
for (const [code, isHuman] of keys) {
|
||||
if (!isHuman) continue;
|
||||
prev.delete(code);
|
||||
let node = this.heldNodes.get(code);
|
||||
if (!node) {
|
||||
let i = this.occupiedHeld.findIndex((it) => it === undefined);
|
||||
if (i === -1) {
|
||||
i = this.occupiedHeld.length;
|
||||
this.occupiedHeld.push(true);
|
||||
} else {
|
||||
this.occupiedHeld[i] = true;
|
||||
}
|
||||
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
node.textContent = KBD_ICONS.get(code) ?? null;
|
||||
node.setAttribute("i", i.toString());
|
||||
this.heldNodes.set(code, node);
|
||||
node.style.transform = `${this.cursorNode.style.transform} translateY(calc(${fontSize} * ${
|
||||
i + 1.5
|
||||
}))`;
|
||||
node.style.fontSize = `calc(${fontSize} * ${this.heldKeySize})`;
|
||||
this.svg.appendChild(node);
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{
|
||||
transform: `translateY(calc(-${fontSize} * ${this.heldNodes.size})) scale(0)`,
|
||||
},
|
||||
{ transform: "translateY(0px) scale(1)" },
|
||||
],
|
||||
{ duration: 200, composite: "add", easing: "ease-out" },
|
||||
)
|
||||
.play();
|
||||
}
|
||||
}
|
||||
|
||||
for (const code of prev) {
|
||||
const node = this.heldNodes.get(code);
|
||||
if (!node) continue;
|
||||
this.heldNodes.delete(code);
|
||||
|
||||
this.occupiedHeld[Number(node.getAttribute("i"))] = undefined;
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{ transform: "translateX(0px)" },
|
||||
{ transform: "translateX(-10px)" },
|
||||
],
|
||||
{
|
||||
duration: 500,
|
||||
composite: "accumulate",
|
||||
easing: "ease-in",
|
||||
},
|
||||
)
|
||||
.play();
|
||||
const animation = node.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 500,
|
||||
easing: "ease-in",
|
||||
});
|
||||
animation.onfinish = () => {
|
||||
node.remove();
|
||||
};
|
||||
animation.play();
|
||||
}
|
||||
}
|
||||
|
||||
get animated(): boolean {
|
||||
return this.cursorNode.classList.contains("animated");
|
||||
}
|
||||
|
||||
set animated(value: boolean) {
|
||||
if (value) {
|
||||
this.cursorNode.classList.add("animated");
|
||||
} else {
|
||||
this.cursorNode.classList.remove("animated");
|
||||
}
|
||||
}
|
||||
|
||||
set cursor(cursor: number) {
|
||||
const bounds = this.node.getBoundingClientRect();
|
||||
const style = getComputedStyle(this.node);
|
||||
|
||||
const pos = this.getAtRange(cursor);
|
||||
const x = pos[0] - bounds.x;
|
||||
const y = pos[1] - bounds.y;
|
||||
|
||||
this.cursorNode.setAttribute("height", style.fontSize);
|
||||
this.cursorNode.setAttribute("width", "1");
|
||||
|
||||
this.cursorNode.style.transform = `translate(${x}px, calc(${y}px - ${style.fontSize} / 2))`;
|
||||
}
|
||||
|
||||
set text(text: TextToken[]) {
|
||||
const prev = new Set(this.nodes.keys());
|
||||
|
||||
const bounds = this.node.getBoundingClientRect();
|
||||
|
||||
this.svg.setAttribute("width", bounds.width.toFixed(2));
|
||||
this.svg.setAttribute("height", bounds.height.toFixed(2));
|
||||
this.svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${bounds.width.toFixed(2)} ${bounds.height.toFixed(2)}`,
|
||||
);
|
||||
|
||||
text.forEach((token, i) => {
|
||||
prev.delete(token);
|
||||
let node = this.nodes.get(token);
|
||||
|
||||
const pos = this.getAtRange(i);
|
||||
const x = pos[0] - bounds.x;
|
||||
const y = pos[1] - bounds.y;
|
||||
const xStr = x.toFixed(2);
|
||||
const yStr = y.toFixed(2);
|
||||
|
||||
if (!node) {
|
||||
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
this.nodes.set(token, node);
|
||||
this.svg.appendChild(node);
|
||||
node.setAttribute("x", xStr);
|
||||
node.setAttribute("y", yStr);
|
||||
node.setAttribute("i", i.toString());
|
||||
if (token.source === "ghost") {
|
||||
node.setAttribute("opacity", "0.5");
|
||||
}
|
||||
this.occupied[i] ??= 0;
|
||||
if (this.animated) {
|
||||
if (this.occupied[i] > 0) {
|
||||
node
|
||||
.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
...this.animationOptions,
|
||||
easing: "ease-out",
|
||||
})
|
||||
.play();
|
||||
} else {
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(10px)" },
|
||||
{ opacity: 1, transform: "translateY(0px)" },
|
||||
],
|
||||
{ ...this.animationOptions, easing: "ease-out" },
|
||||
)
|
||||
.play();
|
||||
}
|
||||
}
|
||||
this.occupied[i]++;
|
||||
}
|
||||
|
||||
if (!token.correct) {
|
||||
node.setAttribute("incorrect", "");
|
||||
} else {
|
||||
node.removeAttribute("incorrect");
|
||||
}
|
||||
|
||||
const prevX = node.getAttribute("x");
|
||||
if (prevX && prevX !== xStr) {
|
||||
const prev = parseFloat(prevX);
|
||||
node.setAttribute("x", xStr);
|
||||
/*if (this.animated) {
|
||||
node.animate(
|
||||
[{ transform: `translateX(${prev - x}px)` }, { transform: `translateX(0px)` }],
|
||||
this.animationOptions
|
||||
);
|
||||
}*/
|
||||
}
|
||||
const prevY = node.getAttribute("y");
|
||||
if (prevY && prevY !== yStr) {
|
||||
const prev = parseFloat(prevY);
|
||||
node.setAttribute("y", yStr);
|
||||
/*if (this.animated) {
|
||||
node.animate(
|
||||
[{ transform: `translateY(${prev - y}px)` }, { transform: `translateY(0px)` }],
|
||||
this.animationOptions
|
||||
);
|
||||
}*/
|
||||
}
|
||||
if (node.textContent !== token.text) {
|
||||
node.textContent = token.text;
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of prev) {
|
||||
const node = this.nodes.get(token)!;
|
||||
const i = parseInt(node.getAttribute("i")!);
|
||||
this.nodes.delete(token);
|
||||
if (this.animated) {
|
||||
const animation = node.animate(
|
||||
[{ opacity: 1 }, { opacity: 0 }],
|
||||
this.animationOptions,
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (this.occupied[i] === 1) {
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{ transform: "translateY(0px)" },
|
||||
{ transform: "translateY(10px)" },
|
||||
],
|
||||
this.animationOptions,
|
||||
)
|
||||
.play();
|
||||
}
|
||||
}, 10);
|
||||
animation.onfinish = () => {
|
||||
node.remove();
|
||||
this.occupied[i]!--;
|
||||
};
|
||||
animation.play();
|
||||
} else {
|
||||
node.remove();
|
||||
this.occupied[i]!--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isShiny(char: TextToken, index: number) {
|
||||
return (
|
||||
this.shiny?.includes(index) ||
|
||||
(this.shinyChords && char.source === "robot")
|
||||
);
|
||||
}
|
||||
}
|
||||
71
src/lib/chat/MatrixRoomMembers.svelte
Normal file
71
src/lib/chat/MatrixRoomMembers.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import type { RoomMember } from "matrix-js-sdk";
|
||||
import { matrixClient, memberColor } from "./chat";
|
||||
import { theme } from "$lib/preferences";
|
||||
import { hexFromArgb } from "@material/material-color-utilities";
|
||||
|
||||
let { members }: { members: RoomMember[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="member-list">
|
||||
{#each members as member (member.userId)}
|
||||
{@const avatar = member.getMxcAvatarUrl()}
|
||||
<div class="member">
|
||||
{#if avatar}
|
||||
<img
|
||||
class="avatar"
|
||||
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
|
||||
alt={member.name}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{:else}
|
||||
{@const color = memberColor(member, $theme)}
|
||||
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
|
||||
<div
|
||||
style:background={hexFromArgb(modeColor.color)}
|
||||
style:color={hexFromArgb(modeColor.onColor)}
|
||||
class="avatar avatar-placeholder icon"
|
||||
>
|
||||
person
|
||||
</div>
|
||||
{/if}
|
||||
<span>{member.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.avatar {
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 8px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
span {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
251
src/lib/chat/MatrixTimeline.svelte
Normal file
251
src/lib/chat/MatrixTimeline.svelte
Normal file
@@ -0,0 +1,251 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
EventTimeline,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomMember,
|
||||
RoomMemberEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { matrixClient } from "./chat";
|
||||
import MatrixEventComponent from "./events/MatrixEvent.svelte";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import { type Socket, io } from "socket.io-client";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
let { timeline }: { timeline: EventTimeline } = $props();
|
||||
|
||||
const excludeEvents = ["m.reaction", "m.room.redaction"];
|
||||
|
||||
let events = $state(
|
||||
timeline
|
||||
.getEvents()
|
||||
.filter((it) => !excludeEvents.includes(it.getType()))
|
||||
.reverse(),
|
||||
);
|
||||
|
||||
let recorder = $state(new ReplayRecorder());
|
||||
let showCursor = $state(false);
|
||||
|
||||
let timelineElement: HTMLElement = $state()!;
|
||||
|
||||
async function onTimeline(
|
||||
event: MatrixEvent,
|
||||
room?: Room,
|
||||
toStartOfTimeline?: boolean,
|
||||
) {
|
||||
if (room?.roomId !== timeline.getRoomId()) return;
|
||||
const sender = event.getSender();
|
||||
if (sender) {
|
||||
live.delete(sender);
|
||||
}
|
||||
if (excludeEvents.includes(event.getType())) return;
|
||||
if (toStartOfTimeline) {
|
||||
events.push(event);
|
||||
} else {
|
||||
const needScroll = timelineElement.scrollTop < 20;
|
||||
events.unshift(event);
|
||||
if (needScroll) {
|
||||
await tick();
|
||||
timelineElement.scroll({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let typing = $state<string[]>([]);
|
||||
|
||||
function onTyping(event: MatrixEvent, member: RoomMember) {
|
||||
typing = event.event.content?.["user_ids"] ?? [];
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const roomId = timeline.getRoomId();
|
||||
if (!roomId) return;
|
||||
const finalText = recorder.player.stepper.text
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
const finalRecording = recorder.finish();
|
||||
if (!finalText) return;
|
||||
recorder = new ReplayRecorder();
|
||||
await $matrixClient.sendMessage(roomId, {
|
||||
msgtype: "m.text" as MsgType.Text,
|
||||
body: finalText,
|
||||
// @ts-expect-error
|
||||
"m.replay": finalRecording,
|
||||
});
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
|
||||
send();
|
||||
return;
|
||||
} else {
|
||||
recorder.next(event);
|
||||
}
|
||||
|
||||
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
|
||||
recorder = new ReplayRecorder();
|
||||
} else {
|
||||
socket.emit("message", {
|
||||
timeStamp: event.timeStamp,
|
||||
type: event.type,
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
username: $matrixClient.getUserId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let socket: Socket = $state()!;
|
||||
let live = new SvelteMap<string, ReplayRecorder>();
|
||||
|
||||
onMount(() => {
|
||||
socket = io("https://srv.charachorder.io");
|
||||
socket.emit("join", timeline.getRoomId());
|
||||
|
||||
socket.on("message", async ({ message }) => {
|
||||
let userRecorder = live.get(message.username);
|
||||
if (!userRecorder) {
|
||||
userRecorder = new ReplayRecorder();
|
||||
live.set(message.username, userRecorder);
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
userRecorder.next(message);
|
||||
|
||||
if (userRecorder.player.stepper.text.length === 0) {
|
||||
live.delete(message.username);
|
||||
}
|
||||
});
|
||||
|
||||
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
||||
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
socket?.disconnect();
|
||||
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
||||
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div bind:this={timelineElement} class="timeline">
|
||||
{#each live.entries() as [userId, recorder] (userId)}
|
||||
{@const roomId = timeline.getRoomId()}
|
||||
{#if roomId}
|
||||
{@const room = $matrixClient.getRoom(roomId)}
|
||||
{@const member = room?.getMember(userId)}
|
||||
{#if member}
|
||||
<MatrixEventComponent sender={member} replay={recorder.player} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{#each events as event, i (event.event["event_id"])}
|
||||
{@const prev = events[i + 1]}
|
||||
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="static-elements">
|
||||
<div class="indicators"></div>
|
||||
<div class="input-box">
|
||||
<button class="icon">add</button>
|
||||
<div
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
class="input"
|
||||
onkeydown={onKey}
|
||||
onkeyup={onKey}
|
||||
onfocusin={() => (showCursor = true)}
|
||||
onfocusout={() => (showCursor = false)}
|
||||
>
|
||||
<CharRecorder replay={recorder.player} cursor={showCursor} />
|
||||
</div>
|
||||
<button class="icon" onclick={send}>send</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
$border-radius: 16px;
|
||||
|
||||
h2 {
|
||||
height: min-content;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
flex-grow: 1;
|
||||
cursor: text;
|
||||
padding: 0.5em;
|
||||
font-size: 1rem;
|
||||
border-radius: $border-radius;
|
||||
|
||||
text-wrap: wrap;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding-block: 8px;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.static-elements {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
contain: content;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-to-present {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.scroll-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
min-height: 16px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--md-sys-color-background)
|
||||
);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
justify-content: flex-end;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
109
src/lib/chat/chat.ts
Normal file
109
src/lib/chat/chat.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { derived, writable, type Writable } from "svelte/store";
|
||||
import type {
|
||||
ClientEvent,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
argbFromHex,
|
||||
type CustomColorGroup,
|
||||
} from "@material/material-color-utilities";
|
||||
import type { UserTheme } from "$lib/preferences";
|
||||
import { MatrixRx } from "./matrix-rx/client";
|
||||
|
||||
export const matrixClient: Writable<MatrixClient> = writable();
|
||||
|
||||
export const isLoggedIn: Writable<boolean> = writable(false);
|
||||
|
||||
export const matrix = derived(
|
||||
[matrixClient, isLoggedIn],
|
||||
([matrixClient, isLoggedIn]) =>
|
||||
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
|
||||
);
|
||||
|
||||
export const currentRoomId = persistentWritable<string | null>(
|
||||
"currentRoomId",
|
||||
null,
|
||||
);
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
export async function initMatrixClient() {
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
const cryptoStore = new IndexedDBCryptoStore(
|
||||
window.indexedDB,
|
||||
"matrix-crypto",
|
||||
);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
console.log("store");
|
||||
await store.startup();
|
||||
console.log("cryptoStore");
|
||||
await cryptoStore.startup();
|
||||
console.log("client");
|
||||
await client.startClient();
|
||||
client.once("sync" as ClientEvent.Sync, () => {
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
storeLogin(await client.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
}
|
||||
|
||||
matrixClient.set(client);
|
||||
console.log("done");
|
||||
}
|
||||
|
||||
export function memberColor(
|
||||
member: RoomMember,
|
||||
theme: UserTheme,
|
||||
): CustomColorGroup {
|
||||
let hash = 0;
|
||||
member.userId.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
||||
{ value: argbFromHex(color), name: "member", blend: true },
|
||||
]).customColors.find((c) => c.color.name === "member")!;
|
||||
}
|
||||
357
src/lib/chat/events/MatrixEvent.svelte
Normal file
357
src/lib/chat/events/MatrixEvent.svelte
Normal file
@@ -0,0 +1,357 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
EventTimeline,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
Relations,
|
||||
RelationsEvent,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
|
||||
import { matrixClient, memberColor } from "../chat";
|
||||
import { theme } from "$lib/preferences";
|
||||
import { hexFromArgb } from "@material/material-color-utilities";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
let {
|
||||
event,
|
||||
prev,
|
||||
sender,
|
||||
replay: replayPlayer,
|
||||
timeline,
|
||||
}: {
|
||||
event?: MatrixEvent;
|
||||
prev?: MatrixEvent;
|
||||
sender?: RoomMember | null;
|
||||
replay?: Replay | ReplayPlayer;
|
||||
timeline?: EventTimeline;
|
||||
} = $props();
|
||||
|
||||
let toolbarHover = $state(false);
|
||||
let mainHover = $state(false);
|
||||
|
||||
let hover = $derived(toolbarHover || mainHover);
|
||||
|
||||
let replay: Replay | undefined = $state();
|
||||
|
||||
let reactions: Relations | undefined = $state(
|
||||
timeline && event?.event.event_id
|
||||
? timeline
|
||||
.getTimelineSet()
|
||||
.relations.getChildEventsForEvent(
|
||||
event.event.event_id,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
|
||||
|
||||
function createRelations() {
|
||||
if (!timeline || !event?.event.event_id) return;
|
||||
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
|
||||
reactions?.off(
|
||||
"Relations.remove" as RelationsEvent.Remove,
|
||||
createRelations,
|
||||
);
|
||||
reactions = timeline
|
||||
.getTimelineSet()
|
||||
.relations.getChildEventsForEvent(
|
||||
event.event.event_id,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
);
|
||||
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
|
||||
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
|
||||
reactions?.on(
|
||||
"Relations.redaction" as RelationsEvent.Redaction,
|
||||
createRelations,
|
||||
);
|
||||
annotations.set(
|
||||
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
|
||||
);
|
||||
console.log("create");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createRelations();
|
||||
event?.on(
|
||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
||||
createRelations,
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
event?.off(
|
||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
||||
createRelations,
|
||||
);
|
||||
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
|
||||
reactions?.off(
|
||||
"Relations.remove" as RelationsEvent.Remove,
|
||||
createRelations,
|
||||
);
|
||||
reactions?.off(
|
||||
"Relations.redaction" as RelationsEvent.Redaction,
|
||||
createRelations,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="event"
|
||||
role="log"
|
||||
onmouseover={() => (mainHover = true)}
|
||||
onfocus={() => (mainHover = true)}
|
||||
onmouseout={() => (mainHover = false)}
|
||||
onblur={() => (mainHover = false)}
|
||||
>
|
||||
{#if event && hover}
|
||||
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
|
||||
{/if}
|
||||
|
||||
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
|
||||
{@const color = memberColor(sender, $theme)}
|
||||
{@const avatarMxc = sender.getMxcAvatarUrl()}
|
||||
{#if avatarMxc}
|
||||
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
|
||||
<img
|
||||
class="avatar"
|
||||
src={avatar}
|
||||
alt={sender.name}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="avatar avatar-placeholder icon"
|
||||
style:background={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
style:color={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
|
||||
)}
|
||||
>
|
||||
person
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="sender"
|
||||
style:color={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
>
|
||||
<strong>{sender.name}</strong>
|
||||
{#if replay || replayPlayer}
|
||||
<div class="dots">
|
||||
{#each new Array(3) as _, i}
|
||||
<div
|
||||
style:animation-delay={i * 0.2 + "s"}
|
||||
style:background={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
class="dot"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
{#if event}
|
||||
{#if event.getType() === "m.room.message"}
|
||||
<MatrixMessageEvent {event} bind:replay />
|
||||
{:else}
|
||||
<details>
|
||||
<summary>{event.getType()}</summary>
|
||||
<pre>{JSON.stringify(event.event, null, 2)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if replayPlayer}
|
||||
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if event && hover}
|
||||
<div
|
||||
role="toolbar"
|
||||
tabindex="0"
|
||||
class="toolbar"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onmouseover={() => (toolbarHover = true)}
|
||||
onfocus={() => (toolbarHover = true)}
|
||||
onmouseout={() => (toolbarHover = false)}
|
||||
onblur={() => (toolbarHover = false)}
|
||||
>
|
||||
<button class="icon">add_reaction</button>
|
||||
<button class="icon">reply</button>
|
||||
{#if event.event.content?.["m.replay"]}
|
||||
{#if replay}
|
||||
<button class="icon" onclick={() => (replay = undefined)}>stop</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => (replay = event.event.content?.["m.replay"])}
|
||||
>replay</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="icon">more_horiz</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $annotations && $annotations.length > 0}
|
||||
<div class="reactions">
|
||||
{#each $annotations as [reaction, events]}
|
||||
<button class="reaction"
|
||||
>{reaction} <span class="count">{events.size}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
details {
|
||||
opacity: 0.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
pre {
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
right: 0;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
z-index: 100;
|
||||
|
||||
button {
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
.sender,
|
||||
.avatar {
|
||||
margin-block: 2px 4px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
translate: 0 2px;
|
||||
}
|
||||
|
||||
div.avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sender {
|
||||
display: flex;
|
||||
grid-area: sender;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reactions {
|
||||
grid-area: reactions;
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
font-size: 12px;
|
||||
|
||||
> .count {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
position: relative;
|
||||
padding-inline: 0.5em;
|
||||
margin-inline: 0.5em;
|
||||
padding-block: 0.25em;
|
||||
border-radius: 4px;
|
||||
|
||||
grid-template-areas:
|
||||
"avatar sender date"
|
||||
"avatar content content"
|
||||
"none reactions reactions";
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.reactions,
|
||||
.content,
|
||||
.sender {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
opacity: 0.25;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
56
src/lib/chat/events/MatrixMessageEvent.svelte
Normal file
56
src/lib/chat/events/MatrixMessageEvent.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { fade } from "svelte/transition";
|
||||
import { matrixClient } from "../chat";
|
||||
|
||||
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if event.event.content?.msgtype === "m.image"}
|
||||
<img
|
||||
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
|
||||
alt={event.event.content["body"]}
|
||||
/>
|
||||
{:else}
|
||||
<span class="content" style:opacity={replay && 0}
|
||||
>{event.event.content?.["body"]}</span
|
||||
>
|
||||
{/if}
|
||||
{#if replay}
|
||||
<div class="replay" out:fade>
|
||||
<CharRecorder
|
||||
{replay}
|
||||
cursor={true}
|
||||
keys={true}
|
||||
ondone={() => (replay = undefined)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
max-height: 16em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.content {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.replay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
71
src/lib/chat/matrix-rx/client.ts
Normal file
71
src/lib/chat/matrix-rx/client.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
distinctUntilChanged,
|
||||
merge,
|
||||
} from "rxjs";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
|
||||
function roomListDistinct(prev: Room[], curr: Room[]) {
|
||||
if (prev.length !== curr.length) return false;
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class MatrixRx {
|
||||
topLevelRooms$: Observable<Room[]>;
|
||||
|
||||
topLevelSpaces$: Observable<Room[]>;
|
||||
|
||||
topLevelChats$: Observable<Room[]>;
|
||||
|
||||
constructor(private client: MatrixClient) {
|
||||
this.topLevelRooms$ = merge(
|
||||
of([]),
|
||||
fromMatrixClientEvent(client, "Room"),
|
||||
fromMatrixClientEvent(client, "deleteRoom"),
|
||||
fromMatrixClientEvent(client, "Room.myMembership"),
|
||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
||||
filter(
|
||||
([_room, prev, curr]) =>
|
||||
prev.getStateEvents("m.space.parent").length !==
|
||||
curr.getStateEvents("m.space.parent").length,
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
map(() =>
|
||||
this.client.getVisibleRooms().filter(
|
||||
(room) =>
|
||||
room.getMyMembership() !== "leave" &&
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState("f" as Direction.Forward)
|
||||
?.getStateEvents("m.space.parent").length === 0,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
|
||||
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
|
||||
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
|
||||
this.topLevelChats$ = this.topLevelRooms$.pipe(
|
||||
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SpaceRx {
|
||||
constructor(
|
||||
private client: MatrixClient,
|
||||
private space: Room,
|
||||
) {}
|
||||
}
|
||||
11
src/lib/chat/matrix-rx/events.ts
Normal file
11
src/lib/chat/matrix-rx/events.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
|
||||
import { fromEvent, type Observable } from "rxjs";
|
||||
|
||||
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
|
||||
client: MatrixClient,
|
||||
eventName: `${T}`, // hack so we can use strings instead of enums
|
||||
): Observable<Parameters<ClientEventHandlerMap[T]>> {
|
||||
return fromEvent(client, eventName) as Observable<
|
||||
Parameters<ClientEventHandlerMap[T]>
|
||||
>;
|
||||
}
|
||||
85
src/lib/chat/matrix-rx/rooms.ts
Normal file
85
src/lib/chat/matrix-rx/rooms.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
Direction,
|
||||
RoomState,
|
||||
RoomStateEventHandlerMap,
|
||||
EventType,
|
||||
} from "matrix-js-sdk";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
import {
|
||||
map,
|
||||
filter,
|
||||
merge,
|
||||
startWith,
|
||||
Observable,
|
||||
of,
|
||||
fromEvent,
|
||||
concat,
|
||||
defer,
|
||||
} from "rxjs";
|
||||
|
||||
export function matrixRoom$(
|
||||
client: MatrixClient,
|
||||
roomId: string | undefined,
|
||||
): Observable<Room | undefined> {
|
||||
return merge([
|
||||
fromMatrixClientEvent(client, "Room").pipe(
|
||||
filter(([room]) => room.roomId === roomId),
|
||||
),
|
||||
fromMatrixClientEvent(client, "deleteRoom").pipe(
|
||||
filter(([id]) => id === roomId),
|
||||
),
|
||||
]).pipe(
|
||||
startWith([]),
|
||||
map(() => client.getRoom(roomId) ?? undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function roomTimeline$(
|
||||
client: MatrixClient,
|
||||
room: Room | undefined,
|
||||
): Observable<MatrixEvent[] | undefined> {
|
||||
if (!room) return of(undefined);
|
||||
const eventTimeline = room.getLiveTimeline();
|
||||
|
||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
||||
filter(
|
||||
([, eventRoom]) =>
|
||||
eventRoom !== undefined && eventRoom.roomId === room.roomId,
|
||||
),
|
||||
startWith([]),
|
||||
map(() => eventTimeline.getEvents()),
|
||||
);
|
||||
}
|
||||
|
||||
export function roomCurrentStateEvents$(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
eventType: EventType | string,
|
||||
): Observable<MatrixEvent[]> {
|
||||
return concat(
|
||||
defer(() =>
|
||||
of(
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState("f" as Direction.Forward)
|
||||
?.getStateEvents(eventType) ?? [],
|
||||
),
|
||||
),
|
||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
||||
filter(([room]) => room.roomId === room.roomId),
|
||||
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
|
||||
state: RoomState,
|
||||
eventName: `${T}`,
|
||||
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
|
||||
return fromEvent(state, eventName) as Observable<
|
||||
Parameters<RoomStateEventHandlerMap[T]>
|
||||
>;
|
||||
}
|
||||
19
src/lib/chat/matrix-rx/timeline.ts
Normal file
19
src/lib/chat/matrix-rx/timeline.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { filter, map, of, startWith, type Observable } from "rxjs";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
|
||||
export function roomTimeline(
|
||||
client: MatrixClient,
|
||||
roomId: string | undefined,
|
||||
): Observable<MatrixEvent[]> {
|
||||
if (!roomId) return of([]);
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) return of([]);
|
||||
const eventTimeline = room.getLiveTimeline();
|
||||
|
||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
||||
filter(([, room]) => room?.roomId === roomId),
|
||||
startWith([]),
|
||||
map(() => eventTimeline.getEvents()),
|
||||
);
|
||||
}
|
||||
@@ -1,50 +1,71 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
import {action as title} from "$lib/title"
|
||||
import {osLayout} from "$lib/os-layout"
|
||||
import LL from "../../i18n/i18n-svelte"
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { action as title } from "$lib/title";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
|
||||
export let action: number | KeyInfo
|
||||
export let display: "inline-keys" | "keys" = "inline-keys"
|
||||
let {
|
||||
action,
|
||||
display,
|
||||
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
|
||||
|
||||
$: info = typeof action === "number" ? KEYMAP_CODES[action] ?? {code: action} : action
|
||||
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode)
|
||||
let info = $derived(
|
||||
typeof action === "number"
|
||||
? (KEYMAP_CODES.get(action) ?? { code: action })
|
||||
: action,
|
||||
);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
|
||||
$: tooltip =
|
||||
(info.title ?? info.id ?? `0x${info.code.toString(16)}`) +
|
||||
(info.variant === "left" ? " (left)" : info.variant === "right" ? " (right)" : "")
|
||||
let tooltip = $derived(
|
||||
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
||||
(info.title ?? "") +
|
||||
(info.variant === "left"
|
||||
? " (left)"
|
||||
: info.variant === "right"
|
||||
? " (right)"
|
||||
: ""),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if dynamicMapping}
|
||||
<span
|
||||
use:title={{title: $LL.actionSearch.LIVE_LAYOUT_INFO()}}
|
||||
class="dynamic"
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:inline={display === "inline-keys"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if display === "keys"}
|
||||
{#if display === "keys"}
|
||||
<kbd
|
||||
class:icon={!!info.icon}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
use:title={{title: tooltip}}
|
||||
use:title={{ title: tooltip }}
|
||||
>
|
||||
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}
|
||||
</kbd>
|
||||
{:else if display === "inline-keys"}
|
||||
{#if !info.icon && info.id?.length === 1}
|
||||
<span class:left={info.variant === "left"} class:right={info.variant === "right"}>{info.id}</span>
|
||||
{#if !info.icon && dynamicMapping?.length === 1}
|
||||
<span
|
||||
use:title={{ title: tooltip }}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if !info.icon && info.id?.length === 1}
|
||||
<span
|
||||
use:title={{ title: tooltip }}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{info.id}</span
|
||||
>
|
||||
{:else}
|
||||
<kbd
|
||||
class="inline-kbd"
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:icon={!!info.icon}
|
||||
use:title={{title: tooltip}}
|
||||
use:title={{ title: tooltip }}
|
||||
>
|
||||
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}</kbd
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}</kbd
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
import LL from "../../i18n/i18n-svelte"
|
||||
import Action from "$lib/components/Action.svelte"
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
export let id: number | KeyInfo
|
||||
let {
|
||||
id,
|
||||
onclick,
|
||||
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
|
||||
$props();
|
||||
|
||||
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
|
||||
let key = $derived(
|
||||
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
||||
| number
|
||||
| KeyInfo,
|
||||
);
|
||||
</script>
|
||||
|
||||
<button on:click>
|
||||
<button {onclick}>
|
||||
{#if typeof key === "object"}
|
||||
<div class="title">
|
||||
<b>
|
||||
@@ -23,10 +32,10 @@
|
||||
{#if key.description}
|
||||
<i>{key.description}</i>
|
||||
{/if}
|
||||
{#if key.category.name === "ASCII Macros"}
|
||||
{#if key.category?.name === "ASCII Macros"}
|
||||
<span class="warning">{@html $LL.actionSearch.SHIFT_WARNING()}</span>
|
||||
{/if}
|
||||
{#if key.category.name === "CP-1252"}
|
||||
{#if key.category?.name === "CP-1252"}
|
||||
<span class="warning">{@html $LL.actionSearch.ALT_CODE_WARNING()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -48,16 +57,28 @@
|
||||
padding: 8px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-visible {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
outline: none;
|
||||
@media not (forced-colors: active) {
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
|
||||
&:focus-visible {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid ButtonBorder;
|
||||
margin-block: 4px;
|
||||
|
||||
&:hover {
|
||||
color: ActiveText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Action from "$lib/components/Action.svelte"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
|
||||
export let actions: Array<number | KeyInfo>
|
||||
export let display: "keys" | "inline-keys" = "inline-keys"
|
||||
let {
|
||||
actions,
|
||||
display = "inline-keys",
|
||||
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script>
|
||||
import {useRegisterSW} from "virtual:pwa-register/svelte"
|
||||
// @ts-expect-error no types here
|
||||
import { useRegisterSW } from "virtual:pwa-register/svelte";
|
||||
|
||||
const {needRefresh, updateServiceWorker, offlineReady} = useRegisterSW()
|
||||
const { needRefresh, updateServiceWorker, offlineReady } = useRegisterSW();
|
||||
</script>
|
||||
|
||||
{#if $needRefresh}
|
||||
<button title="Update ready" on:click={() => updateServiceWorker(true)}
|
||||
<button title="Update ready" onclick={() => updateServiceWorker(true)}
|
||||
>Update <span class="icon">update</span></button
|
||||
>
|
||||
{:else if $offlineReady}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
<script lang="ts">
|
||||
import {serialLog, serialPort} from "$lib/serial/connection"
|
||||
import {slide} from "svelte/transition"
|
||||
import { serialLog, serialPort } from "$lib/serial/connection";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
function submit(event: Event) {
|
||||
event.preventDefault()
|
||||
$serialPort.send(value.trim())
|
||||
value = ""
|
||||
io.scrollTo({top: io.scrollHeight})
|
||||
event.preventDefault();
|
||||
$serialPort?.send(0, value.trim());
|
||||
value = "";
|
||||
io.scrollTo({ top: io.scrollHeight });
|
||||
}
|
||||
|
||||
let value: string
|
||||
let io: HTMLDivElement
|
||||
let value: string = $state("");
|
||||
let io: HTMLDivElement;
|
||||
</script>
|
||||
|
||||
<form on:submit={submit}>
|
||||
<form onsubmit={submit}>
|
||||
<div bind:this={io} class="io">
|
||||
{#each $serialLog as { type, value }}
|
||||
{#if type === "input"}
|
||||
@@ -24,10 +24,10 @@
|
||||
<p transition:slide>{value}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="anchor" />
|
||||
<div class="anchor"></div>
|
||||
</div>
|
||||
<fieldset>
|
||||
<input on:submit={submit} bind:value />
|
||||
<input onsubmit={submit} bind:value />
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
export let title: string | undefined
|
||||
export let shortcut: string | undefined
|
||||
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
@@ -18,5 +17,11 @@
|
||||
<style lang="scss">
|
||||
p {
|
||||
margin-block: 0;
|
||||
|
||||
:global(kbd.icon) {
|
||||
display: inline-flex;
|
||||
font-size: inherit;
|
||||
translate: 0 0.2em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,120 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CATEGORIES, KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
import Index from "flexsearch"
|
||||
import {createEventDispatcher} from "svelte"
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte"
|
||||
import LL from "../../../i18n/i18n-svelte"
|
||||
import {action} from "$lib/title"
|
||||
import {
|
||||
KEYMAP_CATEGORIES,
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import { onMount } from "svelte";
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { action } from "$lib/title";
|
||||
|
||||
export let currentAction: number | undefined = undefined
|
||||
export let nextAction: number | undefined = undefined
|
||||
let {
|
||||
currentAction = undefined,
|
||||
nextAction = undefined,
|
||||
onselect,
|
||||
onclose,
|
||||
}: {
|
||||
currentAction?: number;
|
||||
nextAction?: number;
|
||||
onselect: (id: number) => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
const index = new Index({tokenize: "full"})
|
||||
for (const action of Object.values(KEYMAP_CODES)) {
|
||||
index?.add(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
)
|
||||
onMount(() => {
|
||||
searchBox.focus();
|
||||
});
|
||||
|
||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||
createIndex();
|
||||
|
||||
async function createIndex() {
|
||||
for (const [, action] of KEYMAP_CODES) {
|
||||
await index?.addAsync(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
|
||||
Object.values(KEYMAP_CODES)
|
||||
.filter(it => !!it.id)
|
||||
.map(it => [it.id, it] as const),
|
||||
)
|
||||
|
||||
function search() {
|
||||
results = index!.search(searchBox.value)
|
||||
exact = exactIndex[searchBox.value]?.code
|
||||
code = Number(searchBox.value)
|
||||
async function search() {
|
||||
results = (await index!.searchAsync(searchBox.value)) as number[];
|
||||
exact = KEYMAP_IDS.get(searchBox.value)?.code;
|
||||
code = Number(searchBox.value);
|
||||
}
|
||||
|
||||
function select(id?: number) {
|
||||
if (id !== undefined) {
|
||||
dispatch("select", id)
|
||||
onselect(id);
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardNavigation(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.key === "Enter") {
|
||||
dispatch("select", exact)
|
||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||
onselect(exact);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
|
||||
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||
resultList.querySelector("li:not(.exact)");
|
||||
if (element instanceof HTMLLIElement) {
|
||||
element.querySelector("button")?.focus()
|
||||
element.querySelector("button")?.focus();
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.previousSibling ??
|
||||
resultList.querySelector("li:not(.exact)")
|
||||
resultList.querySelector("li:not(.exact)");
|
||||
if (element instanceof HTMLLIElement) {
|
||||
element.querySelector("button")?.focus()
|
||||
element.querySelector("button")?.focus();
|
||||
}
|
||||
} else {
|
||||
searchBox.focus()
|
||||
return
|
||||
searchBox.focus();
|
||||
return;
|
||||
}
|
||||
event.preventDefault()
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let results: number[] = Object.keys(KEYMAP_CODES).map(Number)
|
||||
let exact: number | undefined = undefined
|
||||
let code: number = Number.NaN
|
||||
let results: number[] = $state([]);
|
||||
let exact: number | undefined = $state(undefined);
|
||||
let code: number = $state(Number.NaN);
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let searchBox: HTMLInputElement
|
||||
let resultList: HTMLUListElement
|
||||
let filter: Set<number>
|
||||
let searchBox: HTMLInputElement;
|
||||
let resultList: HTMLUListElement;
|
||||
let filter = $state(new Set<number>());
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={keyboardNavigation} />
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<dialog open on:click|self={() => dispatch("close")}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<dialog
|
||||
open
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<div class="content">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
bind:this={searchBox}
|
||||
on:input={search}
|
||||
on:keypress={event => {
|
||||
oninput={search}
|
||||
onkeypress={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
select(exact)
|
||||
select(exact);
|
||||
}
|
||||
}}
|
||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||
/>
|
||||
<button on:click={() => select(0)} use:action={{shortcut: "shift+esc"}}
|
||||
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
||||
>{$LL.actionSearch.DELETE()}</button
|
||||
>
|
||||
<button
|
||||
use:action={{title: $LL.modal.CLOSE(), shortcut: "esc"}}
|
||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
||||
class="icon"
|
||||
on:click={() => dispatch("close")}>close</button
|
||||
onclick={onclose}>close</button
|
||||
>
|
||||
</div>
|
||||
<fieldset class="filters">
|
||||
@@ -133,19 +154,25 @@
|
||||
{#if exact !== undefined}
|
||||
<li class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} on:click={() => select(exact)} />
|
||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||
</li>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<li><button on:click={() => select(code)}>USE CODE</button></li>
|
||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||
{:else}
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each filter ? results.filter(it => filter.has(it)) : results as id (id)}
|
||||
<li><ActionListItem {id} on:click={() => select(id)} /></li>
|
||||
{/each}
|
||||
{#if filter !== undefined || results.length > 0}
|
||||
{@const resultValue =
|
||||
results.length === 0
|
||||
? Array.from(KEYMAP_CODES, ([it]) => it)
|
||||
: results}
|
||||
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
||||
<li><ActionListItem {id} onclick={() => select(id)} /></li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -186,6 +213,7 @@
|
||||
height: 100%;
|
||||
|
||||
background: rgba(0 0 0 / 60%);
|
||||
|
||||
border: none;
|
||||
}
|
||||
|
||||
@@ -207,6 +235,15 @@
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
opacity: 1;
|
||||
color: GrayText;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
@@ -231,6 +268,10 @@
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
border-radius: 16px;
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
@@ -298,5 +339,9 @@
|
||||
background: var(--md-sys-color-primary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
background: Mark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,177 +1,193 @@
|
||||
<script lang="ts">
|
||||
import {compileLayout} from "$lib/serialization/visual-layout"
|
||||
import type {VisualLayout, CompiledLayoutKey} from "$lib/serialization/visual-layout"
|
||||
import {deviceLayout} from "$lib/serial/connection"
|
||||
import {dev} from "$app/environment"
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
|
||||
import {get} from "svelte/store"
|
||||
import type {Writable} from "svelte/store"
|
||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte"
|
||||
import {getContext} from "svelte"
|
||||
import type {VisualLayoutConfig} from "./visual-layout.js"
|
||||
import {changes, ChangeType, layout} from "$lib/undo-redo"
|
||||
import { compileLayout } from "$lib/serialization/visual-layout";
|
||||
import type {
|
||||
VisualLayout,
|
||||
CompiledLayoutKey,
|
||||
} from "$lib/serialization/visual-layout";
|
||||
import { deviceLayout } from "$lib/serial/connection";
|
||||
import { dev } from "$app/environment";
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||
import { getContext, mount, unmount } from "svelte";
|
||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||
import { fly } from "svelte/transition";
|
||||
import { expoOut } from "svelte/easing";
|
||||
|
||||
const {scale, margin, strokeWidth, fontSize, iconFontSize} =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config")
|
||||
const activeLayer = getContext<Writable<number>>("active-layer")
|
||||
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
if (dev) {
|
||||
// you have absolutely no idea what a difference this makes for performance
|
||||
console.assert(scale % 1 === 0, "Scale must be an integer")
|
||||
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2")
|
||||
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer")
|
||||
console.assert(margin % 1 === 0, "Margin must be an integer")
|
||||
console.assert(fontSize % 1 === 0, "Font size must be an integer")
|
||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer")
|
||||
console.assert(scale % 1 === 0, "Scale must be an integer");
|
||||
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2");
|
||||
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer");
|
||||
console.assert(margin % 1 === 0, "Margin must be an integer");
|
||||
console.assert(fontSize % 1 === 0, "Font size must be an integer");
|
||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
||||
}
|
||||
|
||||
export let visualLayout: VisualLayout
|
||||
$: layoutInfo = compileLayout(visualLayout)
|
||||
let { visualLayout }: { visualLayout: VisualLayout } = $props();
|
||||
let layoutInfo = $state(compileLayout(visualLayout));
|
||||
|
||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]
|
||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||
}
|
||||
|
||||
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
|
||||
const x1 = a.pos[0] + margin
|
||||
const y1 = a.pos[1] + margin
|
||||
const x1b = x1 + a.size[0] - margin
|
||||
const y1b = y1 + a.size[1] - margin
|
||||
const x2 = b.pos[0] + margin
|
||||
const y2 = b.pos[1] + margin
|
||||
const x2b = x2 + b.size[0] - margin
|
||||
const y2b = y2 + b.size[1] - margin
|
||||
const x1 = a.pos[0] + margin;
|
||||
const y1 = a.pos[1] + margin;
|
||||
const x1b = x1 + a.size[0] - margin;
|
||||
const y1b = y1 + a.size[1] - margin;
|
||||
const x2 = b.pos[0] + margin;
|
||||
const y2 = b.pos[1] + margin;
|
||||
const x2b = x2 + b.size[0] - margin;
|
||||
const y2b = y2 + b.size[1] - margin;
|
||||
|
||||
const left = x2b < x1
|
||||
const right = x1b < x2
|
||||
const bottom = y2b < y1
|
||||
const top = y1b < y2
|
||||
const left = x2b < x1;
|
||||
const right = x1b < x2;
|
||||
const bottom = y2b < y1;
|
||||
const top = y1b < y2;
|
||||
|
||||
return top && left
|
||||
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
|
||||
: left && bottom
|
||||
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
|
||||
: bottom && right
|
||||
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
|
||||
: right && top
|
||||
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
|
||||
: left
|
||||
? x1 - x2b
|
||||
: right
|
||||
? x2 - x1b
|
||||
: bottom
|
||||
? y1 - y2b
|
||||
: top
|
||||
? y2 - y1b
|
||||
: 0
|
||||
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
|
||||
: bottom && right
|
||||
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
|
||||
: right && top
|
||||
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
|
||||
: left
|
||||
? x1 - x2b
|
||||
: right
|
||||
? x2 - x1b
|
||||
: bottom
|
||||
? y1 - y2b
|
||||
: top
|
||||
? y2 - y1b
|
||||
: 0;
|
||||
}
|
||||
|
||||
function navigate(event: KeyboardEvent) {
|
||||
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return
|
||||
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey)
|
||||
return;
|
||||
|
||||
let wantedAngle: number
|
||||
const angleThreshold = Math.PI
|
||||
let wantedAngle: number;
|
||||
const angleThreshold = Math.PI;
|
||||
|
||||
if (event.key === "ArrowUp") wantedAngle = Math.PI
|
||||
else if (event.key === "ArrowDown") wantedAngle = 0
|
||||
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2
|
||||
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2
|
||||
else return
|
||||
if (event.key === "ArrowUp") wantedAngle = Math.PI;
|
||||
else if (event.key === "ArrowDown") wantedAngle = 0;
|
||||
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2;
|
||||
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2;
|
||||
else return;
|
||||
|
||||
event.preventDefault()
|
||||
if (!focusKey) (groupParent.firstChild as SVGGElement).focus()
|
||||
const [focusX, focusY] = getCenter(focusKey)
|
||||
event.preventDefault();
|
||||
if (!focusKey) (groupParent.firstChild as SVGGElement).focus();
|
||||
const [focusX, focusY] = getCenter(focusKey);
|
||||
|
||||
let bestDistance = Infinity
|
||||
let bestCandidate = 0
|
||||
let isOptimalAngle = false
|
||||
let bestDistance = Infinity;
|
||||
let bestCandidate = 0;
|
||||
let isOptimalAngle = false;
|
||||
|
||||
for (const [i, key] of layoutInfo.keys.entries()) {
|
||||
if (key === focusKey) continue
|
||||
const [keyX, keyY] = getCenter(key)
|
||||
const deltaX = keyX - focusX
|
||||
const deltaY = keyY - focusY
|
||||
const angle = Math.atan2(deltaX, deltaY)
|
||||
const distance = getDistance(key, focusKey)
|
||||
if (key === focusKey) continue;
|
||||
const [keyX, keyY] = getCenter(key);
|
||||
const deltaX = keyX - focusX;
|
||||
const deltaY = keyY - focusY;
|
||||
const angle = Math.atan2(deltaX, deltaY);
|
||||
const distance = getDistance(key, focusKey);
|
||||
|
||||
const angleDelta = Math.abs(wantedAngle - angle)
|
||||
const angleDelta = Math.abs(wantedAngle - angle);
|
||||
|
||||
if (isOptimalAngle ? angleDelta > Number.EPSILON : angleDelta >= angleThreshold) continue
|
||||
if (distance > bestDistance) continue
|
||||
if (
|
||||
isOptimalAngle
|
||||
? angleDelta > Number.EPSILON
|
||||
: angleDelta >= angleThreshold
|
||||
)
|
||||
continue;
|
||||
if (distance > bestDistance) continue;
|
||||
|
||||
bestDistance = distance
|
||||
bestCandidate = i
|
||||
isOptimalAngle = angleDelta <= Number.EPSILON
|
||||
bestDistance = distance;
|
||||
bestCandidate = i;
|
||||
isOptimalAngle = angleDelta <= Number.EPSILON;
|
||||
}
|
||||
|
||||
const node = groupParent.children.item(bestCandidate)
|
||||
const node = groupParent.children.item(bestCandidate);
|
||||
if (node instanceof SVGGElement) {
|
||||
node.focus()
|
||||
node.focus();
|
||||
}
|
||||
}
|
||||
|
||||
function edit(index: number) {
|
||||
const keyInfo = layoutInfo.keys[index]
|
||||
const clickedGroup = groupParent.children.item(index) as SVGGElement
|
||||
const nextAction = get(layout)[get(activeLayer)][keyInfo.id]
|
||||
const currentAction = get(deviceLayout)[get(activeLayer)][keyInfo.id]
|
||||
const component = new ActionSelector({
|
||||
const keyInfo = layoutInfo.keys[index];
|
||||
if (!keyInfo) return;
|
||||
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
||||
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
||||
const component = mount(ActionSelector, {
|
||||
target: document.body,
|
||||
props: {
|
||||
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 backdrop = document.querySelector("dialog") as HTMLDialogElement
|
||||
const dialogRect = dialog.getBoundingClientRect()
|
||||
const groupRect = clickedGroup.getBoundingClientRect()
|
||||
});
|
||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
|
||||
const dialogRect = dialog.getBoundingClientRect();
|
||||
const groupRect = clickedGroup.getBoundingClientRect();
|
||||
|
||||
const scale = 0.5
|
||||
const scale = 0.5;
|
||||
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
|
||||
1 - scale * (1 - groupRect.height / dialogRect.height)
|
||||
}`
|
||||
}`;
|
||||
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
|
||||
scale * (groupRect.y - dialogRect.y)
|
||||
}px`
|
||||
}px`;
|
||||
|
||||
const duration = 150
|
||||
const options = {duration, easing: "ease"}
|
||||
const duration = 150;
|
||||
const options = { duration, easing: "ease" };
|
||||
const dialogAnimation = dialog.animate(
|
||||
[
|
||||
{scale: dialogScale, translate: dialogTranslate},
|
||||
{translate: "0 0", scale: "1"},
|
||||
{ scale: dialogScale, translate: dialogTranslate },
|
||||
{ translate: "0 0", scale: "1" },
|
||||
],
|
||||
options,
|
||||
)
|
||||
const backdropAnimation = backdrop.animate([{opacity: 0}, {opacity: 1}], options)
|
||||
);
|
||||
const backdropAnimation = backdrop.animate(
|
||||
[{ opacity: 0 }, { opacity: 1 }],
|
||||
options,
|
||||
);
|
||||
|
||||
async function closed() {
|
||||
dialogAnimation.reverse()
|
||||
backdropAnimation.reverse()
|
||||
dialogAnimation.reverse();
|
||||
backdropAnimation.reverse();
|
||||
|
||||
await dialogAnimation.finished
|
||||
await dialogAnimation.finished;
|
||||
|
||||
component.$destroy()
|
||||
unmount(component);
|
||||
}
|
||||
|
||||
component.$on("close", closed)
|
||||
component.$on("select", ({detail}) => {
|
||||
changes.update(changes => {
|
||||
changes.push({
|
||||
type: ChangeType.Layout,
|
||||
id: keyInfo.id,
|
||||
layer: get(activeLayer),
|
||||
action: detail,
|
||||
})
|
||||
return changes
|
||||
})
|
||||
closed()
|
||||
})
|
||||
}
|
||||
|
||||
let focusKey: CompiledLayoutKey
|
||||
let groupParent: SVGElement
|
||||
let focusKey: CompiledLayoutKey;
|
||||
let groupParent: SVGElement;
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={navigate} />
|
||||
@@ -180,16 +196,17 @@
|
||||
class="print"
|
||||
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
|
||||
bind:this={groupParent}
|
||||
transition:fly={{ y: 48, easing: expoOut }}
|
||||
>
|
||||
{#each layoutInfo.keys as key, i}
|
||||
<KeyboardKey
|
||||
{i}
|
||||
{key}
|
||||
on:focusin={() => (focusKey = key)}
|
||||
on:click={() => edit(i)}
|
||||
on:keypress={({key}) => {
|
||||
onfocusin={() => (focusKey = key)}
|
||||
onclick={() => edit(i)}
|
||||
onkeypress={({ key }) => {
|
||||
if (key === "Enter") {
|
||||
edit(i)
|
||||
edit(i);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,30 +1,41 @@
|
||||
<script lang="ts">
|
||||
import {getContext} from "svelte"
|
||||
import type {Writable} from "svelte/store"
|
||||
import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout"
|
||||
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
|
||||
import {layout} from "$lib/undo-redo.js"
|
||||
import {osLayout} from "$lib/os-layout.js"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import {action} from "$lib/title"
|
||||
import { getContext } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
||||
import { layout } from "$lib/undo-redo.js";
|
||||
import { osLayout } from "$lib/os-layout.js";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { action } from "$lib/title";
|
||||
|
||||
const {fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize} =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config")
|
||||
const activeLayer = getContext<Writable<number>>("active-layer")
|
||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
export let key: CompiledLayoutKey
|
||||
export let fontSizeMultiplier = 1
|
||||
|
||||
export let middle: [number, number]
|
||||
export let pos: [number, number]
|
||||
export let rotate: number
|
||||
|
||||
export let positions: [[number, number], [number, number], [number, number]]
|
||||
let {
|
||||
key,
|
||||
fontSizeMultiplier = 1,
|
||||
middle,
|
||||
pos,
|
||||
rotate,
|
||||
positions,
|
||||
}: {
|
||||
key: CompiledLayoutKey;
|
||||
fontSizeMultiplier?: number;
|
||||
middle: [number, number];
|
||||
pos: [number, number];
|
||||
rotate: number;
|
||||
positions: [[number, number], [number, number], [number, number]];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#each positions as position, layer}
|
||||
{@const {action: actionId, isApplied} = $layout[layer][key.id] ?? {action: 0, isApplied: true}}
|
||||
{@const {code, icon, id, display, title, keyCode, variant} = KEYMAP_CODES[actionId] ?? {code: actionId}}
|
||||
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
|
||||
action: 0,
|
||||
isApplied: true,
|
||||
}}
|
||||
{@const { code, icon, id, display, title, keyCode, variant } =
|
||||
KEYMAP_CODES.get(actionId) ?? { code: actionId }}
|
||||
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
|
||||
{@const tooltip =
|
||||
(title ?? id ?? `0x${code.toString(16)}`) +
|
||||
@@ -44,13 +55,13 @@
|
||||
y={pos[1] + middle[1]}
|
||||
font-size={fontSizeMultiplier * (hasIcon ? iconFontSize : fontSize)}
|
||||
font-family={hasIcon ? "Material Symbols Rounded" : undefined}
|
||||
opacity={isActive ? 1 : inactiveOpacity}
|
||||
style:scale={isActive ? 1 : inactiveScale}
|
||||
opacity={isActive ? 1 : `var(--inactive-opacity, ${inactiveOpacity})`}
|
||||
style:scale={isActive ? 1 : `var(--inactive-scale, ${inactiveScale})`}
|
||||
style:translate={isActive
|
||||
? "0 0 0"
|
||||
: `${direction[0].toPrecision(2)}px ${direction[1].toPrecision(2)}px 0`}
|
||||
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
|
||||
style:rotate="{rotate}deg"
|
||||
use:action={{title: tooltip}}
|
||||
use:action={{ title: tooltip }}
|
||||
>
|
||||
{#if code !== 0}
|
||||
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
||||
@@ -75,6 +86,11 @@
|
||||
opacity #{$transition} ease,
|
||||
translate #{$transition} ease,
|
||||
scale #{$transition} ease;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
--inactive-opacity: 0.8;
|
||||
--inactive-scale: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
text:focus-within {
|
||||
|
||||
@@ -1,20 +1,46 @@
|
||||
<script lang="ts">
|
||||
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
|
||||
import {getContext} from "svelte"
|
||||
import type {VisualLayoutConfig} from "./visual-layout.js"
|
||||
import KeyText from "$lib/components/layout/KeyText.svelte"
|
||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
||||
import { getContext } from "svelte";
|
||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||
import KeyText from "$lib/components/layout/KeyText.svelte";
|
||||
import type {
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
} from "svelte/elements";
|
||||
|
||||
const {scale, margin, strokeWidth} = getContext<VisualLayoutConfig>("visual-layout-config")
|
||||
export let i: number
|
||||
export let key: CompiledLayoutKey
|
||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||
"visual-layout-config",
|
||||
);
|
||||
|
||||
$: posX = key.pos[0] * scale
|
||||
$: posY = key.pos[1] * scale
|
||||
$: sizeX = key.size[0] * scale
|
||||
$: sizeY = key.size[1] * scale
|
||||
let {
|
||||
i,
|
||||
key,
|
||||
onclick,
|
||||
onkeypress,
|
||||
onfocusin,
|
||||
}: {
|
||||
i: number;
|
||||
key: CompiledLayoutKey;
|
||||
onclick: MouseEventHandler<SVGGElement>;
|
||||
onkeypress: KeyboardEventHandler<SVGGElement>;
|
||||
onfocusin: FocusEventHandler<SVGGElement>;
|
||||
} = $props();
|
||||
|
||||
let posX = $derived(key.pos[0] * scale);
|
||||
let posY = $derived(key.pos[1] * scale);
|
||||
let sizeX = $derived(key.size[0] * scale);
|
||||
let sizeY = $derived(key.size[1] * scale);
|
||||
</script>
|
||||
|
||||
<g class="key-group" on:click on:keypress on:focusin role="button" tabindex={i + 1}>
|
||||
<g
|
||||
class="key-group"
|
||||
{onclick}
|
||||
{onkeypress}
|
||||
{onfocusin}
|
||||
role="button"
|
||||
tabindex={i + 1}
|
||||
>
|
||||
{#if key.shape === "square"}
|
||||
<rect
|
||||
x={posX + margin}
|
||||
@@ -44,15 +70,23 @@
|
||||
{@const multiplier = 1.25}
|
||||
|
||||
{@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
|
||||
{@const rotX = Math.round((Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
|
||||
{@const rotY = Math.round((Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
|
||||
{@const rotX =
|
||||
Math.round(
|
||||
(Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
|
||||
) / 100}
|
||||
{@const rotY =
|
||||
Math.round(
|
||||
(Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
|
||||
) / 100}
|
||||
|
||||
{@const rc = r1 - (r1 - r2) / 2}
|
||||
{@const middleX = Math.cos(rotateRad) * rc}
|
||||
{@const middleY = Math.sin(rotateRad) * rc}
|
||||
<path
|
||||
style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
|
||||
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(p1 - p2)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
|
||||
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(
|
||||
p1 - p2
|
||||
)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
|
||||
/>
|
||||
<KeyText
|
||||
{key}
|
||||
|
||||
@@ -1,44 +1,68 @@
|
||||
<script lang="ts">
|
||||
import {serialPort} from "$lib/serial/connection"
|
||||
import {action} from "$lib/title"
|
||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"
|
||||
import {getContext} from "svelte"
|
||||
import type {Writable} from "svelte/store"
|
||||
import type {VisualLayout} from "$lib/serialization/visual-layout"
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { action } from "$lib/title";
|
||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
$: device = $serialPort?.device ?? "ONE"
|
||||
const activeLayer = getContext<Writable<number>>("active-layer")
|
||||
let device = $derived($serialPort?.device);
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
const layers = [
|
||||
["Numeric Layer", "123", 1],
|
||||
["Primary Layer", "abc", 0],
|
||||
["Function Layer", "function", 2],
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
const layouts = {
|
||||
ONE: () => import("$lib/assets/layouts/one.yml").then(it => it.default as VisualLayout),
|
||||
LITE: () => import("$lib/assets/layouts/lite.yml").then(it => it.default as VisualLayout),
|
||||
X: () => import("$lib/assets/layouts/generic/103-key.yml").then(it => it.default as VisualLayout),
|
||||
}
|
||||
ONE: () =>
|
||||
import("$lib/assets/layouts/one.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
TWO: () =>
|
||||
import("$lib/assets/layouts/one.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
LITE: () =>
|
||||
import("$lib/assets/layouts/lite.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
X: () =>
|
||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
M4G: () =>
|
||||
import("$lib/assets/layouts/m4g.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
M4GR: () =>
|
||||
import("$lib/assets/layouts/m4gr.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<fieldset>
|
||||
{#each layers as [title, icon, value]}
|
||||
<button
|
||||
class="icon"
|
||||
use:action={{title, shortcut: `alt+${value + 1}`}}
|
||||
on:click={() => ($activeLayer = value)}
|
||||
class:active={$activeLayer === value}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{#if device}
|
||||
{#await layouts[device]() then visualLayout}
|
||||
<fieldset transition:fade>
|
||||
{#each layers as [title, icon, value]}
|
||||
<button
|
||||
class="icon"
|
||||
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
||||
onclick={() => ($activeLayer = value)}
|
||||
class:active={$activeLayer === value}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
</fieldset>
|
||||
|
||||
{#await layouts[device]() then visualLayout}
|
||||
<GenericLayout {visualLayout} />
|
||||
{/await}
|
||||
<GenericLayout {visualLayout} />
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -50,7 +74,7 @@
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-bottom: 96px;
|
||||
max-height: 20cm;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
@@ -60,7 +84,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 0;
|
||||
padding: 8px;
|
||||
|
||||
border: none;
|
||||
}
|
||||
@@ -86,7 +110,6 @@
|
||||
font-size: 32px;
|
||||
|
||||
border-radius: 50%;
|
||||
outline: 8px solid var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
&:first-child,
|
||||
@@ -96,12 +119,14 @@
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
padding-inline: 4px 16px;
|
||||
margin-inline-end: -8px;
|
||||
padding-inline: 4px 24px;
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-inline: 16px 4px;
|
||||
margin-inline-start: -8px;
|
||||
padding-inline: 24px 4px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export interface VisualLayoutConfig {
|
||||
scale: number
|
||||
inactiveScale: number
|
||||
inactiveOpacity: number
|
||||
strokeWidth: number
|
||||
margin: number
|
||||
fontSize: number
|
||||
iconFontSize: number
|
||||
scale: number;
|
||||
inactiveScale: number;
|
||||
inactiveOpacity: number;
|
||||
strokeWidth: number;
|
||||
margin: number;
|
||||
fontSize: number;
|
||||
iconFontSize: number;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
<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";
|
||||
|
||||
export let title: string
|
||||
export let message: string | undefined
|
||||
export let abortTitle: string
|
||||
export let confirmTitle: string
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
abortTitle,
|
||||
confirmTitle,
|
||||
actions = [],
|
||||
onabort,
|
||||
onconfirm,
|
||||
}: {
|
||||
title: string;
|
||||
message?: string;
|
||||
abortTitle: string;
|
||||
confirmTitle: string;
|
||||
actions: number[];
|
||||
onabort: () => void;
|
||||
onconfirm: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
@@ -15,9 +26,10 @@
|
||||
{#if message}
|
||||
<p>{@html message}</p>
|
||||
{/if}
|
||||
<p><ActionString {actions} /></p>
|
||||
<div class="buttons">
|
||||
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
|
||||
<button class="primary" on:click={() => dispatch("confirm")}>{confirmTitle}</button>
|
||||
<button onclick={onabort}>{abortTitle}</button>
|
||||
<button class="primary" onclick={onconfirm}>{confirmTitle}</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
<script lang="ts">
|
||||
import {onMount} from "svelte"
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
onMount(() => {
|
||||
modal.showModal()
|
||||
})
|
||||
modal.showModal();
|
||||
});
|
||||
|
||||
let modal: HTMLDialogElement
|
||||
let modal: HTMLDialogElement;
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal}>
|
||||
<slot />
|
||||
{@render children()}
|
||||
</dialog>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,87 +1,139 @@
|
||||
<script lang="ts">
|
||||
import Dialog from "$lib/dialogs/Dialog.svelte"
|
||||
import type {Change, ChordChange, LayoutChange, SettingChange} from "$lib/undo-redo"
|
||||
import {ChangeType, chords} from "$lib/undo-redo"
|
||||
import ActionString from "$lib/components/ActionString.svelte"
|
||||
import LL from "../../i18n/i18n-svelte"
|
||||
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
|
||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||
import type {
|
||||
Change,
|
||||
ChordChange,
|
||||
LayoutChange,
|
||||
SettingChange,
|
||||
} from "$lib/undo-redo";
|
||||
import { ChangeType, chords } from "$lib/undo-redo";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
|
||||
export let changes: Change[] = [
|
||||
{type: ChangeType.Layout, layer: 0, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
||||
{type: ChangeType.Chord, id: [1], actions: [55], phrase: [55, 63, 37, 36]},
|
||||
{ type: ChangeType.Layout, layer: 0, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
||||
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
||||
id: [1],
|
||||
actions: [55],
|
||||
phrase: [55, 63, 37, 36],
|
||||
},
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
||||
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
||||
id: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
actions: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
phrase: [55, 63, 37, 36],
|
||||
},
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
actions: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
phrase: [],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
$: existingChords = new Set($chords.map(it => JSON.stringify(it.id)))
|
||||
$: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
|
||||
|
||||
$: layoutChanges = Array.from(
|
||||
{length: 3},
|
||||
(_, i) => changes.filter(it => it.type === ChangeType.Layout && it.layer === i) as LayoutChange[],
|
||||
)
|
||||
$: settingChanges = changes.filter(it => it.type === ChangeType.Setting) as SettingChange[]
|
||||
{ length: 3 },
|
||||
(_, i) =>
|
||||
changes.filter(
|
||||
(it) => it.type === ChangeType.Layout && it.layer === i,
|
||||
) as LayoutChange[],
|
||||
);
|
||||
$: settingChanges = changes.filter(
|
||||
(it) => it.type === ChangeType.Setting,
|
||||
) as SettingChange[];
|
||||
$: chordChanges = {
|
||||
added: changes.filter(
|
||||
it =>
|
||||
it.type === ChangeType.Chord && it.phrase.length > 0 && !existingChords.has(JSON.stringify(it.id)),
|
||||
(it) =>
|
||||
it.type === ChangeType.Chord &&
|
||||
it.phrase.length > 0 &&
|
||||
!existingChords.has(JSON.stringify(it.id)),
|
||||
) as ChordChange[],
|
||||
changed: changes.filter(
|
||||
it => it.type === ChangeType.Chord && it.phrase.length > 0 && existingChords.has(JSON.stringify(it.id)),
|
||||
(it) =>
|
||||
it.type === ChangeType.Chord &&
|
||||
it.phrase.length > 0 &&
|
||||
existingChords.has(JSON.stringify(it.id)),
|
||||
) as ChordChange[],
|
||||
deleted: changes.filter(it => it.type === ChangeType.Chord && it.phrase.length === 0) as ChordChange[],
|
||||
}
|
||||
$: totalChordChanges = Object.values(chordChanges).reduce((acc, curr) => acc + curr.length, 0)
|
||||
deleted: changes.filter(
|
||||
(it) => it.type === ChangeType.Chord && it.phrase.length === 0,
|
||||
) as ChordChange[],
|
||||
};
|
||||
$: totalChordChanges = Object.values(chordChanges).reduce(
|
||||
(acc, curr) => acc + curr.length,
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
<h1>{$LL.changes.TITLE()}</h1>
|
||||
<h2>
|
||||
<label><input type="checkbox" class="checkbox" />{$LL.changes.ALL_CHANGES()}</label>
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>{$LL.changes.ALL_CHANGES()}</label
|
||||
>
|
||||
</h2>
|
||||
<ul>
|
||||
{#if layoutChanges.some(it => it.length > 0)}
|
||||
{#if layoutChanges.some((it) => it.length > 0)}
|
||||
<li>
|
||||
<h3>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
{$LL.changes.layout.TITLE(layoutChanges.reduce((acc, curr) => acc + curr.length, 0))}
|
||||
{$LL.changes.layout.TITLE(
|
||||
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
|
||||
)}
|
||||
</label>
|
||||
</h3>
|
||||
<ul>
|
||||
{#each layoutChanges
|
||||
.map((it, i) => /** @type {const} */ ([it, i + 1]))
|
||||
.filter(([it]) => it.length > 0) as [changes, layer]}
|
||||
<li>
|
||||
<h4>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
{$LL.changes.layout.LAYER({changes: changes.length, layer})}
|
||||
</label>
|
||||
</h4>
|
||||
</li>
|
||||
{#each layoutChanges as changes, i}
|
||||
{@const layer = i + 1}
|
||||
{#if changes.length > 0}
|
||||
<li>
|
||||
<h4>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
{$LL.changes.layout.LAYER({
|
||||
changes: changes.length,
|
||||
layer,
|
||||
})}
|
||||
</label>
|
||||
</h4>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
@@ -90,9 +142,10 @@
|
||||
<li>
|
||||
<h3>
|
||||
<label
|
||||
><input type="checkbox" class="checkbox" />{$LL.changes.settings.TITLE(
|
||||
settingChanges.length,
|
||||
)}</label
|
||||
><input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
|
||||
>
|
||||
</h3>
|
||||
</li>
|
||||
@@ -101,7 +154,10 @@
|
||||
<li>
|
||||
<h3>
|
||||
<label
|
||||
><input type="checkbox" class="checkbox" />{$LL.changes.chords.TITLE(totalChordChanges)}</label
|
||||
><input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
|
||||
>
|
||||
</h3>
|
||||
<ul>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte"
|
||||
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
|
||||
|
||||
export async function askForConfirmation(
|
||||
title: string,
|
||||
message: string,
|
||||
confirmTitle: string,
|
||||
abortTitle: string,
|
||||
actions: number[],
|
||||
): Promise<boolean> {
|
||||
const dialog = new ConfirmDialog({
|
||||
target: document.body,
|
||||
@@ -13,19 +14,20 @@ export async function askForConfirmation(
|
||||
message,
|
||||
confirmTitle,
|
||||
abortTitle,
|
||||
actions,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
let resolvePromise: (value: boolean) => void
|
||||
const resultPromise = new Promise<boolean>(resolve => {
|
||||
resolvePromise = resolve
|
||||
})
|
||||
let resolvePromise: (value: boolean) => void;
|
||||
const resultPromise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
dialog.$on("abort", () => resolvePromise(false))
|
||||
dialog.$on("confirm", () => resolvePromise(true))
|
||||
dialog.$on("abort", () => resolvePromise(false));
|
||||
dialog.$on("confirm", () => resolvePromise(true));
|
||||
|
||||
const result = await resultPromise
|
||||
dialog.$destroy()
|
||||
const result = await resultPromise;
|
||||
dialog.$destroy();
|
||||
|
||||
return result
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -73,8 +73,9 @@
|
||||
font-stretch: 62.5% 100%;
|
||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
||||
format("woff2-variations");
|
||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020,
|
||||
U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
|
||||
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||
U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* noto-sans-mono-latin-wght-normal */
|
||||
@@ -86,7 +87,7 @@
|
||||
font-stretch: 62.5% 100%;
|
||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
||||
format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301,
|
||||
U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
||||
U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
101
src/lib/learn/chords.ts
Normal file
101
src/lib/learn/chords.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { type ChordInfo, chords } from "$lib/undo-redo";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export const words = derived(
|
||||
[chords, osLayout],
|
||||
([chords, layout]) =>
|
||||
new Map<string, ChordInfo>(
|
||||
chords
|
||||
.map((chord) => ({
|
||||
chord,
|
||||
output: chord.phrase.map((action) =>
|
||||
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
|
||||
),
|
||||
}))
|
||||
.filter(({ output }) => output.every((it) => !!it))
|
||||
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
|
||||
),
|
||||
);
|
||||
|
||||
interface Score {
|
||||
lastTyped: number;
|
||||
score: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const scores = persistentWritable<Record<string, Score>>("scores", {});
|
||||
|
||||
export const learnConfigDefault = {
|
||||
maxScore: 3,
|
||||
minScore: -3,
|
||||
scoreBlend: 0.5,
|
||||
weakRate: 0.8,
|
||||
weakBoost: 0.5,
|
||||
maxWeak: 3,
|
||||
newRate: 0.3,
|
||||
initialNewRate: 0.9,
|
||||
initialCount: 10,
|
||||
};
|
||||
export const learnConfigStored = persistentWritable<
|
||||
Partial<typeof learnConfigDefault>
|
||||
>("learn-config", {});
|
||||
export const learnConfig = derived(learnConfigStored, (config) => ({
|
||||
...learnConfigDefault,
|
||||
...config,
|
||||
}));
|
||||
|
||||
let lastWord: string | undefined;
|
||||
|
||||
function shuffle<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j]!, array[i]!];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function randomLog2<T>(array: T[], max = array.length): T | undefined {
|
||||
return array[
|
||||
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
|
||||
];
|
||||
}
|
||||
|
||||
export const nextWord = derived(
|
||||
[words, scores, learnConfig],
|
||||
([words, scores, config]) => {
|
||||
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
|
||||
|
||||
values.sort(([, a], [, b]) => a.score - b.score);
|
||||
const weakCount =
|
||||
(values.findIndex(([, { score }]) => score > 0) + 1 ||
|
||||
values.length + 1) - 1;
|
||||
const weak = randomLog2(values, weakCount);
|
||||
if (weak && Math.random() / weakCount < config.weakRate) {
|
||||
lastWord = weak[0];
|
||||
return weak[0];
|
||||
}
|
||||
|
||||
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
|
||||
const recent = randomLog2(values);
|
||||
const newRate =
|
||||
values.length < config.initialCount
|
||||
? config.initialNewRate
|
||||
: config.newRate;
|
||||
if (
|
||||
recent &&
|
||||
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
|
||||
Math.random() > newRate)
|
||||
) {
|
||||
lastWord = recent[0];
|
||||
return recent[0];
|
||||
}
|
||||
|
||||
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
|
||||
const word = newWord || recent?.[0] || weak?.[0];
|
||||
lastWord = word;
|
||||
return word;
|
||||
},
|
||||
);
|
||||
11
src/lib/learn/stats.ts
Normal file
11
src/lib/learn/stats.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
|
||||
interface ChordStats {
|
||||
level: number;
|
||||
lastUprank: number;
|
||||
}
|
||||
|
||||
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
||||
"chord-stats",
|
||||
{},
|
||||
);
|
||||
@@ -1,25 +1,27 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import { get, writable } from "svelte/store";
|
||||
|
||||
export const osLayout = writable<Map<string, string>>(new Map())
|
||||
export const osLayout = writable<Map<string, string>>(new Map());
|
||||
|
||||
async function updateLayout() {
|
||||
const layout: Map<string, string> = await (navigator as any).keyboard.getLayoutMap()
|
||||
const currentLayout = get(osLayout)
|
||||
const layout: Map<string, string> = await (
|
||||
navigator as any
|
||||
).keyboard.getLayoutMap();
|
||||
const currentLayout = get(osLayout);
|
||||
if (
|
||||
layout.size !== currentLayout.size ||
|
||||
[...layout.keys()].some(key => layout.get(key) !== currentLayout.get(key))
|
||||
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
|
||||
) {
|
||||
osLayout.set(layout)
|
||||
osLayout.set(layout);
|
||||
}
|
||||
}
|
||||
|
||||
export function runLayoutDetection(): () => void {
|
||||
if ("keyboard" in navigator) {
|
||||
updateLayout()
|
||||
const timer = setInterval(updateLayout, 5000)
|
||||
return () => clearInterval(timer)
|
||||
updateLayout();
|
||||
const timer = setInterval(updateLayout, 5000);
|
||||
return () => clearInterval(timer);
|
||||
} else {
|
||||
console.warn("Keyboard API not supported")
|
||||
return () => {}
|
||||
console.warn("Keyboard API not supported");
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import tippy from "tippy.js"
|
||||
import type {Action} from "svelte/action"
|
||||
import type {ComponentType, SvelteComponent} from "svelte"
|
||||
import tippy from "tippy.js";
|
||||
import type { Action } from "svelte/action";
|
||||
import { unmount, mount, type Component } from "svelte";
|
||||
|
||||
export const popup: Action<HTMLButtonElement, ComponentType> = (node, Component) => {
|
||||
let component: SvelteComponent | undefined
|
||||
let target: HTMLElement | undefined
|
||||
export const popup: Action<HTMLButtonElement, Component> = (
|
||||
node,
|
||||
Component,
|
||||
) => {
|
||||
let component: {} | undefined;
|
||||
let target: HTMLElement | undefined;
|
||||
const edit = tippy(node, {
|
||||
interactive: true,
|
||||
placement: "right",
|
||||
trigger: "click",
|
||||
onShow(instance) {
|
||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement
|
||||
target.classList.add("active")
|
||||
component ??= new Component({target})
|
||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||
target.classList.add("active");
|
||||
component ??= mount(Component, { target });
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy()
|
||||
target?.classList.remove("active")
|
||||
component = undefined
|
||||
if (component) {
|
||||
unmount(component);
|
||||
component = undefined;
|
||||
}
|
||||
target?.classList.remove("active");
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
edit.destroy()
|
||||
edit.destroy();
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,37 +1,48 @@
|
||||
import type { Action } from "svelte/action"
|
||||
import { persistentWritable } from "$lib/storage"
|
||||
import type { Action } from "svelte/action";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
|
||||
export interface UserPreferences {
|
||||
backup: boolean
|
||||
autoConnect: boolean
|
||||
backup: boolean;
|
||||
autoConnect: boolean;
|
||||
}
|
||||
|
||||
export const theme = persistentWritable("user-theme", {
|
||||
export interface UserTheme {
|
||||
color: string;
|
||||
mode: "light" | "dark" | "auto";
|
||||
}
|
||||
|
||||
export const theme = persistentWritable<UserTheme>("user-theme", {
|
||||
color: "#6D81C7",
|
||||
mode: "dark" as "light" | "dark" | "auto",
|
||||
})
|
||||
mode: "dark",
|
||||
});
|
||||
|
||||
export const userPreferences = persistentWritable<UserPreferences>("user-preferences", {
|
||||
backup: false,
|
||||
autoConnect: false,
|
||||
})
|
||||
export const userPreferences = persistentWritable<UserPreferences>(
|
||||
"user-preferences",
|
||||
{
|
||||
backup: false,
|
||||
autoConnect: false,
|
||||
},
|
||||
);
|
||||
|
||||
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (node, key) => {
|
||||
const unsubscribe = userPreferences.subscribe(it => {
|
||||
node.checked = it[key]
|
||||
})
|
||||
export const preference: Action<HTMLInputElement, keyof UserPreferences> = (
|
||||
node,
|
||||
key,
|
||||
) => {
|
||||
const unsubscribe = userPreferences.subscribe((it) => {
|
||||
node.checked = it[key];
|
||||
});
|
||||
function update() {
|
||||
userPreferences.update(value => {
|
||||
value[key] = node.checked
|
||||
return value
|
||||
})
|
||||
userPreferences.update((value) => {
|
||||
value[key] = node.checked;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
node.addEventListener("input", update)
|
||||
node.addEventListener("input", update);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
unsubscribe()
|
||||
node.removeEventListener("input", update)
|
||||
unsubscribe();
|
||||
node.removeEventListener("input", update);
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
<script lang="ts">
|
||||
import {createEventDispatcher} from "svelte"
|
||||
|
||||
export let ports: SerialPort[]
|
||||
const dispatch = createEventDispatcher<{confirm: SerialPort | undefined}>()
|
||||
let selected = ports[0].getInfo().name
|
||||
let {
|
||||
ports,
|
||||
onconfirm,
|
||||
}: {
|
||||
ports: SerialPort[];
|
||||
onconfirm: (port: SerialPort | undefined) => void;
|
||||
} = $props();
|
||||
let selected = $state(ports[0]?.getInfo().name);
|
||||
</script>
|
||||
|
||||
<dialog>
|
||||
{#each ports as port}
|
||||
{@const info = port.getInfo()}
|
||||
<label>{info.product}<input type="radio" name="port" value={info.name} bind:group={selected} /></label>
|
||||
<label
|
||||
>{info.product}<input
|
||||
type="radio"
|
||||
name="port"
|
||||
value={info.name}
|
||||
bind:group={selected}
|
||||
/></label
|
||||
>
|
||||
{/each}
|
||||
|
||||
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
|
||||
<button onclick={() => onconfirm(undefined)}>Cancel</button>
|
||||
<button
|
||||
on:click={() =>
|
||||
dispatch(
|
||||
"confirm",
|
||||
ports.find(it => it.getInfo().name === selected),
|
||||
)}>Ok</button
|
||||
onclick={() =>
|
||||
onconfirm(ports.find((it) => it.getInfo().name === selected))}>Ok</button
|
||||
>
|
||||
</dialog>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
deserializeActions,
|
||||
parseChordActions,
|
||||
@@ -6,43 +6,55 @@ import {
|
||||
serializeActions,
|
||||
stringifyChordActions,
|
||||
stringifyPhrase,
|
||||
} from "./chord"
|
||||
} from "./chord";
|
||||
|
||||
describe("chords", function () {
|
||||
describe("actions", function () {
|
||||
it("should serialize actions", function () {
|
||||
expect(serializeActions([32, 51]).toString(16)).toEqual(0xcc200000000000000000000000000n.toString(16))
|
||||
})
|
||||
expect(serializeActions([32, 51]).toString(16)).toEqual(
|
||||
0xcc200000000000000000000000000n.toString(16),
|
||||
);
|
||||
});
|
||||
|
||||
it("should deserialize actions", function () {
|
||||
expect(deserializeActions(0xcc200000000000000000000000000n)).toEqual([32, 51])
|
||||
})
|
||||
expect(deserializeActions(0xcc200000000000000000000000000n)).toEqual([
|
||||
32, 51,
|
||||
]);
|
||||
});
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
it(`should serialize back-forth ${i} actions`, function () {
|
||||
const actions = Array.from({length: i}).map((_, i) => i + 1)
|
||||
expect(deserializeActions(serializeActions(actions))).toEqual(actions)
|
||||
})
|
||||
const actions = Array.from({ length: i }).map((_, i) => i + 1);
|
||||
expect(deserializeActions(serializeActions(actions))).toEqual(actions);
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
describe("phrase", function () {
|
||||
it("should stringify", function () {
|
||||
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual("206872D4651FFF")
|
||||
})
|
||||
expect(stringifyPhrase([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])).toEqual(
|
||||
"206872D4651FFF",
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse", function () {
|
||||
expect(parsePhrase("206872D4651FFF")).toEqual([0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff])
|
||||
})
|
||||
})
|
||||
expect(parsePhrase("206872D4651FFF")).toEqual([
|
||||
0x20, 0x68, 0x72, 0xd4, 0x65, 0x1fff,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chord actions", function () {
|
||||
it("should stringify", function () {
|
||||
expect(stringifyChordActions([32, 51])).toEqual("000CC200000000000000000000000000")
|
||||
})
|
||||
expect(stringifyChordActions([32, 51])).toEqual(
|
||||
"000CC200000000000000000000000000",
|
||||
);
|
||||
});
|
||||
|
||||
it("should parse", function () {
|
||||
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([32, 51])
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(parseChordActions("000CC200000000000000000000000000")).toEqual([
|
||||
32, 51,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
import {compressActions, decompressActions} from "../serialization/actions"
|
||||
import { compressActions, decompressActions } from "../serialization/actions";
|
||||
|
||||
export interface Chord {
|
||||
actions: number[]
|
||||
phrase: number[]
|
||||
actions: number[];
|
||||
phrase: number[];
|
||||
}
|
||||
|
||||
export function parsePhrase(phrase: string): number[] {
|
||||
return decompressActions(
|
||||
Uint8Array.from({length: phrase.length / 2}).map((_, i) =>
|
||||
Uint8Array.from({ length: phrase.length / 2 }).map((_, i) =>
|
||||
Number.parseInt(phrase.slice(i * 2, i * 2 + 2), 16),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function stringifyPhrase(phrase: number[]): string {
|
||||
return [...compressActions(phrase)]
|
||||
.map(it => it.toString(16).padStart(2, "0"))
|
||||
.map((it) => it.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function parseChordActions(actions: string): number[] {
|
||||
return deserializeActions(BigInt(`0x${actions}`))
|
||||
return deserializeActions(BigInt(`0x${actions}`));
|
||||
}
|
||||
|
||||
export function stringifyChordActions(actions: number[]): string {
|
||||
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase()
|
||||
return serializeActions(actions).toString(16).padStart(32, "0").toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,25 +34,40 @@ export function stringifyChordActions(actions: number[]): string {
|
||||
* Actions are represented as 10-bit codes, for a maximum of 12 actions
|
||||
*/
|
||||
export function serializeActions(actions: number[]): bigint {
|
||||
let native = 0n
|
||||
let native = 0n;
|
||||
for (let i = 1; i <= actions.length; i++) {
|
||||
native |= BigInt(actions[actions.length - i] & 0x3ff) << BigInt((12 - i) * 10)
|
||||
native |=
|
||||
BigInt(actions[actions.length - i]! & 0x3ff) << BigInt((12 - i) * 10);
|
||||
}
|
||||
return native
|
||||
return native;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see {serializeActions}
|
||||
*/
|
||||
export function deserializeActions(native: bigint): number[] {
|
||||
const actions = []
|
||||
const actions = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const action = Number(native & 0x3ffn)
|
||||
if (action !== 0) {
|
||||
actions.push(action)
|
||||
}
|
||||
native >>= 10n
|
||||
const action = Number(native & 0x3ffn);
|
||||
actions.push(action);
|
||||
native >>= 10n;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import {get, writable} from "svelte/store"
|
||||
import {CharaDevice} from "$lib/serial/device"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import type {Writable} from "svelte/store"
|
||||
import type {CharaLayout} from "$lib/serialization/layout"
|
||||
import {persistentWritable} from "$lib/storage"
|
||||
import {userPreferences} from "$lib/preferences"
|
||||
import settingInfo from "$lib/assets/settings.yml"
|
||||
import { get, writable } from "svelte/store";
|
||||
import { CharaDevice } from "$lib/serial/device";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { CharaLayout } from "$lib/serialization/layout";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { userPreferences } from "$lib/preferences";
|
||||
import settingInfo from "$lib/assets/settings.yml";
|
||||
|
||||
export const serialPort = writable<CharaDevice | undefined>()
|
||||
export const serialPort = writable<CharaDevice | undefined>();
|
||||
|
||||
export interface SerialLogEntry {
|
||||
type: "input" | "output" | "system"
|
||||
value: string
|
||||
type: "input" | "output" | "system";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const serialLog = writable<SerialLogEntry[]>([])
|
||||
export const serialLog = writable<SerialLogEntry[]>([]);
|
||||
|
||||
/**
|
||||
* Chords as read from the device
|
||||
@@ -23,7 +23,7 @@ export const deviceChords = persistentWritable<Chord[]>(
|
||||
"chord-library",
|
||||
[],
|
||||
() => get(userPreferences).backup,
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Layout as read from the device
|
||||
@@ -32,7 +32,7 @@ export const deviceLayout = persistentWritable<CharaLayout>(
|
||||
"layout",
|
||||
[[], [], []],
|
||||
() => get(userPreferences).backup,
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Settings as read from the device
|
||||
@@ -41,61 +41,70 @@ export const deviceSettings = persistentWritable<number[]>(
|
||||
"device-settings",
|
||||
[],
|
||||
() => get(userPreferences).backup,
|
||||
)
|
||||
);
|
||||
|
||||
export const syncStatus: Writable<"done" | "error" | "downloading" | "uploading"> = writable("done")
|
||||
export const syncStatus: Writable<
|
||||
"done" | "error" | "downloading" | "uploading"
|
||||
> = writable("done");
|
||||
|
||||
export interface ProgressInfo {
|
||||
max: number
|
||||
current: number
|
||||
max: number;
|
||||
current: number;
|
||||
}
|
||||
export const syncProgress = writable<ProgressInfo | undefined>(undefined)
|
||||
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
||||
|
||||
export async function initSerial(manual = false) {
|
||||
const device = get(serialPort) ?? new CharaDevice()
|
||||
await device.init(manual)
|
||||
serialPort.set(device)
|
||||
sync()
|
||||
export async function initSerial(manual = false, withSync = true) {
|
||||
const device = get(serialPort) ?? new CharaDevice();
|
||||
await device.init(manual);
|
||||
serialPort.set(device);
|
||||
if (withSync) {
|
||||
await sync();
|
||||
}
|
||||
}
|
||||
|
||||
export async function sync() {
|
||||
const device = get(serialPort)
|
||||
if (!device) return
|
||||
const chordCount = await device.getChordCount()
|
||||
syncStatus.set("downloading")
|
||||
const device = get(serialPort);
|
||||
if (!device) return;
|
||||
const chordCount = await device.getChordCount();
|
||||
syncStatus.set("downloading");
|
||||
|
||||
const max = Object.keys(settingInfo.settings).length + device.keyCount * 3 + chordCount
|
||||
let current = 0
|
||||
syncProgress.set({max, current})
|
||||
const max =
|
||||
Object.keys(settingInfo["settings"]).length +
|
||||
device.keyCount * 3 +
|
||||
chordCount;
|
||||
let current = 0;
|
||||
syncProgress.set({ max, current });
|
||||
function progressTick() {
|
||||
current++
|
||||
syncProgress.set({max, current})
|
||||
current++;
|
||||
syncProgress.set({ max, current });
|
||||
}
|
||||
|
||||
const parsedSettings: number[] = []
|
||||
for (const key in settingInfo.settings) {
|
||||
const parsedSettings: number[] = [];
|
||||
for (const key in settingInfo["settings"]) {
|
||||
try {
|
||||
parsedSettings[Number.parseInt(key)] = await device.getSetting(Number.parseInt(key))
|
||||
parsedSettings[Number.parseInt(key)] = await device.getSetting(
|
||||
Number.parseInt(key),
|
||||
);
|
||||
} catch {}
|
||||
progressTick()
|
||||
progressTick();
|
||||
}
|
||||
deviceSettings.set(parsedSettings)
|
||||
deviceSettings.set(parsedSettings);
|
||||
|
||||
const parsedLayout: CharaLayout = [[], [], []]
|
||||
const parsedLayout: CharaLayout = [[], [], []];
|
||||
for (let layer = 1; layer <= 3; layer++) {
|
||||
for (let i = 0; i < device.keyCount; i++) {
|
||||
parsedLayout[layer - 1][i] = await device.getLayoutKey(layer, i)
|
||||
progressTick()
|
||||
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
|
||||
progressTick();
|
||||
}
|
||||
}
|
||||
deviceLayout.set(parsedLayout)
|
||||
deviceLayout.set(parsedLayout);
|
||||
|
||||
const chordInfo = []
|
||||
const chordInfo = [];
|
||||
for (let i = 0; i < chordCount; i++) {
|
||||
chordInfo.push(await device.getChord(i))
|
||||
progressTick()
|
||||
chordInfo.push(await device.getChord(i));
|
||||
progressTick();
|
||||
}
|
||||
deviceChords.set(chordInfo)
|
||||
syncStatus.set("done")
|
||||
syncProgress.set(undefined)
|
||||
deviceChords.set(chordInfo);
|
||||
syncStatus.set("done");
|
||||
syncProgress.set(undefined);
|
||||
}
|
||||
|
||||
@@ -1,250 +1,356 @@
|
||||
import {LineBreakTransformer} from "$lib/serial/line-break-transformer"
|
||||
import {serialLog} from "$lib/serial/connection"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import {SemVer} from "$lib/serial/sem-ver"
|
||||
import {parseChordActions, parsePhrase, stringifyChordActions, stringifyPhrase} from "$lib/serial/chord"
|
||||
import {browser} from "$app/environment"
|
||||
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
|
||||
import { serialLog } from "$lib/serial/connection";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
import { SemVer } from "$lib/serial/sem-ver";
|
||||
import {
|
||||
parseChordActions,
|
||||
parsePhrase,
|
||||
stringifyChordActions,
|
||||
stringifyPhrase,
|
||||
} from "$lib/serial/chord";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||
["ONE M0", {usbProductId: 32783, usbVendorId: 9114}],
|
||||
["LITE S2", {usbProductId: 33070, usbVendorId: 12346}],
|
||||
["LITE M0", {usbProductId: 32796, usbVendorId: 9114}],
|
||||
["X", {usbProductId: 33163, usbVendorId: 12346}],
|
||||
])
|
||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
|
||||
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
||||
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
||||
]);
|
||||
|
||||
const KEY_COUNTS = {
|
||||
ONE: 90,
|
||||
TWO: 90,
|
||||
LITE: 67,
|
||||
X: 256,
|
||||
} as const
|
||||
M4G: 90,
|
||||
M4GR: 90,
|
||||
} as const;
|
||||
|
||||
if (browser && navigator.serial === undefined && import.meta.env.TAURI_FAMILY !== undefined) {
|
||||
await import("./tauri-serial")
|
||||
if (
|
||||
browser &&
|
||||
navigator.serial === undefined &&
|
||||
import.meta.env.TAURI_FAMILY !== undefined
|
||||
) {
|
||||
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[]> {
|
||||
return navigator.serial.getPorts().then(ports =>
|
||||
ports.filter(it => {
|
||||
const {usbProductId, usbVendorId} = it.getInfo()
|
||||
return navigator.serial.getPorts().then((ports) =>
|
||||
ports.filter((it) => {
|
||||
const { usbProductId, usbVendorId } = it.getInfo();
|
||||
for (const filter of PORT_FILTERS.values()) {
|
||||
if (filter.usbProductId === usbProductId && filter.usbVendorId === usbVendorId) {
|
||||
return true
|
||||
if (
|
||||
filter.usbProductId === usbProductId &&
|
||||
filter.usbVendorId === usbVendorId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
return false;
|
||||
}),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
type LengthArray<T, N extends number, R extends T[] = []> = number extends N
|
||||
? T[]
|
||||
: R["length"] extends N
|
||||
? R
|
||||
: LengthArray<T, N, [T, ...R]>;
|
||||
|
||||
export async function canAutoConnect() {
|
||||
return getViablePorts().then(it => it.length === 1)
|
||||
return getViablePorts().then((it) => it.length === 1);
|
||||
}
|
||||
|
||||
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
let timer: number;
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Timeout")),
|
||||
ms,
|
||||
) as unknown as number;
|
||||
}),
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
export class CharaDevice {
|
||||
private port!: SerialPort
|
||||
private reader!: ReadableStreamDefaultReader<string>
|
||||
private port!: SerialPort;
|
||||
private reader!: ReadableStreamDefaultReader<string>;
|
||||
|
||||
private readonly abortController1 = new AbortController()
|
||||
private readonly abortController2 = new AbortController()
|
||||
private readonly abortController1 = new AbortController();
|
||||
private readonly abortController2 = new AbortController();
|
||||
|
||||
private streamClosed!: Promise<void>
|
||||
private streamClosed!: Promise<void>;
|
||||
|
||||
private lock?: Promise<true>
|
||||
private lock?: Promise<true>;
|
||||
|
||||
private readonly suspendDebounce = 100
|
||||
private suspendDebounceId?: number
|
||||
private readonly suspendDebounce = 100;
|
||||
private suspendDebounceId?: number;
|
||||
|
||||
version!: SemVer
|
||||
company!: "CHARACHORDER"
|
||||
device!: "ONE" | "LITE" | "X"
|
||||
chipset!: "M0" | "S2"
|
||||
keyCount!: 90 | 67 | 256
|
||||
version!: SemVer;
|
||||
company!: "CHARACHORDER" | "FORGE";
|
||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
|
||||
chipset!: "M0" | "S2" | "S3";
|
||||
keyCount!: 90 | 67 | 256;
|
||||
|
||||
get portInfo() {
|
||||
return this.port.getInfo()
|
||||
return this.port.getInfo();
|
||||
}
|
||||
|
||||
constructor(private readonly baudRate = 115200) {}
|
||||
|
||||
async init(manual = false) {
|
||||
try {
|
||||
const ports = await getViablePorts()
|
||||
const ports = await getViablePorts();
|
||||
this.port =
|
||||
!manual && ports.length === 1
|
||||
? ports[0]
|
||||
: await navigator.serial.requestPort({filters: [...PORT_FILTERS.values()]})
|
||||
? ports[0]!
|
||||
: await navigator.serial.requestPort({
|
||||
filters: [...PORT_FILTERS.values()],
|
||||
});
|
||||
|
||||
await this.port.open({baudRate: this.baudRate})
|
||||
const info = this.port.getInfo()
|
||||
serialLog.update(it => {
|
||||
await this.port.open({ baudRate: this.baudRate });
|
||||
const info = this.port.getInfo();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "system",
|
||||
value: `Connected; ID: 0x${info.usbProductId?.toString(16)}; Vendor: 0x${info.usbVendorId?.toString(
|
||||
value: `Connected; ID: 0x${info.usbProductId?.toString(
|
||||
16,
|
||||
)}`,
|
||||
})
|
||||
return it
|
||||
})
|
||||
await this.port.close()
|
||||
)}; Vendor: 0x${info.usbVendorId?.toString(16)}`,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
await this.port.close();
|
||||
|
||||
this.version = new SemVer(await this.send("VERSION").then(([version]) => version))
|
||||
const [company, device, chipset] = await this.send("ID")
|
||||
this.company = company as "CHARACHORDER"
|
||||
this.device = device as "ONE" | "LITE" | "X"
|
||||
this.chipset = chipset as "M0" | "S2"
|
||||
this.keyCount = KEY_COUNTS[this.device]
|
||||
this.version = new SemVer(
|
||||
await this.send(1, "VERSION").then(([version]) => version),
|
||||
);
|
||||
const [company, device, chipset] = await this.send(3, "ID");
|
||||
this.company = company as typeof this.company;
|
||||
this.device = device as typeof this.device;
|
||||
this.chipset = chipset as typeof this.chipset;
|
||||
this.keyCount = KEY_COUNTS[this.device];
|
||||
} catch (e) {
|
||||
alert(e)
|
||||
console.error(e)
|
||||
throw e
|
||||
alert(e);
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async suspend() {
|
||||
await this.reader.cancel()
|
||||
await this.reader.cancel();
|
||||
await this.streamClosed.catch(() => {
|
||||
/** noop */
|
||||
})
|
||||
this.reader.releaseLock()
|
||||
await this.port.close()
|
||||
});
|
||||
this.reader.releaseLock();
|
||||
await this.port.close();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "system",
|
||||
value: "Connection suspended",
|
||||
});
|
||||
return it;
|
||||
});
|
||||
}
|
||||
|
||||
private async wake() {
|
||||
await this.port.open({baudRate: this.baudRate})
|
||||
const decoderStream = new TextDecoderStream()
|
||||
await this.port.open({ baudRate: this.baudRate });
|
||||
const decoderStream = new TextDecoderStream();
|
||||
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
|
||||
signal: this.abortController1.signal,
|
||||
})
|
||||
});
|
||||
|
||||
this.reader = decoderStream
|
||||
.readable!.pipeThrough(new TransformStream(new LineBreakTransformer()), {
|
||||
signal: this.abortController2.signal,
|
||||
})
|
||||
.getReader()
|
||||
.getReader();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "system",
|
||||
value: "Connection resumed",
|
||||
});
|
||||
return it;
|
||||
});
|
||||
}
|
||||
|
||||
private async internalRead() {
|
||||
const {value} = await this.reader.read()
|
||||
serialLog.update(it => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: value!,
|
||||
})
|
||||
return it
|
||||
})
|
||||
return value!
|
||||
try {
|
||||
const { value } = await timeout(this.reader.read(), 5000);
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: value!,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return value!;
|
||||
} catch (e) {
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: `${e}`,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a command to the device
|
||||
*/
|
||||
private async internalSend(...command: string[]) {
|
||||
const writer = this.port.writable!.getWriter()
|
||||
const writer = this.port.writable!.getWriter();
|
||||
try {
|
||||
serialLog.update(it => {
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: command.join(" "),
|
||||
})
|
||||
return it
|
||||
})
|
||||
await writer.write(new TextEncoder().encode(`${command.join(" ")}\r\n`))
|
||||
});
|
||||
return it;
|
||||
});
|
||||
await writer.write(new TextEncoder().encode(`${command.join(" ")}\r\n`));
|
||||
} finally {
|
||||
writer.releaseLock()
|
||||
writer.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async forget() {
|
||||
await this.port.forget()
|
||||
await this.port.forget();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write to serial port
|
||||
*/
|
||||
async runWith<T>(
|
||||
callback: (send: typeof this.internalSend, read: typeof this.internalRead) => T | Promise<T>,
|
||||
callback: (
|
||||
send: typeof this.internalSend,
|
||||
read: typeof this.internalRead,
|
||||
) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
while (this.lock) {
|
||||
await this.lock
|
||||
await this.lock;
|
||||
}
|
||||
const send = this.internalSend.bind(this)
|
||||
const read = this.internalRead.bind(this)
|
||||
const exec = new Promise<T>(async resolve => {
|
||||
let result!: T
|
||||
try {
|
||||
if (this.suspendDebounceId) {
|
||||
clearTimeout(this.suspendDebounceId)
|
||||
} else {
|
||||
await this.wake()
|
||||
}
|
||||
result = await callback(send, read)
|
||||
} finally {
|
||||
delete this.lock
|
||||
this.suspendDebounceId = setTimeout(() => {
|
||||
// cannot be locked here as all the code until clearTimeout is sync
|
||||
console.assert(this.lock === undefined)
|
||||
this.lock = this.suspend().then(() => {
|
||||
delete this.lock
|
||||
delete this.suspendDebounceId
|
||||
return true
|
||||
})
|
||||
}, this.suspendDebounce) as any
|
||||
resolve(result)
|
||||
const send = this.internalSend.bind(this);
|
||||
const read = this.internalRead.bind(this);
|
||||
let resolveLock: (result: true) => void;
|
||||
this.lock = new Promise<true>((resolve) => {
|
||||
resolveLock = resolve;
|
||||
});
|
||||
let result!: T;
|
||||
try {
|
||||
if (this.suspendDebounceId) {
|
||||
clearTimeout(this.suspendDebounceId);
|
||||
} else {
|
||||
await this.wake();
|
||||
}
|
||||
})
|
||||
this.lock = exec.then(() => true)
|
||||
return exec
|
||||
result = await callback(send, read);
|
||||
} finally {
|
||||
delete this.lock;
|
||||
this.suspendDebounceId = setTimeout(() => {
|
||||
// cannot be locked here as all the code until clearTimeout is sync
|
||||
console.assert(this.lock === undefined);
|
||||
this.lock = this.suspend().then(() => {
|
||||
delete this.lock;
|
||||
delete this.suspendDebounceId;
|
||||
return true;
|
||||
});
|
||||
}, this.suspendDebounce) as any;
|
||||
resolveLock!(true);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send to serial port
|
||||
*/
|
||||
async send(...command: string[]) {
|
||||
async send<T extends number>(
|
||||
expectedLength: T,
|
||||
...command: string[]
|
||||
): Promise<LengthArray<string, T>> {
|
||||
return this.runWith(async (send, read) => {
|
||||
await send(...command)
|
||||
const commandString = command.join(" ").replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&")
|
||||
return read().then(it => it.replace(new RegExp(`^${commandString} `), "").split(" "))
|
||||
})
|
||||
await send(...command);
|
||||
const commandString = command
|
||||
.join(" ")
|
||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||
const readResult = await read();
|
||||
if (readResult === undefined) {
|
||||
console.error("No response");
|
||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||
string,
|
||||
T
|
||||
>;
|
||||
}
|
||||
const array = readResult
|
||||
.replace(new RegExp(`^${commandString} `), "")
|
||||
.split(" ");
|
||||
if (array.length < expectedLength) {
|
||||
console.error("Response too short");
|
||||
return array.concat(
|
||||
Array(expectedLength - array.length).fill("TOO_SHORT"),
|
||||
) as LengthArray<string, T>;
|
||||
}
|
||||
return array as LengthArray<string, T>;
|
||||
});
|
||||
}
|
||||
|
||||
async getChordCount(): Promise<number> {
|
||||
const [count] = await this.send("CML C0")
|
||||
return Number.parseInt(count)
|
||||
const [count] = await this.send(1, "CML C0");
|
||||
return Number.parseInt(count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a chord by index
|
||||
*/
|
||||
async getChord(index: number | number[]): Promise<Chord> {
|
||||
const [actions, phrase] = await this.send(`CML C1 ${index}`)
|
||||
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
|
||||
return {
|
||||
actions: parseChordActions(actions),
|
||||
phrase: parsePhrase(phrase),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the phrase for a set of actions
|
||||
*/
|
||||
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
||||
const [phrase] = await this.send(`CML C2 ${stringifyChordActions(actions)}`)
|
||||
return phrase === "2" ? undefined : parsePhrase(phrase)
|
||||
const [phrase] = await this.send(
|
||||
1,
|
||||
`CML C2 ${stringifyChordActions(actions)}`,
|
||||
);
|
||||
return phrase === "2" ? undefined : parsePhrase(phrase);
|
||||
}
|
||||
|
||||
async setChord(chord: Chord) {
|
||||
const [status] = await this.send(
|
||||
1,
|
||||
"CML",
|
||||
"C3",
|
||||
stringifyChordActions(chord.actions),
|
||||
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">) {
|
||||
const status = await this.send(`CML C4 ${stringifyChordActions(chord.actions)}`)
|
||||
console.log(status)
|
||||
if (status.at(-1) !== "2") throw new Error(`Failed with status ${status}`)
|
||||
const status = await this.send(
|
||||
1,
|
||||
`CML C4 ${stringifyChordActions(chord.actions)}`,
|
||||
);
|
||||
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
||||
throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,9 +360,8 @@ export class CharaDevice {
|
||||
* @param action the assigned action id
|
||||
*/
|
||||
async setLayoutKey(layer: number, id: number, action: number) {
|
||||
const [status] = await this.send(`VAR B4 A${layer} ${id} ${action}`)
|
||||
console.log(status)
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,9 +371,9 @@ export class CharaDevice {
|
||||
* @returns the assigned action id
|
||||
*/
|
||||
async getLayoutKey(layer: number, id: number) {
|
||||
const [position, status] = await this.send(`VAR B3 A${layer} ${id}`)
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
return Number(position)
|
||||
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
return Number(position);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,8 +384,8 @@ export class CharaDevice {
|
||||
* **This does not need to be called for chords**
|
||||
*/
|
||||
async commit() {
|
||||
const [status] = await this.send("VAR B0")
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
const [status] = await this.send(1, "VAR B0");
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,39 +395,49 @@ export class CharaDevice {
|
||||
* To permanently store the settings, you *must* call commit.
|
||||
*/
|
||||
async setSetting(id: number, value: number) {
|
||||
const [status] = await this.send(`VAR B2 ${id.toString(16).toUpperCase()} ${value}`)
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`)
|
||||
const [status] = await this.send(
|
||||
1,
|
||||
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
|
||||
);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a setting from the device
|
||||
*/
|
||||
async getSetting(id: number): Promise<number> {
|
||||
const [value, status] = await this.send(`VAR B1 ${id.toString(16).toUpperCase()}`)
|
||||
const [value, status] = await this.send(
|
||||
2,
|
||||
`VAR B1 ${id.toString(16).toUpperCase()}`,
|
||||
);
|
||||
if (status !== "0")
|
||||
throw new Error(`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`)
|
||||
return Number(value)
|
||||
throw new Error(
|
||||
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
||||
);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboots the device
|
||||
*/
|
||||
async reboot() {
|
||||
await this.send("RST")
|
||||
await this.send(0, "RST");
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboots the device to the bootloader
|
||||
*/
|
||||
async bootloader() {
|
||||
await this.send("RST BOOTLOADER")
|
||||
await this.send(0, "RST BOOTLOADER");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the device
|
||||
*/
|
||||
async reset(type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC") {
|
||||
await this.send(`RST ${type}`)
|
||||
async reset(
|
||||
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
||||
) {
|
||||
await this.send(0, `RST ${type}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,6 +446,97 @@ export class CharaDevice {
|
||||
* This is useful for debugging when there is a suspected heap or stack issue.
|
||||
*/
|
||||
async getRamBytesAvailable(): Promise<number> {
|
||||
return Number(await this.send("RAM"))
|
||||
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
|
||||
}
|
||||
|
||||
async updateFirmware(file: File | Blob): Promise<void> {
|
||||
while (this.lock) {
|
||||
await this.lock;
|
||||
}
|
||||
let resolveLock: (result: true) => void;
|
||||
this.lock = new Promise<true>((resolve) => {
|
||||
resolveLock = resolve;
|
||||
});
|
||||
try {
|
||||
if (this.suspendDebounceId) {
|
||||
clearTimeout(this.suspendDebounceId);
|
||||
} else {
|
||||
await this.wake();
|
||||
}
|
||||
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "system",
|
||||
value: "OTA Update",
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
const writer = this.port.writable!.getWriter();
|
||||
try {
|
||||
await writer.write(new TextEncoder().encode(`RST OTA\r\n`));
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: "RST OTA",
|
||||
});
|
||||
return it;
|
||||
});
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
// Wait for the device to be ready
|
||||
const signal = await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
await file.stream().pipeTo(this.port.writable!);
|
||||
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: `...${file.size} bytes`,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
const result = (await this.reader.read()).value!.trim();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: result!,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
if (result !== "OTA OK") {
|
||||
throw new Error(result);
|
||||
}
|
||||
|
||||
const writer2 = this.port.writable!.getWriter();
|
||||
try {
|
||||
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: "RST RESTART",
|
||||
});
|
||||
return it;
|
||||
});
|
||||
} finally {
|
||||
writer2.releaseLock();
|
||||
}
|
||||
|
||||
await this.suspend();
|
||||
} finally {
|
||||
delete this.lock;
|
||||
resolveLock!(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,38 @@
|
||||
import type {ActionInfo, KeymapCategory} from "$lib/assets/keymaps/keymap"
|
||||
import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
|
||||
|
||||
export interface KeyInfo extends Partial<ActionInfo> {
|
||||
code: number
|
||||
category: KeymapCategory
|
||||
code: number;
|
||||
category?: KeymapCategory;
|
||||
}
|
||||
|
||||
export const KEYMAP_CATEGORIES = (await Promise.all(
|
||||
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(async load =>
|
||||
load().then(it => (it as any).default),
|
||||
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
|
||||
async (load) => load().then((it) => (it as any).default),
|
||||
),
|
||||
)) as KeymapCategory[]
|
||||
)) as KeymapCategory[];
|
||||
|
||||
export const KEYMAP_CODES: Record<number, KeyInfo> = Object.fromEntries(
|
||||
KEYMAP_CATEGORIES.flatMap(category =>
|
||||
export const KEYMAP_CODES = new Map<number, KeyInfo>(
|
||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
||||
Object.entries(category.actions).map(([code, action]) => [
|
||||
Number(code),
|
||||
{...action, code: Number(code), category},
|
||||
{ ...action, code: Number(code), category },
|
||||
]),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
export const KEYMAP_KEYCODES: Map<string, number> = new Map(
|
||||
KEYMAP_CATEGORIES.flatMap(category =>
|
||||
Object.entries(category.actions).map(([code, action]) => [action.keyCode!, Number(code)] as const),
|
||||
).filter(([keyCode]) => keyCode !== undefined),
|
||||
)
|
||||
|
||||
export const KEYMAP_IDS: Map<string, KeyInfo> = new Map(
|
||||
KEYMAP_CATEGORIES.flatMap(category =>
|
||||
export const KEYMAP_KEYCODES = new Map<string, number>(
|
||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) => [action.id!, {...action, code: Number(code), category}] as const,
|
||||
([code, action]) => [action.keyCode!, Number(code)] as const,
|
||||
),
|
||||
).filter(([keyCode]) => keyCode !== undefined),
|
||||
);
|
||||
|
||||
export const KEYMAP_IDS = new Map<string, KeyInfo>(
|
||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) =>
|
||||
[action.id!, { ...action, code: Number(code), category }] as const,
|
||||
),
|
||||
).filter(([id]) => id !== undefined),
|
||||
)
|
||||
|
||||
export const specialKeycodes = new Map([
|
||||
[" ", 32], // Space
|
||||
])
|
||||
);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
export class LineBreakTransformer {
|
||||
private chunks = ""
|
||||
private chunks = "";
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
transform(chunk: string, controller: TransformStreamDefaultController) {
|
||||
this.chunks += chunk
|
||||
const lines = this.chunks.split("\r\n")
|
||||
this.chunks = lines.pop()!
|
||||
this.chunks += chunk;
|
||||
const lines = this.chunks.split("\r\n");
|
||||
this.chunks = lines.pop()!;
|
||||
for (const line of lines) {
|
||||
controller.enqueue(line)
|
||||
controller.enqueue(line);
|
||||
}
|
||||
}
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
flush(controller: TransformStreamDefaultController) {
|
||||
controller.enqueue(this.chunks)
|
||||
controller.enqueue(this.chunks);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
export class SemVer {
|
||||
major: number
|
||||
minor: number
|
||||
patch: number
|
||||
preRelease?: string
|
||||
meta?: string
|
||||
major = 0;
|
||||
minor = 0;
|
||||
patch = 0;
|
||||
preRelease?: string;
|
||||
meta?: string;
|
||||
|
||||
constructor(versionString: string) {
|
||||
const [, major, minor, patch, preRelease, meta] =
|
||||
const result =
|
||||
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
|
||||
versionString,
|
||||
)!
|
||||
this.major = Number.parseInt(major)
|
||||
this.minor = Number.parseInt(minor)
|
||||
this.patch = Number.parseInt(patch)
|
||||
if (preRelease) this.preRelease = preRelease
|
||||
if (meta) this.meta = meta
|
||||
);
|
||||
if (!result) {
|
||||
console.error("Invalid version string:", versionString);
|
||||
} else {
|
||||
const [, major, minor, patch, preRelease, meta] = result;
|
||||
this.major = Number.parseInt(major ?? "NaN");
|
||||
this.minor = Number.parseInt(minor ?? "NaN");
|
||||
this.patch = Number.parseInt(patch ?? "NaN");
|
||||
if (preRelease) this.preRelease = preRelease;
|
||||
if (meta) this.meta = meta;
|
||||
}
|
||||
}
|
||||
|
||||
toString() {
|
||||
@@ -22,6 +27,6 @@ export class SemVer {
|
||||
`${this.major}.${this.minor}.${this.patch}` +
|
||||
(this.preRelease ? `-${this.preRelease}` : "") +
|
||||
(this.meta ? `+${this.meta}` : "")
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,42 +2,53 @@
|
||||
* Compress JSON.stringify with gzip
|
||||
*/
|
||||
export async function stringifyCompressed<T>(chords: T): Promise<Blob> {
|
||||
const stream = new Blob([JSON.stringify(chords)]).stream().pipeThrough(new CompressionStream("gzip"))
|
||||
return await new Response(stream).blob()
|
||||
const stream = new Blob([JSON.stringify(chords)])
|
||||
.stream()
|
||||
.pipeThrough(new CompressionStream("gzip"));
|
||||
return await new Response(stream).blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decompress JSON.parse with gzip
|
||||
*/
|
||||
export async function parseCompressed<T>(blob: Blob): Promise<T> {
|
||||
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"))
|
||||
return await new Response(stream).json()
|
||||
const stream = blob.stream().pipeThrough(new DecompressionStream("deflate"));
|
||||
return await new Response(stream).json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Share JS object as url query param
|
||||
*/
|
||||
export async function getSharableUrl(name: string, data: any, baseHref = window.location.href): Promise<URL> {
|
||||
return new Promise(async resolve => {
|
||||
const reader = new FileReader()
|
||||
export async function getSharableUrl(
|
||||
name: string,
|
||||
data: any,
|
||||
baseHref = window.location.href,
|
||||
): Promise<URL> {
|
||||
return new Promise(async (resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
const base64String = (reader.result as string).replace(/^data:application\/octet-stream;base64,/, "")
|
||||
const url = new URL(baseHref)
|
||||
url.searchParams.set(name, base64String)
|
||||
resolve(url)
|
||||
}
|
||||
reader.readAsDataURL(await stringifyCompressed(data))
|
||||
})
|
||||
const base64String = (reader.result as string).replace(
|
||||
/^data:application\/octet-stream;base64,/,
|
||||
"",
|
||||
);
|
||||
const url = new URL(baseHref);
|
||||
url.searchParams.set(name, base64String);
|
||||
resolve(url);
|
||||
};
|
||||
reader.readAsDataURL(await stringifyCompressed(data));
|
||||
});
|
||||
}
|
||||
|
||||
export async function parseSharableUrl<T>(
|
||||
name: string,
|
||||
url: string = window.location.href,
|
||||
): Promise<T | undefined> {
|
||||
const searchParams = new URL(url).searchParams
|
||||
if (!searchParams.has(name)) return
|
||||
const searchParams = new URL(url).searchParams;
|
||||
if (!searchParams.has(name)) return;
|
||||
|
||||
return await fetch(`data:application/octet-stream;base64,${searchParams.get(name)}`)
|
||||
.then(it => it.blob())
|
||||
.then(it => parseCompressed(it))
|
||||
return await fetch(
|
||||
`data:application/octet-stream;base64,${searchParams.get(name)}`,
|
||||
)
|
||||
.then((it) => it.blob())
|
||||
.then((it) => parseCompressed(it));
|
||||
}
|
||||
|
||||
8
src/lib/serial/tauri-serial-extension.d.ts
vendored
8
src/lib/serial/tauri-serial-extension.d.ts
vendored
@@ -1,8 +1,8 @@
|
||||
/// <references types="@types/w3c-web-serial" />
|
||||
|
||||
interface SerialPortInfo {
|
||||
name?: string
|
||||
serialNumber?: string
|
||||
manufacturer?: string
|
||||
product?: string
|
||||
name?: string;
|
||||
serialNumber?: string;
|
||||
manufacturer?: string;
|
||||
product?: string;
|
||||
}
|
||||
|
||||
@@ -1,65 +1,77 @@
|
||||
import {invoke} from "@tauri-apps/api"
|
||||
import TauriSerialDialog from "$lib/serial/TauriSerialDialog.svelte"
|
||||
import { invoke } from "@tauri-apps/api";
|
||||
import TauriSerialDialog from "$lib/serial/TauriSerialDialog.svelte";
|
||||
|
||||
export type TauriSerialPort = Pick<
|
||||
SerialPort,
|
||||
"getInfo" | "open" | "close" | "readable" | "writable" | "forget"
|
||||
>
|
||||
>;
|
||||
|
||||
function NativeSerialPort(info: SerialPortInfo): TauriSerialPort {
|
||||
return {
|
||||
getInfo() {
|
||||
return info
|
||||
return info;
|
||||
},
|
||||
async open({baudRate}: SerialOptions) {
|
||||
await invoke("plugin:serial|open", {path: info.name, baudRate})
|
||||
async open({ baudRate }: SerialOptions) {
|
||||
await invoke("plugin:serial|open", { path: info.name, baudRate });
|
||||
},
|
||||
async close() {
|
||||
await invoke("plugin:serial|close", {path: info.name})
|
||||
await invoke("plugin:serial|close", { path: info.name });
|
||||
},
|
||||
async forget() {
|
||||
// noop
|
||||
},
|
||||
readable: new ReadableStream({
|
||||
async pull(controller) {
|
||||
const result = await invoke<number[]>("plugin:serial|read", {path: info.name})
|
||||
controller.enqueue(new Uint8Array(result))
|
||||
const result = await invoke<number[]>("plugin:serial|read", {
|
||||
path: info.name,
|
||||
});
|
||||
controller.enqueue(new Uint8Array(result));
|
||||
},
|
||||
}),
|
||||
writable: new WritableStream({
|
||||
async write(chunk) {
|
||||
await invoke("plugin:serial|write", {path: info.name, chunk: Array.from(chunk)})
|
||||
await invoke("plugin:serial|write", {
|
||||
path: info.name,
|
||||
chunk: Array.from(chunk),
|
||||
});
|
||||
},
|
||||
}),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-expect-error polyfill
|
||||
// noinspection JSConstantReassignment
|
||||
navigator.serial = {
|
||||
async getPorts(): Promise<SerialPort[]> {
|
||||
return invoke<any[]>("plugin:serial|get_serial_ports").then(ports =>
|
||||
return invoke<any[]>("plugin:serial|get_serial_ports").then((ports) =>
|
||||
ports.map(NativeSerialPort),
|
||||
) as Promise<SerialPort[]>
|
||||
) as Promise<SerialPort[]>;
|
||||
},
|
||||
async requestPort(options?: SerialPortRequestOptions): Promise<SerialPort> {
|
||||
const ports = await navigator.serial.getPorts().then(ports =>
|
||||
const ports = await navigator.serial.getPorts().then((ports) =>
|
||||
options?.filters !== undefined
|
||||
? ports.filter(port =>
|
||||
options.filters!.some(({usbVendorId, usbProductId}) => {
|
||||
const info = port.getInfo()
|
||||
? ports.filter((port) =>
|
||||
options.filters!.some(({ usbVendorId, usbProductId }) => {
|
||||
const info = port.getInfo();
|
||||
return (
|
||||
(usbVendorId === undefined || info.usbVendorId === usbVendorId) &&
|
||||
(usbProductId === undefined || info.usbProductId === usbProductId)
|
||||
)
|
||||
(usbVendorId === undefined ||
|
||||
info.usbVendorId === usbVendorId) &&
|
||||
(usbProductId === undefined ||
|
||||
info.usbProductId === usbProductId)
|
||||
);
|
||||
}),
|
||||
)
|
||||
: ports,
|
||||
)
|
||||
);
|
||||
|
||||
const dialog = new TauriSerialDialog({target: document.body, props: {ports}})
|
||||
const port = await new Promise<SerialPort>(resolve => dialog.$on("confirm", resolve))
|
||||
dialog.$destroy()
|
||||
return port
|
||||
const dialog = new TauriSerialDialog({
|
||||
target: document.body,
|
||||
props: { ports },
|
||||
});
|
||||
const port = await new Promise<SerialPort>((resolve) =>
|
||||
// @ts-expect-error polyfill
|
||||
dialog.$on("confirm", resolve),
|
||||
);
|
||||
dialog.$destroy();
|
||||
return port;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export async function updateDevice(port: SerialPort) {
|
||||
await port.open({
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
bufferSize: 255,;
|
||||
})
|
||||
|
||||
const writer = port.writable!.getWriter()
|
||||
const reader = port.readable!.getReader()
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import {compressActions, decompressActions} from "./actions"
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { compressActions, decompressActions } from "./actions";
|
||||
|
||||
describe("layout", function () {
|
||||
const actions = [1023, 255, 256, 42, 32, 532, 8000]
|
||||
const actions = [1023, 255, 256, 42, 32, 532, 8000];
|
||||
|
||||
describe("compression", function () {
|
||||
it("should compress back and forth arrays divisible by 4", function () {
|
||||
expect(decompressActions(compressActions(actions))).toEqual(actions)
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(decompressActions(compressActions(actions))).toEqual(actions);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
* Action codes <32 are invalid.
|
||||
*/
|
||||
export function compressActions(actions: number[]): Uint8Array {
|
||||
const buffer = new Uint8Array(actions.length * 2)
|
||||
let i = 0
|
||||
const buffer = new Uint8Array(actions.length * 2);
|
||||
let i = 0;
|
||||
for (const action of actions) {
|
||||
if (action > 0xff) {
|
||||
buffer[i++] = action >>> 8
|
||||
buffer[i++] = action >>> 8;
|
||||
}
|
||||
buffer[i++] = action & 0xff
|
||||
buffer[i++] = action & 0xff;
|
||||
}
|
||||
return buffer.slice(0, i)
|
||||
return buffer.slice(0, i);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,13 +21,13 @@ export function compressActions(actions: number[]): Uint8Array {
|
||||
* @see {compressActions}
|
||||
*/
|
||||
export function decompressActions(raw: Uint8Array): number[] {
|
||||
const actions: number[] = []
|
||||
const actions: number[] = [];
|
||||
for (let i = 0; i < raw.length; i++) {
|
||||
let action = raw[i]
|
||||
if (action > 0 && action < 32) {
|
||||
action = (action << 8) | raw[++i]
|
||||
let action = raw[i]!;
|
||||
if (action > 0 && action < 32 && i + 1 < raw.length) {
|
||||
action = (action << 8) | raw[++i]!;
|
||||
}
|
||||
actions.push(action)
|
||||
actions.push(action);
|
||||
}
|
||||
return actions
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {describe, it, expect} from "vitest"
|
||||
import {fromBase64, toBase64} from "./base64"
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { fromBase64, toBase64 } from "./base64";
|
||||
|
||||
describe("base64", function () {
|
||||
const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21])
|
||||
const data = new Uint8Array([24, 235, 22, 67, 84, 73, 23, 77, 21]);
|
||||
|
||||
it("should convert back-forth", async function () {
|
||||
expect(await fromBase64(await toBase64(new Blob([data]))).then(it => it.arrayBuffer())).toEqual(
|
||||
data.buffer,
|
||||
)
|
||||
})
|
||||
})
|
||||
expect(
|
||||
await fromBase64(await toBase64(new Blob([data]))).then((it) =>
|
||||
it.arrayBuffer(),
|
||||
),
|
||||
).toEqual(data.buffer);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* meaning some chars are swapped for compatibility
|
||||
*/
|
||||
export async function toBase64(blob: Blob): Promise<string> {
|
||||
return new Promise(async resolve => {
|
||||
const reader = new FileReader()
|
||||
return new Promise(async (resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
resolve(
|
||||
`${(reader.result as string)
|
||||
@@ -14,17 +14,20 @@ export async function toBase64(blob: Blob): Promise<string> {
|
||||
.replaceAll("+", ".")
|
||||
.replaceAll("/", "_")
|
||||
.replaceAll("=", "-")}`,
|
||||
)
|
||||
}
|
||||
reader.readAsDataURL(blob)
|
||||
})
|
||||
);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export async function fromBase64(base64: string, fetch = window.fetch): Promise<Blob> {
|
||||
export async function fromBase64(
|
||||
base64: string,
|
||||
fetch = window.fetch,
|
||||
): Promise<Blob> {
|
||||
return fetch(
|
||||
`data:application/octet-stream;base64,${base64
|
||||
.replaceAll(".", "+")
|
||||
.replaceAll("_", "/")
|
||||
.replaceAll("-", "=")}`,
|
||||
).then(it => it.blob())
|
||||
).then((it) => it.blob());
|
||||
}
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
[
|
||||
[
|
||||
600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603, 114, 298, 32, 101, 604, 105,
|
||||
127, 46, 111, 605, 39, 512, 44, 117, 552, 513, 514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566,
|
||||
567, 609, 563, 63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296, 544, 116, 613,
|
||||
108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518, 551, 542, 616, 336, 338, 335, 337, 617, 566,
|
||||
568, 565, 567
|
||||
600, 47, 45, 515, 297, 601, 119, 562, 103, 122, 602, 107, 118, 109, 99, 603,
|
||||
114, 298, 32, 101, 604, 105, 127, 46, 111, 605, 39, 512, 44, 117, 552, 513,
|
||||
514, 550, 540, 607, 335, 338, 336, 337, 608, 565, 568, 566, 567, 609, 563,
|
||||
63, 519, 297, 610, 98, 120, 536, 113, 611, 102, 112, 104, 100, 612, 97, 296,
|
||||
544, 116, 613, 108, 299, 106, 110, 614, 121, 516, 59, 115, 553, 517, 518,
|
||||
551, 542, 616, 336, 338, 335, 337, 617, 566, 568, 565, 567
|
||||
],
|
||||
[
|
||||
0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51, 50, 0, 52, 127, 54, 53, 0,
|
||||
96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569, 572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519,
|
||||
297, 0, 98, 120, 91, 93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516, 59, 115,
|
||||
0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567
|
||||
0, 92, 45, 515, 297, 0, 119, 562, 91, 93, 0, 55, 56, 57, 48, 0, 49, 298, 51,
|
||||
50, 0, 52, 127, 54, 53, 0, 96, 512, 61, 124, 0, 513, 514, 550, 540, 0, 569,
|
||||
572, 570, 571, 0, 565, 568, 566, 567, 0, 563, 63, 519, 297, 0, 98, 120, 91,
|
||||
93, 0, 55, 56, 57, 48, 0, 49, 296, 51, 50, 0, 52, 299, 54, 53, 0, 61, 516,
|
||||
59, 115, 0, 517, 518, 551, 542, 0, 570, 572, 569, 571, 0, 566, 568, 565, 567
|
||||
],
|
||||
[
|
||||
0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314, 298, 316, 315, 0, 317, 127,
|
||||
319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0, 540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0,
|
||||
563, 63, 519, 297, 0, 98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317, 299, 319,
|
||||
318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338, 335, 337, 0, 570, 572, 569, 571
|
||||
0, 47, 45, 515, 297, 0, 119, 324, 325, 122, 0, 320, 321, 322, 323, 0, 314,
|
||||
298, 316, 315, 0, 317, 127, 319, 318, 0, 39, 512, 44, 117, 552, 513, 514, 0,
|
||||
540, 0, 335, 338, 336, 337, 0, 569, 572, 570, 571, 0, 563, 63, 519, 297, 0,
|
||||
98, 324, 325, 113, 0, 320, 321, 322, 323, 0, 314, 296, 316, 315, 0, 317,
|
||||
299, 319, 318, 0, 121, 516, 59, 115, 553, 517, 518, 0, 542, 0, 336, 338,
|
||||
335, 337, 0, 570, 572, 569, 571
|
||||
]
|
||||
]
|
||||
|
||||
@@ -1,37 +1,49 @@
|
||||
import {compressActions, decompressActions} from "./actions"
|
||||
import {fromBase64, toBase64} from "$lib/serialization/base64"
|
||||
import { compressActions, decompressActions } from "./actions";
|
||||
import { fromBase64, toBase64 } from "$lib/serialization/base64";
|
||||
|
||||
export interface NewCharaLayout {
|
||||
charaLayoutVersion: 1
|
||||
device: "one" | "lite" | string
|
||||
charaLayoutVersion: 1;
|
||||
device: "one" | "lite" | string;
|
||||
/**
|
||||
* Layers A1-A3, with numeric action codes on each
|
||||
*/
|
||||
layers: [number[], number[], number[]]
|
||||
layers: [number[], number[], number[]];
|
||||
}
|
||||
|
||||
export type CharaLayout = [number[], number[], number[]]
|
||||
export type CharaLayout = [number[], number[], number[]];
|
||||
|
||||
/**
|
||||
* Serialize a layout into a micro package
|
||||
*/
|
||||
export async function serializeLayout(layout: CharaLayout): Promise<Blob> {
|
||||
const items = compressActions(layout.flat())
|
||||
const stream = new Blob([items]).stream().pipeThrough(new CompressionStream("deflate"))
|
||||
return new Response(stream).blob()
|
||||
const items = compressActions(layout.flat());
|
||||
const stream = new Blob([items])
|
||||
.stream()
|
||||
.pipeThrough(new CompressionStream("deflate"));
|
||||
return new Response(stream).blob();
|
||||
}
|
||||
|
||||
export async function deserializeLayout(layout: Blob): Promise<CharaLayout> {
|
||||
const stream = layout.stream().pipeThrough(new DecompressionStream("deflate"))
|
||||
const raw = await new Response(stream).arrayBuffer()
|
||||
const actions = decompressActions(new Uint8Array(raw))
|
||||
return [actions.slice(0, 90), actions.slice(90, 180), actions.slice(180, 270)]
|
||||
const stream = layout
|
||||
.stream()
|
||||
.pipeThrough(new DecompressionStream("deflate"));
|
||||
const raw = await new Response(stream).arrayBuffer();
|
||||
const actions = decompressActions(new Uint8Array(raw));
|
||||
return [
|
||||
actions.slice(0, 90),
|
||||
actions.slice(90, 180),
|
||||
actions.slice(180, 270),
|
||||
];
|
||||
}
|
||||
|
||||
export async function layoutAsUrlComponent(layout: CharaLayout): Promise<string> {
|
||||
return serializeLayout(layout).then(toBase64)
|
||||
export async function layoutAsUrlComponent(
|
||||
layout: CharaLayout,
|
||||
): Promise<string> {
|
||||
return serializeLayout(layout).then(toBase64);
|
||||
}
|
||||
|
||||
export async function layoutFromUrlComponent(base64: string): Promise<CharaLayout> {
|
||||
return fromBase64(base64).then(deserializeLayout)
|
||||
export async function layoutFromUrlComponent(
|
||||
base64: string,
|
||||
): Promise<CharaLayout> {
|
||||
return fromBase64(base64).then(deserializeLayout);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user