mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-05 08:42:41 +00:00
Compare commits
79 Commits
gh-pages
...
7f27499003
| Author | SHA1 | Date | |
|---|---|---|---|
|
7f27499003
|
|||
|
|
b6ded5f94c | ||
|
|
63d0ad7ae8 | ||
|
|
1c8f53caf6 | ||
|
1d60b12d43
|
|||
|
e85a731410
|
|||
|
050af564ab
|
|||
|
6545124aa2
|
|||
| b93724add3 | |||
|
|
e1092113f6 | ||
|
|
0bb4bbe838 | ||
|
089812c555
|
|||
|
45c5f21cc4
|
|||
|
fb5959998a
|
|||
|
f319714489
|
|||
|
fb1f5b7ec7
|
|||
|
ac16cfd3bf
|
|||
|
9d5b0e01d2
|
|||
|
e7517f821d
|
|||
|
762f73063a
|
|||
|
7ca9e04dd3
|
|||
|
4d73dad780
|
|||
|
5419824c06
|
|||
|
075d05dd0b
|
|||
|
9266702cbb
|
|||
|
77e2d2b20e
|
|||
|
7819f546a6
|
|||
|
e37b38085d
|
|||
|
a3bf9ac32b
|
|||
|
|
5bd3245084 | ||
|
1cd2ec318a
|
|||
|
6c8bfa0272
|
|||
|
f69be14b5e
|
|||
|
dce554fc66
|
|||
|
f152dbdcf5
|
|||
|
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 |
49
.github/workflows/build.yml
vendored
49
.github/workflows/build.yml
vendored
@@ -2,19 +2,19 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
branches:
|
||||||
- "v*"
|
- master
|
||||||
workflow_dispatch:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
CI:
|
build:
|
||||||
name: 🔨🚀 Build and deploy
|
name: 🔨🚀 Build and deploy
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: 🚚 Checkout
|
- name: 🚚 Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: 🐍 Use Python 3.x
|
- name: 🐍 Use Python 3.x
|
||||||
uses: actions/setup-python@v3.1.4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.x
|
python-version: 3.x
|
||||||
cache: pip
|
cache: pip
|
||||||
@@ -24,11 +24,11 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 8
|
version: 10
|
||||||
- name: 🐉 Use Node.js 18.16.x
|
- name: 🐉 Use Node.js 22.14.x
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.16.x
|
node-version: 22.14.x
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
- name: ⏬ Install Node dependencies
|
- name: ⏬ Install Node dependencies
|
||||||
run: pnpm install
|
run: pnpm install
|
||||||
@@ -38,17 +38,18 @@ jobs:
|
|||||||
- name: 🔨 Build site
|
- name: 🔨 Build site
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: 📦 Upload build artifacts
|
- name: Setup SSH
|
||||||
uses: actions/upload-artifact@v3.1.2
|
run: |
|
||||||
with:
|
install -m 600 -D /dev/null ~/.ssh/id_rsa
|
||||||
name: build
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa
|
||||||
path: build
|
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||||
- name: Disable jekyll
|
|
||||||
run: touch build/.nojekyll
|
- name: Publish Stable
|
||||||
- name: Custom domain
|
if: ${{ github.ref == 'refs/tags/v*' && !github.event.pull_request.head.repo.fork }}
|
||||||
run: echo 'manager.charachorder.com' > build/CNAME
|
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
|
||||||
- run: git config user.name github-actions
|
|
||||||
- run: git config user.email github-actions@github.com
|
- name: Publish Branch
|
||||||
- run: git --work-tree build add --all
|
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||||
- run: git commit -m "Automatic Deploy action run by github-actions"
|
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
|
||||||
- run: git push origin HEAD:gh-pages --force
|
- name: Publish Commit
|
||||||
|
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}
|
||||||
|
|||||||
@@ -29,9 +29,15 @@ You may need to run through some additional setup to get Rust running inside Int
|
|||||||
- Python >=3.10
|
- Python >=3.10
|
||||||
- Rust Stable (For Tauri Development)
|
- Rust Stable (For Tauri Development)
|
||||||
|
|
||||||
I know, python in JS projects is extremely annoying, unfortunately,
|
I know, python in JS projects is extremely annoying. Unfortunately,
|
||||||
it seems to be the only platform that offers a functional
|
it seems to be the only platform that offers a functional
|
||||||
way to subset variable woff2 fonts with ligatures.
|
way to subset variable woff2 fonts with ligatures.
|
||||||
|
|
||||||
In other words, either have python as a development dependency or
|
In other words, either have python as a development dependency or
|
||||||
serve a 3.5MB icons font of which 99.5% is completely unused.
|
serve a 3.5MB icons font of which 99.5% is completely unused.
|
||||||
|
|
||||||
|
To generate the icons use the following command:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
npm run minify-icons
|
||||||
|
```
|
||||||
58
flake.lock
generated
58
flake.lock
generated
@@ -5,29 +5,11 @@
|
|||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1689068808,
|
"lastModified": 1731533236,
|
||||||
"narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
"owner": "numtide",
|
"owner": "numtide",
|
||||||
"repo": "flake-utils",
|
"repo": "flake-utils",
|
||||||
"rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"flake-utils_2": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems_2"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681202837,
|
|
||||||
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -38,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1689752456,
|
"lastModified": 1743259260,
|
||||||
"narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
|
"narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
|
"rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -54,11 +36,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1681358109,
|
"lastModified": 1736320768,
|
||||||
"narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
|
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
|
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -77,15 +59,14 @@
|
|||||||
},
|
},
|
||||||
"rust-overlay": {
|
"rust-overlay": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils_2",
|
|
||||||
"nixpkgs": "nixpkgs_2"
|
"nixpkgs": "nixpkgs_2"
|
||||||
},
|
},
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1690942540,
|
"lastModified": 1743388531,
|
||||||
"narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
|
"narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=",
|
||||||
"owner": "oxalica",
|
"owner": "oxalica",
|
||||||
"repo": "rust-overlay",
|
"repo": "rust-overlay",
|
||||||
"rev": "aa3994f054038262df55122dfa552b9eab71a994",
|
"rev": "011de3c895927300651d9c2cb8e062adf17aa665",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -108,21 +89,6 @@
|
|||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"systems_2": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"root": "root",
|
"root": "root",
|
||||||
|
|||||||
16
flake.nix
16
flake.nix
@@ -14,7 +14,13 @@
|
|||||||
flake-utils.lib.eachDefaultSystem (
|
flake-utils.lib.eachDefaultSystem (
|
||||||
system:
|
system:
|
||||||
let
|
let
|
||||||
overlays = [ (import rust-overlay) ];
|
overlays = [
|
||||||
|
(import rust-overlay)
|
||||||
|
(final: prev: {
|
||||||
|
nodejs = prev.nodejs_22;
|
||||||
|
corepack = prev.corepack_22;
|
||||||
|
})
|
||||||
|
];
|
||||||
pkgs = import nixpkgs { inherit system overlays; };
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||||
extensions = [
|
extensions = [
|
||||||
@@ -46,8 +52,8 @@
|
|||||||
];
|
];
|
||||||
packages =
|
packages =
|
||||||
(with pkgs; [
|
(with pkgs; [
|
||||||
nodejs_18
|
nodejs
|
||||||
nodePackages.pnpm
|
pnpm
|
||||||
rust-bin
|
rust-bin
|
||||||
fontMin
|
fontMin
|
||||||
])
|
])
|
||||||
@@ -59,7 +65,7 @@
|
|||||||
openssl_3
|
openssl_3
|
||||||
glib
|
glib
|
||||||
gtk3
|
gtk3
|
||||||
libsoup
|
libsoup_2_4
|
||||||
webkitgtk
|
webkitgtk
|
||||||
librsvg
|
librsvg
|
||||||
# serial plugin
|
# serial plugin
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
devShell = pkgs.mkShell {
|
devShell = pkgs.mkShell {
|
||||||
buildInputs = packages;
|
buildInputs = packages;
|
||||||
shellHook = ''
|
shellHook = ''
|
||||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
#export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const config = {
|
|||||||
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
|
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
|
||||||
outputPath: "src/lib/assets/icons.min.woff2",
|
outputPath: "src/lib/assets/icons.min.woff2",
|
||||||
icons: [
|
icons: [
|
||||||
|
"rocket_launch",
|
||||||
|
"deployed_code_update",
|
||||||
"adjust",
|
"adjust",
|
||||||
"add",
|
"add",
|
||||||
"piano",
|
"piano",
|
||||||
@@ -18,6 +20,8 @@ const config = {
|
|||||||
"update",
|
"update",
|
||||||
"offline_pin",
|
"offline_pin",
|
||||||
"warning",
|
"warning",
|
||||||
|
"dangerous",
|
||||||
|
"check",
|
||||||
"cable",
|
"cable",
|
||||||
"person",
|
"person",
|
||||||
"sync",
|
"sync",
|
||||||
@@ -40,6 +44,19 @@ const config = {
|
|||||||
"arrow_back_ios_new",
|
"arrow_back_ios_new",
|
||||||
"save",
|
"save",
|
||||||
"settings_backup_restore",
|
"settings_backup_restore",
|
||||||
|
"sound_detection_loud_sound",
|
||||||
|
"ring_volume",
|
||||||
|
"wifi",
|
||||||
|
"power_settings_circle",
|
||||||
|
"audio",
|
||||||
|
"mail",
|
||||||
|
"calculator",
|
||||||
|
"open_in_browser",
|
||||||
|
"chevron_backward",
|
||||||
|
"chevron_forward",
|
||||||
|
"bookmark",
|
||||||
|
"drag_pan",
|
||||||
|
"markdown_copy",
|
||||||
"sort",
|
"sort",
|
||||||
"shopping_bag",
|
"shopping_bag",
|
||||||
"filter_list",
|
"filter_list",
|
||||||
@@ -65,12 +82,19 @@ const config = {
|
|||||||
"bolt",
|
"bolt",
|
||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"redo",
|
||||||
|
"replay",
|
||||||
|
"reply",
|
||||||
"navigate_before",
|
"navigate_before",
|
||||||
"navigate_next",
|
"navigate_next",
|
||||||
|
"library_add",
|
||||||
|
"reset_wrench",
|
||||||
|
"reset_settings",
|
||||||
|
"delete_sweep",
|
||||||
"print",
|
"print",
|
||||||
"restore_from_trash",
|
"restore_from_trash",
|
||||||
"history",
|
"history",
|
||||||
"history_toggle_off",
|
"history_toggle_off",
|
||||||
|
"text_to_speech",
|
||||||
"sentiment_satisfied",
|
"sentiment_satisfied",
|
||||||
"sentiment_dissatisfied",
|
"sentiment_dissatisfied",
|
||||||
"sentiment_very_satisfied",
|
"sentiment_very_satisfied",
|
||||||
@@ -84,6 +108,7 @@ const config = {
|
|||||||
"sentiment_sad",
|
"sentiment_sad",
|
||||||
"sentiment_content",
|
"sentiment_content",
|
||||||
"sentiment_worried",
|
"sentiment_worried",
|
||||||
|
"construction",
|
||||||
"timer",
|
"timer",
|
||||||
"target",
|
"target",
|
||||||
"download",
|
"download",
|
||||||
@@ -91,9 +116,23 @@ const config = {
|
|||||||
"upload_2",
|
"upload_2",
|
||||||
"stat_minus_2",
|
"stat_minus_2",
|
||||||
"stat_2",
|
"stat_2",
|
||||||
|
"send",
|
||||||
|
"more_horiz",
|
||||||
|
"add_reaction",
|
||||||
|
"stop",
|
||||||
"description",
|
"description",
|
||||||
"add_circle",
|
"add_circle",
|
||||||
"refresh",
|
"refresh",
|
||||||
|
"tune",
|
||||||
|
"edit_document",
|
||||||
|
"chat",
|
||||||
|
"account_circle",
|
||||||
|
"experiment",
|
||||||
|
"code",
|
||||||
|
"dictionary",
|
||||||
|
"developer_board",
|
||||||
|
"developer_board_off",
|
||||||
|
"memory",
|
||||||
],
|
],
|
||||||
codePoints: {
|
codePoints: {
|
||||||
speed: "e9e4",
|
speed: "e9e4",
|
||||||
@@ -113,6 +152,8 @@ const config = {
|
|||||||
stat_minus_2: "e69c",
|
stat_minus_2: "e69c",
|
||||||
stat_2: "e699",
|
stat_2: "e699",
|
||||||
routine: "e20c",
|
routine: "e20c",
|
||||||
|
experiment: "e686",
|
||||||
|
dictionary: "f539",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
92
package.json
92
package.json
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "1.5.2",
|
"version": "2.2.3",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.16",
|
"node": ">=22.14",
|
||||||
"pnpm": ">=8.6"
|
"pnpm": ">=10.7"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -34,55 +34,63 @@
|
|||||||
"typesafe-i18n": "typesafe-i18n"
|
"typesafe-i18n": "typesafe-i18n"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.17.0",
|
"@codemirror/autocomplete": "^6.18.6",
|
||||||
"@codemirror/commands": "^6.6.0",
|
"@codemirror/commands": "^6.8.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.2",
|
"@codemirror/lang-javascript": "^6.2.3",
|
||||||
"@codemirror/language": "^6.10.2",
|
"@codemirror/language": "^6.11.0",
|
||||||
"@codemirror/state": "^6.4.1",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.28.4",
|
"@codemirror/view": "^6.36.5",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.0.34",
|
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
"@fontsource-variable/noto-sans-mono": "^5.2.6",
|
||||||
"@lezer/highlight": "^1.2.0",
|
"@lezer/highlight": "^1.2.1",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@melt-ui/pp": "^0.3.2",
|
||||||
"@sveltejs/adapter-static": "^3.0.2",
|
"@melt-ui/svelte": "^0.86.6",
|
||||||
"@sveltejs/kit": "^2.5.18",
|
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.1",
|
"@sveltejs/adapter-static": "^3.0.8",
|
||||||
|
"@sveltejs/kit": "^2.20.2",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tauri-apps/api": "^1.6.0",
|
"@tauri-apps/api": "^1.6.0",
|
||||||
"@tauri-apps/cli": "^1.6.0",
|
"@tauri-apps/cli": "^1.6.0",
|
||||||
"@types/dom-view-transitions": "^1.0.4",
|
"@types/dom-view-transitions": "^1.0.6",
|
||||||
"@types/flexsearch": "^0.7.6",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
"@types/w3c-web-serial": "^1.0.6",
|
|
||||||
"@types/w3c-web-usb": "^1.0.10",
|
"@types/w3c-web-usb": "^1.0.10",
|
||||||
"@vite-pwa/sveltekit": "^0.6.0",
|
"@types/wicg-file-system-access": "^2023.10.5",
|
||||||
"autoprefixer": "^10.4.19",
|
"@vite-pwa/sveltekit": "^1.0.0",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.1",
|
||||||
"cypress": "^13.13.0",
|
"cypress": "^14.2.1",
|
||||||
"flexsearch": "^0.7.43",
|
"d3": "^7.9.0",
|
||||||
"fontkit": "^2.0.2",
|
"esptool-js": "^0.5.4",
|
||||||
"glob": "^10.4.3",
|
"flexsearch": "^0.8.147",
|
||||||
"jsdom": "^24.1.0",
|
"fontkit": "^2.0.4",
|
||||||
|
"glob": "^11.0.1",
|
||||||
|
"jsdom": "^26.0.0",
|
||||||
|
"matrix-js-sdk": "^37.2.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.3.2",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-svelte": "^3.2.5",
|
"prettier-plugin-svelte": "^3.3.3",
|
||||||
"sass": "^1.77.6",
|
"rxjs": "^7.8.2",
|
||||||
"stylelint": "^16.6.1",
|
"sass": "^1.86.0",
|
||||||
"stylelint-config-clean-order": "^6.1.0",
|
"socket.io-client": "^4.8.1",
|
||||||
|
"stylelint": "^16.17.0",
|
||||||
|
"stylelint-config-clean-order": "^7.0.0",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-recommended-scss": "^14.0.0",
|
"stylelint-config-recommended-scss": "^14.1.0",
|
||||||
"stylelint-config-standard-scss": "^13.1.0",
|
"stylelint-config-standard-scss": "^14.0.0",
|
||||||
"svelte": "^4.2.18",
|
"svelte": "5.25.3",
|
||||||
"svelte-check": "^3.8.4",
|
"svelte-check": "^4.1.5",
|
||||||
"svelte-preprocess": "^6.0.1",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.5.3",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^5.3.3",
|
"vite": "^6.2.4",
|
||||||
"vite-plugin-mkcert": "^1.17.5",
|
"vite-plugin-mkcert": "^1.17.8",
|
||||||
"vite-plugin-pwa": "^0.20.0",
|
"vite-plugin-pwa": "^1.0.0",
|
||||||
"vitest": "^1.6.0",
|
"vitest": "^3.1.1",
|
||||||
"workbox-window": "^7.1.0"
|
"web-serial-polyfill": "^1.0.15",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
10544
pnpm-lock.yaml
generated
10544
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "1.5.2"
|
version = "2.2.3"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||||
license = "AGPL-3"
|
license = "AGPL-3"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devPath": "http://localhost:5173",
|
"devPath": "http://localhost:5173",
|
||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": { "productName": "amacc1ng", "version": "1.5.2" },
|
"package": { "productName": "amacc1ng", "version": "2.2.3" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
2
src/env.d.ts
vendored
2
src/env.d.ts
vendored
@@ -14,6 +14,8 @@ interface ImportMetaEnv {
|
|||||||
readonly VITE_LEARN_URL: string;
|
readonly VITE_LEARN_URL: string;
|
||||||
readonly VITE_LATEST_FIRMWARE: string;
|
readonly VITE_LATEST_FIRMWARE: string;
|
||||||
readonly VITE_STORE_URL: string;
|
readonly VITE_STORE_URL: string;
|
||||||
|
readonly VITE_MATRIX_URL: string;
|
||||||
|
readonly VITE_FIRMWARE_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ const de = {
|
|||||||
RELOAD: "Neu laden",
|
RELOAD: "Neu laden",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Lokale Kopie",
|
TITLE: "Backup",
|
||||||
INDIVIDUAL: "Einzeldateien",
|
AUTO_BACKUP: "Auto-backup",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
||||||
DOWNLOAD: "Alles herunterladen",
|
DOWNLOAD: "Alles",
|
||||||
RESTORE: "Wiederherstellen",
|
RESTORE: "Wiederherstellen",
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
@@ -109,7 +109,7 @@ const de = {
|
|||||||
},
|
},
|
||||||
configure: {
|
configure: {
|
||||||
chords: {
|
chords: {
|
||||||
TITLE: "Akkorde",
|
TITLE: "Bibliothek",
|
||||||
HOLD_KEYS: "Akkord halten",
|
HOLD_KEYS: "Akkord halten",
|
||||||
NEW_CHORD: "Neuer Akkord",
|
NEW_CHORD: "Neuer Akkord",
|
||||||
DUPLICATE: "Akkord existiert bereits",
|
DUPLICATE: "Akkord existiert bereits",
|
||||||
@@ -131,7 +131,7 @@ const de = {
|
|||||||
TITLE: "Layout",
|
TITLE: "Layout",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
TITLE: "Einstellungen",
|
TITLE: "Gerät",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ const en = {
|
|||||||
TITLE: "Update your device",
|
TITLE: "Update your device",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Local backup",
|
TITLE: "Backup",
|
||||||
INDIVIDUAL: "Individual backups",
|
AUTO_BACKUP: "Auto-backup",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"A backup is made and stored in this browser, and always remains only on your computer.",
|
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
|
||||||
DOWNLOAD: "Download Everything",
|
DOWNLOAD: "Everything",
|
||||||
RESTORE: "Restore",
|
RESTORE: "Restore",
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
@@ -108,7 +108,7 @@ const en = {
|
|||||||
},
|
},
|
||||||
configure: {
|
configure: {
|
||||||
chords: {
|
chords: {
|
||||||
TITLE: "Chords",
|
TITLE: "Library",
|
||||||
HOLD_KEYS: "Hold chord",
|
HOLD_KEYS: "Hold chord",
|
||||||
NEW_CHORD: "New chord",
|
NEW_CHORD: "New chord",
|
||||||
DUPLICATE: "Chord already exists",
|
DUPLICATE: "Chord already exists",
|
||||||
@@ -130,7 +130,7 @@ const en = {
|
|||||||
TITLE: "Layout",
|
TITLE: "Layout",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
TITLE: "Settings",
|
TITLE: "Device",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
|
|||||||
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>
|
||||||
@@ -1,497 +0,0 @@
|
|||||||
s + Dup,say
|
|
||||||
y + b,by
|
|
||||||
y + Dup,why
|
|
||||||
n + Dup,no
|
|
||||||
n + f,find
|
|
||||||
l + f,life
|
|
||||||
l + p,people
|
|
||||||
l + s + p,spell
|
|
||||||
l + n,line
|
|
||||||
l + n + p,plant
|
|
||||||
t + h,that
|
|
||||||
t + n + h,than
|
|
||||||
t + n + p,plant
|
|
||||||
t + l + f,left
|
|
||||||
a + f,after
|
|
||||||
a + d,add
|
|
||||||
a + d + f,had
|
|
||||||
a + h,has
|
|
||||||
a + s,as
|
|
||||||
a + s + h,has
|
|
||||||
a + y + d,day
|
|
||||||
a + y + s,say
|
|
||||||
a + n,an
|
|
||||||
a + n + d,and
|
|
||||||
a + n + d + f,hand
|
|
||||||
a + n + h,hand
|
|
||||||
a + n + y,any
|
|
||||||
a + l,all
|
|
||||||
a + l + d,land
|
|
||||||
a + l + p,plant
|
|
||||||
a + l + s,last
|
|
||||||
a + l + y + p,play
|
|
||||||
a + l + n + d,land
|
|
||||||
a + t,at
|
|
||||||
a + t + h,that
|
|
||||||
a + t + n + h,than
|
|
||||||
a + t + l + s,last
|
|
||||||
a + t + l + n + p,plant
|
|
||||||
- + ?,question
|
|
||||||
w + h,who
|
|
||||||
w + s,saw
|
|
||||||
w + y + h,why
|
|
||||||
w + t,without
|
|
||||||
w + t + h,watch
|
|
||||||
w + t + n,went
|
|
||||||
w + a,was
|
|
||||||
w + a + h,what
|
|
||||||
w + a + s,saw
|
|
||||||
w + a + y,way
|
|
||||||
w + a + y + Dup,away
|
|
||||||
w + a + n,want
|
|
||||||
w + a + l + f,walk
|
|
||||||
w + a + l + y,always
|
|
||||||
w + a + l + y + s,always
|
|
||||||
w + a + t,watch
|
|
||||||
w + a + t + h,watch
|
|
||||||
w + a + t + n,want
|
|
||||||
g + b,begin
|
|
||||||
g + Dup,question
|
|
||||||
g + h,here
|
|
||||||
g + p,page
|
|
||||||
g + l + n,long
|
|
||||||
g + t,get
|
|
||||||
k + q,quick
|
|
||||||
k + f,and
|
|
||||||
k + l + y + q,quickly
|
|
||||||
k + t + l,talk
|
|
||||||
k + a + b,back
|
|
||||||
k + a + s,ask
|
|
||||||
k + a + t + l,talk
|
|
||||||
k + w + a + l,walk
|
|
||||||
m + f,form
|
|
||||||
m + y,my
|
|
||||||
m + t + Dup,mountain
|
|
||||||
m + a + f,family
|
|
||||||
m + a + s,small
|
|
||||||
m + a + y,may
|
|
||||||
m + a + n,man
|
|
||||||
m + a + n + y,many
|
|
||||||
m + a + l,almost
|
|
||||||
m + a + l + s,small
|
|
||||||
m + a + l + s + Dup,small
|
|
||||||
c + b,because
|
|
||||||
c + Dup,sea
|
|
||||||
c + h,head
|
|
||||||
c + a + n,can
|
|
||||||
c + a + n + h,change
|
|
||||||
c + a + l,call
|
|
||||||
c + a + l + p,place
|
|
||||||
c + k + a + b,back
|
|
||||||
u + p,us
|
|
||||||
u + y,you
|
|
||||||
u + j,just
|
|
||||||
u + j + s,just
|
|
||||||
u + t + b,but
|
|
||||||
u + t + p,put
|
|
||||||
u + t + s + d,study
|
|
||||||
u + t + n,until
|
|
||||||
u + t + j + s,just
|
|
||||||
u + a + l,last
|
|
||||||
u + k + q,quick
|
|
||||||
u + k + l + y + q,quickly
|
|
||||||
u + m + h,much
|
|
||||||
u + m + n,number
|
|
||||||
u + m + t + s,must
|
|
||||||
u + m + c + h,much
|
|
||||||
u + c + s + h,such
|
|
||||||
u + c + t,cut
|
|
||||||
u + c + t + n,country
|
|
||||||
' + a + s,say
|
|
||||||
' + a + n,any
|
|
||||||
' + m + a + n,many
|
|
||||||
o + Dup,off
|
|
||||||
o + f,of
|
|
||||||
o + f + f,food
|
|
||||||
o + d,do
|
|
||||||
o + s,so
|
|
||||||
o + y + b,boy
|
|
||||||
o + n,on
|
|
||||||
o + n + s + Dup,soon
|
|
||||||
o + l,line
|
|
||||||
o + l + Dup,oil
|
|
||||||
o + l + d,old
|
|
||||||
o + l + y,only
|
|
||||||
o + l + n + y,only
|
|
||||||
o + t,to
|
|
||||||
o + t + b,both
|
|
||||||
o + t + Dup,too
|
|
||||||
o + t + s,stop
|
|
||||||
o + t + s + p,stop
|
|
||||||
o + t + n,not
|
|
||||||
o + t + n + f,often
|
|
||||||
o + t + n + d,don't
|
|
||||||
o + t + n + p,point
|
|
||||||
o + a + n + h,another
|
|
||||||
o + a + l,also
|
|
||||||
o + a + l + s,also
|
|
||||||
o + w,own
|
|
||||||
o + w + h,how
|
|
||||||
o + w + s + h,show
|
|
||||||
o + w + n,now
|
|
||||||
o + w + n + d,down
|
|
||||||
o + w + l + f,follow
|
|
||||||
o + w + t,two
|
|
||||||
o + g,go
|
|
||||||
o + g + Dup,good
|
|
||||||
o + g + n + s,song
|
|
||||||
o + g + l + n,long
|
|
||||||
o + g + t,got
|
|
||||||
o + g + a + l,along
|
|
||||||
o + g + a + l + n,along
|
|
||||||
o + k + b,book
|
|
||||||
o + k + n,know
|
|
||||||
o + k + l,look
|
|
||||||
o + k + t,took
|
|
||||||
o + k + t + Dup,took
|
|
||||||
o + k + w + n,know
|
|
||||||
o + v + a + b,above
|
|
||||||
o + v + k,move
|
|
||||||
o + m + s,some
|
|
||||||
o + m + t + s,most
|
|
||||||
o + m + a + l,almost
|
|
||||||
o + m + a + l + s,almost
|
|
||||||
o + m + a + t + l + s,almost
|
|
||||||
o + c + f,food
|
|
||||||
o + c + l + s + h,school
|
|
||||||
o + u,our
|
|
||||||
o + u + f,four
|
|
||||||
o + u + s + h,should
|
|
||||||
o + u + y,you
|
|
||||||
o + u + n + f,found
|
|
||||||
o + u + n + f + f,found
|
|
||||||
o + u + n + s,sound
|
|
||||||
o + u + n + s + d,sound
|
|
||||||
o + u + l + s + d + f,should
|
|
||||||
o + u + l + s + h,should
|
|
||||||
o + u + t,out
|
|
||||||
o + u + a + b,about
|
|
||||||
o + u + a + t + b,about
|
|
||||||
o + u + w + l + d,would
|
|
||||||
o + u + g + n + y,young
|
|
||||||
o + u + g + t + h,thought
|
|
||||||
o + u + m + t + n,mountain
|
|
||||||
o + u + m + t + n + Dup,mountain
|
|
||||||
o + u + c + l + d,could
|
|
||||||
o + ' + t + n + d,don't
|
|
||||||
o + o + l,oil
|
|
||||||
o + o + t + n,into
|
|
||||||
o + o + t + n + p,point
|
|
||||||
o + o + u + w + t + h,without
|
|
||||||
o + o + u + m + a + t + n,mountain
|
|
||||||
i + f,if
|
|
||||||
i + f + f,different
|
|
||||||
i + d,did
|
|
||||||
i + RH_Thumb_1_Center,different
|
|
||||||
i + s,is
|
|
||||||
i + s + d,side
|
|
||||||
i + s + h,his
|
|
||||||
i + n,in
|
|
||||||
i + n + f + f,find
|
|
||||||
i + l,list
|
|
||||||
i + l + n,line
|
|
||||||
i + t,it
|
|
||||||
i + t + s + Dup,still
|
|
||||||
i + t + s + h,this
|
|
||||||
i + t + n,into
|
|
||||||
i + t + l + s,list
|
|
||||||
i + t + l + s + Dup,still
|
|
||||||
i + a + s,said
|
|
||||||
i + a + s + d,said
|
|
||||||
i + a + n,animal
|
|
||||||
i + w + h,which
|
|
||||||
i + w + h + Dup,which
|
|
||||||
i + w + l,will
|
|
||||||
i + w + l + Dup,will
|
|
||||||
i + w + l + h,while
|
|
||||||
i + w + t + h,with
|
|
||||||
i + g,give
|
|
||||||
i + g + b,big
|
|
||||||
i + g + h,high
|
|
||||||
i + g + n + h,night
|
|
||||||
i + g + l + h,light
|
|
||||||
i + g + t + n + h,thing
|
|
||||||
i + g + t + l + h,light
|
|
||||||
i + g + a + n,again
|
|
||||||
i + g + a + n + Dup,again
|
|
||||||
i + k + n + d,kind
|
|
||||||
i + k + l,like
|
|
||||||
i + k + t + n + h,think
|
|
||||||
i + v + l,live
|
|
||||||
i + m + h,him
|
|
||||||
i + m + p,important
|
|
||||||
i + m + s,miss
|
|
||||||
i + m + s + Dup,miss
|
|
||||||
i + m + l,mile
|
|
||||||
i + m + t,time
|
|
||||||
i + m + t + h,might
|
|
||||||
i + m + a + n,animal
|
|
||||||
i + m + a + n + Dup,animal
|
|
||||||
i + m + a + l + n,another
|
|
||||||
i + m + g + t + h,might
|
|
||||||
i + c + p,picture
|
|
||||||
i + c + t + y,city
|
|
||||||
i + u + t + l + n,until
|
|
||||||
i + u + w + t + h,without
|
|
||||||
i + u + k + q,quick
|
|
||||||
i + u + k + l + y + q,quickly
|
|
||||||
i + u + c + k + q,quick
|
|
||||||
i + u + c + k + l + y + q,quickly
|
|
||||||
i + ' + t,it's
|
|
||||||
i + ' + t + s,it's
|
|
||||||
e + b,be
|
|
||||||
e + Dup,earth
|
|
||||||
e + x,example
|
|
||||||
e + f + b,before
|
|
||||||
e + h,he
|
|
||||||
e + h + Dup,here
|
|
||||||
e + s,state
|
|
||||||
e + s + Dup,see
|
|
||||||
e + s + h,she
|
|
||||||
e + y + Dup,eye
|
|
||||||
e + n,name
|
|
||||||
e + n + b,been
|
|
||||||
e + n + Dup,need
|
|
||||||
e + n + d,end
|
|
||||||
e + l + h,help
|
|
||||||
e + l + h + f,help
|
|
||||||
e + l + s + p,spell
|
|
||||||
e + t,the
|
|
||||||
e + t + Dup,eat
|
|
||||||
e + t + f,feet
|
|
||||||
e + t + h,there
|
|
||||||
e + t + s,set
|
|
||||||
e + t + s + h,these
|
|
||||||
e + t + y + h,they
|
|
||||||
e + t + n + x,next
|
|
||||||
e + t + n + h,then
|
|
||||||
e + t + l,let
|
|
||||||
e + t + l + Dup,tell
|
|
||||||
e + t + l + f,left
|
|
||||||
e + a,at
|
|
||||||
e + a + f,father
|
|
||||||
e + a + d + f,head
|
|
||||||
e + a + h,hear
|
|
||||||
e + a + p,paper
|
|
||||||
e + a + s,sea
|
|
||||||
e + a + y,year
|
|
||||||
e + a + n,name
|
|
||||||
e + a + t,eat
|
|
||||||
e + a + t + s + Dup,state
|
|
||||||
e + w,we
|
|
||||||
e + w + f,few
|
|
||||||
e + w + n,new
|
|
||||||
e + w + n + h,when
|
|
||||||
e + w + l,well
|
|
||||||
e + w + l + Dup,well
|
|
||||||
e + w + t + b,between
|
|
||||||
e + w + t + n,went
|
|
||||||
e + w + t + n + b,between
|
|
||||||
e + g + h,here
|
|
||||||
e + g + t,get
|
|
||||||
e + g + a + p,page
|
|
||||||
e + g + a + n + b,began
|
|
||||||
e + k,keep
|
|
||||||
e + k + p,keep
|
|
||||||
e + k + t,take
|
|
||||||
e + k + a + t,take
|
|
||||||
e + v + y,every
|
|
||||||
e + v + n,even
|
|
||||||
e + v + a + h,have
|
|
||||||
e + v + a + l,leave
|
|
||||||
e + v + a + l + Dup,leave
|
|
||||||
e + m,me
|
|
||||||
e + m + s,seem
|
|
||||||
e + m + s + Dup,seem
|
|
||||||
e + m + n,men
|
|
||||||
e + m + t + h,them
|
|
||||||
e + m + a,make
|
|
||||||
e + m + a + d,made
|
|
||||||
e + m + a + s,same
|
|
||||||
e + m + a + n,mean
|
|
||||||
e + m + a + l + p + x,example
|
|
||||||
e + m + c + a,came
|
|
||||||
e + c + s,second
|
|
||||||
e + c + t + n + s,sentence
|
|
||||||
e + c + a,came
|
|
||||||
e + c + a + f,face
|
|
||||||
e + c + a + h,each
|
|
||||||
e + c + a + l + p,place
|
|
||||||
e + LH_Thumb_1_Center + a,make
|
|
||||||
e + u + s,use
|
|
||||||
e + u + s + q,question
|
|
||||||
e + u + n + d,under
|
|
||||||
e + u + t + q,quite
|
|
||||||
e + u + c + a + s + b,because
|
|
||||||
e + o + p,open
|
|
||||||
e + o + s + d,does
|
|
||||||
e + o + n,one
|
|
||||||
e + o + n + p,open
|
|
||||||
e + o + l + b,below
|
|
||||||
e + o + l + h,hello
|
|
||||||
e + o + l + h + Dup,hello
|
|
||||||
e + o + l + p,people
|
|
||||||
e + o + t + f,often
|
|
||||||
e + o + t + h,other
|
|
||||||
e + o + t + s + h,those
|
|
||||||
e + o + t + n + f,often
|
|
||||||
e + o + w + l + b,below
|
|
||||||
e + o + g + t + h,together
|
|
||||||
e + o + v,over
|
|
||||||
e + o + v + a + b,above
|
|
||||||
e + o + v + k,move
|
|
||||||
e + o + m,move
|
|
||||||
e + o + m + h,home
|
|
||||||
e + o + m + s,some
|
|
||||||
e + o + m + t + s,sometime
|
|
||||||
e + o + m + t + s + h,something
|
|
||||||
e + o + m + g + t + n + s + h,something
|
|
||||||
e + o + m + c,come
|
|
||||||
e + o + c,come
|
|
||||||
e + o + c + n,once
|
|
||||||
e + o + c + n + s + d,second
|
|
||||||
e + o + c + l + s,close
|
|
||||||
e + o + u + s + h,house
|
|
||||||
e + o + u + n,enough
|
|
||||||
e + o + u + g + n + h,enough
|
|
||||||
e + i + s + d,side
|
|
||||||
e + i + l + f,life
|
|
||||||
e + i + l + n,line
|
|
||||||
e + i + t + q,quite
|
|
||||||
e + i + t + l,little
|
|
||||||
e + i + t + l + Dup,little
|
|
||||||
e + i + a + d,idea
|
|
||||||
e + i + w + l + h,while
|
|
||||||
e + i + w + t + h,white
|
|
||||||
e + i + g,give
|
|
||||||
e + i + g + p,give
|
|
||||||
e + i + g + n + b,begin
|
|
||||||
e + i + k + l,like
|
|
||||||
e + i + v + l,live
|
|
||||||
e + i + m + l,mile
|
|
||||||
e + i + m + t,time
|
|
||||||
e + i + u + t + q,quite
|
|
||||||
e + i + u + u + t + n + s + q,question
|
|
||||||
r + e + h,her
|
|
||||||
r + e + t + Dup,tree
|
|
||||||
r + e + t + h,there
|
|
||||||
r + e + t + h + Dup,three
|
|
||||||
r + e + t + l,letter
|
|
||||||
r + e + a,are
|
|
||||||
r + e + a + d,read
|
|
||||||
r + e + a + h,hear
|
|
||||||
r + e + a + p,paper
|
|
||||||
r + e + a + n,near
|
|
||||||
r + e + a + l + y,really
|
|
||||||
r + e + a + l + y + Dup,really
|
|
||||||
r + e + a + t + f,after
|
|
||||||
r + e + a + t + h,earth
|
|
||||||
r + e + a + t + RH_Thumb_1_Center,father
|
|
||||||
r + e + a + t + l,learn
|
|
||||||
r + e + w,were
|
|
||||||
r + e + w + Dup,were
|
|
||||||
r + e + w + h,where
|
|
||||||
r + e + w + a + n + s,answer
|
|
||||||
r + e + w + a + t,water
|
|
||||||
r + e + g + h,here
|
|
||||||
r + e + g + a + l,large
|
|
||||||
r + e + g + a + t,great
|
|
||||||
r + e + v + y,very
|
|
||||||
r + e + v + y + Dup,every
|
|
||||||
r + e + v + n,never
|
|
||||||
r + e + u + n + d,under
|
|
||||||
r + e + u + m + n + b,number
|
|
||||||
r + e + o + f + b,before
|
|
||||||
r + e + o + t + h,other
|
|
||||||
r + e + o + g + t + h,together
|
|
||||||
r + e + o + v,over
|
|
||||||
r + e + o + m,more
|
|
||||||
r + e + o + m + t + h,mother
|
|
||||||
r + e + i + t + h,their
|
|
||||||
r + e + i + t + n + f + f,different
|
|
||||||
r + e + i + v,river
|
|
||||||
r + e + i + c + l + d + f,children
|
|
||||||
r + e + i + u + c + t + p,picture
|
|
||||||
r + f,for
|
|
||||||
r + h,her
|
|
||||||
r + y,your
|
|
||||||
r + n,near
|
|
||||||
r + l,learn
|
|
||||||
r + t + Dup,tree
|
|
||||||
r + t + f,father
|
|
||||||
r + t + s + Dup,start
|
|
||||||
r + t + y,try
|
|
||||||
r + t + l,letter
|
|
||||||
r + t + l + Dup,letter
|
|
||||||
r + a,are
|
|
||||||
r + a + f,far
|
|
||||||
r + a + d,read
|
|
||||||
r + a + d + f,hard
|
|
||||||
r + a + h,hard
|
|
||||||
r + a + p,part
|
|
||||||
r + a + l + y,really
|
|
||||||
r + a + l + y + Dup,really
|
|
||||||
r + a + t + p,part
|
|
||||||
r + a + t + s + Dup,start
|
|
||||||
r + w,were
|
|
||||||
r + w + h,where
|
|
||||||
r + w + t,water
|
|
||||||
r + w + a + n + s,answer
|
|
||||||
r + g + t,great
|
|
||||||
r + g + a + l,large
|
|
||||||
r + v + y,very
|
|
||||||
r + v + n,never
|
|
||||||
r + v + l,later
|
|
||||||
r + m + f,form
|
|
||||||
r + c + a,car
|
|
||||||
r + c + a + y,carry
|
|
||||||
r + c + a + y + Dup,carry
|
|
||||||
r + u + n,run
|
|
||||||
r + u + t + h,through
|
|
||||||
r + u + t + n,turn
|
|
||||||
r + ' + t + s,story
|
|
||||||
r + o,or
|
|
||||||
r + o + f,for
|
|
||||||
r + o + t + y + s,story
|
|
||||||
r + o + a + n + h,another
|
|
||||||
r + o + w,work
|
|
||||||
r + o + w + d,word
|
|
||||||
r + o + w + l + d,world
|
|
||||||
r + o + g,grow
|
|
||||||
r + o + LeftDoubleClick,grow
|
|
||||||
r + o + k + w,work
|
|
||||||
r + o + m,more
|
|
||||||
r + o + m + f,from
|
|
||||||
r + o + m + t + h,mother
|
|
||||||
r + o + u,our
|
|
||||||
r + o + u + f,four
|
|
||||||
r + o + u + y,your
|
|
||||||
r + o + u + a,around
|
|
||||||
r + o + u + a + n,around
|
|
||||||
r + o + u + a + n + d,around
|
|
||||||
r + o + u + g,group
|
|
||||||
r + o + u + g + p,group
|
|
||||||
r + o + u + g + t + h,through
|
|
||||||
r + o + u + c + t + n,country
|
|
||||||
r + o + u + c + t + n + y,country
|
|
||||||
r + i + t + h,their
|
|
||||||
r + i + t + s + f,first
|
|
||||||
r + i + a,air
|
|
||||||
r + i + w + t,write
|
|
||||||
r + i + g + h,right
|
|
||||||
r + i + g + l,girl
|
|
||||||
r + i + g + t + h,right
|
|
||||||
r + i + v,river
|
|
||||||
r + i + v + Dup,river
|
|
||||||
r + i + m + t + n + p,important
|
|
||||||
r + i + c + l + h,children
|
|
||||||
|
@@ -9,145 +9,193 @@ actions:
|
|||||||
This action is unique in this way. Technically it is "printable", but it is not visible.
|
This action is unique in this way. Technically it is "printable", but it is not visible.
|
||||||
39:
|
39:
|
||||||
id: "'"
|
id: "'"
|
||||||
|
keyCode: Quote
|
||||||
title: Single Quote
|
title: Single Quote
|
||||||
44:
|
44:
|
||||||
id: ","
|
id: ","
|
||||||
|
keyCode: Comma
|
||||||
title: Comma
|
title: Comma
|
||||||
45:
|
45:
|
||||||
id: "-"
|
id: "-"
|
||||||
|
keyCode: Minus
|
||||||
title: Minus
|
title: Minus
|
||||||
46:
|
46:
|
||||||
id: "."
|
id: "."
|
||||||
|
keyCode: Period
|
||||||
title: Period
|
title: Period
|
||||||
47:
|
47:
|
||||||
id: "/"
|
id: "/"
|
||||||
|
keyCode: Slash
|
||||||
title: Forward Slash
|
title: Forward Slash
|
||||||
48:
|
48:
|
||||||
id: "0"
|
id: "0"
|
||||||
|
keyCode: Digit0
|
||||||
title: Zero
|
title: Zero
|
||||||
49:
|
49:
|
||||||
id: "1"
|
id: "1"
|
||||||
|
keyCode: Digit1
|
||||||
title: One
|
title: One
|
||||||
50:
|
50:
|
||||||
id: "2"
|
id: "2"
|
||||||
|
keyCode: Digit2
|
||||||
title: Two
|
title: Two
|
||||||
51:
|
51:
|
||||||
id: "3"
|
id: "3"
|
||||||
|
keyCode: Digit3
|
||||||
title: Three
|
title: Three
|
||||||
52:
|
52:
|
||||||
id: "4"
|
id: "4"
|
||||||
|
keyCode: Digit4
|
||||||
title: Four
|
title: Four
|
||||||
53:
|
53:
|
||||||
id: "5"
|
id: "5"
|
||||||
|
keyCode: Digit5
|
||||||
title: Five
|
title: Five
|
||||||
54:
|
54:
|
||||||
id: "6"
|
id: "6"
|
||||||
|
keyCode: Digit6
|
||||||
title: Six
|
title: Six
|
||||||
55:
|
55:
|
||||||
id: "7"
|
id: "7"
|
||||||
|
keyCode: Digit7
|
||||||
title: Seven
|
title: Seven
|
||||||
56:
|
56:
|
||||||
id: "8"
|
id: "8"
|
||||||
|
keyCode: Digit8
|
||||||
title: Eight
|
title: Eight
|
||||||
57:
|
57:
|
||||||
id: "9"
|
id: "9"
|
||||||
|
keyCode: Digit9
|
||||||
title: Nine
|
title: Nine
|
||||||
59:
|
59:
|
||||||
id: ";"
|
id: ";"
|
||||||
|
keyCode: Semicolon
|
||||||
title: Semicolon
|
title: Semicolon
|
||||||
61:
|
61:
|
||||||
id: "="
|
id: "="
|
||||||
|
keyCode: Equal
|
||||||
title: Equals
|
title: Equals
|
||||||
91:
|
91:
|
||||||
id: "["
|
id: "["
|
||||||
|
keyCode: BracketLeft
|
||||||
title: Left Bracket
|
title: Left Bracket
|
||||||
92:
|
92:
|
||||||
id: "\\"
|
id: "\\"
|
||||||
|
keyCode: Backslash
|
||||||
title: Backslash
|
title: Backslash
|
||||||
93:
|
93:
|
||||||
id: "]"
|
id: "]"
|
||||||
|
keyCode: BracketRight
|
||||||
title: Right Bracket
|
title: Right Bracket
|
||||||
96:
|
96:
|
||||||
id: "`"
|
id: "`"
|
||||||
|
keyCode: Backquote
|
||||||
title: Backtick
|
title: Backtick
|
||||||
97:
|
97:
|
||||||
id: "a"
|
id: "a"
|
||||||
|
keyCode: KeyA
|
||||||
title: Lowercase a
|
title: Lowercase a
|
||||||
98:
|
98:
|
||||||
id: "b"
|
id: "b"
|
||||||
|
keyCode: KeyB
|
||||||
title: Lowercase b
|
title: Lowercase b
|
||||||
99:
|
99:
|
||||||
id: "c"
|
id: "c"
|
||||||
|
keyCode: KeyC
|
||||||
title: Lowercase c
|
title: Lowercase c
|
||||||
100:
|
100:
|
||||||
id: "d"
|
id: "d"
|
||||||
|
keyCode: KeyD
|
||||||
title: Lowercase d
|
title: Lowercase d
|
||||||
101:
|
101:
|
||||||
id: "e"
|
id: "e"
|
||||||
|
keyCode: KeyE
|
||||||
title: Lowercase e
|
title: Lowercase e
|
||||||
102:
|
102:
|
||||||
id: "f"
|
id: "f"
|
||||||
|
keyCode: KeyF
|
||||||
title: Lowercase f
|
title: Lowercase f
|
||||||
103:
|
103:
|
||||||
id: "g"
|
id: "g"
|
||||||
|
keyCode: KeyG
|
||||||
title: Lowercase g
|
title: Lowercase g
|
||||||
104:
|
104:
|
||||||
id: "h"
|
id: "h"
|
||||||
|
keyCode: KeyH
|
||||||
title: Lowercase h
|
title: Lowercase h
|
||||||
105:
|
105:
|
||||||
id: "i"
|
id: "i"
|
||||||
|
keyCode: KeyI
|
||||||
title: Lowercase i
|
title: Lowercase i
|
||||||
106:
|
106:
|
||||||
id: "j"
|
id: "j"
|
||||||
|
keyCode: KeyJ
|
||||||
title: Lowercase j
|
title: Lowercase j
|
||||||
107:
|
107:
|
||||||
id: "k"
|
id: "k"
|
||||||
|
keyCode: KeyK
|
||||||
title: Lowercase k
|
title: Lowercase k
|
||||||
108:
|
108:
|
||||||
id: "l"
|
id: "l"
|
||||||
|
keyCode: KeyL
|
||||||
title: Lowercase l
|
title: Lowercase l
|
||||||
109:
|
109:
|
||||||
id: "m"
|
id: "m"
|
||||||
|
keyCode: KeyM
|
||||||
title: Lowercase m
|
title: Lowercase m
|
||||||
110:
|
110:
|
||||||
id: "n"
|
id: "n"
|
||||||
|
keyCode: KeyN
|
||||||
title: Lowercase n
|
title: Lowercase n
|
||||||
111:
|
111:
|
||||||
id: "o"
|
id: "o"
|
||||||
|
keyCode: KeyO
|
||||||
title: Lowercase o
|
title: Lowercase o
|
||||||
112:
|
112:
|
||||||
id: "p"
|
id: "p"
|
||||||
|
keyCode: KeyP
|
||||||
title: Lowercase p
|
title: Lowercase p
|
||||||
113:
|
113:
|
||||||
id: "q"
|
id: "q"
|
||||||
|
keyCode: KeyQ
|
||||||
title: Lowercase q
|
title: Lowercase q
|
||||||
114:
|
114:
|
||||||
id: "r"
|
id: "r"
|
||||||
|
keyCode: KeyR
|
||||||
title: Lowercase r
|
title: Lowercase r
|
||||||
115:
|
115:
|
||||||
id: "s"
|
id: "s"
|
||||||
|
keyCode: KeyS
|
||||||
title: Lowercase s
|
title: Lowercase s
|
||||||
116:
|
116:
|
||||||
id: "t"
|
id: "t"
|
||||||
|
keyCode: KeyT
|
||||||
title: Lowercase t
|
title: Lowercase t
|
||||||
117:
|
117:
|
||||||
id: "u"
|
id: "u"
|
||||||
|
keyCode: KeyU
|
||||||
title: Lowercase u
|
title: Lowercase u
|
||||||
118:
|
118:
|
||||||
id: "v"
|
id: "v"
|
||||||
|
keyCode: KeyV
|
||||||
title: Lowercase v
|
title: Lowercase v
|
||||||
119:
|
119:
|
||||||
id: "w"
|
id: "w"
|
||||||
|
KeyCode: KeyW
|
||||||
title: Lowercase w
|
title: Lowercase w
|
||||||
120:
|
120:
|
||||||
id: "x"
|
id: "x"
|
||||||
|
keyCode: KeyX
|
||||||
title: Lowercase x
|
title: Lowercase x
|
||||||
121:
|
121:
|
||||||
id: "y"
|
id: "y"
|
||||||
|
keyCode: KeyY
|
||||||
title: Lowercase y
|
title: Lowercase y
|
||||||
122:
|
122:
|
||||||
id: "z"
|
id: "z"
|
||||||
|
keyCode: KeyZ
|
||||||
title: Lowercase z
|
title: Lowercase z
|
||||||
127:
|
127:
|
||||||
id: "DEL"
|
id: "DEL"
|
||||||
|
keyCode: Delete
|
||||||
title: Delete
|
title: Delete
|
||||||
|
|||||||
@@ -104,6 +104,23 @@ actions:
|
|||||||
<<: *tertiary_keymap
|
<<: *tertiary_keymap
|
||||||
id: "KM_3_R"
|
id: "KM_3_R"
|
||||||
variant: right
|
variant: right
|
||||||
|
558:
|
||||||
|
id: HOLD_COMPOUND
|
||||||
|
title: Dynamic Library
|
||||||
|
icon: layers
|
||||||
|
description: |
|
||||||
|
Allows for the activation & creation of dynamic chord libraries.
|
||||||
|
When included as part of a chord output,
|
||||||
|
that chord's input becomes the seed for a dynamic chord library,
|
||||||
|
and that library is activated.
|
||||||
|
Any new chords created while a dynamic library is active are established one level above its seed.
|
||||||
|
559:
|
||||||
|
id: RELEASE_COMPOUND
|
||||||
|
title: Base Library
|
||||||
|
icon: layers_clear
|
||||||
|
description: |
|
||||||
|
Re-activates your base chord library,
|
||||||
|
and deactivates any currently active dynamic chord library.
|
||||||
576:
|
576:
|
||||||
id: ACTION_DELAY_1000
|
id: ACTION_DELAY_1000
|
||||||
icon: clock_loader_90
|
icon: clock_loader_90
|
||||||
|
|||||||
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 }
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
||||||
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
||||||
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
||||||
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
|
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
|
||||||
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
||||||
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
||||||
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
||||||
|
|||||||
@@ -1,118 +1,154 @@
|
|||||||
settings:
|
- name: spurring
|
||||||
0x1:
|
description: |
|
||||||
title: Enable Serial Header
|
"Chording only" mode which tells your device to output chords on a press
|
||||||
description: boolean 0 or 1, default is 0
|
rather than a press & release. It also enables you to jump from one
|
||||||
0x2:
|
chord to another without releasing everything and can be activated in
|
||||||
title: Enable Serial Logging
|
GTM or by chording both mirror keys. It can provide significant speed
|
||||||
description: boolean 0 or 1, default is 0
|
gains with chording, but also takes away the flexibility of character
|
||||||
0x3:
|
entry.
|
||||||
title: Enable Serial Debugging
|
items:
|
||||||
description: boolean 0 or 1, default is 0
|
- id: 0x41
|
||||||
0x4:
|
name: enable
|
||||||
title: Enable Serial Raw
|
range: [0, 1]
|
||||||
description: boolean 0 or 1, default is 0
|
- id: 0x43
|
||||||
0x5:
|
name: character counter timeout
|
||||||
title: Enable Serial Chord
|
range: [0, 240000]
|
||||||
description: boolean 0 or 1, default is 0
|
step: 1000
|
||||||
0x6:
|
scale: 0.001
|
||||||
title: Enable Serial Keyboard
|
unit: s
|
||||||
description: boolean 0 or 1, default is 0
|
- name: arpeggiates
|
||||||
0x7:
|
description: |
|
||||||
title: Enable Serial Mouse
|
Allows chord modifiers to be hit after instead of with a chord,
|
||||||
description: boolean 0 or 1, default is 0
|
and enables select keys to be placed before auto-spaces.
|
||||||
0x11:
|
items:
|
||||||
title: Enable USB HID Keyboard
|
- id: 0x51
|
||||||
description: boolean 0 or 1, default is 1
|
name: enable
|
||||||
0x12:
|
range: [0, 1]
|
||||||
title: Enable Character Entry
|
- id: 0x54
|
||||||
description: boolean 0 or 1
|
name: timeout
|
||||||
0x13:
|
range: [0, 2550]
|
||||||
title: GUI-CTRL Swap Mode
|
step: 10
|
||||||
description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only)
|
unit: ms
|
||||||
0x14:
|
- name: keyboard
|
||||||
title: Key Scan Duration
|
items:
|
||||||
description: scan rate described in milliseconds; default is 2ms = 500Hz
|
- id: 0x11
|
||||||
0x15:
|
name: enable
|
||||||
title: Key Debounce Press Duration
|
range: [0, 1]
|
||||||
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
|
- id: 0x12
|
||||||
0x16:
|
name: character entry
|
||||||
title: Key Debounce Release Duration
|
range: [0, 1]
|
||||||
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
|
- id: 0x13
|
||||||
0x17:
|
name: command option swap
|
||||||
title: Keyboard Output Character Microsecond Delays
|
range: [0, 1]
|
||||||
description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us
|
description: |
|
||||||
0x21:
|
Swaps ⌥ and ⌘ to make transitioning between Mac and other systems easier.
|
||||||
title: Enable USB HID Mouse
|
- id: 0x14
|
||||||
description: boolean 0 or 1; default is 1
|
name: poll rate
|
||||||
0x22:
|
range: [0, 255]
|
||||||
title: Slow Mouse Speed
|
unit: Hz
|
||||||
description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s
|
inverse: 1000
|
||||||
0x23:
|
- id: 0x15
|
||||||
title: Fast Mouse Speed
|
name: debounce press
|
||||||
description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s
|
range: [0, 255]
|
||||||
0x24:
|
unit: ms
|
||||||
title: Enable Active Mouse
|
- id: 0x16
|
||||||
description: boolean 0 or 1; moves mouse back and forth every 60s
|
name: debounce release
|
||||||
0x25:
|
range: [0, 255]
|
||||||
title: Mouse Scroll Speed
|
unit: ms
|
||||||
description: default is 1; polls at 1/4th the rate of the mouse move updates
|
- id: 0x17
|
||||||
0x26:
|
name: output delay
|
||||||
title: Mouse Poll Duration
|
range: [0, 10200]
|
||||||
description: poll rate described in milliseconds; default is 20ms = 50Hz
|
step: 40
|
||||||
0x31:
|
unit: µs
|
||||||
title: Enable Chording
|
- name: mouse
|
||||||
description: boolean 0 or 1
|
items:
|
||||||
0x32:
|
- id: 0x21
|
||||||
title: Enable Chording Character Counter Timeout
|
name: enable
|
||||||
description: boolean 0 or 1; default is 1
|
range: [0, 1]
|
||||||
0x33:
|
- id: 0x22
|
||||||
title: Chording Character Counter Timeout Timer
|
name: slow speed
|
||||||
description: 0-255 deciseconds; default is 40 or 4.0 seconds
|
range: [0, 255]
|
||||||
0x34:
|
unit: px
|
||||||
title: Chord Detection Press Tolerance(ms)
|
- id: 0x23
|
||||||
description: 1-50 milliseconds
|
name: fast speed
|
||||||
0x35:
|
range: [0, 255]
|
||||||
title: Chord Detection Release Tolerance(ms)
|
unit: px
|
||||||
description: 1-50 milliseconds
|
- id: 0x24
|
||||||
0x41:
|
name: caffeine
|
||||||
title: Enable Spurring
|
range: [0, 1]
|
||||||
description: boolean 0 or 1; default is 1
|
description: |
|
||||||
0x42:
|
Keeps computer alive by moving the mouse back and forth one pixel every 60s
|
||||||
title: Enable Spurring Character Counter Timeout
|
- id: 0x25
|
||||||
description: boolean 0 or 1; default is 1
|
name: scroll speed
|
||||||
0x43:
|
range: [0, 255]
|
||||||
title: Spurring Character Counter Timeout Timer
|
unit: pg
|
||||||
description: 0-255 seconds; default is 240
|
- id: 0x26
|
||||||
0x51:
|
name: poll rate
|
||||||
title: Enable Arpeggiates
|
range: [0, 255]
|
||||||
description: boolean 0 or 1; default is 1
|
unit: Hz
|
||||||
0x54:
|
inverse: 1000
|
||||||
title: Arpeggiate Tolerance
|
- name: chording
|
||||||
description: in milliseconds; default 800ms
|
items:
|
||||||
0x61:
|
- id: 0x31
|
||||||
title: Enable Compound Chording (coming soon)
|
name: enable
|
||||||
description: boolean 0 or 1; default is 0
|
range: [0, 1]
|
||||||
0x64:
|
- id: 0x33
|
||||||
title: Compound Tolerance
|
name: auto delete timeout
|
||||||
description: in milliseconds; default 1500ms
|
range: [0, 25500]
|
||||||
0x81:
|
step: 100
|
||||||
title: LED Brightness
|
- id: 0x34
|
||||||
description: 0-50 (CCL only); default is 5, which draws around 100 mA of current
|
name: press tolerance
|
||||||
0x82:
|
description: |
|
||||||
title: LED Color Code
|
Scales with the number of chord inputs.
|
||||||
description: Color Codes to be listed (CCL only)
|
range: [0, 255]
|
||||||
0x83:
|
unit: ms
|
||||||
title: Enable LED Key Highlight (coming soon)
|
- id: 0x35
|
||||||
description: boolean 0 or 1 (CCL only)
|
name: release tolerance
|
||||||
0x84:
|
description: |
|
||||||
title: Enable LEDs
|
Scales with the number of chord inputs.
|
||||||
description: boolean 0 or 1; default is 1 (CCL only)
|
range: [0, 255]
|
||||||
0x91:
|
unit: ms
|
||||||
title: Operating System
|
- name: leds
|
||||||
description: Operating system codes listed below
|
items:
|
||||||
0x92:
|
- id: 0x84
|
||||||
title: Enable Realtime Feedback
|
name: enable
|
||||||
description: boolean 0 or 1; default is 1
|
range: [0, 1]
|
||||||
0x93:
|
- id: 0x81
|
||||||
title: Enable CharaChorder Ready on startup
|
name: brightness
|
||||||
description: boolean 0 or 1; default is 1
|
range: [0, 50]
|
||||||
|
- id: 0x82
|
||||||
|
name: base color code
|
||||||
|
enum:
|
||||||
|
white: 0
|
||||||
|
red: 1
|
||||||
|
orange: 2
|
||||||
|
yellow: 3
|
||||||
|
charteuse: 4
|
||||||
|
green: 5
|
||||||
|
spring green: 6
|
||||||
|
cyan: 7
|
||||||
|
azure: 8
|
||||||
|
blue: 9
|
||||||
|
violet: 10
|
||||||
|
magenta: 11
|
||||||
|
rose: 12
|
||||||
|
rainbow: 13
|
||||||
|
- id: 0x83
|
||||||
|
name: highlight
|
||||||
|
range: [0, 1]
|
||||||
|
- name: misc
|
||||||
|
items:
|
||||||
|
- id: 0x91
|
||||||
|
name: operating system
|
||||||
|
enum:
|
||||||
|
windows: 0
|
||||||
|
mac: 1
|
||||||
|
linux: 2
|
||||||
|
ios: 3
|
||||||
|
android: 4
|
||||||
|
- id: 0x92
|
||||||
|
name: GTM realtime feedback
|
||||||
|
range: [0, 1]
|
||||||
|
- id: 0x93
|
||||||
|
name: startup message
|
||||||
|
range: [0, 1]
|
||||||
|
|||||||
@@ -107,32 +107,32 @@ export function restoreFromFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(
|
changes.push([
|
||||||
...getChangesFromChordFile(recent[0]),
|
...getChangesFromChordFile(recent[0]),
|
||||||
...getChangesFromLayoutFile(recent[1]),
|
...getChangesFromLayoutFile(recent[1]),
|
||||||
...getChangesFromSettingsFile(recent[2]),
|
...getChangesFromSettingsFile(recent[2]),
|
||||||
);
|
]);
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chords": {
|
case "chords": {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(...getChangesFromChordFile(file));
|
changes.push(getChangesFromChordFile(file));
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "layout": {
|
case "layout": {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(...getChangesFromLayoutFile(file));
|
changes.push(getChangesFromLayoutFile(file));
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "settings": {
|
case "settings": {
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push(...getChangesFromSettingsFile(file));
|
changes.push(getChangesFromSettingsFile(file));
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -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
|
|
||||||
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>
|
||||||
20
src/lib/charrecorder/TrackText.svelte
Normal file
20
src/lib/charrecorder/TrackText.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import type { ReplayPlayer } from "./core/player";
|
||||||
|
import { TextPlugin } from "./core/plugins/text";
|
||||||
|
|
||||||
|
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||||
|
|
||||||
|
let { text = $bindable("") } = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!player.player) return;
|
||||||
|
const tracker = new TextPlugin();
|
||||||
|
tracker.register(player.player);
|
||||||
|
const unsubscribe = tracker.subscribe((value) => {
|
||||||
|
text = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
import { WpmReplayPlugin } from "./core/plugins/wpm";
|
||||||
|
import type { ReplayPlayer } from "./core/player";
|
||||||
|
|
||||||
|
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||||
|
|
||||||
|
let { wpm = $bindable(0) } = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!player.player) return;
|
||||||
|
const tracker = new WpmReplayPlugin();
|
||||||
|
tracker.register(player.player);
|
||||||
|
const unsubscribe = tracker.subscribe((value) => {
|
||||||
|
wpm = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/lib/charrecorder/core/plugins/chords.ts
Normal file
111
src/lib/charrecorder/core/plugins/chords.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ReplayPlayer } from "../player";
|
||||||
|
import type { ReplayPlugin, StoreContract } from "../types";
|
||||||
|
|
||||||
|
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
|
||||||
|
private subscribers = new Set<(value: string) => void>();
|
||||||
|
|
||||||
|
register(replay: ReplayPlayer) {
|
||||||
|
replay.subscribe(() => {
|
||||||
|
if (this.subscribers.size === 0) return;
|
||||||
|
const text = replay.stepper.text
|
||||||
|
.filter((it) => it.source !== "ghost")
|
||||||
|
.map((it) => it.text)
|
||||||
|
.join("");
|
||||||
|
for (const subscription of this.subscribers) {
|
||||||
|
subscription(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
subscribe(subscription: (value: string) => void) {
|
||||||
|
this.subscribers.add(subscription);
|
||||||
|
return () => this.subscribers.delete(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
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"],
|
||||||
|
]);
|
||||||
288
src/lib/charrecorder/renderer/renderer.ts
Normal file
288
src/lib/charrecorder/renderer/renderer.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
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.cursorNode.setAttribute("class", "cursor");
|
||||||
|
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>
|
||||||
73
src/lib/chat/MatrixRooms.svelte
Normal file
73
src/lib/chat/MatrixRooms.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Room } from "matrix-js-sdk";
|
||||||
|
import { matrixClient, currentRoomId } from "./chat";
|
||||||
|
|
||||||
|
let { rooms }: { rooms: Room[] } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rooms">
|
||||||
|
{#each $matrixClient.getRooms() as room}
|
||||||
|
{@const avatar = room.getMxcAvatarUrl()}
|
||||||
|
<button
|
||||||
|
class:active={$currentRoomId === room.roomId}
|
||||||
|
class="room"
|
||||||
|
onclick={() => ($currentRoomId = room.roomId)}
|
||||||
|
>
|
||||||
|
{#if avatar}
|
||||||
|
<img
|
||||||
|
alt={room.name}
|
||||||
|
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<div>#</div>
|
||||||
|
{/if}
|
||||||
|
<div>{room.name}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#await $matrixClient.publicRooms()}
|
||||||
|
<div>Loading...</div>
|
||||||
|
{:then rooms}
|
||||||
|
{#each rooms.chunk as room}
|
||||||
|
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
|
||||||
|
<div>#</div>
|
||||||
|
<div>{room.name}</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{:catch error}
|
||||||
|
<div>{error.message}</div>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.rooms {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
padding-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.room {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-block: 2px;
|
||||||
|
min-height: 0;
|
||||||
|
height: unset;
|
||||||
|
padding-inline: 16px;
|
||||||
|
padding-block: 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--md-sys-color-primary-container);
|
||||||
|
color: var(--md-sys-color-on-primary-container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
0
src/lib/chat/MatrixSpace.svelte
Normal file
0
src/lib/chat/MatrixSpace.svelte
Normal file
233
src/lib/chat/MatrixTimeline.svelte
Normal file
233
src/lib/chat/MatrixTimeline.svelte
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
<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;
|
||||||
|
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
109
src/lib/chat/chat-rx.ts
Normal file
109
src/lib/chat/chat-rx.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")!;
|
||||||
|
}
|
||||||
35
src/lib/chat/chat.ts
Normal file
35
src/lib/chat/chat.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { writable, type Writable } from "svelte/store";
|
||||||
|
import type { 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";
|
||||||
|
|
||||||
|
export const matrixClient: Writable<MatrixClient> = writable();
|
||||||
|
|
||||||
|
export const currentRoomId = persistentWritable<string | null>(
|
||||||
|
"currentRoomId",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
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")!;
|
||||||
|
}
|
||||||
381
src/lib/chat/events/MatrixEvent.svelte
Normal file
381
src/lib/chat/events/MatrixEvent.svelte
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<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)}
|
||||||
|
>
|
||||||
|
{#if event.getType() === "m.room.message"}
|
||||||
|
{@const message = event.event.content?.["body"]}
|
||||||
|
<a
|
||||||
|
class="icon rocket"
|
||||||
|
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
|
||||||
|
>rocket_launch</a
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rocket {
|
||||||
|
0% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
90% {
|
||||||
|
transform: translate(4px, -4px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.icon.rocket {
|
||||||
|
animation: rocket 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
|
||||||
|
a,
|
||||||
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,47 +3,53 @@
|
|||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import { action as title } from "$lib/title";
|
import { action as title } from "$lib/title";
|
||||||
import { osLayout } from "$lib/os-layout";
|
import { osLayout } from "$lib/os-layout";
|
||||||
import LL from "$i18n/i18n-svelte";
|
|
||||||
|
|
||||||
export let action: number | KeyInfo;
|
let {
|
||||||
export let display: "inline-keys" | "keys" = "inline-keys";
|
action,
|
||||||
|
display,
|
||||||
|
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
|
||||||
|
|
||||||
$: info =
|
let info = $derived(
|
||||||
typeof action === "number"
|
typeof action === "number"
|
||||||
? KEYMAP_CODES.get(action) ?? { code: action }
|
? ($KEYMAP_CODES.get(action) ?? { code: action })
|
||||||
: action;
|
: action,
|
||||||
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
|
);
|
||||||
|
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||||
|
|
||||||
$: tooltip =
|
let tooltip = $derived(
|
||||||
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
||||||
(info.title ?? "") +
|
(info.title ?? "") +
|
||||||
(info.variant === "left"
|
(info.variant === "left"
|
||||||
? " (left)"
|
? " (left)"
|
||||||
: info.variant === "right"
|
: info.variant === "right"
|
||||||
? " (right)"
|
? " (right)"
|
||||||
: "");
|
: ""),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dynamicMapping}
|
{#if display === "keys"}
|
||||||
<span
|
|
||||||
use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
|
|
||||||
class="dynamic"
|
|
||||||
class:left={info.variant === "left"}
|
|
||||||
class:right={info.variant === "right"}
|
|
||||||
class:inline={display === "inline-keys"}>{dynamicMapping}</span
|
|
||||||
>
|
|
||||||
{:else if display === "keys"}
|
|
||||||
<kbd
|
<kbd
|
||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
use:title={{ title: tooltip }}
|
use:title={{ title: tooltip }}
|
||||||
>
|
>
|
||||||
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
|
{dynamicMapping ??
|
||||||
|
info.icon ??
|
||||||
|
info.display ??
|
||||||
|
info.id ??
|
||||||
|
`0x${info.code.toString(16)}`}
|
||||||
</kbd>
|
</kbd>
|
||||||
{:else if display === "inline-keys"}
|
{:else if display === "inline-keys"}
|
||||||
{#if !info.icon && info.id?.length === 1}
|
{#if !info.icon && dynamicMapping?.length === 1}
|
||||||
<span
|
<span
|
||||||
|
use:title={{ title: tooltip }}
|
||||||
|
class:left={info.variant === "left"}
|
||||||
|
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||||
|
>
|
||||||
|
{:else if !info.icon && info.id?.length === 1}
|
||||||
|
<span
|
||||||
|
use:title={{ title: tooltip }}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{info.id}</span
|
class:right={info.variant === "right"}>{info.id}</span
|
||||||
>
|
>
|
||||||
@@ -55,7 +61,8 @@
|
|||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
use:title={{ title: tooltip }}
|
use:title={{ title: tooltip }}
|
||||||
>
|
>
|
||||||
{info.icon ??
|
{dynamicMapping ??
|
||||||
|
info.icon ??
|
||||||
info.display ??
|
info.display ??
|
||||||
info.id ??
|
info.id ??
|
||||||
`0x${info.code.toString(16)}`}</kbd
|
`0x${info.code.toString(16)}`}</kbd
|
||||||
@@ -77,16 +84,6 @@
|
|||||||
border-right-width: 3px;
|
border-right-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dynamic {
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 1px;
|
|
||||||
min-width: 8px;
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
|
|
||||||
&.inline {
|
|
||||||
padding: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.inline-kbd {
|
.inline-kbd {
|
||||||
margin-inline-end: 2px;
|
margin-inline-end: 2px;
|
||||||
|
|||||||
@@ -3,15 +3,22 @@
|
|||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "$i18n/i18n-svelte";
|
||||||
import Action from "$lib/components/Action.svelte";
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
export let id: number | KeyInfo;
|
let {
|
||||||
|
id,
|
||||||
|
onclick,
|
||||||
|
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
|
||||||
|
$props();
|
||||||
|
|
||||||
$: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
let key = $derived(
|
||||||
| number
|
(typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
|
||||||
| KeyInfo;
|
| number
|
||||||
|
| KeyInfo,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click>
|
<button {onclick}>
|
||||||
{#if typeof key === "object"}
|
{#if typeof key === "object"}
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<b>
|
<b>
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
import Action from "$lib/components/Action.svelte";
|
import Action from "$lib/components/Action.svelte";
|
||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
|
|
||||||
export let actions: Array<number | KeyInfo>;
|
let {
|
||||||
export let display: "keys" | "inline-keys" = "inline-keys";
|
actions,
|
||||||
|
display = "inline-keys",
|
||||||
|
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}
|
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}
|
||||||
|
|||||||
51
src/lib/components/AnimatedNumber.svelte
Normal file
51
src/lib/components/AnimatedNumber.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fade, slide } from "svelte/transition";
|
||||||
|
|
||||||
|
let { value }: { value: number } = $props();
|
||||||
|
|
||||||
|
let digits: number[] = $derived(value.toString().split("").map(Number));
|
||||||
|
const nums = Array.from({ length: 10 }, (_, i) => i);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="digits" style:width="{digits.length}ch">
|
||||||
|
{#each digits as digit, i (digits.length - i)}
|
||||||
|
<div
|
||||||
|
class="digit-wrapper"
|
||||||
|
style:right="{digits.length - 1 - i}ch"
|
||||||
|
transition:fade
|
||||||
|
>
|
||||||
|
{#each nums as num (num)}
|
||||||
|
<div
|
||||||
|
class="digit"
|
||||||
|
style:transform="translateY({(digit - num) / 4}em)"
|
||||||
|
style:opacity={digit === num ? 1 : 0}
|
||||||
|
>
|
||||||
|
{num}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.digits {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
transition: width 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digit-wrapper {
|
||||||
|
display: inline-grid;
|
||||||
|
height: 1em;
|
||||||
|
width: 1ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digit {
|
||||||
|
display: inline-block;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
transition:
|
||||||
|
transform 500ms ease,
|
||||||
|
opacity 500ms ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $needRefresh}
|
{#if $needRefresh}
|
||||||
<button title="Update ready" on:click={() => updateServiceWorker(true)}
|
<button title="Update ready" onclick={() => updateServiceWorker(true)}
|
||||||
>Update <span class="icon">update</span></button
|
>Update <span class="icon">update</span></button
|
||||||
>
|
>
|
||||||
{:else if $offlineReady}
|
{:else if $offlineReady}
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
io.scrollTo({ top: io.scrollHeight });
|
io.scrollTo({ top: io.scrollHeight });
|
||||||
}
|
}
|
||||||
|
|
||||||
let value: string;
|
let value: string = $state("");
|
||||||
let io: HTMLDivElement;
|
let io: HTMLDivElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form on:submit={submit}>
|
<form onsubmit={submit}>
|
||||||
<div bind:this={io} class="io">
|
<div bind:this={io} class="io">
|
||||||
{#each $serialLog as { type, value }}
|
{#each $serialLog as { type, value }}
|
||||||
{#if type === "input"}
|
{#if type === "input"}
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
<p transition:slide>{value}</p>
|
<p transition:slide>{value}</p>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
<div class="anchor" />
|
<div class="anchor"></div>
|
||||||
</div>
|
</div>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<input on:submit={submit} bind:value />
|
<input onsubmit={submit} bind:value />
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let title: string | undefined;
|
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
|
||||||
export let shortcut: string | undefined;
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if title}
|
{#if title}
|
||||||
@@ -18,5 +17,11 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
p {
|
p {
|
||||||
margin-block: 0;
|
margin-block: 0;
|
||||||
|
|
||||||
|
:global(kbd.icon) {
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: inherit;
|
||||||
|
translate: 0 0.2em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,25 +3,39 @@
|
|||||||
KEYMAP_CATEGORIES,
|
KEYMAP_CATEGORIES,
|
||||||
KEYMAP_CODES,
|
KEYMAP_CODES,
|
||||||
KEYMAP_IDS,
|
KEYMAP_IDS,
|
||||||
|
type KeyInfo,
|
||||||
} from "$lib/serial/keymap-codes";
|
} from "$lib/serial/keymap-codes";
|
||||||
import FlexSearch from "flexsearch";
|
import FlexSearch from "flexsearch";
|
||||||
import { createEventDispatcher, onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "$i18n/i18n-svelte";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
export let currentAction: number | undefined = undefined;
|
let {
|
||||||
export let nextAction: number | undefined = undefined;
|
currentAction = undefined,
|
||||||
|
nextAction = undefined,
|
||||||
|
onselect,
|
||||||
|
onclose,
|
||||||
|
}: {
|
||||||
|
currentAction?: number;
|
||||||
|
nextAction?: number;
|
||||||
|
onselect: (id: number) => void;
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
searchBox.focus();
|
searchBox.focus();
|
||||||
});
|
});
|
||||||
|
|
||||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||||
createIndex();
|
|
||||||
|
|
||||||
async function createIndex() {
|
$effect(() => {
|
||||||
for (const [, action] of KEYMAP_CODES) {
|
createIndex($KEYMAP_CODES);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||||
|
for (const [, action] of codes) {
|
||||||
await index?.addAsync(
|
await index?.addAsync(
|
||||||
action.code,
|
action.code,
|
||||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||||
@@ -33,19 +47,19 @@
|
|||||||
|
|
||||||
async function search() {
|
async function search() {
|
||||||
results = (await index!.searchAsync(searchBox.value)) as number[];
|
results = (await index!.searchAsync(searchBox.value)) as number[];
|
||||||
exact = KEYMAP_IDS.get(searchBox.value)?.code;
|
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||||
code = Number(searchBox.value);
|
code = Number(searchBox.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(id?: number) {
|
function select(id?: number) {
|
||||||
if (id !== undefined) {
|
if (id !== undefined) {
|
||||||
dispatch("select", id);
|
onselect(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function keyboardNavigation(event: KeyboardEvent) {
|
function keyboardNavigation(event: KeyboardEvent) {
|
||||||
if (event.shiftKey && event.key === "Enter") {
|
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||||
dispatch("select", exact);
|
onselect(exact);
|
||||||
} else if (event.key === "ArrowDown") {
|
} else if (event.key === "ArrowDown") {
|
||||||
const element =
|
const element =
|
||||||
resultList.querySelector("li:focus-within")?.nextSibling ??
|
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||||
@@ -67,40 +81,45 @@
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
let results: number[] = [];
|
let results: number[] = $state([]);
|
||||||
let exact: number | undefined = undefined;
|
let exact: number | undefined = $state(undefined);
|
||||||
let code: number = Number.NaN;
|
let code: number = $state(Number.NaN);
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
let searchBox: HTMLInputElement;
|
let searchBox: HTMLInputElement;
|
||||||
let resultList: HTMLUListElement;
|
let resultList: HTMLUListElement;
|
||||||
let filter: Set<number>;
|
let filter: Set<number> | undefined = $state(undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={keyboardNavigation} />
|
<svelte:window on:keydown={keyboardNavigation} />
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
<dialog open on:click|self={() => dispatch("close")}>
|
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||||
|
<dialog
|
||||||
|
open
|
||||||
|
onclick={(event) => {
|
||||||
|
if (event.target === event.currentTarget) onclose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
bind:this={searchBox}
|
bind:this={searchBox}
|
||||||
on:input={search}
|
oninput={search}
|
||||||
on:keypress={(event) => {
|
onkeypress={(event) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
select(exact);
|
select(exact);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
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
|
>{$LL.actionSearch.DELETE()}</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
||||||
class="icon"
|
class="icon"
|
||||||
on:click={() => dispatch("close")}>close</button
|
onclick={onclose}>close</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<fieldset class="filters">
|
<fieldset class="filters">
|
||||||
@@ -113,7 +132,7 @@
|
|||||||
bind:group={filter}
|
bind:group={filter}
|
||||||
/></label
|
/></label
|
||||||
>
|
>
|
||||||
{#each KEYMAP_CATEGORIES as category}
|
{#each $KEYMAP_CATEGORIES as category}
|
||||||
<label
|
<label
|
||||||
>{category.name}<input
|
>{category.name}<input
|
||||||
name="category"
|
name="category"
|
||||||
@@ -140,12 +159,12 @@
|
|||||||
{#if exact !== undefined}
|
{#if exact !== undefined}
|
||||||
<li class="exact">
|
<li class="exact">
|
||||||
<i>Exact match</i>
|
<i>Exact match</i>
|
||||||
<ActionListItem id={exact} on:click={() => select(exact)} />
|
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !exact && code}
|
{#if !exact && code}
|
||||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||||
<li><button on:click={() => select(code)}>USE CODE</button></li>
|
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||||
{:else}
|
{:else}
|
||||||
<li>Action code is out of range</li>
|
<li>Action code is out of range</li>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -153,10 +172,10 @@
|
|||||||
{#if filter !== undefined || results.length > 0}
|
{#if filter !== undefined || results.length > 0}
|
||||||
{@const resultValue =
|
{@const resultValue =
|
||||||
results.length === 0
|
results.length === 0
|
||||||
? Array.from(KEYMAP_CODES, ([it]) => it)
|
? Array.from($KEYMAP_CODES, ([it]) => it)
|
||||||
: results}
|
: results}
|
||||||
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
||||||
<li><ActionListItem {id} on:click={() => select(id)} /></li>
|
<li><ActionListItem {id} onclick={() => select(id)} /></li>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext, mount, unmount } from "svelte";
|
||||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
||||||
}
|
}
|
||||||
|
|
||||||
export let visualLayout: VisualLayout;
|
let { visualLayout }: { visualLayout: VisualLayout } = $props();
|
||||||
$: layoutInfo = compileLayout(visualLayout);
|
let layoutInfo = $state(compileLayout(visualLayout));
|
||||||
|
|
||||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||||
@@ -127,11 +127,28 @@
|
|||||||
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||||
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
||||||
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
||||||
const component = new ActionSelector({
|
const component = mount(ActionSelector, {
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: {
|
||||||
currentAction,
|
currentAction,
|
||||||
nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
|
nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
|
||||||
|
onclose() {
|
||||||
|
closed();
|
||||||
|
},
|
||||||
|
onselect(action) {
|
||||||
|
changes.update((changes) => {
|
||||||
|
changes.push([
|
||||||
|
{
|
||||||
|
type: ChangeType.Layout,
|
||||||
|
id: keyInfo.id,
|
||||||
|
layer: get(activeLayer),
|
||||||
|
action,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
closed();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||||
@@ -167,22 +184,8 @@
|
|||||||
|
|
||||||
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 focusKey: CompiledLayoutKey;
|
||||||
@@ -201,9 +204,9 @@
|
|||||||
<KeyboardKey
|
<KeyboardKey
|
||||||
{i}
|
{i}
|
||||||
{key}
|
{key}
|
||||||
on:focusin={() => (focusKey = key)}
|
onfocusin={() => (focusKey = key)}
|
||||||
on:click={() => edit(i)}
|
onclick={() => edit(i)}
|
||||||
on:keypress={({ key }) => {
|
onkeypress={({ key }) => {
|
||||||
if (key === "Enter") {
|
if (key === "Enter") {
|
||||||
edit(i);
|
edit(i);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,15 +11,25 @@
|
|||||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||||
|
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
||||||
|
"highlight-action",
|
||||||
|
);
|
||||||
|
|
||||||
export let key: CompiledLayoutKey;
|
let {
|
||||||
export let fontSizeMultiplier = 1;
|
key,
|
||||||
|
fontSizeMultiplier = 1,
|
||||||
export let middle: [number, number];
|
middle,
|
||||||
export let pos: [number, number];
|
pos,
|
||||||
export let rotate: number;
|
rotate,
|
||||||
|
positions,
|
||||||
export let positions: [[number, number], [number, number], [number, number]];
|
}: {
|
||||||
|
key: CompiledLayoutKey;
|
||||||
|
fontSizeMultiplier?: number;
|
||||||
|
middle: [number, number];
|
||||||
|
pos: [number, number];
|
||||||
|
rotate: number;
|
||||||
|
positions: [[number, number], [number, number], [number, number]];
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each positions as position, layer}
|
{#each positions as position, layer}
|
||||||
@@ -28,7 +38,7 @@
|
|||||||
isApplied: true,
|
isApplied: true,
|
||||||
}}
|
}}
|
||||||
{@const { code, icon, id, display, title, keyCode, variant } =
|
{@const { code, icon, id, display, title, keyCode, variant } =
|
||||||
KEYMAP_CODES.get(actionId) ?? { code: actionId }}
|
$KEYMAP_CODES.get(actionId) ?? { code: actionId }}
|
||||||
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
|
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
|
||||||
{@const tooltip =
|
{@const tooltip =
|
||||||
(title ?? id ?? `0x${code.toString(16)}`) +
|
(title ?? id ?? `0x${code.toString(16)}`) +
|
||||||
@@ -40,6 +50,7 @@
|
|||||||
]}
|
]}
|
||||||
{@const hasIcon = !dynamicMapping && !!icon}
|
{@const hasIcon = !dynamicMapping && !!icon}
|
||||||
<text
|
<text
|
||||||
|
class:hidden={$currentAction?.has(actionId) === false}
|
||||||
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
||||||
font-weight={isApplied ? "" : "bold"}
|
font-weight={isApplied ? "" : "bold"}
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
@@ -89,4 +100,8 @@
|
|||||||
text:focus-within {
|
text:focus-within {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text.hidden {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,24 +3,46 @@
|
|||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import KeyText from "$lib/components/layout/KeyText.svelte";
|
import KeyText from "$lib/components/layout/KeyText.svelte";
|
||||||
|
import type {
|
||||||
|
FocusEventHandler,
|
||||||
|
KeyboardEventHandler,
|
||||||
|
MouseEventHandler,
|
||||||
|
} from "svelte/elements";
|
||||||
|
import { type Writable } from "svelte/store";
|
||||||
|
|
||||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||||
"visual-layout-config",
|
"visual-layout-config",
|
||||||
);
|
);
|
||||||
export let i: number;
|
|
||||||
export let key: CompiledLayoutKey;
|
|
||||||
|
|
||||||
$: posX = key.pos[0] * scale;
|
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
|
||||||
$: posY = key.pos[1] * scale;
|
|
||||||
$: sizeX = key.size[0] * scale;
|
let {
|
||||||
$: sizeY = key.size[1] * scale;
|
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>
|
</script>
|
||||||
|
|
||||||
<g
|
<g
|
||||||
class="key-group"
|
class="key-group"
|
||||||
on:click
|
class:highlight={$highlight?.has(key.id) === true}
|
||||||
on:keypress
|
class:faded={$highlight?.has(key.id) === false}
|
||||||
on:focusin
|
{onclick}
|
||||||
|
{onkeypress}
|
||||||
|
{onfocusin}
|
||||||
role="button"
|
role="button"
|
||||||
tabindex={i + 1}
|
tabindex={i + 1}
|
||||||
>
|
>
|
||||||
@@ -114,12 +136,14 @@
|
|||||||
stroke-opacity: 0.3;
|
stroke-opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.faded,
|
||||||
g:hover {
|
g:hover {
|
||||||
cursor: default;
|
cursor: default;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity #{$transition} ease;
|
transition: opacity #{$transition} ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.highlight,
|
||||||
g:focus-within {
|
g:focus-within {
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { serialPort } from "$lib/serial/connection";
|
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||||
import { action } from "$lib/title";
|
import { action } from "$lib/title";
|
||||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
||||||
import { fade } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
|
import { restoreFromFile } from "$lib/backup/backup";
|
||||||
|
|
||||||
$: device = $serialPort?.device;
|
let device = $derived($serialPort?.device);
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||||
|
|
||||||
const layers = [
|
const layers = [
|
||||||
@@ -33,6 +34,14 @@
|
|||||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as VisualLayout,
|
||||||
),
|
),
|
||||||
|
M4G: () =>
|
||||||
|
import("$lib/assets/layouts/m4g.yml").then(
|
||||||
|
(it) => it.default as VisualLayout,
|
||||||
|
),
|
||||||
|
M4GR: () =>
|
||||||
|
import("$lib/assets/layouts/m4gr.yml").then(
|
||||||
|
(it) => it.default as VisualLayout,
|
||||||
|
),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,12 +53,22 @@
|
|||||||
<button
|
<button
|
||||||
class="icon"
|
class="icon"
|
||||||
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
||||||
on:click={() => ($activeLayer = value)}
|
onclick={() => ($activeLayer = value)}
|
||||||
class:active={$activeLayer === value}
|
class:active={$activeLayer === value}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
{#if $deviceMeta?.factoryDefaults?.layout}
|
||||||
|
<button
|
||||||
|
use:action={{ title: "Reset Layout" }}
|
||||||
|
transition:fly={{ x: -8 }}
|
||||||
|
class="icon reset-layout"
|
||||||
|
onclick={() =>
|
||||||
|
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
|
||||||
|
>reset_wrench</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<GenericLayout {visualLayout} />
|
<GenericLayout {visualLayout} />
|
||||||
@@ -66,7 +85,7 @@
|
|||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-bottom: 96px;
|
max-height: 20cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
@@ -105,7 +124,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:first-child,
|
&:first-child,
|
||||||
&:last-child {
|
&:nth-child(3) {
|
||||||
aspect-ratio: unset;
|
aspect-ratio: unset;
|
||||||
height: unset;
|
height: unset;
|
||||||
}
|
}
|
||||||
@@ -116,12 +135,21 @@
|
|||||||
border-radius: 16px 0 0 16px;
|
border-radius: 16px 0 0 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:last-child {
|
&:nth-child(3) {
|
||||||
margin-inline-start: -8px;
|
margin-inline-start: -8px;
|
||||||
padding-inline: 24px 4px;
|
padding-inline: 24px 4px;
|
||||||
border-radius: 0 16px 16px 0;
|
border-radius: 0 16px 16px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.reset-layout {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 0;
|
||||||
|
transform: translate(100%, -50%);
|
||||||
|
background: none;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
color: var(--md-sys-color-on-tertiary);
|
color: var(--md-sys-color-on-tertiary);
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
|
||||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||||
import ActionString from "$lib/components/ActionString.svelte";
|
import type { Chord } from "$lib/serial/chord";
|
||||||
|
import ChordActionEdit from "../../routes/(app)/config/chords/ChordActionEdit.svelte";
|
||||||
|
|
||||||
export let title: string;
|
let {
|
||||||
export let message: string | undefined;
|
title,
|
||||||
export let abortTitle: string;
|
message,
|
||||||
export let confirmTitle: string;
|
abortTitle,
|
||||||
|
confirmTitle,
|
||||||
export let actions: number[] = [];
|
chord,
|
||||||
|
onabort,
|
||||||
const dispatch = createEventDispatcher();
|
onconfirm,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
abortTitle: string;
|
||||||
|
confirmTitle: string;
|
||||||
|
chord: Chord & { deleted: boolean };
|
||||||
|
onabort: () => void;
|
||||||
|
onconfirm: () => void;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
@@ -18,12 +27,23 @@
|
|||||||
{#if message}
|
{#if message}
|
||||||
<p>{@html message}</p>
|
<p>{@html message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p><ActionString {actions} /></p>
|
<p>
|
||||||
|
<ChordActionEdit
|
||||||
|
chord={{
|
||||||
|
...chord,
|
||||||
|
isApplied: false,
|
||||||
|
phraseChanged: false,
|
||||||
|
actionsChanged: false,
|
||||||
|
sortBy: "",
|
||||||
|
id: chord.actions,
|
||||||
|
}}
|
||||||
|
interactive={false}
|
||||||
|
onsubmit={() => {}}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
|
<button onclick={onabort}>{abortTitle}</button>
|
||||||
<button class="primary" on:click={() => dispatch("confirm")}
|
<button class="primary" onclick={onconfirm}>{confirmTitle}</button>
|
||||||
>{confirmTitle}</button
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
106
src/lib/dialogs/ConnectionFailed.svelte
Normal file
106
src/lib/dialogs/ConnectionFailed.svelte
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||||
|
import LL from "$i18n/i18n-svelte";
|
||||||
|
|
||||||
|
let {
|
||||||
|
message,
|
||||||
|
onclose,
|
||||||
|
}: {
|
||||||
|
message: string;
|
||||||
|
onclose: () => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog>
|
||||||
|
{#if !navigator.serial}
|
||||||
|
<h1>Incompatible Browser</h1>
|
||||||
|
<p>Your browser does not support the Web Serial API.</p>
|
||||||
|
<p>Supported browsers are any Chromium based Browsers, such as</p>
|
||||||
|
<ul>
|
||||||
|
<li>Google Chrome</li>
|
||||||
|
<li>Microsoft Edge</li>
|
||||||
|
<li>Opera</li>
|
||||||
|
<li>Brave</li>
|
||||||
|
</ul>
|
||||||
|
{:else}
|
||||||
|
<h1>Connection Failed</h1>
|
||||||
|
<pre>{message}</pre>
|
||||||
|
<h2>Troubleshooting Steps</h2>
|
||||||
|
<ul>
|
||||||
|
{#if navigator.userAgent.includes("Linux")}
|
||||||
|
<li>
|
||||||
|
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
|
||||||
|
<p>
|
||||||
|
In most cases you can simply follow the <a
|
||||||
|
target="_blank"
|
||||||
|
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
|
||||||
|
>Arduino Guide</a
|
||||||
|
> on serial port permissions.
|
||||||
|
</p>
|
||||||
|
<p>Special systems:</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
|
||||||
|
>Arch and Arch-based like Manjaro or EndeavourOS</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
|
||||||
|
>NixOS</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
|
||||||
|
>Gentoo</a
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
<li>
|
||||||
|
You device may be pre-CCOS. refer to <a
|
||||||
|
target="_blank"
|
||||||
|
href="https://docs.charachorder.com/CCOS.html#upgrade-to-ccos"
|
||||||
|
>Upgrade to CCOS</a
|
||||||
|
> on how to upgrade your device.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Some USB cables or hubs can cause issues, try directly connecting to a
|
||||||
|
port on your computer with the included cable.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="primary" onclick={onclose}>Close</button>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount, type Snippet } from "svelte";
|
||||||
|
|
||||||
|
let { children }: { children: Snippet } = $props();
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
@@ -9,7 +11,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog bind:this={modal}>
|
<dialog bind:this={modal}>
|
||||||
<slot />
|
{@render children()}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -1,33 +1,34 @@
|
|||||||
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
|
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import type { Chord } from "$lib/serial/chord";
|
||||||
|
|
||||||
export async function askForConfirmation(
|
export async function askForConfirmation(
|
||||||
title: string,
|
title: string,
|
||||||
message: string,
|
message: string,
|
||||||
confirmTitle: string,
|
confirmTitle: string,
|
||||||
abortTitle: string,
|
abortTitle: string,
|
||||||
actions: number[],
|
chord: Chord,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const dialog = new ConfirmDialog({
|
let resolvePromise: (value: boolean) => void;
|
||||||
|
const resultPromise = new Promise<boolean>((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = mount(ConfirmDialog, {
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: {
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
confirmTitle,
|
confirmTitle,
|
||||||
abortTitle,
|
abortTitle,
|
||||||
actions,
|
chord,
|
||||||
|
onabort: () => resolvePromise(false),
|
||||||
|
onconfirm: () => resolvePromise(true),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let resolvePromise: (value: boolean) => void;
|
|
||||||
const resultPromise = new Promise<boolean>((resolve) => {
|
|
||||||
resolvePromise = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
dialog.$on("abort", () => resolvePromise(false));
|
|
||||||
dialog.$on("confirm", () => resolvePromise(true));
|
|
||||||
|
|
||||||
const result = await resultPromise;
|
const result = await resultPromise;
|
||||||
dialog.$destroy();
|
unmount(dialog);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/lib/dialogs/connection-failed-dialog.ts
Normal file
24
src/lib/dialogs/connection-failed-dialog.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import ConnectionFailed from "$lib/dialogs/ConnectionFailed.svelte";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
|
||||||
|
export async function showConnectionFailedDialog(
|
||||||
|
message: string,
|
||||||
|
): Promise<void> {
|
||||||
|
let resolvePromise: (value: void) => void;
|
||||||
|
const resultPromise = new Promise<void>((resolve) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dialog = mount(ConnectionFailed, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
message,
|
||||||
|
onclose: () => resolvePromise(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resultPromise;
|
||||||
|
unmount(dialog);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
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",
|
||||||
|
{},
|
||||||
|
);
|
||||||
147
src/lib/meta/meta-storage.ts
Normal file
147
src/lib/meta/meta-storage.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import type { RawVersionMeta, SettingsMeta, VersionMeta } from "./types/meta";
|
||||||
|
import type { Listing } from "./types/listing";
|
||||||
|
import type { KeymapCategory } from "./types/actions";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
|
let lock: Promise<void> | undefined = undefined;
|
||||||
|
|
||||||
|
export async function getMeta(
|
||||||
|
device: string,
|
||||||
|
version: string,
|
||||||
|
fetch: typeof window.fetch = window.fetch,
|
||||||
|
): Promise<VersionMeta> {
|
||||||
|
while (lock) await lock;
|
||||||
|
let resolveLock!: () => void;
|
||||||
|
lock = new Promise((resolve) => (resolveLock = resolve));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!browser) return fetchMeta(device, version, fetch);
|
||||||
|
|
||||||
|
const dbRequest = indexedDB.open("version-meta", 3);
|
||||||
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
|
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||||
|
dbRequest.onerror = () => reject(dbRequest.error);
|
||||||
|
dbRequest.onupgradeneeded = () => {
|
||||||
|
const db = dbRequest.result;
|
||||||
|
if (db.objectStoreNames.contains("meta")) {
|
||||||
|
db.deleteObjectStore("meta");
|
||||||
|
}
|
||||||
|
db.createObjectStore("meta", { keyPath: ["device", "version"] });
|
||||||
|
};
|
||||||
|
});
|
||||||
|
console.log("upgrading version meta db");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const readTransaction = db.transaction(["meta"], "readonly");
|
||||||
|
const store = readTransaction.objectStore("meta");
|
||||||
|
const itemRequest = store.get([device, version]);
|
||||||
|
const item = await new Promise<VersionMeta | undefined>((resolve) => {
|
||||||
|
itemRequest.onsuccess = () => resolve(itemRequest.result);
|
||||||
|
itemRequest.onerror = () => resolve(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (item) return item;
|
||||||
|
|
||||||
|
const meta = await fetchMeta(device, version);
|
||||||
|
|
||||||
|
const putTransaction = db.transaction(["meta"], "readwrite");
|
||||||
|
const putStore = putTransaction.objectStore("meta");
|
||||||
|
const putRequest = putStore.put(meta);
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
putRequest.onsuccess = () => resolve();
|
||||||
|
putRequest.onerror = () => reject(putRequest.error);
|
||||||
|
});
|
||||||
|
putTransaction.commit();
|
||||||
|
|
||||||
|
return meta;
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
resolveLock();
|
||||||
|
lock = undefined;
|
||||||
|
}
|
||||||
|
return fetchMeta(device, version, fetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMeta(
|
||||||
|
device: string,
|
||||||
|
version: string,
|
||||||
|
fetch: typeof window.fetch = window.fetch,
|
||||||
|
): Promise<VersionMeta> {
|
||||||
|
const path = `${import.meta.env.VITE_FIRMWARE_URL}/${device}/${version}`;
|
||||||
|
const files: Listing[] = await fetch(`${path}/`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.catch(() => []);
|
||||||
|
const meta: Partial<RawVersionMeta> | undefined = files.some(
|
||||||
|
(entry) => entry.type === "file" && entry.name === "meta.json",
|
||||||
|
)
|
||||||
|
? await fetch(`${path}/meta.json`).then((res) => res.json())
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: meta?.version ?? version,
|
||||||
|
device: meta?.target ?? device,
|
||||||
|
date: new Date(meta?.git_date ?? files[0]?.mtime ?? ""),
|
||||||
|
path,
|
||||||
|
commit: meta?.git_commit ?? undefined,
|
||||||
|
dirty: meta?.git_is_dirty ?? false,
|
||||||
|
public: meta?.public_build ?? !version.includes("+"),
|
||||||
|
developmentBuild: (meta?.development_mode ?? 0) === 1,
|
||||||
|
factoryDefaults: meta?.factory_defaults
|
||||||
|
? {
|
||||||
|
layout: await fetch(`${path}/${meta.factory_defaults.layout}`).then(
|
||||||
|
(it) => it.json(),
|
||||||
|
),
|
||||||
|
settings: await fetch(
|
||||||
|
`${path}/${meta.factory_defaults.settings}`,
|
||||||
|
).then((it) => it.json()),
|
||||||
|
chords: Object.fromEntries(
|
||||||
|
await Promise.all(
|
||||||
|
Object.entries(meta.factory_defaults.chords).map(
|
||||||
|
async ([name, file]) => [
|
||||||
|
name,
|
||||||
|
await fetch(`${path}/${file}`).then((it) => it.json()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
settings: await (meta?.settings
|
||||||
|
? fetch(`${path}/${meta.settings}`).then((it) => it.json())
|
||||||
|
: import("$lib/assets/settings.yml")
|
||||||
|
.then((it) => (it as any).default)
|
||||||
|
.then((settings: SettingsMeta[]) => {
|
||||||
|
if (!device.startsWith("lite_")) {
|
||||||
|
settings = settings.filter((it) => it.name !== "leds");
|
||||||
|
}
|
||||||
|
return settings;
|
||||||
|
})),
|
||||||
|
actions: await (meta?.actions
|
||||||
|
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
||||||
|
: Promise.all<KeymapCategory[]>(
|
||||||
|
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
|
||||||
|
async (load) => load().then((it) => (it as any).default),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
update: {
|
||||||
|
uf2:
|
||||||
|
meta?.update?.uf2 ??
|
||||||
|
files.find(
|
||||||
|
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
|
||||||
|
)?.name ??
|
||||||
|
undefined,
|
||||||
|
ota:
|
||||||
|
meta?.update?.ota ??
|
||||||
|
files.find(
|
||||||
|
(entry) => entry.type === "file" && entry.name === "firmware.bin",
|
||||||
|
)?.name ??
|
||||||
|
undefined,
|
||||||
|
esptool: meta?.update?.esptool ?? undefined,
|
||||||
|
},
|
||||||
|
spiFlash: meta?.spi_flash ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
src/lib/meta/types/actions.ts
Normal file
19
src/lib/meta/types/actions.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface KeymapCategory {
|
||||||
|
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;
|
||||||
|
}
|
||||||
14
src/lib/meta/types/listing.ts
Normal file
14
src/lib/meta/types/listing.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export type Listing = FileListing | DirectoryListing;
|
||||||
|
|
||||||
|
export interface DirectoryListing {
|
||||||
|
name: string;
|
||||||
|
type: "directory";
|
||||||
|
mtime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileListing {
|
||||||
|
name: string;
|
||||||
|
type: "file";
|
||||||
|
mtime: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
96
src/lib/meta/types/meta.ts
Normal file
96
src/lib/meta/types/meta.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type {
|
||||||
|
CharaChordFile,
|
||||||
|
CharaLayoutFile,
|
||||||
|
CharaSettingsFile,
|
||||||
|
} from "$lib/share/chara-file";
|
||||||
|
import type { KeymapCategory } from "./actions";
|
||||||
|
|
||||||
|
export interface SettingsMeta {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
items: SettingsItemMeta[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsItemMeta {
|
||||||
|
id: number;
|
||||||
|
description?: string;
|
||||||
|
enum?: string[];
|
||||||
|
range: [number, number];
|
||||||
|
step?: number;
|
||||||
|
unit?: string;
|
||||||
|
inverse?: number;
|
||||||
|
scale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawVersionMeta {
|
||||||
|
version: string;
|
||||||
|
target: string;
|
||||||
|
git_commit: string;
|
||||||
|
git_is_dirty: boolean;
|
||||||
|
git_date: string;
|
||||||
|
public_build: boolean;
|
||||||
|
development_mode: number;
|
||||||
|
actions: string;
|
||||||
|
settings: string;
|
||||||
|
factory_defaults: {
|
||||||
|
layout: string;
|
||||||
|
settings: string;
|
||||||
|
chords: Record<string, string>;
|
||||||
|
};
|
||||||
|
update: {
|
||||||
|
ota: string | null;
|
||||||
|
uf2: string | null;
|
||||||
|
esptool: EspToolData | null;
|
||||||
|
};
|
||||||
|
files: string[];
|
||||||
|
spi_flash: SPIFlashInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionMeta {
|
||||||
|
version: string;
|
||||||
|
device: string;
|
||||||
|
path: string;
|
||||||
|
date: Date;
|
||||||
|
public: boolean;
|
||||||
|
commit?: string;
|
||||||
|
dirty: boolean;
|
||||||
|
developmentBuild: boolean;
|
||||||
|
actions: KeymapCategory[];
|
||||||
|
settings: SettingsMeta[];
|
||||||
|
factoryDefaults?: {
|
||||||
|
layout: CharaLayoutFile;
|
||||||
|
settings: CharaSettingsFile;
|
||||||
|
chords: Record<string, CharaChordFile>;
|
||||||
|
};
|
||||||
|
update: {
|
||||||
|
ota?: string;
|
||||||
|
uf2?: string;
|
||||||
|
esptool?: EspToolData;
|
||||||
|
};
|
||||||
|
spiFlash?: SPIFlashInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SPIFlashInfo {
|
||||||
|
type: string;
|
||||||
|
size: string;
|
||||||
|
connection: SPIConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SPIConnection {
|
||||||
|
clk: number;
|
||||||
|
q: number;
|
||||||
|
d: number;
|
||||||
|
hd: number;
|
||||||
|
cs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EspToolData {
|
||||||
|
chip: string;
|
||||||
|
baud: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
flash_mode: string;
|
||||||
|
flash_freq: string;
|
||||||
|
flash_size: string;
|
||||||
|
files: Record<string, string>;
|
||||||
|
}
|
||||||
@@ -1,25 +1,28 @@
|
|||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import type { ComponentType, SvelteComponent } from "svelte";
|
import { unmount, mount, type Component } from "svelte";
|
||||||
|
|
||||||
export const popup: Action<HTMLButtonElement, ComponentType> = (
|
export const popup: Action<HTMLButtonElement, Component> = (
|
||||||
node,
|
node,
|
||||||
Component,
|
Component,
|
||||||
) => {
|
) => {
|
||||||
let component: SvelteComponent | undefined;
|
let component: {} | undefined;
|
||||||
let target: HTMLElement | undefined;
|
let target: HTMLElement | undefined;
|
||||||
const edit = tippy(node, {
|
const edit = tippy(node, {
|
||||||
interactive: true,
|
interactive: true,
|
||||||
|
placement: "right",
|
||||||
trigger: "click",
|
trigger: "click",
|
||||||
onShow(instance) {
|
onShow(instance) {
|
||||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||||
target.classList.add("active");
|
target.classList.add("active");
|
||||||
component ??= new Component({ target });
|
component ??= mount(Component, { target });
|
||||||
},
|
},
|
||||||
onHidden() {
|
onHidden() {
|
||||||
component?.$destroy();
|
if (component) {
|
||||||
|
unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
}
|
||||||
target?.classList.remove("active");
|
target?.classList.remove("active");
|
||||||
component = undefined;
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ export interface UserPreferences {
|
|||||||
autoConnect: 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",
|
color: "#6D81C7",
|
||||||
mode: "dark" as "light" | "dark" | "auto",
|
mode: "dark",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const userPreferences = persistentWritable<UserPreferences>(
|
export const userPreferences = persistentWritable<UserPreferences>(
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher } from "svelte";
|
let {
|
||||||
|
ports,
|
||||||
export let ports: SerialPort[];
|
onconfirm,
|
||||||
const dispatch = createEventDispatcher<{ confirm: SerialPort | undefined }>();
|
}: {
|
||||||
let selected = ports[0]?.getInfo().name;
|
ports: SerialPort[];
|
||||||
|
onconfirm: (port: SerialPort | undefined) => void;
|
||||||
|
} = $props();
|
||||||
|
let selected = $state(ports[0]?.getInfo().name);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog>
|
<dialog>
|
||||||
@@ -19,12 +22,9 @@
|
|||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
|
<button onclick={() => onconfirm(undefined)}>Cancel</button>
|
||||||
<button
|
<button
|
||||||
on:click={() =>
|
onclick={() =>
|
||||||
dispatch(
|
onconfirm(ports.find((it) => it.getInfo().name === selected))}>Ok</button
|
||||||
"confirm",
|
|
||||||
ports.find((it) => it.getInfo().name === selected),
|
|
||||||
)}>Ok</button
|
|
||||||
>
|
>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|||||||
@@ -55,3 +55,19 @@ export function deserializeActions(native: bigint): number[] {
|
|||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashes a chord input the same way as CCOS
|
||||||
|
*/
|
||||||
|
export function hashChord(actions: number[]) {
|
||||||
|
const chord = new Uint8Array(16);
|
||||||
|
const view = new DataView(chord.buffer);
|
||||||
|
const serialized = serializeActions(actions);
|
||||||
|
view.setBigUint64(0, serialized & 0xffff_ffff_ffff_ffffn, true);
|
||||||
|
view.setBigUint64(8, serialized >> 64n, true);
|
||||||
|
let hash = 2166136261;
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
|
||||||
|
}
|
||||||
|
return hash & 0x3fff_ffff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import type { Writable } from "svelte/store";
|
|||||||
import type { CharaLayout } from "$lib/serialization/layout";
|
import type { CharaLayout } from "$lib/serialization/layout";
|
||||||
import { persistentWritable } from "$lib/storage";
|
import { persistentWritable } from "$lib/storage";
|
||||||
import { userPreferences } from "$lib/preferences";
|
import { userPreferences } from "$lib/preferences";
|
||||||
import settingInfo from "$lib/assets/settings.yml";
|
import { getMeta } from "$lib/meta/meta-storage";
|
||||||
|
import type { VersionMeta } from "$lib/meta/types/meta";
|
||||||
|
|
||||||
export const serialPort = writable<CharaDevice | undefined>();
|
export const serialPort = writable<CharaDevice | undefined>();
|
||||||
|
|
||||||
@@ -47,29 +48,39 @@ export const syncStatus: Writable<
|
|||||||
"done" | "error" | "downloading" | "uploading"
|
"done" | "error" | "downloading" | "uploading"
|
||||||
> = writable("done");
|
> = writable("done");
|
||||||
|
|
||||||
|
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
|
||||||
|
|
||||||
export interface ProgressInfo {
|
export interface ProgressInfo {
|
||||||
max: number;
|
max: number;
|
||||||
current: number;
|
current: number;
|
||||||
}
|
}
|
||||||
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
||||||
|
|
||||||
export async function initSerial(manual = false) {
|
export async function initSerial(manual = false, withSync = true) {
|
||||||
const device = get(serialPort) ?? new CharaDevice();
|
const device = get(serialPort) ?? new CharaDevice();
|
||||||
await device.init(manual);
|
await device.init(manual);
|
||||||
serialPort.set(device);
|
serialPort.set(device);
|
||||||
await sync();
|
if (withSync) {
|
||||||
|
await sync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sync() {
|
export async function sync() {
|
||||||
const device = get(serialPort);
|
const device = get(serialPort);
|
||||||
if (!device) return;
|
if (!device) return;
|
||||||
const chordCount = await device.getChordCount();
|
|
||||||
syncStatus.set("downloading");
|
syncStatus.set("downloading");
|
||||||
|
const meta = await getMeta(
|
||||||
|
`${device.device}_${device.chipset}`.toLowerCase(),
|
||||||
|
device.version.toString(),
|
||||||
|
);
|
||||||
|
deviceMeta.set(meta);
|
||||||
|
const chordCount = await device.getChordCount();
|
||||||
|
|
||||||
const max =
|
const maxSettings = meta.settings
|
||||||
Object.keys(settingInfo["settings"]).length +
|
.map((it) => it.items.length)
|
||||||
device.keyCount * 3 +
|
.reduce((a, b) => a + b, 0);
|
||||||
chordCount;
|
|
||||||
|
const max = maxSettings + device.keyCount * 3 + chordCount;
|
||||||
let current = 0;
|
let current = 0;
|
||||||
syncProgress.set({ max, current });
|
syncProgress.set({ max, current });
|
||||||
function progressTick() {
|
function progressTick() {
|
||||||
@@ -78,12 +89,12 @@ export async function sync() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parsedSettings: number[] = [];
|
const parsedSettings: number[] = [];
|
||||||
for (const key in settingInfo["settings"]) {
|
for (const category of meta.settings) {
|
||||||
try {
|
for (const setting of category.items) {
|
||||||
parsedSettings[Number.parseInt(key)] = await device.getSetting(
|
try {
|
||||||
Number.parseInt(key),
|
parsedSettings[setting.id] = await device.getSetting(setting.id);
|
||||||
);
|
} catch {}
|
||||||
} catch {}
|
}
|
||||||
progressTick();
|
progressTick();
|
||||||
}
|
}
|
||||||
deviceSettings.set(parsedSettings);
|
deviceSettings.set(parsedSettings);
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
stringifyPhrase,
|
stringifyPhrase,
|
||||||
} from "$lib/serial/chord";
|
} from "$lib/serial/chord";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
|
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||||
|
|
||||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||||
["TWO S3", { usbProductId: 0x0056, usbVendorId: 0x2886 }],
|
["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 S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
||||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
||||||
|
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const KEY_COUNTS = {
|
const KEY_COUNTS = {
|
||||||
@@ -23,6 +26,8 @@ const KEY_COUNTS = {
|
|||||||
TWO: 90,
|
TWO: 90,
|
||||||
LITE: 67,
|
LITE: 67,
|
||||||
X: 256,
|
X: 256,
|
||||||
|
M4G: 90,
|
||||||
|
M4GR: 90,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -33,6 +38,13 @@ if (
|
|||||||
await import("./tauri-serial");
|
await import("./tauri-serial");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
|
||||||
|
// @ts-expect-error polyfill
|
||||||
|
navigator.serial = await import("web-serial-polyfill").then(
|
||||||
|
({ serial }) => serial,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getViablePorts(): Promise<SerialPort[]> {
|
export async function getViablePorts(): Promise<SerialPort[]> {
|
||||||
return navigator.serial.getPorts().then((ports) =>
|
return navigator.serial.getPorts().then((ports) =>
|
||||||
ports.filter((it) => {
|
ports.filter((it) => {
|
||||||
@@ -88,8 +100,8 @@ export class CharaDevice {
|
|||||||
private suspendDebounceId?: number;
|
private suspendDebounceId?: number;
|
||||||
|
|
||||||
version!: SemVer;
|
version!: SemVer;
|
||||||
company!: "CHARACHORDER";
|
company!: "CHARACHORDER" | "FORGE";
|
||||||
device!: "ONE" | "TWO" | "LITE" | "X";
|
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
|
||||||
chipset!: "M0" | "S2" | "S3";
|
chipset!: "M0" | "S2" | "S3";
|
||||||
keyCount!: 90 | 67 | 256;
|
keyCount!: 90 | 67 | 256;
|
||||||
|
|
||||||
@@ -123,17 +135,16 @@ export class CharaDevice {
|
|||||||
await this.port.close();
|
await this.port.close();
|
||||||
|
|
||||||
this.version = new SemVer(
|
this.version = new SemVer(
|
||||||
await this.send(1, "VERSION").then(([version]) => version),
|
await this.send(1, ["VERSION"]).then(([version]) => version),
|
||||||
);
|
);
|
||||||
const [company, device, chipset] = await this.send(3, "ID");
|
const [company, device, chipset] = await this.send(3, ["ID"]);
|
||||||
this.company = company as "CHARACHORDER";
|
this.company = company as typeof this.company;
|
||||||
this.device = device as "ONE" | "TWO" | "LITE" | "X";
|
this.device = device as typeof this.device;
|
||||||
this.chipset = chipset as "M0" | "S2" | "S3";
|
this.chipset = chipset as typeof this.chipset;
|
||||||
this.keyCount = KEY_COUNTS[this.device];
|
this.keyCount = KEY_COUNTS[this.device];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e);
|
|
||||||
console.error(e);
|
console.error(e);
|
||||||
throw e;
|
await showConnectionFailedDialog(String(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +185,12 @@ export class CharaDevice {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async internalRead() {
|
private async internalRead(timeoutMs: number | undefined) {
|
||||||
try {
|
try {
|
||||||
const { value } = await timeout(this.reader.read(), 5000);
|
const { value } =
|
||||||
|
timeoutMs !== undefined
|
||||||
|
? await timeout(this.reader.read(), timeoutMs)
|
||||||
|
: await this.reader.read();
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
it.push({
|
it.push({
|
||||||
type: "output",
|
type: "output",
|
||||||
@@ -267,14 +281,15 @@ export class CharaDevice {
|
|||||||
*/
|
*/
|
||||||
async send<T extends number>(
|
async send<T extends number>(
|
||||||
expectedLength: T,
|
expectedLength: T,
|
||||||
...command: string[]
|
command: string[],
|
||||||
|
timeout: number | undefined = 5000,
|
||||||
): Promise<LengthArray<string, T>> {
|
): Promise<LengthArray<string, T>> {
|
||||||
return this.runWith(async (send, read) => {
|
return this.runWith(async (send, read) => {
|
||||||
await send(...command);
|
await send(...command);
|
||||||
const commandString = command
|
const commandString = command
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||||
const readResult = await read();
|
const readResult = await read(timeout);
|
||||||
if (readResult === undefined) {
|
if (readResult === undefined) {
|
||||||
console.error("No response");
|
console.error("No response");
|
||||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||||
@@ -296,7 +311,7 @@ export class CharaDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getChordCount(): Promise<number> {
|
async getChordCount(): Promise<number> {
|
||||||
const [count] = await this.send(1, "CML C0");
|
const [count] = await this.send(1, ["CML", "C0"]);
|
||||||
return Number.parseInt(count);
|
return Number.parseInt(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +319,11 @@ export class CharaDevice {
|
|||||||
* Retrieves a chord by index
|
* Retrieves a chord by index
|
||||||
*/
|
*/
|
||||||
async getChord(index: number | number[]): Promise<Chord> {
|
async getChord(index: number | number[]): Promise<Chord> {
|
||||||
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
|
const [actions, phrase] = await this.send(2, [
|
||||||
|
"CML",
|
||||||
|
"C1",
|
||||||
|
index.toString(),
|
||||||
|
]);
|
||||||
return {
|
return {
|
||||||
actions: parseChordActions(actions),
|
actions: parseChordActions(actions),
|
||||||
phrase: parsePhrase(phrase),
|
phrase: parsePhrase(phrase),
|
||||||
@@ -315,29 +334,30 @@ export class CharaDevice {
|
|||||||
* Retrieves the phrase for a set of actions
|
* Retrieves the phrase for a set of actions
|
||||||
*/
|
*/
|
||||||
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
||||||
const [phrase] = await this.send(
|
const [phrase] = await this.send(1, [
|
||||||
1,
|
"CML",
|
||||||
`CML C2 ${stringifyChordActions(actions)}`,
|
"C2",
|
||||||
);
|
stringifyChordActions(actions),
|
||||||
|
]);
|
||||||
return phrase === "2" ? undefined : parsePhrase(phrase);
|
return phrase === "2" ? undefined : parsePhrase(phrase);
|
||||||
}
|
}
|
||||||
|
|
||||||
async setChord(chord: Chord) {
|
async setChord(chord: Chord) {
|
||||||
const [status] = await this.send(
|
const [status] = await this.send(1, [
|
||||||
1,
|
|
||||||
"CML",
|
"CML",
|
||||||
"C3",
|
"C3",
|
||||||
stringifyChordActions(chord.actions),
|
stringifyChordActions(chord.actions),
|
||||||
stringifyPhrase(chord.phrase),
|
stringifyPhrase(chord.phrase),
|
||||||
);
|
]);
|
||||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
if (status !== "0") console.error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||||
const status = await this.send(
|
const status = await this.send(1, [
|
||||||
1,
|
"CML",
|
||||||
`CML C4 ${stringifyChordActions(chord.actions)}`,
|
"C4",
|
||||||
);
|
stringifyChordActions(chord.actions),
|
||||||
|
]);
|
||||||
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
||||||
throw new Error(`Failed with status ${status}`);
|
throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
@@ -349,7 +369,13 @@ export class CharaDevice {
|
|||||||
* @param action the assigned action id
|
* @param action the assigned action id
|
||||||
*/
|
*/
|
||||||
async setLayoutKey(layer: number, id: number, action: number) {
|
async setLayoutKey(layer: number, id: number, action: number) {
|
||||||
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
|
const [status] = await this.send(1, [
|
||||||
|
"VAR",
|
||||||
|
"B4",
|
||||||
|
`A${layer}`,
|
||||||
|
id.toString(),
|
||||||
|
action.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +386,12 @@ export class CharaDevice {
|
|||||||
* @returns the assigned action id
|
* @returns the assigned action id
|
||||||
*/
|
*/
|
||||||
async getLayoutKey(layer: number, id: number) {
|
async getLayoutKey(layer: number, id: number) {
|
||||||
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
|
const [position, status] = await this.send(2, [
|
||||||
|
"VAR",
|
||||||
|
"B3",
|
||||||
|
`A${layer}`,
|
||||||
|
id.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
return Number(position);
|
return Number(position);
|
||||||
}
|
}
|
||||||
@@ -373,7 +404,7 @@ export class CharaDevice {
|
|||||||
* **This does not need to be called for chords**
|
* **This does not need to be called for chords**
|
||||||
*/
|
*/
|
||||||
async commit() {
|
async commit() {
|
||||||
const [status] = await this.send(1, "VAR B0");
|
const [status] = await this.send(1, ["VAR", "B0"]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,10 +415,12 @@ export class CharaDevice {
|
|||||||
* To permanently store the settings, you *must* call commit.
|
* To permanently store the settings, you *must* call commit.
|
||||||
*/
|
*/
|
||||||
async setSetting(id: number, value: number) {
|
async setSetting(id: number, value: number) {
|
||||||
const [status] = await this.send(
|
const [status] = await this.send(1, [
|
||||||
1,
|
"VAR",
|
||||||
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
|
"B2",
|
||||||
);
|
id.toString(16).toUpperCase(),
|
||||||
|
value.toString(),
|
||||||
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,10 +428,11 @@ export class CharaDevice {
|
|||||||
* Retrieves a setting from the device
|
* Retrieves a setting from the device
|
||||||
*/
|
*/
|
||||||
async getSetting(id: number): Promise<number> {
|
async getSetting(id: number): Promise<number> {
|
||||||
const [value, status] = await this.send(
|
const [value, status] = await this.send(2, [
|
||||||
2,
|
"VAR",
|
||||||
`VAR B1 ${id.toString(16).toUpperCase()}`,
|
"B1",
|
||||||
);
|
id.toString(16).toUpperCase(),
|
||||||
|
]);
|
||||||
if (status !== "0")
|
if (status !== "0")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
||||||
@@ -410,14 +444,14 @@ export class CharaDevice {
|
|||||||
* Reboots the device
|
* Reboots the device
|
||||||
*/
|
*/
|
||||||
async reboot() {
|
async reboot() {
|
||||||
await this.send(0, "RST");
|
await this.send(0, ["RST"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reboots the device to the bootloader
|
* Reboots the device to the bootloader
|
||||||
*/
|
*/
|
||||||
async bootloader() {
|
async bootloader() {
|
||||||
await this.send(0, "RST BOOTLOADER");
|
await this.send(0, ["RST", "BOOTLOADER"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -426,7 +460,12 @@ export class CharaDevice {
|
|||||||
async reset(
|
async reset(
|
||||||
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
||||||
) {
|
) {
|
||||||
await this.send(0, `RST ${type}`);
|
await this.send(0, ["RST", type]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async queryKey(): Promise<number> {
|
||||||
|
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
|
||||||
|
return Number(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -435,6 +474,97 @@ export class CharaDevice {
|
|||||||
* This is useful for debugging when there is a suspected heap or stack issue.
|
* This is useful for debugging when there is a suspected heap or stack issue.
|
||||||
*/
|
*/
|
||||||
async getRamBytesAvailable(): Promise<number> {
|
async getRamBytesAvailable(): Promise<number> {
|
||||||
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
|
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFirmware(file: File | Blob): Promise<void> {
|
||||||
|
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,38 +1,64 @@
|
|||||||
import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
|
import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
|
||||||
|
import { derived, type Readable } from "svelte/store";
|
||||||
|
import { deviceMeta } from "./connection";
|
||||||
|
|
||||||
export interface KeyInfo extends Partial<ActionInfo> {
|
export interface KeyInfo extends Partial<ActionInfo> {
|
||||||
code: number;
|
code: number;
|
||||||
category?: KeymapCategory;
|
category?: KeymapCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const KEYMAP_CATEGORIES = (await Promise.all(
|
const fallbackActions = await Promise.all<KeymapCategory>(
|
||||||
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
|
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
|
||||||
async (load) => load().then((it) => (it as any).default),
|
async (load) => load().then((it) => (it as any).default),
|
||||||
),
|
),
|
||||||
)) as KeymapCategory[];
|
|
||||||
|
|
||||||
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 },
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const KEYMAP_KEYCODES = new Map<string, number>(
|
export let KEYMAP_CATEGORIES: Readable<KeymapCategory[]> = derived(
|
||||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
deviceMeta,
|
||||||
Object.entries(category.actions).map(
|
(deviceMeta) => deviceMeta?.actions ?? fallbackActions,
|
||||||
([code, action]) => [action.keyCode!, Number(code)] as const,
|
);
|
||||||
|
|
||||||
|
export const KEYMAP_CODES: Readable<Map<number, KeyInfo>> = derived(
|
||||||
|
KEYMAP_CATEGORIES,
|
||||||
|
(categories) =>
|
||||||
|
new Map<number, KeyInfo>(
|
||||||
|
categories.flatMap((category) =>
|
||||||
|
Object.entries(category.actions).map(([code, action]) => [
|
||||||
|
Number(code),
|
||||||
|
{ ...action, code: Number(code), category },
|
||||||
|
]),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
).filter(([keyCode]) => keyCode !== undefined),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const KEYMAP_IDS = new Map<string, KeyInfo>(
|
export const KEYMAP_KEYCODES: Readable<Map<string, number>> = derived(
|
||||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
KEYMAP_CATEGORIES,
|
||||||
Object.entries(category.actions).map(
|
(categories) =>
|
||||||
([code, action]) =>
|
new Map<string, number>(
|
||||||
[action.id!, { ...action, code: Number(code), category }] as const,
|
categories
|
||||||
|
.flatMap((category) =>
|
||||||
|
Object.entries(category.actions).map(
|
||||||
|
([code, action]) => [action.keyCode!, Number(code)] as const,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(([keyCode]) => keyCode !== undefined),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const KEYMAP_IDS: Readable<Map<string, KeyInfo>> = derived(
|
||||||
|
KEYMAP_CATEGORIES,
|
||||||
|
(categories) =>
|
||||||
|
new Map<string, KeyInfo>(
|
||||||
|
categories
|
||||||
|
.flatMap((category) =>
|
||||||
|
Object.entries(category.actions).map(
|
||||||
|
([code, action]) =>
|
||||||
|
[
|
||||||
|
action.id!,
|
||||||
|
{ ...action, code: Number(code), category },
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.filter(([id]) => id !== undefined),
|
||||||
),
|
),
|
||||||
).filter(([id]) => id !== undefined),
|
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -63,11 +63,13 @@ export const setting: Action<
|
|||||||
}
|
}
|
||||||
|
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push({
|
changes.push([
|
||||||
type: ChangeType.Setting,
|
{
|
||||||
id: id,
|
type: ChangeType.Setting,
|
||||||
setting: value,
|
id: id,
|
||||||
});
|
setting: value,
|
||||||
|
},
|
||||||
|
]);
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,16 @@ export function persistentWritable<T>(
|
|||||||
): Writable<T> {
|
): Writable<T> {
|
||||||
if (browser) {
|
if (browser) {
|
||||||
const persistedValue = localStorage.getItem(key);
|
const persistedValue = localStorage.getItem(key);
|
||||||
const store =
|
let store: Writable<T>;
|
||||||
persistedValue !== null
|
try {
|
||||||
? writable(JSON.parse(persistedValue))
|
store =
|
||||||
: writable(value);
|
persistedValue !== null
|
||||||
|
? writable(JSON.parse(persistedValue))
|
||||||
|
: writable(value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
store = writable(value);
|
||||||
|
}
|
||||||
store.subscribe((value) => {
|
store.subscribe((value) => {
|
||||||
if (!condition || condition())
|
if (!condition || condition())
|
||||||
localStorage.setItem(key, JSON.stringify(value));
|
localStorage.setItem(key, JSON.stringify(value));
|
||||||
|
|||||||
4
src/lib/style/_reset.scss
Normal file
4
src/lib/style/_reset.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
6
src/lib/style/elements/_h1.scss
Normal file
6
src/lib/style/elements/_h1.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
h1 {
|
||||||
|
margin-block-start: 0;
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--md-sys-color-secondary);
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ button {
|
|||||||
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
border-radius: 32px;
|
||||||
|
transition: all 250ms ease;
|
||||||
|
|
||||||
@media not (forced-colors: active) {
|
@media not (forced-colors: active) {
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
@@ -36,10 +38,6 @@ button {
|
|||||||
color: ButtonText;
|
color: ButtonText;
|
||||||
}
|
}
|
||||||
|
|
||||||
border-radius: 32px;
|
|
||||||
|
|
||||||
transition: all 250ms ease;
|
|
||||||
|
|
||||||
&.icon {
|
&.icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ button {
|
|||||||
padding-inline: 0;
|
padding-inline: 0;
|
||||||
|
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
|
|||||||
@@ -1,10 +1,35 @@
|
|||||||
@import "./form/button";
|
@use "reset";
|
||||||
@import "./form/toggle";
|
|
||||||
@import "./form/checkbox";
|
|
||||||
@import "./kbd";
|
|
||||||
@import "./print";
|
|
||||||
|
|
||||||
* {
|
@use "form/button";
|
||||||
box-sizing: border-box;
|
@use "form/toggle";
|
||||||
appearance: none;
|
@use "form/checkbox";
|
||||||
|
|
||||||
|
@use "kbd";
|
||||||
|
@use "print";
|
||||||
|
|
||||||
|
@use "elements/h1";
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
font-family: "Noto Sans Mono", monospace;
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
contain: strict;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
padding-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import type { SvelteComponent } from "svelte";
|
import { mount, unmount, type SvelteComponent } from "svelte";
|
||||||
import Tooltip from "$lib/components/Tooltip.svelte";
|
import Tooltip from "$lib/components/Tooltip.svelte";
|
||||||
|
|
||||||
export const hotkeys = new Map<string, HTMLElement>();
|
export const hotkeys = new Map<string, HTMLElement>();
|
||||||
@@ -9,20 +9,22 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
|
|||||||
node: Element,
|
node: Element,
|
||||||
{ title, shortcut },
|
{ title, shortcut },
|
||||||
) => {
|
) => {
|
||||||
let component: SvelteComponent | undefined;
|
let component: {} | undefined;
|
||||||
const tooltip = tippy(node, {
|
const tooltip = tippy(node, {
|
||||||
arrow: false,
|
arrow: false,
|
||||||
theme: "tooltip",
|
theme: "tooltip",
|
||||||
animation: "fade",
|
animation: "fade",
|
||||||
onShow(instance) {
|
onShow(instance) {
|
||||||
component ??= new Tooltip({
|
component ??= mount(Tooltip, {
|
||||||
target: instance.popper.querySelector(".tippy-content") as Element,
|
target: instance.popper.querySelector(".tippy-content") as Element,
|
||||||
props: { title, shortcut },
|
props: { title, shortcut },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onHidden() {
|
onHidden() {
|
||||||
component?.$destroy();
|
if (component) {
|
||||||
component = undefined;
|
unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { persistentWritable } from "$lib/storage";
|
import { persistentWritable } from "$lib/storage";
|
||||||
import { derived } from "svelte/store";
|
import { derived } from "svelte/store";
|
||||||
import type { Chord } from "$lib/serial/chord";
|
import { hashChord, type Chord } from "$lib/serial/chord";
|
||||||
import {
|
import {
|
||||||
deviceChords,
|
deviceChords,
|
||||||
deviceLayout,
|
deviceLayout,
|
||||||
@@ -42,7 +42,7 @@ export interface ChangeInfo {
|
|||||||
|
|
||||||
export type Change = LayoutChange | ChordChange | SettingChange;
|
export type Change = LayoutChange | ChordChange | SettingChange;
|
||||||
|
|
||||||
export const changes = persistentWritable<Change[]>("changes", []);
|
export const changes = persistentWritable<Change[][]>("changes", []);
|
||||||
|
|
||||||
export interface Overlay {
|
export interface Overlay {
|
||||||
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
|
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
|
||||||
@@ -57,21 +57,23 @@ export const overlay = derived(changes, (changes) => {
|
|||||||
settings: new Map(),
|
settings: new Map(),
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const change of changes) {
|
for (const changeset of changes) {
|
||||||
switch (change.type) {
|
for (const change of changeset) {
|
||||||
case ChangeType.Layout:
|
switch (change.type) {
|
||||||
overlay.layout[change.layer]?.set(change.id, change.action);
|
case ChangeType.Layout:
|
||||||
break;
|
overlay.layout[change.layer]?.set(change.id, change.action);
|
||||||
case ChangeType.Chord:
|
break;
|
||||||
overlay.chords.set(JSON.stringify(change.id), {
|
case ChangeType.Chord:
|
||||||
actions: change.actions,
|
overlay.chords.set(JSON.stringify(change.id), {
|
||||||
phrase: change.phrase,
|
actions: change.actions,
|
||||||
deleted: change.deleted ?? false,
|
phrase: change.phrase,
|
||||||
});
|
deleted: change.deleted ?? false,
|
||||||
break;
|
});
|
||||||
case ChangeType.Setting:
|
break;
|
||||||
overlay.settings.set(change.id, change.setting);
|
case ChangeType.Setting:
|
||||||
break;
|
overlay.settings.set(change.id, change.setting);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,54 +109,64 @@ export type ChordInfo = Chord &
|
|||||||
id: number[];
|
id: number[];
|
||||||
deleted: boolean;
|
deleted: boolean;
|
||||||
};
|
};
|
||||||
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
|
export const chords = derived(
|
||||||
const newChords = new Set(overlay.chords.keys());
|
[overlay, deviceChords, KEYMAP_CODES],
|
||||||
|
([overlay, chords, codes]) => {
|
||||||
|
const newChords = new Set(overlay.chords.keys());
|
||||||
|
|
||||||
const changedChords = chords.map<ChordInfo>((chord) => {
|
const changedChords = chords.map<ChordInfo>((chord) => {
|
||||||
const id = JSON.stringify(chord.actions);
|
const id = JSON.stringify(chord.actions);
|
||||||
if (overlay.chords.has(id)) {
|
if (overlay.chords.has(id)) {
|
||||||
newChords.delete(id);
|
newChords.delete(id);
|
||||||
const changedChord = overlay.chords.get(id)!;
|
const changedChord = overlay.chords.get(id)!;
|
||||||
return {
|
return {
|
||||||
id: chord.actions,
|
id: chord.actions,
|
||||||
// use the old phrase for stable editing
|
// use the old phrase for stable editing
|
||||||
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
|
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
|
||||||
actions: changedChord.actions,
|
actions: changedChord.actions,
|
||||||
phrase: changedChord.phrase,
|
phrase: changedChord.phrase,
|
||||||
actionsChanged: id !== JSON.stringify(changedChord.actions),
|
actionsChanged: id !== JSON.stringify(changedChord.actions),
|
||||||
phraseChanged:
|
phraseChanged:
|
||||||
JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase),
|
JSON.stringify(chord.phrase) !==
|
||||||
isApplied: false,
|
JSON.stringify(changedChord.phrase),
|
||||||
deleted: changedChord.deleted,
|
isApplied: false,
|
||||||
};
|
deleted: changedChord.deleted,
|
||||||
} else {
|
};
|
||||||
return {
|
} else {
|
||||||
id: chord.actions,
|
return {
|
||||||
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
|
id: chord.actions,
|
||||||
actions: chord.actions,
|
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
|
||||||
phrase: chord.phrase,
|
actions: chord.actions,
|
||||||
phraseChanged: false,
|
phrase: chord.phrase,
|
||||||
actionsChanged: false,
|
phraseChanged: false,
|
||||||
isApplied: true,
|
actionsChanged: false,
|
||||||
deleted: false,
|
isApplied: true,
|
||||||
};
|
deleted: false,
|
||||||
}
|
};
|
||||||
});
|
}
|
||||||
for (const id of newChords) {
|
|
||||||
const chord = overlay.chords.get(id)!;
|
|
||||||
changedChords.push({
|
|
||||||
sortBy: "",
|
|
||||||
isApplied: false,
|
|
||||||
actionsChanged: true,
|
|
||||||
phraseChanged: false,
|
|
||||||
deleted: chord.deleted,
|
|
||||||
id: JSON.parse(id),
|
|
||||||
phrase: chord.phrase,
|
|
||||||
actions: chord.actions,
|
|
||||||
});
|
});
|
||||||
}
|
for (const id of newChords) {
|
||||||
|
const chord = overlay.chords.get(id)!;
|
||||||
|
changedChords.push({
|
||||||
|
sortBy: "",
|
||||||
|
isApplied: false,
|
||||||
|
actionsChanged: true,
|
||||||
|
phraseChanged: false,
|
||||||
|
deleted: chord.deleted,
|
||||||
|
id: JSON.parse(id),
|
||||||
|
phrase: chord.phrase,
|
||||||
|
actions: chord.actions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
|
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
|
||||||
a.localeCompare(b),
|
a.localeCompare(b),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const chordHashes = derived(
|
||||||
|
chords,
|
||||||
|
(chords) =>
|
||||||
|
new Map(chords.map((chord) => [hashChord(chord.actions), chord] as const)),
|
||||||
|
);
|
||||||
|
|||||||
15
src/lib/util/shuffle.ts
Normal file
15
src/lib/util/shuffle.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
|
||||||
|
*/
|
||||||
|
export function shuffleInPlace<T>(array: T[]) {
|
||||||
|
for (let i = array.length - 1; i >= 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j]!, array[i]!];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shuffle<T>(array: T[]): T[] {
|
||||||
|
const result = [...array];
|
||||||
|
shuffleInPlace(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import {
|
|
||||||
themeBase,
|
|
||||||
themeColor,
|
|
||||||
themeSuccessBase,
|
|
||||||
} from "$lib/style/theme.server";
|
|
||||||
import type { LayoutServerLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load = (async () => ({
|
|
||||||
themeSuccessBase,
|
|
||||||
themeBase,
|
|
||||||
themeColor,
|
|
||||||
})) satisfies LayoutServerLoad;
|
|
||||||
@@ -4,18 +4,17 @@
|
|||||||
import "$lib/style/scrollbar.scss";
|
import "$lib/style/scrollbar.scss";
|
||||||
import "$lib/style/tippy.scss";
|
import "$lib/style/tippy.scss";
|
||||||
import "$lib/style/theme.scss";
|
import "$lib/style/theme.scss";
|
||||||
import { onDestroy, onMount } from "svelte";
|
import Sidebar from "./Sidebar.svelte";
|
||||||
|
import { onDestroy, onMount, type Snippet } from "svelte";
|
||||||
import {
|
import {
|
||||||
applyTheme,
|
applyTheme,
|
||||||
argbFromHex,
|
argbFromHex,
|
||||||
themeFromSourceColor,
|
themeFromSourceColor,
|
||||||
} from "@material/material-color-utilities";
|
} from "@material/material-color-utilities";
|
||||||
import Navigation from "./Navigation.svelte";
|
|
||||||
import { canAutoConnect } from "$lib/serial/device";
|
import { canAutoConnect } from "$lib/serial/device";
|
||||||
import { initSerial } from "$lib/serial/connection";
|
import { initSerial } from "$lib/serial/connection";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import BrowserWarning from "./BrowserWarning.svelte";
|
|
||||||
import "tippy.js/animations/shift-away.css";
|
import "tippy.js/animations/shift-away.css";
|
||||||
import "tippy.js/dist/tippy.css";
|
import "tippy.js/dist/tippy.css";
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
@@ -49,7 +48,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export let data: LayoutData;
|
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
theme.subscribe((it) => {
|
theme.subscribe((it) => {
|
||||||
@@ -79,7 +78,7 @@
|
|||||||
stopLayoutDetection?.();
|
stopLayoutDetection?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
let webManifestLink = "";
|
let webManifestLink = $state("");
|
||||||
|
|
||||||
function handleHotkey(event: KeyboardEvent) {
|
function handleHotkey(event: KeyboardEvent) {
|
||||||
let key = $osLayout.get(event.code);
|
let key = $osLayout.get(event.code);
|
||||||
@@ -114,51 +113,32 @@
|
|||||||
<meta name="theme-color" content={data.themeColor} />
|
<meta name="theme-color" content={data.themeColor} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<svelte:window on:keydown={handleHotkey} />
|
<svelte:window onkeydown={handleHotkey} />
|
||||||
|
|
||||||
<Navigation />
|
<div class="layout">
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
<!-- <PickChangesDialog /> -->
|
<!-- <PickChangesDialog /> -->
|
||||||
|
|
||||||
<PageTransition>
|
<PageTransition>
|
||||||
<slot />
|
{#if children}
|
||||||
</PageTransition>
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
</PageTransition>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
</div>
|
||||||
{#if import.meta.env.TAURI_FAMILY === undefined && browser && !("serial" in navigator)}
|
|
||||||
<BrowserWarning />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss" global>
|
|
||||||
body {
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.layout {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
display: grid;
|
||||||
color: var(--md-sys-color-on-background);
|
grid-template-areas:
|
||||||
|
"sidebar main"
|
||||||
background: var(--md-sys-color-background);
|
"sidebar footer";
|
||||||
}
|
grid-template-columns: auto 1fr;
|
||||||
|
grid-template-rows: 1fr;
|
||||||
main {
|
|
||||||
contain: strict;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
|
||||||
align-items: center;
|
|
||||||
padding-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-block-start: 0;
|
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--md-sys-color-secondary);
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import type { LayoutLoad } from "./$types";
|
import type { LayoutLoad } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { charaFileFromUriComponent } from "$lib/share/share-url";
|
import { charaFileFromUriComponent } from "$lib/share/share-url";
|
||||||
|
import { themeBase, themeColor, themeSuccessBase } from "$lib/style/theme";
|
||||||
|
|
||||||
export const load = (async ({ url, data, fetch }) => {
|
export const load = (async ({ url, data, fetch }) => {
|
||||||
const importFile = browser && new URLSearchParams(url.search).get("import");
|
const importFile = browser && new URLSearchParams(url.search).get("import");
|
||||||
return {
|
return {
|
||||||
...data,
|
themeSuccessBase,
|
||||||
|
themeBase,
|
||||||
|
themeColor,
|
||||||
importFile: importFile
|
importFile: importFile
|
||||||
? await charaFileFromUriComponent(importFile, fetch)
|
? await charaFileFromUriComponent(importFile, fetch)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|||||||
0
src/routes/(app)/+page.svelte
Normal file
0
src/routes/(app)/+page.svelte
Normal file
@@ -1,6 +0,0 @@
|
|||||||
import { redirect } from "@sveltejs/kit";
|
|
||||||
import type { PageLoad } from "./$types";
|
|
||||||
|
|
||||||
export const load = (() => {
|
|
||||||
redirect(302, "/config/");
|
|
||||||
}) satisfies PageLoad;
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { preference } from "$lib/preferences";
|
|
||||||
import LL from "$i18n/i18n-svelte";
|
|
||||||
import {
|
|
||||||
createChordBackup,
|
|
||||||
createLayoutBackup,
|
|
||||||
createSettingsBackup,
|
|
||||||
downloadBackup,
|
|
||||||
downloadFile,
|
|
||||||
restoreBackup,
|
|
||||||
} from "$lib/backup/backup";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2>
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
use:preference={"backup"}
|
|
||||||
/>{$LL.backup.TITLE()}</label
|
|
||||||
>
|
|
||||||
</h2>
|
|
||||||
<p class="disclaimer">
|
|
||||||
<i>{$LL.backup.DISCLAIMER()}</i>
|
|
||||||
</p>
|
|
||||||
<fieldset>
|
|
||||||
<legend>{$LL.backup.INDIVIDUAL()}</legend>
|
|
||||||
<button on:click={() => downloadFile(createChordBackup())}>
|
|
||||||
<span class="icon">piano</span>
|
|
||||||
{$LL.configure.chords.TITLE()}
|
|
||||||
</button>
|
|
||||||
<button on:click={() => downloadFile(createLayoutBackup())}>
|
|
||||||
<span class="icon">keyboard</span>
|
|
||||||
{$LL.configure.layout.TITLE()}
|
|
||||||
</button>
|
|
||||||
<button on:click={() => downloadFile(createSettingsBackup())}>
|
|
||||||
<span class="icon">settings</span>
|
|
||||||
{$LL.configure.settings.TITLE()}
|
|
||||||
</button>
|
|
||||||
</fieldset>
|
|
||||||
<div class="save">
|
|
||||||
<button class="primary" on:click={downloadBackup}
|
|
||||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
|
||||||
>
|
|
||||||
<label class="button"
|
|
||||||
><input on:input={restoreBackup} type="file" /><span class="icon"
|
|
||||||
>settings_backup_restore</span
|
|
||||||
>{$LL.backup.RESTORE()}</label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
h2 {
|
|
||||||
margin-block-end: 0;
|
|
||||||
|
|
||||||
> label {
|
|
||||||
gap: 10px;
|
|
||||||
font-size: 24px;
|
|
||||||
|
|
||||||
> input {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldset {
|
|
||||||
display: flex;
|
|
||||||
margin-block: 16px;
|
|
||||||
border: 1px solid currentcolor;
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: min-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.disclaimer {
|
|
||||||
max-width: 16cm;
|
|
||||||
font-size: 12px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="file"] {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.save {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { page } from "$app/stores";
|
|
||||||
import LL from "$i18n/i18n-svelte";
|
|
||||||
|
|
||||||
$: paths = [
|
|
||||||
{
|
|
||||||
href: "/config/chords/",
|
|
||||||
title: $LL.configure.chords.TITLE(),
|
|
||||||
icon: "piano",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/config/layout/",
|
|
||||||
title: $LL.configure.layout.TITLE(),
|
|
||||||
icon: "keyboard",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: "/config/settings/",
|
|
||||||
title: $LL.configure.settings.TITLE(),
|
|
||||||
icon: "settings",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<nav>
|
|
||||||
{#each paths as { href, title, icon }}
|
|
||||||
<a {href} class:active={$page.url.pathname.startsWith(href)}>
|
|
||||||
<span class="icon">{icon}</span>
|
|
||||||
{title}
|
|
||||||
</a>
|
|
||||||
{/each}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<slot />
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
border: none;
|
|
||||||
border-radius: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.active {
|
|
||||||
--icon-fill: 1;
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,260 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { initSerial, serialPort } from "$lib/serial/connection";
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
import { slide, fade } from "svelte/transition";
|
|
||||||
import { preference } from "$lib/preferences";
|
|
||||||
import LL from "$i18n/i18n-svelte";
|
|
||||||
import { downloadBackup } from "$lib/backup/backup";
|
|
||||||
|
|
||||||
function reboot() {
|
|
||||||
$serialPort?.reboot();
|
|
||||||
$serialPort = undefined;
|
|
||||||
powerDialog = false;
|
|
||||||
setTimeout(() => {
|
|
||||||
initSerial();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function bootloader() {
|
|
||||||
downloadBackup();
|
|
||||||
$serialPort?.bootloader();
|
|
||||||
$serialPort = undefined;
|
|
||||||
rebootInfo = true;
|
|
||||||
powerDialog = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connect() {
|
|
||||||
try {
|
|
||||||
await initSerial(true);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
alert(
|
|
||||||
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let rebootInfo = false;
|
|
||||||
let terminal = false;
|
|
||||||
let powerDialog = false;
|
|
||||||
|
|
||||||
$: if ($serialPort) {
|
|
||||||
rebootInfo = false;
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="row">
|
|
||||||
<h2>{$LL.deviceManager.TITLE()}</h2>
|
|
||||||
<label
|
|
||||||
>{$LL.deviceManager.AUTO_CONNECT()}<input
|
|
||||||
type="checkbox"
|
|
||||||
use:preference={"autoConnect"}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $serialPort}
|
|
||||||
<p transition:slide>
|
|
||||||
{$serialPort.company}
|
|
||||||
{$serialPort.device}
|
|
||||||
{$serialPort.chipset}
|
|
||||||
<br />
|
|
||||||
Version {$serialPort.version}
|
|
||||||
</p>
|
|
||||||
{#if $serialPort.version.toString() !== import.meta.env.VITE_LATEST_FIRMWARE}
|
|
||||||
<a
|
|
||||||
href="https://docs.charachorder.com/CharaChorder%20One.html#updating-the-firmware"
|
|
||||||
>Firmware Update Instructions</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<!--<button on:click={updateFirmware}>Update</button>-->
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if browser}
|
|
||||||
{#if navigator.userAgent.includes("Linux") && !$serialPort}
|
|
||||||
<div class="linux-info">
|
|
||||||
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
|
|
||||||
<p>
|
|
||||||
In most cases you can simply follow the <a
|
|
||||||
target="_blank"
|
|
||||||
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
|
|
||||||
>Arduino Guide</a
|
|
||||||
> on serial port permissions.
|
|
||||||
</p>
|
|
||||||
<p>Special systems:</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
|
|
||||||
>Arch and Arch-based like Manjaro or EndeavourOS</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
|
|
||||||
>NixOS</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
target="_blank"
|
|
||||||
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
|
|
||||||
>Gentoo</a
|
|
||||||
>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{#if rebootInfo}
|
|
||||||
<p transition:slide>
|
|
||||||
<b>{$LL.deviceManager.bootMenu.POWER_WARNING()}</b>
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
<div class="row">
|
|
||||||
{#if $serialPort}
|
|
||||||
<button
|
|
||||||
class="secondary"
|
|
||||||
on:click={() => {
|
|
||||||
$serialPort?.forget();
|
|
||||||
$serialPort = undefined;
|
|
||||||
}}
|
|
||||||
><span class="icon">usb_off</span
|
|
||||||
>{$LL.deviceManager.DISCONNECT()}</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button class="error" on:click={connect}
|
|
||||||
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<div class="row" style="justify-content: flex-end">
|
|
||||||
<a
|
|
||||||
href="/terminal"
|
|
||||||
title={$LL.deviceManager.TERMINAL()}
|
|
||||||
class="icon"
|
|
||||||
class:disabled={$serialPort === undefined}
|
|
||||||
on:click={() => (terminal = !terminal)}>terminal</a
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="icon"
|
|
||||||
title={$LL.deviceManager.bootMenu.TITLE()}
|
|
||||||
disabled={$serialPort === undefined}
|
|
||||||
on:click={() => (powerDialog = !powerDialog)}>settings_power</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if powerDialog}
|
|
||||||
<div
|
|
||||||
class="backdrop"
|
|
||||||
role="button"
|
|
||||||
tabindex="-1"
|
|
||||||
transition:fade={{ duration: 250 }}
|
|
||||||
on:click={() => (powerDialog = !powerDialog)}
|
|
||||||
on:keypress={(event) => {
|
|
||||||
if (event.key === "Enter") powerDialog = !powerDialog;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<dialog open transition:slide={{ duration: 250 }}>
|
|
||||||
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
|
|
||||||
<button on:click={reboot}
|
|
||||||
><span class="icon">restart_alt</span
|
|
||||||
>{$LL.deviceManager.bootMenu.REBOOT()}</button
|
|
||||||
>
|
|
||||||
<button on:click={bootloader}
|
|
||||||
><span class="icon">rule_settings</span
|
|
||||||
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
|
|
||||||
>
|
|
||||||
</dialog>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
h2 {
|
|
||||||
margin-block: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-block: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.linux-info a {
|
|
||||||
display: inline;
|
|
||||||
padding-inline: 0;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backdrop {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
inset: 0;
|
|
||||||
|
|
||||||
background: #0005;
|
|
||||||
border-radius: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
margin-block-start: 16px;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-secondary-container);
|
|
||||||
border: none;
|
|
||||||
border-radius: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > * {
|
|
||||||
margin-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog > :first-child {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding-block: 8px;
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
button:active:not(:disabled) {
|
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -8,16 +8,33 @@
|
|||||||
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
||||||
import { tick } from "svelte";
|
import { tick } from "svelte";
|
||||||
import SyncOverlay from "./SyncOverlay.svelte";
|
import SyncOverlay from "./SyncOverlay.svelte";
|
||||||
import { serialPort } from "$lib/serial/connection";
|
import {
|
||||||
|
initSerial,
|
||||||
|
serialPort,
|
||||||
|
sync,
|
||||||
|
syncProgress,
|
||||||
|
syncStatus,
|
||||||
|
} from "$lib/serial/connection";
|
||||||
|
import { fade, slide } from "svelte/transition";
|
||||||
|
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||||
|
|
||||||
let locale =
|
let locale = $state(
|
||||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale();
|
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
||||||
$: if (browser)
|
);
|
||||||
(async () => {
|
|
||||||
localStorage.setItem("locale", locale);
|
let currentDevice = $derived(
|
||||||
await loadLocaleAsync(locale);
|
$serialPort
|
||||||
|
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!browser) return;
|
||||||
|
localStorage.setItem("locale", locale);
|
||||||
|
loadLocaleAsync(locale).then(() => {
|
||||||
setLocale(locale);
|
setLocale(locale);
|
||||||
})();
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function switchTheme() {
|
function switchTheme() {
|
||||||
const mode = $theme.mode === "light" ? "dark" : "light";
|
const mode = $theme.mode === "light" ? "dark" : "light";
|
||||||
@@ -31,47 +48,83 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
try {
|
||||||
|
await initSerial(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
await showConnectionFailedDialog(String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(event: MouseEvent) {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
sync();
|
||||||
|
} else {
|
||||||
|
$serialPort?.forget();
|
||||||
|
$serialPort = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let languageSelect: HTMLSelectElement;
|
let languageSelect: HTMLSelectElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<!-- svelte-ignore not-defined -->
|
|
||||||
<a
|
<a
|
||||||
|
use:action={{ title: "Branch" }}
|
||||||
href={import.meta.env.VITE_HOMEPAGE_URL}
|
href={import.meta.env.VITE_HOMEPAGE_URL}
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"><span class="icon">commit</span> v{version}</a
|
target="_blank"><span class="icon">commit</span> v{version}</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
<a
|
||||||
><span class="icon">bug_report</span> Issues</a
|
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
|
||||||
>
|
use:action={{ title: "Updates" }}
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href={import.meta.env.VITE_DOCS_URL} rel="noreferrer" target="_blank"
|
|
||||||
><span class="icon">description</span> Docs</a
|
|
||||||
>
|
>
|
||||||
|
CCOS {$serialPort?.version ?? "Updates"}
|
||||||
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div>
|
<div class="sync-box">
|
||||||
{#if !$serialPort}
|
{#if !$serialPort}
|
||||||
<div class="warning">
|
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
|
||||||
<span class="icon">warning</span>{$LL.deviceManager.NO_DEVICE()}
|
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||||
</div>
|
>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
transition:slide={{ axis: "x" }}
|
||||||
|
onclick={disconnect}
|
||||||
|
use:action={{
|
||||||
|
title: "Disconnect<br><kbd class='icon'>shift</kbd> Sync",
|
||||||
|
}}
|
||||||
|
><b
|
||||||
|
>{$serialPort.company}
|
||||||
|
{$serialPort.device}
|
||||||
|
{$serialPort.chipset}</b
|
||||||
|
><span class="icon">usb_off</span></button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if $syncStatus !== "done"}
|
||||||
|
<progress
|
||||||
|
transition:fade
|
||||||
|
max={$syncProgress?.max ?? 1}
|
||||||
|
value={$syncProgress?.current ?? 1}
|
||||||
|
></progress>
|
||||||
{/if}
|
{/if}
|
||||||
<SyncOverlay />
|
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
|
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
||||||
><span class="icon">shopping_bag</span> Store</a
|
><span class="icon">bug_report</span> Bugs</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href={import.meta.env.VITE_LEARN_URL} rel="noreferrer" target="_blank"
|
<a href={import.meta.env.VITE_STORE_URL} rel="noreferrer" target="_blank"
|
||||||
><span class="icon">school</span> Train</a
|
><span class="icon">shopping_bag</span> Store</a
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="hide-forced-colors">
|
<li class="hide-forced-colors">
|
||||||
@@ -86,7 +139,7 @@
|
|||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
|
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
|
||||||
class="icon"
|
class="icon"
|
||||||
on:click={switchTheme}
|
onclick={switchTheme}
|
||||||
>
|
>
|
||||||
dark_mode
|
dark_mode
|
||||||
</button>
|
</button>
|
||||||
@@ -94,33 +147,62 @@
|
|||||||
<button
|
<button
|
||||||
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
|
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
|
||||||
class="icon"
|
class="icon"
|
||||||
on:click={switchTheme}
|
onclick={switchTheme}
|
||||||
>
|
>
|
||||||
light_mode
|
light_mode
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<!--<li>
|
||||||
<button
|
<div
|
||||||
|
role="button"
|
||||||
class="icon"
|
class="icon"
|
||||||
use:action={{ title: $LL.profile.LANGUAGE() }}
|
use:action={{ title: $LL.profile.LANGUAGE() }}
|
||||||
on:click={() => languageSelect.click()}
|
onclick={() => languageSelect.click()}
|
||||||
>translate
|
>
|
||||||
|
translate
|
||||||
|
|
||||||
<select bind:value={locale} bind:this={languageSelect}>
|
<select bind:value={locale} bind:this={languageSelect}>
|
||||||
{#each locales as code}
|
{#each locales as code}
|
||||||
<option value={code}>{code}</option>
|
<option value={code}>{code}</option>
|
||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
</button>
|
</div>
|
||||||
</li>
|
</li>-->
|
||||||
</ul>
|
</ul>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
select {
|
|
||||||
|
.sync-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
button {
|
||||||
|
text-wrap: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
progress {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
z-index: -1;
|
||||||
|
bottom: 0;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
width: calc(100% - 32px);
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress::-webkit-progress-bar {
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
progress::-webkit-progress-value {
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user