mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-05 08:42:41 +00:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
b9c6c05819
|
|||
|
16bf766de9
|
|||
|
ee8d400ad7
|
|||
|
9a1c2b5bf6
|
|||
|
1d1fcb72e3
|
|||
|
ee3f84645d
|
|||
|
82dd08f2a2
|
|||
|
9f65b4bb6c
|
|||
|
e08dda40d9
|
|||
|
a403bf1ac0
|
|||
|
1aff1703ac
|
|||
|
fe42dcd2ab
|
|||
|
b13c34ca15
|
|||
|
4023ab9bd5
|
|||
|
2893afa2ba
|
|||
|
7beab5ac07
|
|||
|
6895fa4a82
|
|||
|
245dd97532
|
|||
|
d84495894a
|
|||
|
1de52f7f81
|
|||
|
45682f0d1a
|
|||
|
5f0bc45851
|
|||
|
c6f1f3f6fc
|
|||
|
32c2ce2f45
|
|||
|
c6e2f59b05
|
|||
|
2a872bafac
|
|||
|
a940d1b480
|
|||
|
f3b1d76666
|
|||
|
0b2695a380
|
|||
|
048dee0a6d
|
|||
|
977bdf3043
|
|||
|
9ca30f412e
|
|||
|
f2a18cafe8
|
|||
|
b27182dc35
|
|||
|
74ce6af318
|
|||
|
782f1fc38b
|
|||
|
|
087ff36d5d | ||
|
bd1c6147fd
|
|||
|
891abda0fb
|
|||
|
3611f65e24
|
|||
|
f76882a09c
|
|||
|
ff7e4f7b2e
|
|||
|
1c1c86241f
|
|||
|
dc8b3c3d66
|
|||
|
|
65911419b0 | ||
|
|
ccfb09e261 | ||
|
b841469505
|
|||
|
bc06e8ee80
|
|||
|
24fc861ef4
|
|||
|
5801e5fbbe
|
|||
|
92b52e08f7
|
|||
|
4192210d27
|
|||
|
|
0e5640a1ee | ||
|
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 | ||
|
888df6dd66
|
|||
|
7ad9612037
|
|||
|
3f9674b399
|
|||
|
92ba5bcb24
|
|||
|
2163a63a7c
|
|||
|
65a5a2517e
|
|||
|
21e8c291b0
|
|||
|
4106a80d53
|
|||
|
|
01fb61d27c | ||
|
3dd91a1cea
|
|||
|
cbcf705f71
|
|||
|
4007810c7b
|
|||
|
f322435c41
|
|||
|
587375e654
|
|||
|
0500a723de
|
|||
|
26dcc56aca
|
|||
|
20b65813bf
|
|||
|
87b23c04b1
|
|||
|
8b2bc6d109
|
|||
|
19cf0b26b3
|
|||
|
3e72dd3cb8
|
|||
|
a40daefbad
|
|||
|
77d4a90519
|
|||
|
c9a031a1fd
|
|||
|
254a0c1aec
|
|||
|
bd75012cf1
|
|||
|
4b738bb340
|
|||
|
3af65106bf
|
|||
|
8087d10d5a
|
|||
|
2782966505
|
|||
|
5b6d369101
|
|||
|
b423d1c661
|
|||
|
92a3c6012f
|
|||
|
8ec11c7ec9
|
|||
|
5c8eb1d19f
|
|||
|
91a044bbba
|
|||
|
1a6c85a361
|
|||
|
ecef11ac2d
|
|||
|
a23af9ba9d
|
|||
|
93849f250f
|
|||
|
33890b0aa8
|
|||
|
6f925de1af
|
|||
|
d45fe43f17
|
|||
|
59788f059d
|
|||
|
2808973ad0
|
|||
|
bef51d2a7d
|
|||
|
854ab6d3be
|
|||
|
86ec8651b6
|
|||
|
4e4bff02a0
|
|||
|
5d4dbc7e2a
|
|||
|
dfd1c0bcbd
|
|||
|
6ac2cd1993
|
|||
|
7256dc50d4
|
|||
|
f0ad19e6c2
|
|||
|
9022a09b4c
|
|||
|
7e3e61afd7
|
|||
|
08f594d164
|
|||
|
046595b51f
|
|||
|
fbc5303690
|
|||
|
ad41d39bfb
|
59
.github/workflows/build.yml
vendored
59
.github/workflows/build.yml
vendored
@@ -2,49 +2,56 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- v*
|
||||||
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
|
||||||
- name: ⏬ Install Python dependencies
|
- name: ⏬ Install Python dependencies
|
||||||
run: pip install -r requirements.txt
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
- name: 🐉 Use Node.js 18.16.x
|
- name: Install pnpm
|
||||||
uses: actions/setup-node@v3
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.16.x
|
version: 10
|
||||||
cache: "npm"
|
- name: 🐉 Use Node.js 22.14.x
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22.14.x
|
||||||
|
cache: "pnpm"
|
||||||
- name: ⏬ Install Node dependencies
|
- name: ⏬ Install Node dependencies
|
||||||
run: npm ci
|
run: pnpm install
|
||||||
|
|
||||||
- name: 🔥 Optimize icon font
|
- name: 🔥 Optimize icon font
|
||||||
run: npm run minify-icons
|
run: pnpm minify-icons
|
||||||
- name: 🔨 Build site
|
- name: 🔨 Build site
|
||||||
run: npm run 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 }}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ node_modules
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
|
/openssl*
|
||||||
|
/src/i18n/i18n*
|
||||||
|
|
||||||
# Ignore files for PNPM, NPM and YARN
|
# Ignore files for PNPM, NPM and YARN
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
4
.prettierrc
Normal file
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
...require("@theaninova/prettier-config"),
|
|
||||||
plugins: ["prettier-plugin-svelte"],
|
|
||||||
pluginSearchDirs: ["."],
|
|
||||||
overrides: [{files: "*.svelte", options: {parser: "svelte"}}],
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
"stylelint-config-standard-scss",
|
"stylelint-config-standard-scss",
|
||||||
"stylelint-config-recommended-scss",
|
"stylelint-config-recommended-scss",
|
||||||
"stylelint-config-html/svelte",
|
"stylelint-config-html/svelte",
|
||||||
"stylelint-config-clean-order",
|
|
||||||
"stylelint-config-prettier-scss"
|
"stylelint-config-prettier-scss"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
123
flake.nix
123
flake.nix
@@ -4,56 +4,81 @@
|
|||||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
};
|
};
|
||||||
outputs = {
|
outputs =
|
||||||
self,
|
{
|
||||||
nixpkgs,
|
self,
|
||||||
flake-utils,
|
nixpkgs,
|
||||||
rust-overlay,
|
flake-utils,
|
||||||
}:
|
rust-overlay,
|
||||||
flake-utils.lib.eachDefaultSystem (system: let
|
}:
|
||||||
overlays = [(import rust-overlay)];
|
flake-utils.lib.eachDefaultSystem (
|
||||||
pkgs = import nixpkgs {inherit system overlays;};
|
system:
|
||||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
let
|
||||||
extensions = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
|
overlays = [
|
||||||
};
|
(import rust-overlay)
|
||||||
fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
|
(final: prev: {
|
||||||
tauriPkgs = nixpkgs.legacyPackages.${system};
|
nodejs = prev.nodejs_22;
|
||||||
libraries = with tauriPkgs; [
|
corepack = prev.corepack_22;
|
||||||
webkitgtk
|
})
|
||||||
gtk3
|
];
|
||||||
cairo
|
pkgs = import nixpkgs { inherit system overlays; };
|
||||||
gdk-pixbuf
|
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||||
glib
|
extensions = [
|
||||||
dbus
|
"rust-src"
|
||||||
openssl_3
|
"rust-std"
|
||||||
librsvg
|
"clippy"
|
||||||
];
|
"rust-analyzer"
|
||||||
packages =
|
];
|
||||||
(with pkgs; [
|
};
|
||||||
nodejs_18
|
fontMin = pkgs.python311.withPackages (
|
||||||
rust-bin
|
ps:
|
||||||
fontMin
|
with ps;
|
||||||
])
|
[
|
||||||
++ (with tauriPkgs; [
|
brotli
|
||||||
curl
|
fonttools
|
||||||
wget
|
]
|
||||||
pkg-config
|
++ (with fonttools.optional-dependencies; [ woff ])
|
||||||
|
);
|
||||||
|
tauriPkgs = nixpkgs.legacyPackages.${system};
|
||||||
|
libraries = with tauriPkgs; [
|
||||||
|
webkitgtk
|
||||||
|
gtk3
|
||||||
|
cairo
|
||||||
|
gdk-pixbuf
|
||||||
|
glib
|
||||||
dbus
|
dbus
|
||||||
openssl_3
|
openssl_3
|
||||||
glib
|
|
||||||
gtk3
|
|
||||||
libsoup
|
|
||||||
webkitgtk
|
|
||||||
librsvg
|
librsvg
|
||||||
# serial plugin
|
];
|
||||||
udev
|
packages =
|
||||||
]);
|
(with pkgs; [
|
||||||
in {
|
nodejs
|
||||||
devShell = pkgs.mkShell {
|
pnpm
|
||||||
buildInputs = packages;
|
rust-bin
|
||||||
shellHook = ''
|
fontMin
|
||||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
])
|
||||||
'';
|
++ (with tauriPkgs; [
|
||||||
};
|
curl
|
||||||
});
|
wget
|
||||||
|
pkg-config
|
||||||
|
dbus
|
||||||
|
openssl_3
|
||||||
|
glib
|
||||||
|
gtk3
|
||||||
|
libsoup_2_4
|
||||||
|
webkitgtk
|
||||||
|
librsvg
|
||||||
|
# serial plugin
|
||||||
|
udev
|
||||||
|
]);
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShell = pkgs.mkShell {
|
||||||
|
buildInputs = packages;
|
||||||
|
shellHook = ''
|
||||||
|
#export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,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",
|
||||||
@@ -30,6 +34,7 @@ const config = {
|
|||||||
"abc",
|
"abc",
|
||||||
"function",
|
"function",
|
||||||
"cloud_done",
|
"cloud_done",
|
||||||
|
"counter_4",
|
||||||
"backup",
|
"backup",
|
||||||
"cloud_download",
|
"cloud_download",
|
||||||
"cloud_off",
|
"cloud_off",
|
||||||
@@ -39,8 +44,28 @@ const config = {
|
|||||||
"arrow_back",
|
"arrow_back",
|
||||||
"arrow_back_ios_new",
|
"arrow_back_ios_new",
|
||||||
"save",
|
"save",
|
||||||
|
"step_over",
|
||||||
|
"step_into",
|
||||||
|
"step_out",
|
||||||
|
"timer_play",
|
||||||
"settings_backup_restore",
|
"settings_backup_restore",
|
||||||
|
"sound_detection_loud_sound",
|
||||||
|
"ring_volume",
|
||||||
|
"skillet",
|
||||||
|
"wifi",
|
||||||
|
"power_settings_circle",
|
||||||
|
"graphic_eq",
|
||||||
|
"mail",
|
||||||
|
"calculate",
|
||||||
|
"playground_2",
|
||||||
|
"open_in_browser",
|
||||||
|
"chevron_backward",
|
||||||
|
"chevron_forward",
|
||||||
|
"bookmark",
|
||||||
|
"drag_pan",
|
||||||
|
"markdown_copy",
|
||||||
"sort",
|
"sort",
|
||||||
|
"shopping_bag",
|
||||||
"filter_list",
|
"filter_list",
|
||||||
"settings_power",
|
"settings_power",
|
||||||
"link",
|
"link",
|
||||||
@@ -54,22 +79,37 @@ const config = {
|
|||||||
"light_mode",
|
"light_mode",
|
||||||
"palette",
|
"palette",
|
||||||
"translate",
|
"translate",
|
||||||
|
"smart_toy",
|
||||||
|
"visibility_off",
|
||||||
"play_arrow",
|
"play_arrow",
|
||||||
"extension",
|
"extension",
|
||||||
"upload_file",
|
"upload_file",
|
||||||
|
"file_export",
|
||||||
|
"file_save",
|
||||||
"commit",
|
"commit",
|
||||||
"bug_report",
|
"bug_report",
|
||||||
"delete",
|
"delete",
|
||||||
"remove_selection",
|
"remove_selection",
|
||||||
"bolt",
|
"bolt",
|
||||||
|
"thunderstorm",
|
||||||
|
"join_inner",
|
||||||
|
"uppercase",
|
||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"redo",
|
||||||
|
"replay",
|
||||||
|
"clock_loader_80",
|
||||||
|
"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",
|
||||||
@@ -83,6 +123,7 @@ const config = {
|
|||||||
"sentiment_sad",
|
"sentiment_sad",
|
||||||
"sentiment_content",
|
"sentiment_content",
|
||||||
"sentiment_worried",
|
"sentiment_worried",
|
||||||
|
"construction",
|
||||||
"timer",
|
"timer",
|
||||||
"target",
|
"target",
|
||||||
"download",
|
"download",
|
||||||
@@ -90,18 +131,46 @@ 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",
|
||||||
|
"gamepad_circle_up",
|
||||||
|
"gamepad_circle_left",
|
||||||
|
"gamepad_circle_down",
|
||||||
|
"gamepad_circle_right",
|
||||||
|
"trail_length_medium",
|
||||||
|
"blur_short",
|
||||||
|
"combine_columns",
|
||||||
|
"animation",
|
||||||
|
"text_select_move_back_word",
|
||||||
],
|
],
|
||||||
codePoints: {
|
codePoints: {
|
||||||
speed: "e9e4",
|
speed: "e9e4",
|
||||||
arrow_split: "e985",
|
arrow_split: "e985",
|
||||||
arrow_circle_down: "f181",
|
arrow_circle_down: "f181",
|
||||||
arrow_circle_up: "f182",
|
arrow_circle_up: "f182",
|
||||||
|
gamepad_circle_up: "eecd",
|
||||||
|
gamepad_circle_right: "eece",
|
||||||
|
gamepad_circle_left: "eecf",
|
||||||
|
gamepad_circle_down: "eed0",
|
||||||
counter_1: "f784",
|
counter_1: "f784",
|
||||||
counter_2: "f783",
|
counter_2: "f783",
|
||||||
counter_3: "f782",
|
counter_3: "f782",
|
||||||
|
counter_4: "f781",
|
||||||
ios_share: "e6b8",
|
ios_share: "e6b8",
|
||||||
light_mode: "e518",
|
light_mode: "e518",
|
||||||
upload_file: "e9fc",
|
upload_file: "e9fc",
|
||||||
@@ -111,7 +180,12 @@ const config = {
|
|||||||
upload_2: "ff52",
|
upload_2: "ff52",
|
||||||
stat_minus_2: "e69c",
|
stat_minus_2: "e69c",
|
||||||
stat_2: "e699",
|
stat_2: "e699",
|
||||||
|
routine: "e20c",
|
||||||
|
experiment: "e686",
|
||||||
|
dictionary: "f539",
|
||||||
|
visibility_off: "e8f5",
|
||||||
|
file_save: "f17f",
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
export default config
|
export default config;
|
||||||
|
|||||||
11743
package-lock.json
generated
11743
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
119
package.json
119
package.json
@@ -1,8 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "1.3.2",
|
"version": "2.7.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.14",
|
||||||
|
"pnpm": ">=10.7"
|
||||||
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/CharaChorder/DeviceManager.git"
|
"url": "https://github.com/CharaChorder/DeviceManager.git"
|
||||||
@@ -13,72 +17,87 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm-run-all --parallel vite typesafe-i18n",
|
"dev": "npm-run-all --parallel vite typesafe-i18n",
|
||||||
|
"dev:external": "npm-run-all --parallel vite:external typesafe-i18n",
|
||||||
"dev:tauri": "tauri dev",
|
"dev:tauri": "tauri dev",
|
||||||
"vite": "vite dev",
|
"vite": "vite dev",
|
||||||
|
"vite:external": "vite --host",
|
||||||
"build": "typesafe-i18n --no-watch && vite build",
|
"build": "typesafe-i18n --no-watch && vite build",
|
||||||
"build:tauri": "tauri build",
|
"build:tauri": "tauri build",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"test": "vitest run --coverage",
|
"test": "vitest run --coverage",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"postinstall": "patch-package",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
|
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
|
|
||||||
"minify-icons": "node src/tools/minify-icon-font.js",
|
"minify-icons": "node src/tools/minify-icon-font.js",
|
||||||
"version": "node src/tools/version.js && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
|
"version": "node src/tools/version.js && git add src-tauri/Cargo.toml && git add src-tauri/tauri.conf.json",
|
||||||
"lint": "prettier --plugin-search-dir . --check .",
|
"lint": "prettier --check .",
|
||||||
"format": "prettier --plugin-search-dir . --write .",
|
"format": "prettier --write .",
|
||||||
"typesafe-i18n": "typesafe-i18n"
|
"typesafe-i18n": "typesafe-i18n"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.9.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.2.5",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.1",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/language": "^6.9.0",
|
"@codemirror/language": "^6.11.3",
|
||||||
"@codemirror/state": "^6.2.1",
|
"@codemirror/merge": "^6.11.2",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.0.16",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.0.17",
|
"@codemirror/view": "^6.39.4",
|
||||||
"@material/material-color-utilities": "^0.2.7",
|
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
|
||||||
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
"@fontsource-variable/noto-sans-mono": "^5.2.10",
|
||||||
"@sveltejs/adapter-static": "^2.0.3",
|
"@lezer/common": "^1.4.0",
|
||||||
"@sveltejs/kit": "^1.24.1",
|
"@lezer/generator": "^1.8.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^2.4.5",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@tauri-apps/api": "^1.4.0",
|
"@lezer/lr": "^1.4.5",
|
||||||
"@tauri-apps/cli": "^1.4.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@theaninova/prettier-config": "^1.0.0",
|
"@melt-ui/pp": "^0.3.2",
|
||||||
"@types/dom-view-transitions": "^1.0.1",
|
"@melt-ui/svelte": "^0.86.6",
|
||||||
"@types/flexsearch": "^0.7.3",
|
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||||
"@types/w3c-web-serial": "^1.0.3",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@types/w3c-web-usb": "^1.0.10",
|
"@sveltejs/kit": "^2.49.2",
|
||||||
"@vite-pwa/sveltekit": "^0.2.7",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"autoprefixer": "^10.4.15",
|
"@tauri-apps/api": "^1.6.0",
|
||||||
"codemirror": "^6.0.1",
|
"@tauri-apps/cli": "^1.6.0",
|
||||||
"cypress": "^13.1.0",
|
"@types/dom-view-transitions": "^1.0.6",
|
||||||
"flexsearch": "^0.7.31",
|
"@types/js-yaml": "^4.0.9",
|
||||||
"fontkit": "^2.0.2",
|
"@types/semver": "^7.7.1",
|
||||||
"glob": "^10.3.4",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
"hotkeys-js": "^3.12.0",
|
"@types/w3c-web-usb": "^1.0.13",
|
||||||
"jsdom": "^22.1.0",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
|
"cypress": "^14.5.3",
|
||||||
|
"d3": "^7.9.0",
|
||||||
|
"esptool-js": "^0.5.7",
|
||||||
|
"flexsearch": "^0.8.212",
|
||||||
|
"fontkit": "^2.0.4",
|
||||||
|
"glob": "^11.0.3",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"patch-package": "^8.0.0",
|
"prettier": "^3.7.4",
|
||||||
"prettier": "^3.0.3",
|
"prettier-plugin-css-order": "^2.1.2",
|
||||||
"prettier-plugin-svelte": "^3.0.3",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"sass": "^1.66.1",
|
"rxjs": "^7.8.2",
|
||||||
"stylelint": "^15.10.3",
|
"sass": "^1.97.0",
|
||||||
"stylelint-config-clean-order": "^5.2.0",
|
"semver": "^7.7.3",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
|
"stylelint": "^16.26.1",
|
||||||
"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": "^13.0.0",
|
"stylelint-config-recommended-scss": "^16.0.2",
|
||||||
"stylelint-config-standard-scss": "^11.0.0",
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
"svelte": "^4.2.0",
|
"svelte": "5.37.1",
|
||||||
"svelte-check": "^3.5.1",
|
"svelte-check": "^4.3.4",
|
||||||
"svelte-preprocess": "^5.0.4",
|
"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.2.2",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^4.4.9",
|
"vite": "^7.0.6",
|
||||||
"vite-plugin-mkcert": "^1.16.0",
|
"vite-plugin-mkcert": "^1.17.9",
|
||||||
"vite-plugin-pwa": "^0.17.4",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vitest": "^0.34.4"
|
"vitest": "^4.0.16",
|
||||||
|
"web-serial-polyfill": "^1.0.15",
|
||||||
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
diff --git a/node_modules/@types/flexsearch/index.d.ts b/node_modules/@types/flexsearch/index.d.ts
|
|
||||||
index ecde8e7..64a5f1e 100755
|
|
||||||
--- a/node_modules/@types/flexsearch/index.d.ts
|
|
||||||
+++ b/node_modules/@types/flexsearch/index.d.ts
|
|
||||||
@@ -6,7 +6,6 @@
|
|
||||||
/************************************/
|
|
||||||
/* Utils */
|
|
||||||
/************************************/
|
|
||||||
-export type Id = number | string;
|
|
||||||
export type Limit = number;
|
|
||||||
export type ExportHandler<T> = (id: string | number, value: T) => void;
|
|
||||||
export type AsyncCallback<T = undefined> = T extends undefined ? () => void : (result: T) => void;
|
|
||||||
@@ -165,7 +164,7 @@ export type IndexSearchResult = Id[];
|
|
||||||
* * Usage: https://github.com/nextapps-de/flexsearch#usage
|
|
||||||
*/
|
|
||||||
|
|
||||||
-export class Index {
|
|
||||||
+export default class Index<ID extends number | string = number> {
|
|
||||||
constructor(x?: Preset | IndexOptions<string>);
|
|
||||||
add(id: Id, item: string): this;
|
|
||||||
append(id: Id, item: string): this;
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
diff --git a/node_modules/flexsearch/index.d.ts b/node_modules/flexsearch/index.d.ts
|
|
||||||
deleted file mode 100644
|
|
||||||
index 9f39f41..0000000
|
|
||||||
--- a/node_modules/flexsearch/index.d.ts
|
|
||||||
+++ /dev/null
|
|
||||||
@@ -1,147 +0,0 @@
|
|
||||||
-declare module "flexsearch" {
|
|
||||||
- export interface Index<T> {
|
|
||||||
- readonly id: string;
|
|
||||||
- readonly index: string;
|
|
||||||
- readonly length: number;
|
|
||||||
-
|
|
||||||
- init(options?: CreateOptions): this;
|
|
||||||
- info(): {
|
|
||||||
- id: any;
|
|
||||||
- items: any;
|
|
||||||
- cache: any;
|
|
||||||
- matcher: number;
|
|
||||||
- worker: any;
|
|
||||||
- threshold: any;
|
|
||||||
- depth: any;
|
|
||||||
- resolution: any;
|
|
||||||
- contextual: boolean;
|
|
||||||
- };
|
|
||||||
- add(o: T): this;
|
|
||||||
- add(id: number, o: string): this;
|
|
||||||
-
|
|
||||||
- // Result without pagination -> T[]
|
|
||||||
- search(
|
|
||||||
- query: string,
|
|
||||||
- options: number | SearchOptions,
|
|
||||||
- callback: (results: T[]) => void
|
|
||||||
- ): void;
|
|
||||||
- search(query: string, options?: number | SearchOptions): Promise<T[]>;
|
|
||||||
- search(
|
|
||||||
- options: SearchOptions & { query: string },
|
|
||||||
- callback: (results: T[]) => void
|
|
||||||
- ): void;
|
|
||||||
- search(options: SearchOptions & { query: string }): Promise<T[]>;
|
|
||||||
-
|
|
||||||
- // Result with pagination -> SearchResults<T>
|
|
||||||
- search(
|
|
||||||
- query: string,
|
|
||||||
- options: number | (SearchOptions & { page?: boolean | Cursor }),
|
|
||||||
- callback: (results: SearchResults<T>) => void
|
|
||||||
- ): void;
|
|
||||||
- search(
|
|
||||||
- query: string,
|
|
||||||
- options?: number | (SearchOptions & { page?: boolean | Cursor })
|
|
||||||
- ): Promise<SearchResults<T>>;
|
|
||||||
- search(
|
|
||||||
- options: SearchOptions & { query: string; page?: boolean | Cursor },
|
|
||||||
- callback: (results: SearchResults<T>) => void
|
|
||||||
- ): void;
|
|
||||||
- search(
|
|
||||||
- options: SearchOptions & { query: string; page?: boolean | Cursor }
|
|
||||||
- ): Promise<SearchResults<T>>;
|
|
||||||
-
|
|
||||||
- update(id: number, o: T): this;
|
|
||||||
- remove(id: number): this;
|
|
||||||
- clear(): this;
|
|
||||||
- destroy(): this;
|
|
||||||
- addMatcher(matcher: Matcher): this;
|
|
||||||
-
|
|
||||||
- where(whereObj: { [key: string]: string } | ((o: T) => boolean)): T[];
|
|
||||||
- encode(str: string): string;
|
|
||||||
- export(
|
|
||||||
- callback: (key: string, data: any) => any,
|
|
||||||
- self?: this,
|
|
||||||
- field?: string,
|
|
||||||
- index_doc?: Number,
|
|
||||||
- index?: Number
|
|
||||||
- ): Promise<boolean>;
|
|
||||||
- import(exported: string): this;
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- interface SearchOptions {
|
|
||||||
- limit?: number;
|
|
||||||
- suggest?: boolean;
|
|
||||||
- where?: { [key: string]: string };
|
|
||||||
- field?: string | string[];
|
|
||||||
- bool?: "and" | "or" | "not";
|
|
||||||
- //TODO: Sorting
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- interface SearchResults<T> {
|
|
||||||
- page?: Cursor;
|
|
||||||
- next?: Cursor;
|
|
||||||
- result: T[];
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- interface Document {
|
|
||||||
- id: string;
|
|
||||||
- field: any;
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- export type CreateOptions = {
|
|
||||||
- profile?: IndexProfile;
|
|
||||||
- tokenize?: DefaultTokenizer | TokenizerFn;
|
|
||||||
- split?: RegExp;
|
|
||||||
- encode?: DefaultEncoder | EncoderFn | false;
|
|
||||||
- cache?: boolean | number;
|
|
||||||
- async?: boolean;
|
|
||||||
- worker?: false | number;
|
|
||||||
- depth?: false | number;
|
|
||||||
- threshold?: false | number;
|
|
||||||
- resolution?: number;
|
|
||||||
- stemmer?: Stemmer | string | false;
|
|
||||||
- filter?: FilterFn | string | false;
|
|
||||||
- rtl?: boolean;
|
|
||||||
- doc?: Document;
|
|
||||||
- };
|
|
||||||
-
|
|
||||||
- // limit number Sets the limit of results.
|
|
||||||
- // suggest true, false Enables suggestions in results.
|
|
||||||
- // where object Use a where-clause for non-indexed fields.
|
|
||||||
- // field string, Array<string> Sets the document fields which should be searched. When no field is set, all fields will be searched. Custom options per field are also supported.
|
|
||||||
- // bool "and", "or" Sets the used logical operator when searching through multiple fields.
|
|
||||||
- // page true, false, cursor Enables paginated results.
|
|
||||||
-
|
|
||||||
- type IndexProfile =
|
|
||||||
- | "memory"
|
|
||||||
- | "speed"
|
|
||||||
- | "match"
|
|
||||||
- | "score"
|
|
||||||
- | "balance"
|
|
||||||
- | "fast";
|
|
||||||
- type DefaultTokenizer = "strict" | "forward" | "reverse" | "full";
|
|
||||||
- type TokenizerFn = (str: string) => string[];
|
|
||||||
- type DefaultEncoder = "icase" | "simple" | "advanced" | "extra" | "balance";
|
|
||||||
- type EncoderFn = (str: string) => string;
|
|
||||||
- type Stemmer = { [key: string]: string };
|
|
||||||
- type Matcher = { [key: string]: string };
|
|
||||||
- type FilterFn = (str: string) => boolean;
|
|
||||||
- type Cursor = string;
|
|
||||||
-
|
|
||||||
- export default class FlexSearch {
|
|
||||||
- static create<T>(options?: CreateOptions): Index<T>;
|
|
||||||
- static registerMatcher(matcher: Matcher): typeof FlexSearch;
|
|
||||||
- static registerEncoder(name: string, encoder: EncoderFn): typeof FlexSearch;
|
|
||||||
- static registerLanguage(
|
|
||||||
- lang: string,
|
|
||||||
- options: { stemmer?: Stemmer; filter?: string[] }
|
|
||||||
- ): typeof FlexSearch;
|
|
||||||
- static encode(name: string, str: string): string;
|
|
||||||
- }
|
|
||||||
-}
|
|
||||||
-
|
|
||||||
-// FlexSearch.create(<options>)
|
|
||||||
-// FlexSearch.registerMatcher({KEY: VALUE})
|
|
||||||
-// FlexSearch.registerEncoder(name, encoder)
|
|
||||||
-// FlexSearch.registerLanguage(lang, {stemmer:{}, filter:[]})
|
|
||||||
-// FlexSearch.encode(name, string)
|
|
||||||
9183
pnpm-lock.yaml
generated
Normal file
9183
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- svelte-preprocess
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "1.3.2"
|
version = "2.7.0"
|
||||||
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.3.2" },
|
"package": { "productName": "amacc1ng", "version": "2.7.0" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
@@ -11,4 +11,4 @@ declare global {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {}
|
export {};
|
||||||
|
|||||||
26
src/env.d.ts
vendored
26
src/env.d.ts
vendored
@@ -1,19 +1,23 @@
|
|||||||
/// <references types="vite/client" />
|
/// <references types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly TAURI_FAMILY?: string
|
readonly TAURI_FAMILY?: string;
|
||||||
readonly TAURI_PLATFORM_VERSION?: string
|
readonly TAURI_PLATFORM_VERSION?: string;
|
||||||
readonly TAURI_TARGET_TRIPLE?: string
|
readonly TAURI_TARGET_TRIPLE?: string;
|
||||||
readonly TAURI_ARCH?: string
|
readonly TAURI_ARCH?: string;
|
||||||
readonly TAURI_DEBUG?: boolean
|
readonly TAURI_DEBUG?: boolean;
|
||||||
readonly TAURI_PLATFORM_TYPE?: string
|
readonly TAURI_PLATFORM_TYPE?: string;
|
||||||
|
|
||||||
readonly VITE_HOMEPAGE_URL: string
|
readonly VITE_HOMEPAGE_URL: string;
|
||||||
readonly VITE_BUGS_URL: string
|
readonly VITE_BUGS_URL: string;
|
||||||
readonly VITE_DOCS_URL: string
|
readonly VITE_DOCS_URL: string;
|
||||||
readonly VIET_LEARN_URL: string
|
readonly VITE_LEARN_URL: string;
|
||||||
|
readonly VITE_LATEST_FIRMWARE: string;
|
||||||
|
readonly VITE_STORE_URL: string;
|
||||||
|
readonly VITE_MATRIX_URL: string;
|
||||||
|
readonly VITE_FIRMWARE_URL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type {Translation} from "../i18n-types"
|
import type { Translation } from "../i18n-types";
|
||||||
|
|
||||||
const de = {
|
const de = {
|
||||||
TITLE: "CharaChorder Gerätemanager",
|
TITLE: "CharaChorder Gerätemanager",
|
||||||
@@ -6,7 +6,7 @@ const de = {
|
|||||||
saveActions: {
|
saveActions: {
|
||||||
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
||||||
REDO: "Wiederholen",
|
REDO: "Wiederholen",
|
||||||
SAVE: "Speichern",
|
SAVE: "Anwended",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
TITLE: "Gerät aktualisieren",
|
TITLE: "Gerät aktualisieren",
|
||||||
@@ -17,11 +17,11 @@ const de = {
|
|||||||
RELOAD: "Neu laden",
|
RELOAD: "Neu laden",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Verlauf speichern",
|
TITLE: "Backup",
|
||||||
INDIVIDUAL: "Einzeldateien",
|
AUTO_BACKUP: "Beschleunigtes Verbinden",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"Der Verlauf wird als Backup in diesem Browser gespeichert. Der Verlauf bleibt auf diesem Computer.",
|
"<b>Nicht auf öffentlichen oder geteilten Computern einschalten.</b> Gerätedaten werden für schnelleren Zugriff lokal zwischengespeichert.",
|
||||||
DOWNLOAD: "Alles herunterladen",
|
DOWNLOAD: "Komplettes Profil",
|
||||||
RESTORE: "Wiederherstellen",
|
RESTORE: "Wiederherstellen",
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
@@ -35,7 +35,8 @@ const de = {
|
|||||||
filter: {
|
filter: {
|
||||||
ALL: "Alle",
|
ALL: "Alle",
|
||||||
},
|
},
|
||||||
LIVE_LAYOUT_INFO: "Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
|
LIVE_LAYOUT_INFO:
|
||||||
|
"Diese Aktion wurde auf Basis des Systemtastaturlayouts ermittelt.",
|
||||||
SHIFT_WARNING: "Diese Aktion hält <kbd class='icon'>shift</kbd>",
|
SHIFT_WARNING: "Diese Aktion hält <kbd class='icon'>shift</kbd>",
|
||||||
ALT_CODE_WARNING: "Dieses Alt-Code Makro funktioniert nur unter Windows",
|
ALT_CODE_WARNING: "Dieses Alt-Code Makro funktioniert nur unter Windows",
|
||||||
},
|
},
|
||||||
@@ -66,12 +67,13 @@ const de = {
|
|||||||
APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
|
APPLY_SETTINGS: "Änderungen auf das Gerät brennen",
|
||||||
NO_DEVICE: "Kein Gerät verbunden",
|
NO_DEVICE: "Kein Gerät verbunden",
|
||||||
LINUX_PERMISSIONS:
|
LINUX_PERMISSIONS:
|
||||||
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden.",
|
"Auf den meisten Linux-basierten Systemen müssen zuerst Berechtigungen angepasst werden. Flatpak und Snap Anwendungen benötigen zusätzliche Berechtigungen oder funktionieren möglicherweise gar nicht.",
|
||||||
bootMenu: {
|
bootMenu: {
|
||||||
TITLE: "Bootmenü",
|
TITLE: "Bootmenü",
|
||||||
REBOOT: "Neustarten",
|
REBOOT: "Neustarten",
|
||||||
BOOTLOADER: "Bootloader",
|
BOOTLOADER: "Bootloader",
|
||||||
POWER_WARNING: "Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
|
POWER_WARNING:
|
||||||
|
"Um vom Bootloader aus neu zu starten muss das Gerät neu verbunden werden.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
browserWarning: {
|
browserWarning: {
|
||||||
@@ -83,7 +85,8 @@ const de = {
|
|||||||
INFO_BROWSER_PREFIX:
|
INFO_BROWSER_PREFIX:
|
||||||
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
|
"Auch wenn alle Chromium-basieren Desktop Browser diese Voraussetzung grundsätzlich erfüllen, haben einige Browser ",
|
||||||
INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
|
INFO_BROWSER_INFIX: "wie zum Beispiel Brave",
|
||||||
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
|
INFO_BROWSER_SUFFIX:
|
||||||
|
" sich bewusst dazu entschieden die API zu deaktivieren.",
|
||||||
DOWNLOAD_APP:
|
DOWNLOAD_APP:
|
||||||
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
|
"Chrome oder Edge werden offiziell unterstützt, andere Browser könnten aber auch funktionieren.",
|
||||||
},
|
},
|
||||||
@@ -106,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",
|
||||||
@@ -117,17 +120,18 @@ const de = {
|
|||||||
conflict: {
|
conflict: {
|
||||||
TITLE: "Akkordkonflikt",
|
TITLE: "Akkordkonflikt",
|
||||||
DESCRIPTION:
|
DESCRIPTION:
|
||||||
"Der Akkord {0} würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
|
"Der Akkord würde einen bereits existierenden Akkord überschreiben. Wirklich fortfahren?",
|
||||||
CONFIRM: "Überschreiben",
|
CONFIRM: "Überschreiben",
|
||||||
ABORT: "Überspringen",
|
ABORT: "Überspringen",
|
||||||
},
|
},
|
||||||
|
VOCABULARY: "Vokabelliste",
|
||||||
TRY_TYPING: "Versuche hier zu tippen",
|
TRY_TYPING: "Versuche hier zu tippen",
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
TITLE: "Layout",
|
TITLE: "Layout",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
TITLE: "Einstellungen",
|
TITLE: "Gerät",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
@@ -135,6 +139,6 @@ const de = {
|
|||||||
RUN: "Ausführen",
|
RUN: "Ausführen",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies Translation
|
} satisfies Translation;
|
||||||
|
|
||||||
export default de
|
export default de;
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import type {BaseTranslation} from "../i18n-types"
|
import type { BaseTranslation } from "../i18n-types";
|
||||||
|
|
||||||
const en = {
|
const en = {
|
||||||
TITLE: "CharaChorder Device Manager",
|
TITLE: "CharaChorder Device Manager",
|
||||||
DESCRIPTION: "The device manager and configuration tool for CharaChorder devices.",
|
DESCRIPTION:
|
||||||
|
"The device manager and configuration tool for CharaChorder devices.",
|
||||||
saveActions: {
|
saveActions: {
|
||||||
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
||||||
REDO: "Redo",
|
REDO: "Redo",
|
||||||
SAVE: "Save",
|
SAVE: "Apply",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
TITLE: "Update your device",
|
TITLE: "Update your device",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Store History",
|
TITLE: "Backup",
|
||||||
INDIVIDUAL: "Individual backups",
|
AUTO_BACKUP: "Fast Connect",
|
||||||
DISCLAIMER: "Your history is stored as a backup in this browser. The history remains on your computer.",
|
DISCLAIMER:
|
||||||
DOWNLOAD: "Download Everything",
|
"<b>Turn off if using a shared or public computer.</b> Caches your device's data locally for quick access next time you connect.",
|
||||||
|
DOWNLOAD: "Full profile",
|
||||||
RESTORE: "Restore",
|
RESTORE: "Restore",
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
@@ -64,24 +66,28 @@ const en = {
|
|||||||
TERMINAL: "Terminal",
|
TERMINAL: "Terminal",
|
||||||
APPLY_SETTINGS: "Flash changes to device",
|
APPLY_SETTINGS: "Flash changes to device",
|
||||||
NO_DEVICE: "No device connected",
|
NO_DEVICE: "No device connected",
|
||||||
LINUX_PERMISSIONS: "Most Linux based systems need adjusted permissions in order to connect your device.",
|
LINUX_PERMISSIONS:
|
||||||
|
"Most Linux based systems need adjusted permissions in order to connect your device. Flatpak or Snap versions in particular might need additional permissions or may not work at all.",
|
||||||
bootMenu: {
|
bootMenu: {
|
||||||
TITLE: "Boot Menu",
|
TITLE: "Boot Menu",
|
||||||
REBOOT: "Reboot",
|
REBOOT: "Reboot",
|
||||||
BOOTLOADER: "Bootloader",
|
BOOTLOADER: "Bootloader",
|
||||||
POWER_WARNING: "To reboot from bootloader you need to physically reconnect your device.",
|
POWER_WARNING:
|
||||||
|
"To reboot from bootloader you need to physically reconnect your device.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
browserWarning: {
|
browserWarning: {
|
||||||
TITLE: "Warning",
|
TITLE: "Warning",
|
||||||
INFO_SERIAL_PREFIX: "Your current browser is not supported due to this site's unique requirement for ",
|
INFO_SERIAL_PREFIX:
|
||||||
|
"Your current browser is not supported due to this site's unique requirement for ",
|
||||||
INFO_SERIAL_INFIX: "serial connections",
|
INFO_SERIAL_INFIX: "serial connections",
|
||||||
INFO_SERIAL_SUFFIX: ".",
|
INFO_SERIAL_SUFFIX: ".",
|
||||||
INFO_BROWSER_PREFIX:
|
INFO_BROWSER_PREFIX:
|
||||||
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
|
"Though all chromium-based desktop browsers fulfill this requirement, some derivations such as Brave ",
|
||||||
INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
|
INFO_BROWSER_INFIX: "have been known to disable the API intentionally",
|
||||||
INFO_BROWSER_SUFFIX: ".",
|
INFO_BROWSER_SUFFIX: ".",
|
||||||
DOWNLOAD_APP: "Chrome or Edge are officially supported, but other browsers might work as well.",
|
DOWNLOAD_APP:
|
||||||
|
"Chrome or Edge are officially supported, but other browsers might work as well.",
|
||||||
},
|
},
|
||||||
changes: {
|
changes: {
|
||||||
TITLE: "Import changes",
|
TITLE: "Import changes",
|
||||||
@@ -102,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",
|
||||||
@@ -113,17 +119,18 @@ const en = {
|
|||||||
conflict: {
|
conflict: {
|
||||||
TITLE: "Chord conflict",
|
TITLE: "Chord conflict",
|
||||||
DESCRIPTION:
|
DESCRIPTION:
|
||||||
"Your chord {0} conflicts with an existing chord. Are you sure you want to overwrite this chord?",
|
"Your chord conflicts with an existing chord. Are you sure you want to overwrite this chord?",
|
||||||
CONFIRM: "Overwrite",
|
CONFIRM: "Overwrite",
|
||||||
ABORT: "Skip",
|
ABORT: "Skip",
|
||||||
},
|
},
|
||||||
|
VOCABULARY: "Vocabulary",
|
||||||
TRY_TYPING: "Try typing here",
|
TRY_TYPING: "Try typing here",
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
TITLE: "Layout",
|
TITLE: "Layout",
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
TITLE: "Settings",
|
TITLE: "Device",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugin: {
|
plugin: {
|
||||||
@@ -131,6 +138,6 @@ const en = {
|
|||||||
RUN: "Run",
|
RUN: "Run",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} satisfies BaseTranslation
|
} satisfies BaseTranslation;
|
||||||
|
|
||||||
export default en
|
export default en;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type {FormattersInitializer} from "typesafe-i18n"
|
import type { FormattersInitializer } from "typesafe-i18n";
|
||||||
import type {Locales, Formatters} from "./i18n-types"
|
import type { Locales, Formatters } from "./i18n-types";
|
||||||
|
|
||||||
export const initFormatters: FormattersInitializer<Locales, Formatters> = (locale: Locales) => {
|
export const initFormatters: FormattersInitializer<Locales, Formatters> = (
|
||||||
|
_locale: Locales,
|
||||||
|
) => {
|
||||||
const formatters: Formatters = {
|
const formatters: Formatters = {
|
||||||
// add your formatter functions here
|
// add your formatter functions here
|
||||||
}
|
};
|
||||||
|
|
||||||
return formatters
|
return formatters;
|
||||||
}
|
};
|
||||||
|
|||||||
55
src/lib/PageTransition.svelte
Normal file
55
src/lib/PageTransition.svelte
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fly } from "svelte/transition";
|
||||||
|
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
||||||
|
import { expoIn, expoOut } from "svelte/easing";
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
|
||||||
|
$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>
|
||||||
121
src/lib/ProgressButton.svelte
Normal file
121
src/lib/ProgressButton.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
onclick,
|
||||||
|
children,
|
||||||
|
working,
|
||||||
|
progress,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
element = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
onclick: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
working: boolean;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
element?: HTMLButtonElement;
|
||||||
|
} & HTMLButtonAttributes = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class:working={working && (progress <= 0 || progress >= 1)}
|
||||||
|
class:progress={working && progress > 0 && progress < 1}
|
||||||
|
style:--progress="{progress * 100}%"
|
||||||
|
class:primary={!error}
|
||||||
|
class:error={!!error}
|
||||||
|
disabled={disabled || working}
|
||||||
|
bind:this={element}
|
||||||
|
{...restProps}
|
||||||
|
{onclick}>{@render children()}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(120deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
transform: rotate(120deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
--height: 42px;
|
||||||
|
--border-radius: calc(var(--height) / 2);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
border 200ms ease,
|
||||||
|
color 200ms ease;
|
||||||
|
|
||||||
|
margin: 6px;
|
||||||
|
|
||||||
|
outline: 2px dashed currentcolor;
|
||||||
|
outline-offset: 4px;
|
||||||
|
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
height: var(--height);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: none;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.progress,
|
||||||
|
&.working {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.working::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: calc(var(--border-radius) - 2px);
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
height: calc(100% - 4px);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.working::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -2;
|
||||||
|
animation: rotate 1s ease-out forwards infinite;
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
width: 120%;
|
||||||
|
height: 30%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.progress::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.2;
|
||||||
|
z-index: -2;
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
width: var(--progress);
|
||||||
|
height: 100%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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
|
|
||||||
|
@@ -28,6 +28,9 @@ actions:
|
|||||||
42:
|
42:
|
||||||
id: "*"
|
id: "*"
|
||||||
title: Asterisk
|
title: Asterisk
|
||||||
|
43:
|
||||||
|
id: "+"
|
||||||
|
title: Plus
|
||||||
58:
|
58:
|
||||||
id: ":"
|
id: ":"
|
||||||
title: Colon
|
title: Colon
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: CharaChorder
|
name: CharaChorder
|
||||||
description: CharaChorder specific actions
|
description: CharaChorder specific actions
|
||||||
actions:
|
actions:
|
||||||
|
0:
|
||||||
|
id: "NO_ACTION"
|
||||||
|
display: "No Action"
|
||||||
528:
|
528:
|
||||||
id: "RESTART"
|
id: "RESTART"
|
||||||
title: Restart Device
|
title: Restart Device
|
||||||
@@ -58,6 +61,7 @@ actions:
|
|||||||
544:
|
544:
|
||||||
variantOf: 36
|
variantOf: 36
|
||||||
id: "SPACERIGHT"
|
id: "SPACERIGHT"
|
||||||
|
display: " "
|
||||||
title: Right Spacebar (eg CC Lite)
|
title: Right Spacebar (eg CC Lite)
|
||||||
icon: space_bar
|
icon: space_bar
|
||||||
variant: right
|
variant: right
|
||||||
@@ -66,6 +70,9 @@ actions:
|
|||||||
title: Primary Keymap
|
title: Primary Keymap
|
||||||
icon: counter_1
|
icon: counter_1
|
||||||
variant: left
|
variant: left
|
||||||
|
description: |
|
||||||
|
Acts as a toggle if the same action is not assigned
|
||||||
|
to the target layer
|
||||||
549:
|
549:
|
||||||
variantOf: 548
|
variantOf: 548
|
||||||
<<: *primary_keymap
|
<<: *primary_keymap
|
||||||
@@ -76,6 +83,9 @@ actions:
|
|||||||
title: Numeric Layer
|
title: Numeric Layer
|
||||||
icon: counter_2
|
icon: counter_2
|
||||||
variant: left
|
variant: left
|
||||||
|
description: |
|
||||||
|
Acts as a toggle if the same action is not assigned
|
||||||
|
to the target layer
|
||||||
551:
|
551:
|
||||||
variantOf: 550
|
variantOf: 550
|
||||||
<<: *secondary_keymap
|
<<: *secondary_keymap
|
||||||
@@ -86,8 +96,44 @@ actions:
|
|||||||
title: Function Layer
|
title: Function Layer
|
||||||
icon: counter_3
|
icon: counter_3
|
||||||
variant: left
|
variant: left
|
||||||
|
description: |
|
||||||
|
Acts as a toggle if the same action is not assigned
|
||||||
|
to the target layer
|
||||||
553:
|
553:
|
||||||
variationOf: 552
|
variationOf: 552
|
||||||
<<: *tertiary_keymap
|
<<: *tertiary_keymap
|
||||||
id: "KM_3_R"
|
id: "KM_3_R"
|
||||||
variant: right
|
variant: right
|
||||||
|
558:
|
||||||
|
id: HOLD_COMPOUND
|
||||||
|
title: Dynamic Library
|
||||||
|
icon: layers
|
||||||
|
description: |
|
||||||
|
Allows for the activation & creation of dynamic chord libraries.
|
||||||
|
When included as part of a chord output,
|
||||||
|
that chord's input becomes the seed for a dynamic chord library,
|
||||||
|
and that library is activated.
|
||||||
|
Any new chords created while a dynamic library is active are established one level above its seed.
|
||||||
|
559:
|
||||||
|
id: RELEASE_COMPOUND
|
||||||
|
title: Base Library
|
||||||
|
icon: layers_clear
|
||||||
|
description: |
|
||||||
|
Re-activates your base chord library,
|
||||||
|
and deactivates any currently active dynamic chord library.
|
||||||
|
576:
|
||||||
|
id: ACTION_DELAY_1000
|
||||||
|
icon: clock_loader_90
|
||||||
|
description: Wait for one second
|
||||||
|
577:
|
||||||
|
id: ACTION_DELAY_100
|
||||||
|
icon: clock_loader_60
|
||||||
|
description: Wait for 100 milliseconds
|
||||||
|
578:
|
||||||
|
id: ACTION_DELAY_10
|
||||||
|
icon: clock_loader_40
|
||||||
|
description: Wait for 10 milliseconds
|
||||||
|
579:
|
||||||
|
id: ACTION_DELAY_1
|
||||||
|
icon: clock_loader_10
|
||||||
|
description: Wait for one millisecond
|
||||||
|
|||||||
31
src/lib/assets/keymaps/keymap.d.ts
vendored
31
src/lib/assets/keymaps/keymap.d.ts
vendored
@@ -1,19 +1,22 @@
|
|||||||
export interface KeymapCategory {
|
export interface KeymapCategory {
|
||||||
name: string
|
name: string;
|
||||||
description: string
|
description: string;
|
||||||
icon?: string
|
icon?: string;
|
||||||
display?: string
|
display?: string;
|
||||||
type?: "unassigned"
|
type?: "unassigned";
|
||||||
actions: Record<number, Partial<ActionInfo>>
|
actions: Record<number, Partial<ActionInfo>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActionInfo {
|
export interface ActionInfo {
|
||||||
id: string
|
id: string;
|
||||||
title: string
|
title: string;
|
||||||
icon: string
|
icon: string;
|
||||||
display: string
|
display: string;
|
||||||
description: string
|
description: string;
|
||||||
variant: "left" | "right"
|
variant: "left" | "right";
|
||||||
variantOf: number
|
variantOf: number;
|
||||||
keyCode: string
|
keyCode: string;
|
||||||
|
printable?: boolean;
|
||||||
|
separator?: boolean;
|
||||||
|
breaking?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -422,8 +422,8 @@ actions:
|
|||||||
title: Keyboard Non-US \ and | (US English)
|
title: Keyboard Non-US \ and | (US English)
|
||||||
357:
|
357:
|
||||||
id: "COMPOSE"
|
id: "COMPOSE"
|
||||||
|
icon: menu
|
||||||
title: Keyboard Application
|
title: Keyboard Application
|
||||||
description: Officially supported by Win, Unix, and Boot
|
|
||||||
358:
|
358:
|
||||||
id: "POWER"
|
id: "POWER"
|
||||||
keyCode: "Power"
|
keyCode: "Power"
|
||||||
@@ -944,99 +944,99 @@ actions:
|
|||||||
title: Keyboard Right GUI
|
title: Keyboard Right GUI
|
||||||
488:
|
488:
|
||||||
id: "KSC_E8"
|
id: "KSC_E8"
|
||||||
|
icon: play_pause
|
||||||
keyCode: "MediaPlayPause"
|
keyCode: "MediaPlayPause"
|
||||||
title: Media Play Pause
|
title: Media Play Pause
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
489:
|
489:
|
||||||
id: "KSC_E9"
|
id: "KSC_E9"
|
||||||
|
icon: stop
|
||||||
keyCode: "MediaStop"
|
keyCode: "MediaStop"
|
||||||
title: Media Stop CD
|
title: Media Stop CD
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
490:
|
490:
|
||||||
id: "KSC_EA"
|
id: "KSC_EA"
|
||||||
|
icon: skip_previous
|
||||||
keyCode: "MediaTrackPrevious"
|
keyCode: "MediaTrackPrevious"
|
||||||
title: Media Previous Song
|
title: Media Previous Song
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
491:
|
491:
|
||||||
id: "KSC_EB"
|
id: "KSC_EB"
|
||||||
|
icon: skip_next
|
||||||
keyCode: "MediaTrackNext"
|
keyCode: "MediaTrackNext"
|
||||||
title: Media Next Song
|
title: Media Next Song
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
492:
|
492:
|
||||||
id: "KSC_EC"
|
id: "KSC_EC"
|
||||||
|
icon: eject
|
||||||
keyCode: "Eject"
|
keyCode: "Eject"
|
||||||
title: Media Eject CD
|
title: Media Eject CD
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: MacOS only
|
||||||
493:
|
493:
|
||||||
id: "KSC_ED"
|
id: "KSC_ED"
|
||||||
|
icon: volume_up
|
||||||
keyCode: "AudioVolumeUp"
|
keyCode: "AudioVolumeUp"
|
||||||
title: Media Volume Up
|
title: Media Volume Up
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
494:
|
494:
|
||||||
id: "KSC_EE"
|
id: "KSC_EE"
|
||||||
|
icon: volume_down
|
||||||
keyCode: "AudioVolumeDown"
|
keyCode: "AudioVolumeDown"
|
||||||
title: Media Volume Down
|
title: Media Volume Down
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
495:
|
495:
|
||||||
id: "KSC_EF"
|
id: "KSC_EF"
|
||||||
|
icon: volume_off
|
||||||
keyCode: "AudioVolumeMute"
|
keyCode: "AudioVolumeMute"
|
||||||
title: Media Mute
|
title: Media Mute
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
496:
|
496:
|
||||||
id: "KSC_F0"
|
id: "KSC_F0"
|
||||||
title: Media www
|
icon: language
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
title: Media Browser
|
||||||
497:
|
497:
|
||||||
id: "KSC_F1"
|
id: "KSC_F1"
|
||||||
keyCode: "BrowserBack"
|
keyCode: "BrowserBack"
|
||||||
title: Media Back
|
title: Media Browser Back
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
498:
|
498:
|
||||||
id: "KSC_F2"
|
id: "KSC_F2"
|
||||||
keyCode: "BrowserForward"
|
keyCode: "BrowserForward"
|
||||||
title: Media Forward
|
title: Media Browser Forward
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
499:
|
499:
|
||||||
id: "KSC_F3"
|
id: "KSC_F3"
|
||||||
keyCode: "BrowserStop"
|
keyCode: "BrowserStop"
|
||||||
title: Media Stop
|
title: Media Browser Stop
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not supported on MacOS
|
||||||
500:
|
500:
|
||||||
id: "KSC_F4"
|
id: "KSC_F4"
|
||||||
|
icon: search
|
||||||
keyCode: "BrowserSearch"
|
keyCode: "BrowserSearch"
|
||||||
title: Media Find
|
title: Media Browser Search
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
501:
|
501:
|
||||||
id: "KSC_F5"
|
id: "KSC_F5"
|
||||||
title: Media Scroll Up
|
icon: brightness_high
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
title: Media Brightness Up
|
||||||
502:
|
502:
|
||||||
id: "KSC_F6"
|
id: "KSC_F6"
|
||||||
title: Media Scroll Down
|
icon: brightness_low
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
title: Media Brightness Down
|
||||||
503:
|
503:
|
||||||
id: "KSC_F7"
|
id: "KSC_F7"
|
||||||
title: Media Edit
|
title: Media Edit
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
504:
|
504:
|
||||||
id: "KSC_F8"
|
id: "KSC_F8"
|
||||||
|
icon: bedtime
|
||||||
keyCode: "Sleep"
|
keyCode: "Sleep"
|
||||||
title: Media Sleep
|
title: Media System Sleep
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not required to be supported by any OS. Possibly deprecated.
|
||||||
505:
|
505:
|
||||||
id: "KSC_F9"
|
id: "KSC_F9"
|
||||||
|
icon: routine
|
||||||
keyCode: "WakeUp"
|
keyCode: "WakeUp"
|
||||||
title: Media Coffee
|
title: Media System Wake
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not supported on Windows
|
||||||
506:
|
506:
|
||||||
id: "KSC_FA"
|
id: "KSC_FA"
|
||||||
keyCode: "BrowserRefresh"
|
keyCode: "BrowserRefresh"
|
||||||
title: Media Refresh
|
title: Media Browser Refresh
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
|
||||||
507:
|
507:
|
||||||
id: "KSC_FB"
|
id: "KSC_FB"
|
||||||
title: Media Calc
|
title: Media Calculator
|
||||||
description: Not required to be supported by any OS. Possibly deprecated.
|
description: Not supported on MacOS
|
||||||
508:
|
508:
|
||||||
id: "KSC_FC"
|
id: "KSC_FC"
|
||||||
description: Not required to be supported by any OS.
|
description: Not required to be supported by any OS.
|
||||||
|
|||||||
19
src/lib/assets/layouts/layout.d.ts
vendored
Normal file
19
src/lib/assets/layouts/layout.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface CompiledLayout {
|
||||||
|
name: string;
|
||||||
|
size: [number, number];
|
||||||
|
keys: CompiledLayoutKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompiledLayoutKey {
|
||||||
|
id: number;
|
||||||
|
shape: "quarter-circle" | "square";
|
||||||
|
cornerRadius: number;
|
||||||
|
size: [number, number];
|
||||||
|
pos: [number, number];
|
||||||
|
rotate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.layout.yml" {
|
||||||
|
const layout: CompiledLayout;
|
||||||
|
export default layout;
|
||||||
|
}
|
||||||
@@ -15,10 +15,10 @@ col:
|
|||||||
- key: 64
|
- key: 64
|
||||||
- key: 65
|
- key: 65
|
||||||
- key: 66
|
- key: 66
|
||||||
size: [ 2, 1 ]
|
size: [2, 1]
|
||||||
- row:
|
- row:
|
||||||
- key: 39
|
- key: 39
|
||||||
size: [ 1.5, 1 ]
|
size: [1.5, 1]
|
||||||
- key: 40
|
- key: 40
|
||||||
- key: 41
|
- key: 41
|
||||||
- key: 42
|
- key: 42
|
||||||
@@ -32,10 +32,10 @@ col:
|
|||||||
- key: 50
|
- key: 50
|
||||||
- key: 51
|
- key: 51
|
||||||
- key: 52
|
- key: 52
|
||||||
size: [ 1.5, 1 ]
|
size: [1.5, 1]
|
||||||
- row:
|
- row:
|
||||||
- key: 26
|
- key: 26
|
||||||
size: [ 1.75, 1 ]
|
size: [1.75, 1]
|
||||||
- key: 27
|
- key: 27
|
||||||
- key: 28
|
- key: 28
|
||||||
- key: 29
|
- key: 29
|
||||||
@@ -48,10 +48,10 @@ col:
|
|||||||
- key: 36
|
- key: 36
|
||||||
- key: 37
|
- key: 37
|
||||||
- key: 38
|
- key: 38
|
||||||
size: [ 2.25, 1 ]
|
size: [2.25, 1]
|
||||||
- row:
|
- row:
|
||||||
- key: 12
|
- key: 12
|
||||||
size: [ 2, 1 ]
|
size: [2, 1]
|
||||||
- key: 13
|
- key: 13
|
||||||
- key: 14
|
- key: 14
|
||||||
- key: 15
|
- key: 15
|
||||||
@@ -68,20 +68,19 @@ col:
|
|||||||
- row:
|
- row:
|
||||||
- key: 0
|
- key: 0
|
||||||
- key: 1
|
- key: 1
|
||||||
size: [ 1.25, 1 ]
|
size: [1.25, 1]
|
||||||
- key: 2
|
- key: 2
|
||||||
size: [ 1.25, 1 ]
|
size: [1.25, 1]
|
||||||
- key: 3
|
- key: 3
|
||||||
size: [ 2, 1 ]
|
size: [2, 1]
|
||||||
- key: 4
|
- key: 4
|
||||||
- key: 5
|
- key: 5
|
||||||
- key: 6
|
- key: 6
|
||||||
size: [ 2, 1 ]
|
size: [2, 1]
|
||||||
- key: 7
|
- key: 7
|
||||||
size: [ 1.25, 1 ]
|
size: [1.25, 1]
|
||||||
- key: 8
|
- key: 8
|
||||||
size: [ 1.25, 1 ]
|
size: [1.25, 1]
|
||||||
- key: 9
|
- key: 9
|
||||||
- key: 10
|
- key: 10
|
||||||
- key: 11
|
- key: 11
|
||||||
|
|
||||||
37
src/lib/assets/layouts/m4g.layout.yml
Normal file
37
src/lib/assets/layouts/m4g.layout.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.layout.yml
Normal file
37
src/lib/assets/layouts/m4gr.layout.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 }
|
||||||
10
src/lib/assets/layouts/t4g.layout.yml
Normal file
10
src/lib/assets/layouts/t4g.layout.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: T4G
|
||||||
|
col:
|
||||||
|
- row:
|
||||||
|
- switch: { e: 3, n: 5, w: 4, s: 6 }
|
||||||
|
- offset: [0.5, 0]
|
||||||
|
row:
|
||||||
|
- key: 2
|
||||||
|
- row:
|
||||||
|
- key: 0
|
||||||
|
- key: 1
|
||||||
38
src/lib/assets/random-tips/en.json
Normal file
38
src/lib/assets/random-tips/en.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
[
|
||||||
|
"You can use DUP+i to create chords on the fly in any text box",
|
||||||
|
"This site is open source! Check out the full source code on GitHub in the bottom left",
|
||||||
|
"Two letter chords can be activated accidentally in chentry. Be cautious around them",
|
||||||
|
"More inputs in a chord increase the tolerance, making them easier to activate",
|
||||||
|
"The maximum number of outputs in a chord is 256",
|
||||||
|
"You can create backups of your device on the top right",
|
||||||
|
"For programming you should set your auto-delete timeout to about 200ms",
|
||||||
|
"Large parts of this site were written on a CC1",
|
||||||
|
"I use VIM btw...",
|
||||||
|
"I use NixOS btw...",
|
||||||
|
"You can hold shift on the undo button to undo all changes",
|
||||||
|
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
||||||
|
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
||||||
|
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
||||||
|
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add 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",
|
||||||
|
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
||||||
|
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
||||||
|
"Spurring is a chording only mode which is more advanced, but can greatly improve typing speed when mastered",
|
||||||
|
"The forced chord phenomenon is when typing a word character by character starts to feel unnatural",
|
||||||
|
"Don't be afraid to delete chords you keep getting wrong",
|
||||||
|
"Most people find it easier to start their chord library from scratch rather than learning someone else's",
|
||||||
|
"A common techinque to deal with conflicts is to add DUP or the same key mirrored on the other hand",
|
||||||
|
"A longer chord is not always more difficult",
|
||||||
|
"Riley Keen made headlines when his Monkeytype score of 500WPM using a CC1 got him banned off the site",
|
||||||
|
"A 3d press refers to pressing down into a 5-way switch",
|
||||||
|
"The serial communication protocol used by CCOS is documented on docs.charachorder.com",
|
||||||
|
"The 'CCOS is ready' message can be turned off in the settings",
|
||||||
|
"Most people using the CC1 don't change the a-z key layout, as further modification provides very little benefit",
|
||||||
|
"Using VIM on the default CC1 a-z layout is perfectly doable, it's just a matter of getting used to it",
|
||||||
|
"You can use Nexus to track words you might want to add to your chord library",
|
||||||
|
"The CC1 default layout was 80% science, 20% art",
|
||||||
|
"There is little to no reason to use hjkl in VIM on a CC1 since the arrows keys are so close already",
|
||||||
|
"The device manager automatically creates a backup for you when you reboot your device into the bootloader",
|
||||||
|
"You can use \"compound\", \"macro\", \"suffix\" and \"cursor warp\" in the chord search to find specific types of chords",
|
||||||
|
"You can search for chord inputs by using a leading \"+\", for example \"+a +DUP\" will show all chords with inputs that contain both a and DUP"
|
||||||
|
]
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -4,33 +4,45 @@ import type {
|
|||||||
CharaFile,
|
CharaFile,
|
||||||
CharaLayoutFile,
|
CharaLayoutFile,
|
||||||
CharaSettingsFile,
|
CharaSettingsFile,
|
||||||
} from "$lib/share/chara-file.js"
|
} from "$lib/share/chara-file.js";
|
||||||
import type {Change} from "$lib/undo-redo.js"
|
import type { Change } from "$lib/undo-redo.js";
|
||||||
import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js"
|
import {
|
||||||
import {get} from "svelte/store"
|
changes,
|
||||||
import {serialPort} from "../serial/connection"
|
ChangeType,
|
||||||
import {csvLayoutToJson, isCsvLayout} from "$lib/backup/compat/legacy-layout"
|
chords,
|
||||||
import {isCsvChords, csvChordsToJson} from "./compat/legacy-chords"
|
layout,
|
||||||
|
settings,
|
||||||
|
} from "$lib/undo-redo.js";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import { activeProfile, serialPort } from "../serial/connection";
|
||||||
|
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
|
||||||
|
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
|
||||||
|
|
||||||
export function downloadFile<T extends CharaFile<string>>(contents: T) {
|
export function downloadFile<T extends CharaFile<string>>(contents: T) {
|
||||||
const downloadUrl = URL.createObjectURL(new Blob([JSON.stringify(contents)], {type: "application/json"}))
|
const downloadUrl = URL.createObjectURL(
|
||||||
const element = document.createElement("a")
|
new Blob([JSON.stringify(contents)], { type: "application/json" }),
|
||||||
|
);
|
||||||
|
const element = document.createElement("a");
|
||||||
element.setAttribute(
|
element.setAttribute(
|
||||||
"download",
|
"download",
|
||||||
`${contents.type}-${get(serialPort)?.device}-${new Date().toISOString()}.json`,
|
`${contents.type}-${
|
||||||
)
|
get(serialPort)?.device
|
||||||
element.href = downloadUrl
|
}-${new Date().toISOString()}.json`,
|
||||||
element.setAttribute("target", "_blank")
|
);
|
||||||
element.click()
|
element.href = downloadUrl;
|
||||||
URL.revokeObjectURL(downloadUrl)
|
element.setAttribute("target", "_blank");
|
||||||
|
element.click();
|
||||||
|
URL.revokeObjectURL(downloadUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function downloadBackup() {
|
export function downloadBackup() {
|
||||||
downloadFile<CharaBackupFile>({
|
downloadFile<CharaBackupFile>({
|
||||||
charaVersion: 1,
|
charaVersion: 1,
|
||||||
type: "backup",
|
type: "backup",
|
||||||
history: [[createChordBackup(), createLayoutBackup(), createSettingsBackup()]],
|
history: [
|
||||||
})
|
[createChordBackup(), createLayoutBackup(), createSettingsBackup()],
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLayoutBackup(): CharaLayoutFile {
|
export function createLayoutBackup(): CharaLayoutFile {
|
||||||
@@ -38,127 +50,170 @@ export function createLayoutBackup(): CharaLayoutFile {
|
|||||||
charaVersion: 1,
|
charaVersion: 1,
|
||||||
type: "layout",
|
type: "layout",
|
||||||
device: get(serialPort)?.device,
|
device: get(serialPort)?.device,
|
||||||
layout: get(layout).map(it => it.map(it => it.action)) as [number[], number[], number[]],
|
layout: (get(layout)[get(activeProfile)]?.map((it) =>
|
||||||
}
|
it.map((it) => it.action),
|
||||||
|
) ?? []) as [number[], number[], number[]],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createChordBackup(): CharaChordFile {
|
export function createChordBackup(): CharaChordFile {
|
||||||
return {charaVersion: 1, type: "chords", chords: get(chords).map(it => [it.actions, it.phrase])}
|
return {
|
||||||
|
charaVersion: 1,
|
||||||
|
type: "chords",
|
||||||
|
chords: get(chords).map((it) => [it.actions, it.phrase]),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSettingsBackup(): CharaSettingsFile {
|
export function createSettingsBackup(): CharaSettingsFile {
|
||||||
return {charaVersion: 1, type: "settings", settings: get(settings).map(it => it.value)}
|
return {
|
||||||
|
charaVersion: 1,
|
||||||
|
type: "settings",
|
||||||
|
settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreBackup(event: Event) {
|
export async function restoreBackup(
|
||||||
const input = (event.target as HTMLInputElement).files![0]
|
event: Event,
|
||||||
if (!input) return
|
only?: "chords" | "layout" | "settings",
|
||||||
const text = await input.text()
|
) {
|
||||||
|
const input = (event.target as HTMLInputElement).files![0];
|
||||||
|
if (!input) return;
|
||||||
|
const text = await input.text();
|
||||||
if (input.name.endsWith(".json")) {
|
if (input.name.endsWith(".json")) {
|
||||||
restoreFromFile(JSON.parse(text))
|
restoreFromFile(JSON.parse(text), only);
|
||||||
} else if (isCsvLayout(text)) {
|
} else if (isCsvLayout(text)) {
|
||||||
restoreFromFile(csvLayoutToJson(text))
|
restoreFromFile(csvLayoutToJson(text), only);
|
||||||
} else if (isCsvChords(text)) {
|
} else if (isCsvChords(text)) {
|
||||||
restoreFromFile(csvChordsToJson(text))
|
restoreFromFile(csvChordsToJson(text), only);
|
||||||
} else {
|
} else {
|
||||||
alert("Unknown backup format")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreFromFile(
|
export function restoreFromFile(
|
||||||
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
||||||
|
only?: "chords" | "layout" | "settings",
|
||||||
) {
|
) {
|
||||||
if (file.charaVersion !== 1) throw new Error("Incompatible backup")
|
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
||||||
switch (file.type) {
|
switch (file.type) {
|
||||||
case "backup": {
|
case "backup": {
|
||||||
const recent = file.history[0]
|
const recent = file.history[0];
|
||||||
if (recent[1].device !== get(serialPort)?.device) {
|
if (!recent) return;
|
||||||
alert("Backup is incompatible with this device")
|
let backupDevice = recent[1].device;
|
||||||
throw new Error("Backup is incompatible with this device")
|
if (backupDevice === "TWO" || backupDevice === "M4G")
|
||||||
|
backupDevice = "ONE";
|
||||||
|
else if (backupDevice === "ZERO" || backupDevice === "ENGINE")
|
||||||
|
backupDevice = "X";
|
||||||
|
let currentDevice = get(serialPort)?.device;
|
||||||
|
if (currentDevice === "TWO" || currentDevice === "M4G")
|
||||||
|
currentDevice = "ONE";
|
||||||
|
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
|
||||||
|
currentDevice = "X";
|
||||||
|
|
||||||
|
if (backupDevice !== currentDevice) {
|
||||||
|
alert("Backup is incompatible with this device");
|
||||||
|
throw new Error("Backup is incompatible with this device");
|
||||||
}
|
}
|
||||||
|
|
||||||
changes.update(changes => {
|
changes.update((changes) => {
|
||||||
changes.push(
|
changes.push([
|
||||||
...getChangesFromChordFile(recent[0]),
|
...(!only || only === "chords"
|
||||||
...getChangesFromLayoutFile(recent[1]),
|
? getChangesFromChordFile(recent[0])
|
||||||
...getChangesFromSettingsFile(recent[2]),
|
: []),
|
||||||
)
|
...(!only || only === "layout"
|
||||||
return changes
|
? getChangesFromLayoutFile(recent[1])
|
||||||
})
|
: []),
|
||||||
break
|
...(!only || only === "settings"
|
||||||
|
? getChangesFromSettingsFile(recent[2])
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case "chords": {
|
case "chords": {
|
||||||
changes.update(changes => {
|
if (!only || only === "chords") {
|
||||||
changes.push(...getChangesFromChordFile(file))
|
changes.update((changes) => {
|
||||||
return changes
|
changes.push(getChangesFromChordFile(file));
|
||||||
})
|
return changes;
|
||||||
break
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case "layout": {
|
case "layout": {
|
||||||
changes.update(changes => {
|
if (!only || only === "layout") {
|
||||||
changes.push(...getChangesFromLayoutFile(file))
|
changes.update((changes) => {
|
||||||
return changes
|
changes.push(getChangesFromLayoutFile(file));
|
||||||
})
|
return changes;
|
||||||
break
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
case "settings": {
|
case "settings": {
|
||||||
changes.update(changes => {
|
if (!only || only === "settings") {
|
||||||
changes.push(...getChangesFromSettingsFile(file))
|
changes.update((changes) => {
|
||||||
return changes
|
changes.push(getChangesFromSettingsFile(file));
|
||||||
})
|
return changes;
|
||||||
break
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error(`Unknown backup type "${(file as CharaFile<string>).type}"`)
|
throw new Error(
|
||||||
|
`Unknown backup type "${(file as CharaFile<string>).type}"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChangesFromChordFile(file: CharaChordFile) {
|
export function getChangesFromChordFile(file: CharaChordFile) {
|
||||||
const changes: Change[] = []
|
const changes: Change[] = [];
|
||||||
const existingChords = new Set(get(chords).map(({phrase, actions}) => JSON.stringify([actions, phrase])))
|
const existingChords = new Set(
|
||||||
|
get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])),
|
||||||
|
);
|
||||||
for (const [input, output] of file.chords) {
|
for (const [input, output] of file.chords) {
|
||||||
if (existingChords.has(JSON.stringify([input, output]))) {
|
if (existingChords.has(JSON.stringify([input, output]))) {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Chord,
|
type: ChangeType.Chord,
|
||||||
actions: input,
|
actions: input,
|
||||||
phrase: output,
|
phrase: output,
|
||||||
id: input,
|
id: input,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
return changes
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
|
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
|
||||||
const changes: Change[] = []
|
const changes: Change[] = [];
|
||||||
for (const [id, value] of file.settings.entries()) {
|
for (const [id, value] of file.settings.entries()) {
|
||||||
const setting = get(settings)[id]
|
const setting = get(settings)[get(activeProfile)]?.[id];
|
||||||
if (setting !== undefined && setting.value !== value) {
|
if (setting !== undefined && setting.value !== value) {
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Setting,
|
type: ChangeType.Setting,
|
||||||
id,
|
id,
|
||||||
setting: value,
|
setting: value,
|
||||||
})
|
profile: get(activeProfile),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changes
|
return changes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChangesFromLayoutFile(file: CharaLayoutFile) {
|
export function getChangesFromLayoutFile(file: CharaLayoutFile) {
|
||||||
const changes: Change[] = []
|
const changes: Change[] = [];
|
||||||
for (const [layer, keys] of file.layout.entries()) {
|
for (const [layer, keys] of file.layout.entries()) {
|
||||||
for (const [id, action] of keys.entries()) {
|
for (const [id, action] of keys.entries()) {
|
||||||
if (get(layout)[layer][id].action !== action) {
|
if (get(layout)[get(activeProfile)]?.[layer]?.[id]?.action !== action) {
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Layout,
|
type: ChangeType.Layout,
|
||||||
layer,
|
layer,
|
||||||
id,
|
id,
|
||||||
action,
|
action,
|
||||||
})
|
profile: get(activeProfile),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return changes
|
return changes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
e + b + a,babe
|
|
||||||
e + c + b,because
|
|
||||||
f + e + c + a,face
|
|
||||||
h + e + c + a,each
|
|
||||||
i + d + ',I'd
|
|
||||||
i + g + b,big
|
|
||||||
i + g + e,give
|
|
||||||
k + b + a,back
|
|
||||||
k + e + a,take
|
|
||||||
l + e + a,late
|
|
||||||
l + e + d + a,lead
|
|
||||||
l + f + e,feel
|
|
||||||
l + g + e + a,large
|
|
||||||
l + h + e,help
|
|
||||||
l + i + a,Lia
|
|
||||||
l + i + f,fill
|
|
||||||
l + i + f + e,life
|
|
||||||
l + i + g + b + a,gitlab
|
|
||||||
l + k + i + e,like
|
|
||||||
m + e + a,make
|
|
||||||
m + i + ',I'm
|
|
||||||
n + c + a,can
|
|
||||||
n + d + a,and
|
|
||||||
n + e + b,been
|
|
||||||
n + e + b + a,enable
|
|
||||||
n + e + d,end
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
|
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||||
import type {CharaChordFile} from "$lib/share/chara-file"
|
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||||
|
|
||||||
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]])
|
const SPECIAL_KEYS = new Map<string, string>([[" ", "SPACE"]]);
|
||||||
|
|
||||||
export function csvChordsToJson(csv: string): CharaChordFile {
|
export function csvChordsToJson(csv: string): CharaChordFile {
|
||||||
return {
|
return {
|
||||||
@@ -10,22 +10,22 @@ export function csvChordsToJson(csv: string): CharaChordFile {
|
|||||||
chords: csv
|
chords: csv
|
||||||
.trim()
|
.trim()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map(line => {
|
.map((line) => {
|
||||||
const [input, output] = line.split(/,(?=[^,]*$)/, 2)
|
const [input, output] = line.split(/,(?=[^,]*$)/, 2);
|
||||||
return [
|
return [
|
||||||
input
|
input!
|
||||||
.split("+")
|
.split("+")
|
||||||
.map(it => KEYMAP_IDS.get(it.trim())?.code ?? 0)
|
.map((it) => KEYMAP_IDS.get(it.trim())?.code ?? 0)
|
||||||
.sort((a, b) => a - b),
|
.sort((a, b) => a - b),
|
||||||
output
|
output!
|
||||||
.trim()
|
.trim()
|
||||||
.split("")
|
.split("")
|
||||||
.map(it => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
|
.map((it) => KEYMAP_IDS.get(SPECIAL_KEYS.get(it) ?? it)?.code ?? 0),
|
||||||
]
|
];
|
||||||
}),
|
}),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCsvChords(csv: string): boolean {
|
export function isCsvChords(csv: string): boolean {
|
||||||
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv)
|
return /^([^+]+( *\+ *[^+]+)* *, *[^+, ]+ *(\n|(?=$)))+$/.test(csv);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,24 @@
|
|||||||
"device": "one",
|
"device": "one",
|
||||||
"layout": [
|
"layout": [
|
||||||
[
|
[
|
||||||
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262, 288, 277, 298, 307, 264, 287,
|
309, 304, 312, 303, 306, 290, 282, 301, 266, 285, 289, 270, 281, 272, 262,
|
||||||
268, 332, 311, 274, 286, 308, 329, 310, 280, 358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315,
|
288, 277, 298, 307, 264, 287, 268, 332, 311, 274, 286, 308, 329, 310, 280,
|
||||||
314, 317, 316, 312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263, 293, 260, 296,
|
358, 512, 515, 513, 514, 313, 319, 318, 321, 320, 326, 315, 314, 317, 316,
|
||||||
544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278, 357, 516, 519, 517, 518, 327, 336, 338, 335,
|
312, 330, 331, 333, 334, 291, 261, 283, 536, 276, 292, 265, 275, 267, 263,
|
||||||
337, 328, 325, 322, 323, 324
|
293, 260, 296, 544, 279, 294, 271, 299, 269, 273, 295, 284, 297, 302, 278,
|
||||||
|
357, 516, 519, 517, 518, 327, 336, 338, 335, 337, 328, 325, 322, 323, 324
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import {describe, expect, it} from "vitest"
|
import { describe, expect, it } from "vitest";
|
||||||
import legacyLayout from "./legacy-layout.sample.csv?raw"
|
import legacyLayout from "./legacy-layout.sample.csv?raw";
|
||||||
import legacyLayoutConverted from "./legacy-layout-converted.sample.json"
|
import legacyLayoutConverted from "./legacy-layout-converted.sample.json";
|
||||||
import {csvLayoutToJson, isCsvLayout} from "./legacy-layout"
|
import { csvLayoutToJson, isCsvLayout } from "./legacy-layout";
|
||||||
|
|
||||||
describe("legacy layout", () => {
|
describe("legacy layout", () => {
|
||||||
it("should detect a legacy layout", () => {
|
it("should detect a legacy layout", () => {
|
||||||
expect(isCsvLayout(legacyLayout)).to.be.true
|
expect(isCsvLayout(legacyLayout)).to.be.true;
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should not detect chord maps as layouts", () => {
|
it("should not detect chord maps as layouts", () => {
|
||||||
expect(isCsvLayout("e + h + t,the")).to.be.false
|
expect(isCsvLayout("e + h + t,the")).to.be.false;
|
||||||
})
|
});
|
||||||
|
|
||||||
it("should convert legacy layouts", () => {
|
it("should convert legacy layouts", () => {
|
||||||
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted)
|
expect(csvLayoutToJson(legacyLayout)).to.deep.equal(legacyLayoutConverted);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import type {CharaLayoutFile} from "$lib/share/chara-file"
|
import type { CharaLayoutFile } from "$lib/share/chara-file";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a legacy CSV-based layout to the modern JSON-based format
|
* Converts a legacy CSV-based layout to the modern JSON-based format
|
||||||
*/
|
*/
|
||||||
export function csvLayoutToJson(csv: string, device: CharaLayoutFile["device"] = "one"): CharaLayoutFile {
|
export function csvLayoutToJson(
|
||||||
|
csv: string,
|
||||||
|
device: CharaLayoutFile["device"] = "one",
|
||||||
|
): CharaLayoutFile {
|
||||||
const layout: CharaLayoutFile = {
|
const layout: CharaLayoutFile = {
|
||||||
charaVersion: 1,
|
charaVersion: 1,
|
||||||
type: "layout",
|
type: "layout",
|
||||||
device,
|
device,
|
||||||
layout: [[], [], []],
|
layout: [[], [], []],
|
||||||
}
|
};
|
||||||
|
|
||||||
for (const layer of csv.trim().split("\n")) {
|
for (const layer of csv.trim().split("\n")) {
|
||||||
const [layerId, key, action] = layer.substring(1).split(",").map(Number)
|
const [layerId, key, action] = layer.substring(1).split(",").map(Number);
|
||||||
|
|
||||||
layout.layout[Number(layerId) - 1][Number(key)] = Number(action)
|
layout.layout[Number(layerId) - 1]![Number(key)] = Number(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
return layout
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCsvLayout(csv: string): boolean {
|
export function isCsvLayout(csv: string): boolean {
|
||||||
return /^(A[123],\d+,\d+\n?)+$/.test(csv)
|
return /^(A[123],\d+,\d+\n?)+$/.test(csv);
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/lib/ccos/attachment.ts
Normal file
35
src/lib/ccos/attachment.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
import type { CharaDevice } from "$lib/serial/device";
|
||||||
|
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
|
||||||
|
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
|
||||||
|
export function ccosKeyInterceptor(
|
||||||
|
port: CharaDevice | undefined,
|
||||||
|
recorder: ReplayRecorder,
|
||||||
|
) {
|
||||||
|
return ((element: HTMLElement) => {
|
||||||
|
const ccos =
|
||||||
|
port?.port && "handleKeyEvent" in port?.port
|
||||||
|
? (port.port as CCOS)
|
||||||
|
: undefined;
|
||||||
|
console.log("Attaching CCOS key interceptor", ccos);
|
||||||
|
|
||||||
|
function onEvent(event: KeyboardEvent) {
|
||||||
|
ccos?.handleKeyEvent(event);
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
recorder.next(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ccos) {
|
||||||
|
element.addEventListener("keydown", onEvent, true);
|
||||||
|
element.addEventListener("keyup", onEvent, true);
|
||||||
|
element.add;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("keydown", onEvent, true);
|
||||||
|
element.removeEventListener("keyup", onEvent, true);
|
||||||
|
};
|
||||||
|
}) satisfies Attachment<HTMLElement>;
|
||||||
|
}
|
||||||
37
src/lib/ccos/ccos-events.ts
Normal file
37
src/lib/ccos/ccos-events.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface CCOSInitEvent {
|
||||||
|
type: "init";
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSKeyPressEvent {
|
||||||
|
type: "press";
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSKeyReleaseEvent {
|
||||||
|
type: "release";
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSSerialEvent {
|
||||||
|
type: "serial";
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CCOSInEvent =
|
||||||
|
| CCOSInitEvent
|
||||||
|
| CCOSKeyPressEvent
|
||||||
|
| CCOSKeyReleaseEvent
|
||||||
|
| CCOSSerialEvent;
|
||||||
|
|
||||||
|
export interface CCOSReportEvent {
|
||||||
|
type: "report";
|
||||||
|
modifiers: number;
|
||||||
|
keys: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSReadyEvent {
|
||||||
|
type: "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CCOSOutEvent = CCOSReportEvent | CCOSReadyEvent | CCOSSerialEvent;
|
||||||
111
src/lib/ccos/ccos-interop.ts
Normal file
111
src/lib/ccos/ccos-interop.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
export const KEYCODE_TO_SCANCODE = new Map<string, number | undefined>(
|
||||||
|
Object.entries({
|
||||||
|
KeyA: 0x04,
|
||||||
|
KeyB: 0x05,
|
||||||
|
KeyC: 0x06,
|
||||||
|
KeyD: 0x07,
|
||||||
|
KeyE: 0x08,
|
||||||
|
KeyF: 0x09,
|
||||||
|
KeyG: 0x0a,
|
||||||
|
KeyH: 0x0b,
|
||||||
|
KeyI: 0x0c,
|
||||||
|
KeyJ: 0x0d,
|
||||||
|
KeyK: 0x0e,
|
||||||
|
KeyL: 0x0f,
|
||||||
|
KeyM: 0x10,
|
||||||
|
KeyN: 0x11,
|
||||||
|
KeyO: 0x12,
|
||||||
|
KeyP: 0x13,
|
||||||
|
KeyQ: 0x14,
|
||||||
|
KeyR: 0x15,
|
||||||
|
KeyS: 0x16,
|
||||||
|
KeyT: 0x17,
|
||||||
|
KeyU: 0x18,
|
||||||
|
KeyV: 0x19,
|
||||||
|
KeyW: 0x1a,
|
||||||
|
KeyX: 0x1b,
|
||||||
|
KeyY: 0x1c,
|
||||||
|
KeyZ: 0x1d,
|
||||||
|
Digit1: 0x1e,
|
||||||
|
Digit2: 0x1f,
|
||||||
|
Digit3: 0x20,
|
||||||
|
Digit4: 0x21,
|
||||||
|
Digit5: 0x22,
|
||||||
|
Digit6: 0x23,
|
||||||
|
Digit7: 0x24,
|
||||||
|
Digit8: 0x25,
|
||||||
|
Digit9: 0x26,
|
||||||
|
Digit0: 0x27,
|
||||||
|
Enter: 0x28,
|
||||||
|
Escape: 0x29,
|
||||||
|
Backspace: 0x2a,
|
||||||
|
Tab: 0x2b,
|
||||||
|
Space: 0x2c,
|
||||||
|
Minus: 0x2d,
|
||||||
|
Equal: 0x2e,
|
||||||
|
BracketLeft: 0x2f,
|
||||||
|
BracketRight: 0x30,
|
||||||
|
Backslash: 0x31,
|
||||||
|
Semicolon: 0x33,
|
||||||
|
Quote: 0x34,
|
||||||
|
Backquote: 0x35,
|
||||||
|
Comma: 0x36,
|
||||||
|
Period: 0x37,
|
||||||
|
Slash: 0x38,
|
||||||
|
CapsLock: 0x39,
|
||||||
|
F1: 0x3a,
|
||||||
|
F2: 0x3b,
|
||||||
|
F3: 0x3c,
|
||||||
|
F4: 0x3d,
|
||||||
|
F5: 0x3e,
|
||||||
|
F6: 0x3f,
|
||||||
|
F7: 0x40,
|
||||||
|
F8: 0x41,
|
||||||
|
F9: 0x42,
|
||||||
|
F10: 0x43,
|
||||||
|
F11: 0x44,
|
||||||
|
F12: 0x45,
|
||||||
|
PrintScreen: 0x46,
|
||||||
|
ScrollLock: 0x47,
|
||||||
|
Pause: 0x48,
|
||||||
|
Insert: 0x49,
|
||||||
|
Home: 0x4a,
|
||||||
|
PageUp: 0x4b,
|
||||||
|
Delete: 0x4c,
|
||||||
|
End: 0x4d,
|
||||||
|
PageDown: 0x4e,
|
||||||
|
ArrowRight: 0x4f,
|
||||||
|
ArrowLeft: 0x50,
|
||||||
|
ArrowDown: 0x51,
|
||||||
|
ArrowUp: 0x52,
|
||||||
|
NumLock: 0x53,
|
||||||
|
NumpadDivide: 0x54,
|
||||||
|
NumpadMultiply: 0x55,
|
||||||
|
NumpadSubtract: 0x56,
|
||||||
|
NumpadAdd: 0x57,
|
||||||
|
NumpadEnter: 0x58,
|
||||||
|
Numpad1: 0x59,
|
||||||
|
Numpad2: 0x5a,
|
||||||
|
Numpad3: 0x5b,
|
||||||
|
Numpad4: 0x5c,
|
||||||
|
Numpad5: 0x5d,
|
||||||
|
Numpad6: 0x5e,
|
||||||
|
Numpad7: 0x5f,
|
||||||
|
Numpad8: 0x60,
|
||||||
|
Numpad9: 0x61,
|
||||||
|
Numpad0: 0x62,
|
||||||
|
NumpadDecimal: 0x63,
|
||||||
|
ControlLeft: 0xe0,
|
||||||
|
ShiftLeft: 0xe1,
|
||||||
|
AltLeft: 0xe2,
|
||||||
|
MetaLeft: 0xe3,
|
||||||
|
ControlRight: 0xe4,
|
||||||
|
ShiftRight: 0xe5,
|
||||||
|
AltRight: 0xe6,
|
||||||
|
MetaRight: 0xe7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SCANCODE_TO_KEYCODE = new Map<number, string>(
|
||||||
|
KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]),
|
||||||
|
);
|
||||||
269
src/lib/ccos/ccos.ts
Normal file
269
src/lib/ccos/ccos.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { getMeta } from "$lib/meta/meta-storage";
|
||||||
|
import type { SerialPortLike } from "$lib/serial/device";
|
||||||
|
import type {
|
||||||
|
CCOSInitEvent,
|
||||||
|
CCOSKeyPressEvent,
|
||||||
|
CCOSKeyReleaseEvent,
|
||||||
|
CCOSOutEvent,
|
||||||
|
} from "./ccos-events";
|
||||||
|
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
||||||
|
|
||||||
|
const device = "zero_wasm";
|
||||||
|
|
||||||
|
export class CCOSKeyboardEvent extends KeyboardEvent {
|
||||||
|
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||||
|
super(...params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASK_CTRL = 0b0001_0001;
|
||||||
|
const MASK_SHIFT = 0b0010_0010;
|
||||||
|
const MASK_ALT = 0b0100_0100;
|
||||||
|
const MASK_ALT_GRAPH = 0b0000_0100;
|
||||||
|
const MASK_GUI = 0b1000_1000;
|
||||||
|
|
||||||
|
export class CCOS implements SerialPortLike {
|
||||||
|
private readonly currKeys = new Set<number>();
|
||||||
|
|
||||||
|
private readonly layout = new Map<string, string>([
|
||||||
|
...Array.from(
|
||||||
|
{ length: 26 },
|
||||||
|
(_, i) =>
|
||||||
|
[
|
||||||
|
JSON.stringify([`Key${String.fromCharCode(65 + i)}`, "Shift"]),
|
||||||
|
String.fromCharCode(65 + i),
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, i) => [JSON.stringify([`Key${i}`]), i.toString()] as const,
|
||||||
|
),
|
||||||
|
|
||||||
|
[JSON.stringify(["Space"]), " "],
|
||||||
|
[JSON.stringify(["Backquote"]), "`"],
|
||||||
|
[JSON.stringify(["Minus"]), "-"],
|
||||||
|
[JSON.stringify(["Comma"]), ","],
|
||||||
|
[JSON.stringify(["Period"]), "."],
|
||||||
|
[JSON.stringify(["Semicolon"]), ";"],
|
||||||
|
[JSON.stringify(["Equal"]), "="],
|
||||||
|
|
||||||
|
[JSON.stringify(["Backquote", "Shift"]), "~"],
|
||||||
|
[JSON.stringify(["Minus", "Shift"]), "_"],
|
||||||
|
[JSON.stringify(["Comma", "Shift"]), "<"],
|
||||||
|
[JSON.stringify(["Period", "Shift"]), ">"],
|
||||||
|
[JSON.stringify(["Semicolon", "Shift"]), ":"],
|
||||||
|
[JSON.stringify(["Equal", "Shift"]), "+"],
|
||||||
|
|
||||||
|
[JSON.stringify(["Digit0", "Shift"]), ")"],
|
||||||
|
[JSON.stringify(["Digit1", "Shift"]), "!"],
|
||||||
|
[JSON.stringify(["Digit2", "Shift"]), "@"],
|
||||||
|
[JSON.stringify(["Digit3", "Shift"]), "#"],
|
||||||
|
[JSON.stringify(["Digit4", "Shift"]), "$"],
|
||||||
|
[JSON.stringify(["Digit5", "Shift"]), "%"],
|
||||||
|
[JSON.stringify(["Digit6", "Shift"]), "^"],
|
||||||
|
[JSON.stringify(["Digit7", "Shift"]), "&"],
|
||||||
|
[JSON.stringify(["Digit8", "Shift"]), "*"],
|
||||||
|
[JSON.stringify(["Digit9", "Shift"]), "("],
|
||||||
|
]);
|
||||||
|
|
||||||
|
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||||
|
|
||||||
|
private resolveReady!: () => void;
|
||||||
|
private ready = new Promise<void>((resolve) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
private lastEvent?: KeyboardEvent;
|
||||||
|
|
||||||
|
private onKey(
|
||||||
|
type: ConstructorParameters<typeof KeyboardEvent>[0],
|
||||||
|
modifiers: number,
|
||||||
|
scanCode: number,
|
||||||
|
) {
|
||||||
|
if (!this.lastEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = SCANCODE_TO_KEYCODE.get(scanCode);
|
||||||
|
if (code === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutKey = [code];
|
||||||
|
if (modifiers & MASK_SHIFT) {
|
||||||
|
layoutKey.push("Shift");
|
||||||
|
}
|
||||||
|
if (modifiers & MASK_ALT_GRAPH) {
|
||||||
|
layoutKey.push("AltGraph");
|
||||||
|
}
|
||||||
|
const key = this.layout.get(JSON.stringify(layoutKey)) ?? code;
|
||||||
|
|
||||||
|
const params: Required<KeyboardEventInit> = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
location: this.lastEvent.location,
|
||||||
|
repeat: this.lastEvent.repeat,
|
||||||
|
detail: this.lastEvent.detail,
|
||||||
|
view: this.lastEvent.view,
|
||||||
|
isComposing: this.lastEvent.isComposing,
|
||||||
|
which: this.lastEvent.which,
|
||||||
|
composed: this.lastEvent.composed,
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
charCode: key.charCodeAt(0),
|
||||||
|
keyCode: this.lastEvent.keyCode,
|
||||||
|
shiftKey: (modifiers & MASK_SHIFT) !== 0,
|
||||||
|
ctrlKey: (modifiers & MASK_CTRL) !== 0,
|
||||||
|
metaKey: (modifiers & MASK_GUI) !== 0,
|
||||||
|
altKey: (modifiers & MASK_ALT) !== 0,
|
||||||
|
modifierAltGraph: (modifiers & MASK_ALT_GRAPH) !== 0,
|
||||||
|
modifierCapsLock: this.lastEvent.getModifierState("CapsLock"),
|
||||||
|
modifierFn: this.lastEvent.getModifierState("Fn"),
|
||||||
|
modifierFnLock: this.lastEvent.getModifierState("FnLock"),
|
||||||
|
modifierHyper: this.lastEvent.getModifierState("Hyper"),
|
||||||
|
modifierNumLock: this.lastEvent.getModifierState("NumLock"),
|
||||||
|
modifierSuper: (modifiers & MASK_GUI) !== 0,
|
||||||
|
modifierSymbol: this.lastEvent.getModifierState("Symbol"),
|
||||||
|
modifierSymbolLock: this.lastEvent.getModifierState("SymbolLock"),
|
||||||
|
modifierScrollLock: this.lastEvent.getModifierState("ScrollLock"),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastEvent.target?.dispatchEvent(new CCOSKeyboardEvent(type, params));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onReport(modifiers: number, keys: number[]) {
|
||||||
|
const nextKeys = new Set<number>(keys);
|
||||||
|
nextKeys.delete(0);
|
||||||
|
for (const key of this.currKeys) {
|
||||||
|
if (!nextKeys.has(key)) {
|
||||||
|
this.onKey("keyup", modifiers, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
if (!this.currKeys.has(key)) {
|
||||||
|
this.onKey("keydown", modifiers, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currKeys.clear();
|
||||||
|
for (const key of keys) {
|
||||||
|
this.currKeys.add(key);
|
||||||
|
}
|
||||||
|
this.currKeys.delete(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private controller?: ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
|
||||||
|
readable!: ReadableStream<Uint8Array>;
|
||||||
|
writable!: WritableStream<Uint8Array>;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.worker.addEventListener(
|
||||||
|
"message",
|
||||||
|
(event: MessageEvent<CCOSOutEvent>) => {
|
||||||
|
if (event.data instanceof Uint8Array) {
|
||||||
|
this.controller?.enqueue(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (event.data.type) {
|
||||||
|
case "ready": {
|
||||||
|
this.resolveReady();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "report": {
|
||||||
|
this.onReport(event.data.modifiers, event.data.keys);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(navigator as any).keyboard
|
||||||
|
?.getLayoutMap()
|
||||||
|
?.then((it: Map<string, string>) =>
|
||||||
|
it.entries().forEach(([key, value]) => {
|
||||||
|
this.layout.set(JSON.stringify([key]), value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "init",
|
||||||
|
url,
|
||||||
|
} satisfies CCOSInitEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo(): SerialPortInfo {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(_options: SerialOptions) {
|
||||||
|
this.readable = new ReadableStream<Uint8Array>({
|
||||||
|
start: (controller) => {
|
||||||
|
this.controller = controller;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.writable = new WritableStream<Uint8Array>({
|
||||||
|
write: (chunk) => {
|
||||||
|
this.worker.postMessage(chunk, [chunk.buffer]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.ready;
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
await this.ready;
|
||||||
|
}
|
||||||
|
async forget() {
|
||||||
|
await this.ready;
|
||||||
|
this.close();
|
||||||
|
this.worker.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleKeyEvent(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
console.error("CCOS does not support input elements");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ready || event instanceof CCOSKeyboardEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
this.lastEvent = event;
|
||||||
|
|
||||||
|
const layoutKey = [event.code];
|
||||||
|
if (event.getModifierState("Shift")) {
|
||||||
|
layoutKey.push("Shift");
|
||||||
|
}
|
||||||
|
if (event.getModifierState("AltGraph")) {
|
||||||
|
layoutKey.push("AltGraph");
|
||||||
|
}
|
||||||
|
this.layout.set(JSON.stringify(layoutKey), event.key);
|
||||||
|
|
||||||
|
const scanCode = KEYCODE_TO_SCANCODE.get(event.code);
|
||||||
|
if (scanCode === undefined) return;
|
||||||
|
if (event.type === "keydown") {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "press",
|
||||||
|
code: scanCode,
|
||||||
|
} satisfies CCOSKeyPressEvent);
|
||||||
|
} else {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "release",
|
||||||
|
code: scanCode,
|
||||||
|
} satisfies CCOSKeyReleaseEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCCOS(
|
||||||
|
version = "3.0.0-rc.0",
|
||||||
|
fetch: typeof window.fetch = window.fetch,
|
||||||
|
): Promise<CCOS | undefined> {
|
||||||
|
const meta = await getMeta(device, version, fetch);
|
||||||
|
if (!meta?.update.js || !meta?.update.wasm) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CCOS(`${meta.path}/${meta.update.js}`);
|
||||||
|
}
|
||||||
151
src/lib/charrecorder/CharRecorder.svelte
Normal file
151
src/lib/charrecorder/CharRecorder.svelte
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<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,
|
||||||
|
paused = false,
|
||||||
|
children,
|
||||||
|
ondone,
|
||||||
|
ontick,
|
||||||
|
}: {
|
||||||
|
replay: ReplayPlayer | Replay;
|
||||||
|
cursor?: boolean;
|
||||||
|
keys?: boolean;
|
||||||
|
paused?: boolean;
|
||||||
|
children?: Snippet;
|
||||||
|
ondone?: () => void;
|
||||||
|
ontick?: (time: number) => 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;
|
||||||
|
if (paused) {
|
||||||
|
text.textContent = finalText ?? "";
|
||||||
|
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.onTick = ontick;
|
||||||
|
player.onDone = ondone;
|
||||||
|
player.start();
|
||||||
|
apply();
|
||||||
|
setTimeout(() => {
|
||||||
|
renderer.animated = true;
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
textRenderer = undefined;
|
||||||
|
replayPlayer = undefined;
|
||||||
|
unsubscribePlayer();
|
||||||
|
player.destroy();
|
||||||
|
renderer.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} style:opacity={paused ? 1 : 0}></span>
|
||||||
|
{:else if !(replay instanceof ReplayPlayer)}
|
||||||
|
{finalText}
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
{#if children}
|
||||||
|
{@render children()}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:global(*):has(svg) {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg > :global(text) {
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: 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 {
|
||||||
|
display: grid;
|
||||||
|
position: relative;
|
||||||
|
margin: 1em;
|
||||||
|
margin-bottom: 0;
|
||||||
|
height: 3em;
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating {
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile {
|
||||||
|
border-radius: 0.1em;
|
||||||
|
width: 100%;
|
||||||
|
height: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font-size: 0.6em;
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd + kbd {
|
||||||
|
margin-inline-start: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chord {
|
||||||
|
display: flex;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease,
|
||||||
|
translate 0.3s ease,
|
||||||
|
scale 0.3s ease;
|
||||||
|
will-change: transform, opacity, scale;
|
||||||
|
margin-inline-end: 1em;
|
||||||
|
padding-inline: 0.1em;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
155
src/lib/charrecorder/core/player.ts
Normal file
155
src/lib/charrecorder/core/player.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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: ReturnType<typeof requestAnimationFrame> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
timescale = 1;
|
||||||
|
|
||||||
|
private subscribers = new Set<(value: TextToken | undefined) => void>();
|
||||||
|
|
||||||
|
onDone?: () => void;
|
||||||
|
|
||||||
|
onTick?: (time: number) => 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;
|
||||||
|
this.onTick?.(now);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
this.timeoutId = setTimeout(() => {
|
||||||
|
this.startTime = performance.now();
|
||||||
|
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||||
|
}, delay);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
}
|
||||||
|
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) ?? 0,
|
||||||
|
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]),
|
||||||
|
} satisfies Replay;
|
||||||
|
}
|
||||||
|
}
|
||||||
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"],
|
||||||
|
]);
|
||||||
300
src/lib/charrecorder/renderer/renderer.ts
Normal file
300
src/lib/charrecorder/renderer/renderer.ts
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
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]!--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.cursorNode.remove();
|
||||||
|
for (const node of this.nodes.values()) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
for (const node of this.heldNodes.values()) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
this.nodes.clear();
|
||||||
|
this.heldNodes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private isShiny(char: TextToken, index: number) {
|
||||||
|
return (
|
||||||
|
this.shiny?.includes(index) ||
|
||||||
|
(this.shinyChords && char.source === "robot")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/lib/chord-editor/AutospaceSelector.svelte
Normal file
54
src/lib/chord-editor/AutospaceSelector.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { actionTooltip } from "$lib/title";
|
||||||
|
|
||||||
|
let {
|
||||||
|
onchange,
|
||||||
|
value,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
value: boolean;
|
||||||
|
variant: "start" | "end";
|
||||||
|
onchange: (
|
||||||
|
event: Event & { currentTarget: EventTarget & HTMLInputElement },
|
||||||
|
) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet tooltip()}
|
||||||
|
{#if value}
|
||||||
|
{#if variant === "start"}
|
||||||
|
<b>Remove</b> preceding space
|
||||||
|
{:else}
|
||||||
|
<b>Add</b> trailing space
|
||||||
|
{/if}
|
||||||
|
{:else if variant === "start"}
|
||||||
|
<b>Keep</b> preceding space
|
||||||
|
{:else}
|
||||||
|
<b>Add</b> trailing space
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<label class="autospace" {@attach actionTooltip(tooltip)}
|
||||||
|
><span class="icon">space_bar</span><input
|
||||||
|
checked={!value}
|
||||||
|
{onchange}
|
||||||
|
type="checkbox"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
label.autospace {
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--md-sys-color-tertiary-container);
|
||||||
|
padding-inline: 0;
|
||||||
|
height: 1em;
|
||||||
|
color: var(--md-sys-color-on-tertiary-container);
|
||||||
|
font-size: 1.3em;
|
||||||
|
|
||||||
|
&:has(:checked) {
|
||||||
|
opacity: var(--auto-space-show, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
src/lib/chord-editor/action-plugin.ts
Normal file
107
src/lib/chord-editor/action-plugin.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import type { Range } from "@codemirror/state";
|
||||||
|
|
||||||
|
export class ActionWidget extends WidgetType {
|
||||||
|
component?: {};
|
||||||
|
element?: HTMLElement;
|
||||||
|
|
||||||
|
constructor(readonly id: string | number) {
|
||||||
|
super();
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
override eq(other: ActionWidget) {
|
||||||
|
return this.id == other.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
if (!this.element) {
|
||||||
|
this.element = document.createElement("span");
|
||||||
|
this.element.style.paddingInline = "2px";
|
||||||
|
|
||||||
|
this.component = mount(Action, {
|
||||||
|
target: this.element,
|
||||||
|
props: { action: this.id, display: "keys", inText: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
override ignoreEvent() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
if (this.component) {
|
||||||
|
unmount(this.component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionWidgets(view: EditorView) {
|
||||||
|
const widgets: Range<Decoration>[] = [];
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (node) => {
|
||||||
|
if (node.name !== "ExplicitAction") return;
|
||||||
|
const value =
|
||||||
|
node.node.getChild("ActionId") ??
|
||||||
|
node.node.getChild("HexNumber") ??
|
||||||
|
node.node.getChild("DecimalNumber");
|
||||||
|
if (!value) return;
|
||||||
|
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = view.state.doc.sliceString(value.from, value.to);
|
||||||
|
let deco = Decoration.replace({
|
||||||
|
widget: new ActionWidget(
|
||||||
|
value.name === "ActionId" ? id : parseInt(id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
widgets.push(deco.range(node.from, node.to));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Decoration.set(widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionPlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations = Decoration.none;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = actionWidgets(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||||
|
)
|
||||||
|
this.decorations = actionWidgets(update.view);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(instance) {
|
||||||
|
return instance.decorations;
|
||||||
|
},
|
||||||
|
provide(plugin) {
|
||||||
|
return EditorView.atomicRanges.of(
|
||||||
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
16
src/lib/chord-editor/action-serializer.ts
Normal file
16
src/lib/chord-editor/action-serializer.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||||
|
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToValue(action: number | KeyInfo) {
|
||||||
|
const info =
|
||||||
|
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
|
||||||
|
if (info && info.id?.length === 1)
|
||||||
|
return /^[<>\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
|
||||||
|
if (!info || !canUseIdAsString(info))
|
||||||
|
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
|
||||||
|
return `<${info.id}>`;
|
||||||
|
}
|
||||||
72
src/lib/chord-editor/autocomplete.ts
Normal file
72
src/lib/chord-editor/autocomplete.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
|
import type {
|
||||||
|
Completion,
|
||||||
|
CompletionSection,
|
||||||
|
CompletionSource,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { derived, get } from "svelte/store";
|
||||||
|
import { actionToValue, canUseIdAsString } from "./action-serializer";
|
||||||
|
|
||||||
|
const completionSections = derived(
|
||||||
|
KEYMAP_CATEGORIES,
|
||||||
|
(categories) =>
|
||||||
|
new Map(
|
||||||
|
categories.map(
|
||||||
|
(category) =>
|
||||||
|
[
|
||||||
|
category,
|
||||||
|
{
|
||||||
|
name: category.name,
|
||||||
|
} satisfies CompletionSection,
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionAutocompleteItems = derived(
|
||||||
|
[KEYMAP_CODES, completionSections],
|
||||||
|
([codes, sections]) =>
|
||||||
|
codes
|
||||||
|
.values()
|
||||||
|
.map((info) => {
|
||||||
|
const canUseId = canUseIdAsString(info);
|
||||||
|
const completionValue =
|
||||||
|
(canUseId && info.id) ||
|
||||||
|
`0x${info.code.toString(16).padStart(2, "0")}`;
|
||||||
|
return {
|
||||||
|
label:
|
||||||
|
[
|
||||||
|
canUseId || !info.id ? undefined : `"${info.id}"`,
|
||||||
|
info.title,
|
||||||
|
info.variant?.replace(/^[a-z]/g, (c) => c.toUpperCase()),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") || completionValue,
|
||||||
|
detail: actionToValue(info),
|
||||||
|
section: info.category ? sections.get(info.category) : undefined,
|
||||||
|
info: info.description,
|
||||||
|
type: "keyword",
|
||||||
|
apply: completionValue + ">",
|
||||||
|
} satisfies Completion;
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(item) => typeof item.label === "string" && item.apply !== undefined,
|
||||||
|
)
|
||||||
|
.toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionAutocomplete = ((context) => {
|
||||||
|
let word = context.tokenBefore([
|
||||||
|
"ExplicitDelimStart",
|
||||||
|
"ActionId",
|
||||||
|
"HexNumber",
|
||||||
|
"DecimalNumber",
|
||||||
|
]);
|
||||||
|
if (!word) return null;
|
||||||
|
console.log(get(actionAutocompleteItems));
|
||||||
|
return {
|
||||||
|
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
|
||||||
|
validFor: /^<?[a-zA-Z0-9_]*$/,
|
||||||
|
options: get(actionAutocompleteItems),
|
||||||
|
};
|
||||||
|
}) satisfies CompletionSource;
|
||||||
17
src/lib/chord-editor/changes-plugin.ts
Normal file
17
src/lib/chord-editor/changes-plugin.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
type PluginValue,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
|
||||||
|
export const changesPlugin = ViewPlugin.fromClass(
|
||||||
|
class implements PluginValue {
|
||||||
|
constructor(readonly view: EditorView) {}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventHandlers: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
157
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
157
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import type { Range } from "@codemirror/state";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import Action from "../components/Action.svelte";
|
||||||
|
import type { SyntaxNodeRef } from "@lezer/common";
|
||||||
|
import classNames from "./concatenator-button.module.scss";
|
||||||
|
|
||||||
|
export class DelimWidget extends WidgetType {
|
||||||
|
component?: {};
|
||||||
|
element?: HTMLElement;
|
||||||
|
|
||||||
|
constructor(readonly hasConcatenator: boolean) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override eq(other: DelimWidget) {
|
||||||
|
return this.hasConcatenator == other.hasConcatenator;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
if (!this.element) {
|
||||||
|
this.element = document.createElement("span");
|
||||||
|
this.element.innerHTML =
|
||||||
|
" ⇛" + (this.hasConcatenator ? "" : " ");
|
||||||
|
this.element.style.scale = "1.8";
|
||||||
|
this.element.style.color =
|
||||||
|
"color-mix(in srgb, currentColor 50%, transparent)";
|
||||||
|
|
||||||
|
if (this.hasConcatenator) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = classNames["concatenator-button"]!;
|
||||||
|
this.component = mount(Action, {
|
||||||
|
target: button,
|
||||||
|
props: { action: 574, display: "keys", inText: true, ghost: true },
|
||||||
|
});
|
||||||
|
this.element.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
override ignoreEvent() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
if (this.component) {
|
||||||
|
unmount(this.component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJoinNode(
|
||||||
|
view: EditorView,
|
||||||
|
phraseDelimNode: SyntaxNodeRef,
|
||||||
|
): SyntaxNodeRef | null | undefined {
|
||||||
|
const firstPhraseAction = phraseDelimNode.node.nextSibling
|
||||||
|
?.getChild("ActionString")
|
||||||
|
?.node.firstChild?.node.getChild("ExplicitAction");
|
||||||
|
const idNode = firstPhraseAction?.node.getChild("ActionId");
|
||||||
|
const actionId = idNode
|
||||||
|
? view.state.doc.sliceString(idNode.from, idNode.to)
|
||||||
|
: null;
|
||||||
|
const isJoinAction =
|
||||||
|
actionId === "JOIN" &&
|
||||||
|
!!firstPhraseAction!.node.getChild("ExplicitDelimEnd");
|
||||||
|
return isJoinAction ? firstPhraseAction : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionWidgets(view: EditorView) {
|
||||||
|
const widgets: Range<Decoration>[] = [];
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (node) => {
|
||||||
|
if (node.name !== "PhraseDelim") return;
|
||||||
|
const joinNode = getJoinNode(view, node);
|
||||||
|
|
||||||
|
let deco = Decoration.replace({
|
||||||
|
widget: new DelimWidget(!joinNode),
|
||||||
|
});
|
||||||
|
widgets.push(deco.range(node.from, node.to));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Decoration.set(widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delimPlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations = Decoration.none;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = actionWidgets(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||||
|
)
|
||||||
|
this.decorations = actionWidgets(update.view);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(instance) {
|
||||||
|
return instance.decorations;
|
||||||
|
},
|
||||||
|
provide(plugin) {
|
||||||
|
return EditorView.atomicRanges.of(
|
||||||
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
eventHandlers: {
|
||||||
|
click: (event, view) => {
|
||||||
|
if (!(event.target instanceof HTMLElement)) return;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
event.target instanceof HTMLButtonElement ||
|
||||||
|
(event.target as HTMLElement).parentElement instanceof
|
||||||
|
HTMLButtonElement
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const chordNode = syntaxTree(view.state).resolve(
|
||||||
|
view.posAtDOM(event.target),
|
||||||
|
);
|
||||||
|
const delimNode = (
|
||||||
|
chordNode.name === "ActionString"
|
||||||
|
? chordNode.parent?.parent
|
||||||
|
: chordNode
|
||||||
|
)?.getChild("PhraseDelim");
|
||||||
|
if (!delimNode) return;
|
||||||
|
const joinNode = getJoinNode(view, delimNode);
|
||||||
|
if (!event.target.checked && !joinNode) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: delimNode.to,
|
||||||
|
insert: "<JOIN>",
|
||||||
|
},
|
||||||
|
selection: { anchor: delimNode.to + "<JOIN>".length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
57
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
57
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { parser } from "./chords.grammar";
|
||||||
|
import {
|
||||||
|
LRLanguage,
|
||||||
|
LanguageSupport,
|
||||||
|
HighlightStyle,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { styleTags, tags } from "@lezer/highlight";
|
||||||
|
import { actionAutocomplete } from "./autocomplete";
|
||||||
|
|
||||||
|
export const chordHighlightStyle = HighlightStyle.define([
|
||||||
|
{
|
||||||
|
tag: tags.keyword,
|
||||||
|
paddingInline: "2px",
|
||||||
|
opacity: "0.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: tags.className,
|
||||||
|
backgroundColor:
|
||||||
|
"color-mix(in srgb, var(--md-sys-color-surface-variant) 50%, transparent)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
paddingInline: "4px",
|
||||||
|
marginInline: "-4px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: tags.integer,
|
||||||
|
color: "var(--md-sys-color-tertiary)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: tags.angleBracket,
|
||||||
|
opacity: "0.5",
|
||||||
|
},
|
||||||
|
{ tag: tags.modifier, opacity: "0.25" },
|
||||||
|
{ tag: tags.escape, color: "var(--md-sys-color-primary)" },
|
||||||
|
{ tag: tags.strong, fontWeight: "bold" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const chordLanguage = LRLanguage.define({
|
||||||
|
name: "chords",
|
||||||
|
parser: parser.configure({
|
||||||
|
props: [
|
||||||
|
styleTags({
|
||||||
|
"PhraseDelim CompoundDelim": [tags.keyword, tags.strong],
|
||||||
|
"HexNumber DecimalNumber": [tags.className, tags.integer],
|
||||||
|
"ExplicitDelimStart ExplicitDelimEnd": tags.angleBracket,
|
||||||
|
ActionId: tags.className,
|
||||||
|
EscapedLetter: tags.escape,
|
||||||
|
Escape: [tags.escape, tags.modifier],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function chordLanguageSupport() {
|
||||||
|
return new LanguageSupport(chordLanguage, [
|
||||||
|
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
27
src/lib/chord-editor/chords.grammar
Normal file
27
src/lib/chord-editor/chords.grammar
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@top Program { Chord* }
|
||||||
|
|
||||||
|
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
|
||||||
|
EscapedSingleAction { Escape EscapedLetter }
|
||||||
|
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||||
|
ActionString { Action* }
|
||||||
|
ChordInput { (ActionString CompoundDelim)* ActionString }
|
||||||
|
ChordPhrase { ActionString }
|
||||||
|
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||||
|
|
||||||
|
@tokens {
|
||||||
|
@precedence {HexNumber, DecimalNumber}
|
||||||
|
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||||
|
@precedence {EscapedLetter}
|
||||||
|
ExplicitDelimStart {"<"}
|
||||||
|
ExplicitDelimEnd {">"}
|
||||||
|
CompoundDelim {"+>"}
|
||||||
|
PhraseDelim {"=>"}
|
||||||
|
Escape { "\\" }
|
||||||
|
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||||
|
DecimalNumber { $[0-9]+ }
|
||||||
|
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||||
|
SingleLetter { ![\\] }
|
||||||
|
EscapedLetter { ![] }
|
||||||
|
ChordDelim { ($[\n] | @eof) }
|
||||||
|
}
|
||||||
|
|
||||||
13
src/lib/chord-editor/concatenator-button.module.scss
Normal file
13
src/lib/chord-editor/concatenator-button.module.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.concatenator-button {
|
||||||
|
display: inline;
|
||||||
|
opacity: calc(var(--auto-space-show, 0) * 0.7);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
> :global(kbd) {
|
||||||
|
outline: 1px dashed var(--md-sys-color-outline);
|
||||||
|
outline-offset: -1px;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
declare module "*.grammar" {
|
||||||
|
export const parser: import("@lezer/lr").LRParser;
|
||||||
|
}
|
||||||
16
src/lib/chord-editor/test.txt
Normal file
16
src/lib/chord-editor/test.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.=<LEFT_SHIFT> => =>
|
||||||
|
;ims => <0x219><IMPULSE>
|
||||||
|
-;<KSC_2C><LEFT_SHIFT> => <0x23e>_<0x23e>
|
||||||
|
.;g => <0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||||
|
'dg => <0x23e>'<0x23e>
|
||||||
|
'gl => <0x23e>'ll<0x23e>
|
||||||
|
'ar => <0x23e>'re<0x23e>
|
||||||
|
'gs => <0x23e>'s<0x23e>
|
||||||
|
'ev => <0x23e>'ve<0x23e>
|
||||||
|
<SPACE>-; => <0x23e><0x223>-<0x223><KSC_00>
|
||||||
|
<SPACE>;<LEFT_SHIFT> => <0x23e><0x223><0x23d><0x223><KSC_00>
|
||||||
|
<SPACE>;g => <0x23e><0x223><SPACE><0x223><KSC_00>
|
||||||
|
deg => <0x23e>ed<0x23e>
|
||||||
|
;gr => <0x23e>er<0x23e>
|
||||||
|
;es => <0x23e>es<0x23e>
|
||||||
|
;est => <0x23e>est<0x23e>
|
||||||
@@ -1,84 +1,219 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import {action as title} from "$lib/title"
|
import { osLayout } from "$lib/os-layout";
|
||||||
import {osLayout} from "$lib/os-layout"
|
import { isVerbose } from "./verbose-action";
|
||||||
import LL from "../../i18n/i18n-svelte"
|
import { actionTooltip } from "$lib/title";
|
||||||
|
|
||||||
export let action: number | KeyInfo
|
let {
|
||||||
export let display: "inline-keys" | "keys" = "inline-keys"
|
action,
|
||||||
|
display,
|
||||||
|
inText = false,
|
||||||
|
}: {
|
||||||
|
action: string | number | KeyInfo;
|
||||||
|
display: "inline-keys" | "keys" | "verbose";
|
||||||
|
inText?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
$: info = typeof action === "number" ? KEYMAP_CODES[action] ?? {code: action} : action
|
let retrievedInfo = $derived(
|
||||||
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode)
|
typeof action === "number"
|
||||||
|
? $KEYMAP_CODES.get(action)
|
||||||
$: tooltip =
|
: typeof action === "string"
|
||||||
(info.title ?? info.id ?? `0x${info.code.toString(16)}`) +
|
? $KEYMAP_IDS.get(action)
|
||||||
(info.variant === "left" ? " (left)" : info.variant === "right" ? " (right)" : "")
|
: action,
|
||||||
|
);
|
||||||
|
let info = $derived(
|
||||||
|
retrievedInfo ??
|
||||||
|
(typeof action === "number"
|
||||||
|
? ({ code: action } satisfies KeyInfo)
|
||||||
|
: typeof action === "string"
|
||||||
|
? ({ code: 1024, id: action } satisfies KeyInfo)
|
||||||
|
: action),
|
||||||
|
);
|
||||||
|
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||||
|
let hasPopover = $derived(
|
||||||
|
!retrievedInfo || !info.id || info.title || info.description,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dynamicMapping}
|
{#snippet popover()}
|
||||||
<span
|
{#if retrievedInfo}
|
||||||
use:title={{title: $LL.actionSearch.LIVE_LAYOUT_INFO()}}
|
{#if info.icon || info.display || !info.id}
|
||||||
class="dynamic"
|
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||||
class:left={info.variant === "left"}
|
{/if}
|
||||||
class:right={info.variant === "right"}
|
{#if info.title}
|
||||||
class:inline={display === "inline-keys"}>{dynamicMapping}</span
|
{info.title}
|
||||||
>
|
{/if}
|
||||||
{:else if display === "keys"}
|
{#if info.variant === "left"}
|
||||||
|
(Left)
|
||||||
|
{:else if info.variant === "right"}
|
||||||
|
(Right)
|
||||||
|
{/if}
|
||||||
|
{#if info.description}
|
||||||
|
<br />
|
||||||
|
<small>{info.description}</small>
|
||||||
|
{/if}
|
||||||
|
{#if info.breaking}
|
||||||
|
<br /> <i>Prevents prepended autospaces</i>
|
||||||
|
{/if}
|
||||||
|
{#if info.separator || info.breaking}
|
||||||
|
<br /> <i>Stops autocorrect</i>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<b>Unknown Action</b><br />
|
||||||
|
{#if info.code > 1023}
|
||||||
|
This action cannot be translated and will be ingored.
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet kbdText()}
|
||||||
|
{dynamicMapping ??
|
||||||
|
info.icon ??
|
||||||
|
info.display ??
|
||||||
|
info.id ??
|
||||||
|
`0x${info.code.toString(16)}`}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet kbdSnippet(withPopover = true)}
|
||||||
<kbd
|
<kbd
|
||||||
|
class:in-text={inText}
|
||||||
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}}
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
|
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
|
||||||
>
|
>
|
||||||
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
|
{@render kbdText()}
|
||||||
</kbd>
|
</kbd>
|
||||||
{:else if display === "inline-keys"}
|
{/snippet}
|
||||||
{#if !info.icon && info.id?.length === 1}
|
{#snippet inlineKbdSnippet()}
|
||||||
<span class:left={info.variant === "left"} class:right={info.variant === "right"}>{info.id}</span>
|
{#if !info.icon && dynamicMapping?.length === 1}
|
||||||
|
<span
|
||||||
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
|
class:in-text={inText}
|
||||||
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
|
class:left={info.variant === "left"}
|
||||||
|
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||||
|
>
|
||||||
|
{:else if !info.icon && info.id?.length === 1}
|
||||||
|
<span
|
||||||
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
|
class:in-text={inText}
|
||||||
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
|
class:left={info.variant === "left"}
|
||||||
|
class:right={info.variant === "right"}>{info.id}</span
|
||||||
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<kbd
|
<kbd
|
||||||
class="inline-kbd"
|
class="inline-kbd"
|
||||||
|
class:in-text={inText}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
use:title={{title: tooltip}}
|
class:warn={!retrievedInfo}
|
||||||
>
|
class:error={info.code > 1023}
|
||||||
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}</kbd
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
>
|
>
|
||||||
|
{@render kbdText()}
|
||||||
|
</kbd>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if display === "keys"}
|
||||||
|
{@render kbdSnippet()}
|
||||||
|
{:else if display === "verbose"}
|
||||||
|
{#if isVerbose(info)}
|
||||||
|
<div class="verbose" {@attach hasPopover ? actionTooltip(popover) : null}>
|
||||||
|
{@render kbdSnippet(false)}
|
||||||
|
<div class="verbose-title">{info.title}</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render inlineKbdSnippet()}
|
||||||
|
{/if}
|
||||||
|
{:else if display === "inline-keys" || display === "inline-text"}
|
||||||
|
{@render inlineKbdSnippet()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
kbd:not(.inline-kbd) {
|
kbd:not(.inline-kbd) {
|
||||||
height: 24px;
|
|
||||||
padding-block: auto;
|
|
||||||
transition: color 250ms ease;
|
transition: color 250ms ease;
|
||||||
|
padding-block: auto;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
&.in-text {
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-block: auto;
|
||||||
|
padding-block: revert;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warn:not(.error) {
|
||||||
|
border-color: var(--md-sys-color-error);
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
opacity: 0.6;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant-offset: 12px;
|
||||||
|
$variant-padding: calc(2px + $variant-offset);
|
||||||
|
$variant-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--md-sys-color-on-surface) 50%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
border-left-width: 3px;
|
padding-inline-end: $variant-padding;
|
||||||
|
text-shadow: $variant-offset 0 2px $variant-color;
|
||||||
}
|
}
|
||||||
.right {
|
.right {
|
||||||
border-right-width: 3px;
|
padding-inline-start: $variant-padding;
|
||||||
}
|
text-shadow: -$variant-offset 0 2px $variant-color;
|
||||||
|
|
||||||
.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;
|
||||||
|
|
||||||
|
&.in-text.icon {
|
||||||
|
translate: 0 -4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(span) + .inline-kbd {
|
:global(span) + .inline-kbd {
|
||||||
margin-inline-start: 2px;
|
margin-inline-start: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.verbose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-inline: 2px;
|
||||||
|
min-width: 160px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verbose-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
opacity: 0.9;
|
||||||
|
max-width: 15ch;
|
||||||
|
-webkit-line-clamp: 2; /* number of lines to show */
|
||||||
|
line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import LL from "../../i18n/i18n-svelte"
|
import LL from "$i18n/i18n-svelte";
|
||||||
import Action from "$lib/components/Action.svelte"
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import type { MouseEventHandler } from "svelte/elements";
|
||||||
|
|
||||||
export let id: number | KeyInfo
|
let {
|
||||||
|
id,
|
||||||
|
onclick,
|
||||||
|
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
|
||||||
|
$props();
|
||||||
|
|
||||||
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
|
let key = $derived(
|
||||||
|
(typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
|
||||||
|
| number
|
||||||
|
| KeyInfo,
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button on:click>
|
<button {onclick}>
|
||||||
{#if typeof key === "object"}
|
{#if typeof key === "object"}
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<b>
|
<b>
|
||||||
@@ -23,10 +32,10 @@
|
|||||||
{#if key.description}
|
{#if key.description}
|
||||||
<i>{key.description}</i>
|
<i>{key.description}</i>
|
||||||
{/if}
|
{/if}
|
||||||
{#if key.category.name === "ASCII Macros"}
|
{#if key.category?.name === "ASCII Macros"}
|
||||||
<span class="warning">{@html $LL.actionSearch.SHIFT_WARNING()}</span>
|
<span class="warning">{@html $LL.actionSearch.SHIFT_WARNING()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if key.category.name === "CP-1252"}
|
{#if key.category?.name === "CP-1252"}
|
||||||
<span class="warning">{@html $LL.actionSearch.ALT_CODE_WARNING()}</span>
|
<span class="warning">{@html $LL.actionSearch.ALT_CODE_WARNING()}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -39,25 +48,37 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
button {
|
button {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0;
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
background: transparent;
|
@media not (forced-colors: active) {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
&:focus-visible {
|
background: transparent;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
color: inherit;
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
outline: none;
|
&:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
margin-block: 4px;
|
||||||
|
border: 1px solid ButtonBorder;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: ActiveText;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +86,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
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 } 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 {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
transition: width 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digit-wrapper {
|
||||||
|
display: inline-grid;
|
||||||
|
width: 1ch;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digit {
|
||||||
|
display: inline-block;
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 1;
|
||||||
|
transition:
|
||||||
|
transform 500ms ease,
|
||||||
|
opacity 500ms ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import {useRegisterSW} from "virtual:pwa-register/svelte"
|
// @ts-expect-error no types here
|
||||||
|
import { useRegisterSW } from "virtual:pwa-register/svelte";
|
||||||
|
|
||||||
const {needRefresh, updateServiceWorker, offlineReady} = useRegisterSW()
|
const { needRefresh, updateServiceWorker, offlineReady } = useRegisterSW();
|
||||||
</script>
|
</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}
|
||||||
@@ -15,8 +16,8 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--md-sys-color-on-background);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {serialLog, serialPort} from "$lib/serial/connection"
|
import { serialLog, serialPort } from "$lib/serial/connection";
|
||||||
import {slide} from "svelte/transition"
|
import { onMount } from "svelte";
|
||||||
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
io.scrollTo({ top: io.scrollHeight });
|
||||||
|
});
|
||||||
|
|
||||||
function submit(event: Event) {
|
function submit(event: Event) {
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
$serialPort.send(value.trim())
|
$serialPort?.send(0, [value.trim()]);
|
||||||
value = ""
|
value = "";
|
||||||
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,72 +29,70 @@
|
|||||||
<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>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
form {
|
form {
|
||||||
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
contain: strict;
|
contain: strict;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
border-radius: 16px;
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset::before {
|
fieldset::before {
|
||||||
content: "$";
|
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
content: "$";
|
||||||
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
appearance: none;
|
||||||
margin-block-start: -16px;
|
margin-block-start: -16px;
|
||||||
|
border: none;
|
||||||
|
background: var(--md-sys-color-secondary);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
padding-block-start: 24px;
|
|
||||||
padding-inline-start: calc(8px + 1.5ch);
|
padding-inline-start: calc(8px + 1.5ch);
|
||||||
|
padding-block-start: 24px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
|
|
||||||
appearance: none;
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.io {
|
.io {
|
||||||
--scrollbar-color: var(--md-sys-color-secondary);
|
--scrollbar-color: var(--md-sys-color-secondary);
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
|
||||||
overflow-y: auto;
|
background: var(--md-sys-color-secondary-container);
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
overflow-y: auto;
|
||||||
|
|
||||||
background: var(--md-sys-color-secondary-container);
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
border-radius: 0 0 16px 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@@ -99,10 +102,10 @@
|
|||||||
fieldset {
|
fieldset {
|
||||||
all: unset;
|
all: unset;
|
||||||
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|
||||||
transition: opacity 250ms ease;
|
transition: opacity 250ms ease;
|
||||||
@@ -113,16 +116,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.anchor {
|
.anchor {
|
||||||
overflow-anchor: auto;
|
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
overflow-anchor: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
code,
|
||||||
samp,
|
samp,
|
||||||
p {
|
p {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-anchor: none;
|
|
||||||
margin-block: 0.15rem;
|
margin-block: 0.15rem;
|
||||||
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -130,24 +133,24 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
margin-block-end: 1rem;
|
margin-block-end: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-secondary);
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code::before {
|
code::before {
|
||||||
content: "> ";
|
|
||||||
margin-block-end: 0.25rem;
|
margin-block-end: 0.25rem;
|
||||||
font-weight: 900;
|
content: "> ";
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
color: var(--md-sys-color-background);
|
|
||||||
background: var(--md-sys-color-on-background);
|
background: var(--md-sys-color-on-background);
|
||||||
|
color: var(--md-sys-color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let title: string | undefined
|
import type { Snippet } from "svelte";
|
||||||
export let shortcut: string | undefined
|
|
||||||
|
let { title, shortcut }: { title?: string | Snippet; shortcut?: string } =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if title}
|
{#if typeof title === "string"}
|
||||||
<p>{@html title}</p>
|
<p>{@html title}</p>
|
||||||
|
{:else}
|
||||||
|
{@render title?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if shortcut}
|
{#if shortcut}
|
||||||
@@ -18,5 +22,11 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
p {
|
p {
|
||||||
margin-block: 0;
|
margin-block: 0;
|
||||||
|
|
||||||
|
:global(kbd.icon) {
|
||||||
|
display: inline-flex;
|
||||||
|
translate: 0 0.2em;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
357
src/lib/components/layout/ActionList.svelte
Normal file
357
src/lib/components/layout/ActionList.svelte
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
KEYMAP_CATEGORIES,
|
||||||
|
KEYMAP_CODES,
|
||||||
|
KEYMAP_IDS,
|
||||||
|
type KeyInfo,
|
||||||
|
} from "$lib/serial/keymap-codes";
|
||||||
|
import FlexSearch from "flexsearch";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||||
|
import LL from "$i18n/i18n-svelte";
|
||||||
|
import { actionTooltip } from "$lib/title";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import type { KeymapCategory } from "$lib/meta/types/actions";
|
||||||
|
import Action from "../Action.svelte";
|
||||||
|
import { isVerbose } from "../verbose-action";
|
||||||
|
import { actionToValue } from "$lib/chord-editor/action-serializer";
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentAction = undefined,
|
||||||
|
nextAction = undefined,
|
||||||
|
autofocus = false,
|
||||||
|
onselect,
|
||||||
|
onclose,
|
||||||
|
}: {
|
||||||
|
currentAction?: number;
|
||||||
|
nextAction?: number;
|
||||||
|
autofocus?: boolean;
|
||||||
|
onselect?: (id: number) => void;
|
||||||
|
onclose?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
search();
|
||||||
|
if (autofocus) {
|
||||||
|
searchBox.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
createIndex($KEYMAP_CODES);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||||
|
for (const [, action] of codes) {
|
||||||
|
await index?.addAsync(
|
||||||
|
action.code,
|
||||||
|
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||||
|
action.description || ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const groups = new Map(
|
||||||
|
$KEYMAP_CATEGORIES.map(
|
||||||
|
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result =
|
||||||
|
searchBox.value === ""
|
||||||
|
? Array.from($KEYMAP_CODES.keys())
|
||||||
|
: await index!.searchAsync(searchBox.value);
|
||||||
|
for (const id of result) {
|
||||||
|
const action = $KEYMAP_CODES.get(id as number);
|
||||||
|
if (action?.category) {
|
||||||
|
groups.get(action.category)?.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortValue(action: KeyInfo): number {
|
||||||
|
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
|
||||||
|
}
|
||||||
|
for (const actions of groups.values()) {
|
||||||
|
actions.sort((a, b) => sortValue(b) - sortValue(a));
|
||||||
|
}
|
||||||
|
results = groups;
|
||||||
|
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||||
|
code = Number(searchBox.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(id?: number) {
|
||||||
|
if (id !== undefined) {
|
||||||
|
onselect?.(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardNavigation(event: KeyboardEvent) {
|
||||||
|
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||||
|
onselect?.(exact);
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
const element =
|
||||||
|
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||||
|
resultList.querySelector("li:not(.exact)");
|
||||||
|
if (element instanceof HTMLLIElement) {
|
||||||
|
element.querySelector("button")?.focus();
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
const element =
|
||||||
|
resultList.querySelector("li:focus-within")?.previousSibling ??
|
||||||
|
resultList.querySelector("li:not(.exact)");
|
||||||
|
if (element instanceof HTMLLIElement) {
|
||||||
|
element.querySelector("button")?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchBox.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
|
||||||
|
let exact: number | undefined = $state(undefined);
|
||||||
|
let code: number = $state(Number.NaN);
|
||||||
|
|
||||||
|
let searchBox: HTMLInputElement;
|
||||||
|
let resultList: HTMLUListElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="search-row">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:this={searchBox}
|
||||||
|
oninput={search}
|
||||||
|
onkeypress={keyboardNavigation}
|
||||||
|
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||||
|
/>
|
||||||
|
{#if onclose}
|
||||||
|
<button onclick={() => select(0)} {@attach actionTooltip("", "shift+esc")}
|
||||||
|
>{$LL.actionSearch.DELETE()}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{@attach actionTooltip($LL.modal.CLOSE(), "esc")}
|
||||||
|
class="icon"
|
||||||
|
onclick={onclose}>close</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if currentAction !== undefined}
|
||||||
|
<aside>
|
||||||
|
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||||
|
<ActionListItem id={currentAction} />
|
||||||
|
</aside>
|
||||||
|
{#if nextAction}
|
||||||
|
<aside>
|
||||||
|
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
||||||
|
<ActionListItem id={nextAction} />
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<ul bind:this={resultList}>
|
||||||
|
{#if exact !== undefined}
|
||||||
|
<li class="exact">
|
||||||
|
<i>Exact match</i>
|
||||||
|
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if !exact && code}
|
||||||
|
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||||
|
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||||
|
{:else}
|
||||||
|
<li>Action code is out of range</li>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#each results as [category, actions] (category)}
|
||||||
|
{#if actions.length > 0}
|
||||||
|
<div class="category">
|
||||||
|
<h3>{category.name}</h3>
|
||||||
|
<div class="description">{category.description}</div>
|
||||||
|
<ul>
|
||||||
|
{#each actions as action (action.code)}
|
||||||
|
<button
|
||||||
|
class="action-item"
|
||||||
|
draggable={!onclose}
|
||||||
|
onclick={() => select(action.code)}
|
||||||
|
ondragstart={onclose === undefined
|
||||||
|
? (event) => {
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
event.dataTransfer.clearData();
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
"text/plain",
|
||||||
|
actionToValue(action.code),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<Action {action} display="verbose"></Action>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.action-item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
opacity: 0.4;
|
||||||
|
|
||||||
|
margin: 8px;
|
||||||
|
border: 1px dashed var(--md-sys-color-outline);
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
> h3 {
|
||||||
|
margin-inline-start: 16px;
|
||||||
|
margin-block-start: -13px;
|
||||||
|
margin-block-end: 0;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
padding-inline: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
opacity: 1;
|
||||||
|
color: GrayText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
transform-origin: top left;
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
|
||||||
|
width: calc(min(30cm, 90%));
|
||||||
|
height: calc(min(100% - 128px, 90%));
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
border: 1px solid CanvasText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"] {
|
||||||
|
transition: all 250ms ease;
|
||||||
|
margin-block-end: 8px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
padding-inline: 16px;
|
||||||
|
width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
color: currentcolor;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
padding-inline: 4px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
scrollbar-gutter: both-edges stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
.description {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-block-start: -16px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-block: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-block-start: 8px;
|
||||||
|
|
||||||
|
border: 1px solid var(--md-sys-color-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> i {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
|
||||||
|
padding-inline: 6px;
|
||||||
|
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
background: Mark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,302 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {KEYMAP_CATEGORIES, KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
import ActionList from "./ActionList.svelte";
|
||||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
|
||||||
import Index from "flexsearch"
|
|
||||||
import {createEventDispatcher} from "svelte"
|
|
||||||
import ActionListItem from "$lib/components/ActionListItem.svelte"
|
|
||||||
import LL from "../../../i18n/i18n-svelte"
|
|
||||||
import {action} from "$lib/title"
|
|
||||||
|
|
||||||
export let currentAction: number | undefined = undefined
|
let {
|
||||||
export let nextAction: number | undefined = undefined
|
currentAction = undefined,
|
||||||
|
nextAction = undefined,
|
||||||
const index = new Index({tokenize: "full"})
|
onselect,
|
||||||
for (const action of Object.values(KEYMAP_CODES)) {
|
onclose,
|
||||||
index?.add(
|
}: {
|
||||||
action.code,
|
currentAction?: number;
|
||||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
nextAction?: number;
|
||||||
action.description || ""
|
onselect: (id: number) => void;
|
||||||
}`,
|
onclose: () => void;
|
||||||
)
|
} = $props();
|
||||||
}
|
|
||||||
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
|
|
||||||
Object.values(KEYMAP_CODES)
|
|
||||||
.filter(it => !!it.id)
|
|
||||||
.map(it => [it.id, it] as const),
|
|
||||||
)
|
|
||||||
|
|
||||||
function search() {
|
|
||||||
results = index!.search(searchBox.value)
|
|
||||||
exact = exactIndex[searchBox.value]?.code
|
|
||||||
code = Number(searchBox.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
function select(id?: number) {
|
|
||||||
if (id !== undefined) {
|
|
||||||
dispatch("select", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyboardNavigation(event: KeyboardEvent) {
|
|
||||||
if (event.shiftKey && event.key === "Enter") {
|
|
||||||
dispatch("select", exact)
|
|
||||||
} else if (event.key === "ArrowDown") {
|
|
||||||
const element =
|
|
||||||
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
|
|
||||||
if (element instanceof HTMLLIElement) {
|
|
||||||
element.querySelector("button")?.focus()
|
|
||||||
}
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
const element =
|
|
||||||
resultList.querySelector("li:focus-within")?.previousSibling ??
|
|
||||||
resultList.querySelector("li:not(.exact)")
|
|
||||||
if (element instanceof HTMLLIElement) {
|
|
||||||
element.querySelector("button")?.focus()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
searchBox.focus()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
|
|
||||||
let results: number[] = Object.keys(KEYMAP_CODES).map(Number)
|
|
||||||
let exact: number | undefined = undefined
|
|
||||||
let code: number = Number.NaN
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
|
||||||
let searchBox: HTMLInputElement
|
|
||||||
let resultList: HTMLUListElement
|
|
||||||
let filter: Set<number>
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={keyboardNavigation} />
|
<dialog
|
||||||
|
open
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
onclick={(event) => {
|
||||||
<dialog open on:click|self={() => dispatch("close")}>
|
if (event.target === event.currentTarget) onclose();
|
||||||
<div class="content">
|
}}
|
||||||
<div class="search-row">
|
>
|
||||||
<input
|
<ActionList
|
||||||
type="search"
|
autofocus={true}
|
||||||
bind:this={searchBox}
|
{currentAction}
|
||||||
on:input={search}
|
{nextAction}
|
||||||
on:keypress={event => {
|
{onselect}
|
||||||
if (event.key === "Enter") {
|
{onclose}
|
||||||
select(exact)
|
/>
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
|
||||||
/>
|
|
||||||
<button on:click={() => select(0)} use:action={{shortcut: "shift+esc"}}
|
|
||||||
>{$LL.actionSearch.DELETE()}</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
use:action={{title: $LL.modal.CLOSE(), shortcut: "esc"}}
|
|
||||||
class="icon"
|
|
||||||
on:click={() => dispatch("close")}>close</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<fieldset class="filters">
|
|
||||||
<label
|
|
||||||
>{$LL.actionSearch.filter.ALL()}<input
|
|
||||||
checked
|
|
||||||
name="category"
|
|
||||||
type="radio"
|
|
||||||
value={undefined}
|
|
||||||
bind:group={filter}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
{#each KEYMAP_CATEGORIES as category}
|
|
||||||
<label
|
|
||||||
>{category.name}<input
|
|
||||||
name="category"
|
|
||||||
type="radio"
|
|
||||||
value={new Set(Object.keys(category.actions).map(Number))}
|
|
||||||
bind:group={filter}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</fieldset>
|
|
||||||
{#if currentAction !== undefined}
|
|
||||||
<aside>
|
|
||||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
|
||||||
<ActionListItem id={currentAction} />
|
|
||||||
</aside>
|
|
||||||
{#if nextAction}
|
|
||||||
<aside>
|
|
||||||
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
|
||||||
<ActionListItem id={nextAction} />
|
|
||||||
</aside>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<ul bind:this={resultList}>
|
|
||||||
{#if exact !== undefined}
|
|
||||||
<li class="exact">
|
|
||||||
<i>Exact match</i>
|
|
||||||
<ActionListItem id={exact} on:click={() => select(exact)} />
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if !exact && code}
|
|
||||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
|
||||||
<li><button on:click={() => select(code)}>USE CODE</button></li>
|
|
||||||
{:else}
|
|
||||||
<li>Action code is out of range</li>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#each filter ? results.filter(it => filter.has(it)) : results as id (id)}
|
|
||||||
<li><ActionListItem {id} on:click={() => select(id)} /></li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
label {
|
|
||||||
height: unset;
|
|
||||||
padding-block: 2px;
|
|
||||||
padding-inline: 4px;
|
|
||||||
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
border: 1px solid currentcolor;
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
&:has(:checked) {
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
width: 100%;
|
border: none;
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
background: rgba(0 0 0 / 60%);
|
background: rgba(0 0 0 / 60%);
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside {
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
margin: 8px;
|
|
||||||
|
|
||||||
opacity: 0.4;
|
|
||||||
border: 1px dashed var(--md-sys-color-outline);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
> h3 {
|
|
||||||
width: fit-content;
|
|
||||||
margin-block-start: -13px;
|
|
||||||
margin-block-end: 0;
|
|
||||||
margin-inline-start: 16px;
|
|
||||||
padding-inline: 8px;
|
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
margin-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
|
||||||
transform-origin: top left;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: calc(min(30cm, 90%));
|
|
||||||
height: calc(min(100% - 128px, 90%));
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-background);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 64px;
|
|
||||||
margin-block-end: 8px;
|
|
||||||
padding-inline: 16px;
|
|
||||||
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 16px;
|
|
||||||
color: currentcolor;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
|
||||||
|
|
||||||
transition: all 250ms ease;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
|
||||||
|
|
||||||
scrollbar-gutter: both-edges stable;
|
|
||||||
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
padding-inline: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exact {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
margin-block-start: 8px;
|
|
||||||
|
|
||||||
border: 1px solid var(--md-sys-color-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
> i {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
padding-inline: 6px;
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,177 +1,195 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {compileLayout} from "$lib/serialization/visual-layout"
|
import { deviceLayout } from "$lib/serial/connection";
|
||||||
import type {VisualLayout, CompiledLayoutKey} from "$lib/serialization/visual-layout"
|
import { dev } from "$app/environment";
|
||||||
import {deviceLayout} from "$lib/serial/connection"
|
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||||
import {dev} from "$app/environment"
|
import { get } from "svelte/store";
|
||||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
|
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||||
import {get} from "svelte/store"
|
import { getContext, mount, unmount } from "svelte";
|
||||||
import type {Writable} from "svelte/store"
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte"
|
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||||
import {getContext} from "svelte"
|
import { fly } from "svelte/transition";
|
||||||
import type {VisualLayoutConfig} from "./visual-layout.js"
|
import { expoOut } from "svelte/easing";
|
||||||
import {changes, ChangeType, layout} from "$lib/undo-redo"
|
import { activeLayer, activeProfile } from "$lib/serial/connection";
|
||||||
|
import type {
|
||||||
|
CompiledLayout,
|
||||||
|
CompiledLayoutKey,
|
||||||
|
} from "$lib/assets/layouts/layout.d.ts";
|
||||||
|
|
||||||
const {scale, margin, strokeWidth, fontSize, iconFontSize} =
|
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
|
||||||
getContext<VisualLayoutConfig>("visual-layout-config")
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer")
|
|
||||||
|
|
||||||
if (dev) {
|
if (dev) {
|
||||||
// you have absolutely no idea what a difference this makes for performance
|
// you have absolutely no idea what a difference this makes for performance
|
||||||
console.assert(scale % 1 === 0, "Scale must be an integer")
|
console.assert(scale % 1 === 0, "Scale must be an integer");
|
||||||
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2")
|
console.assert((scale / 2) % 1 === 0, "Scale must be divisible by 2");
|
||||||
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer")
|
console.assert(strokeWidth % 1 === 0, "Stroke must be an integer");
|
||||||
console.assert(margin % 1 === 0, "Margin must be an integer")
|
console.assert(margin % 1 === 0, "Margin must be an integer");
|
||||||
console.assert(fontSize % 1 === 0, "Font size must be an integer")
|
console.assert(fontSize % 1 === 0, "Font size must be an integer");
|
||||||
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 { layoutInfo }: { layoutInfo: CompiledLayout } = $props();
|
||||||
$: layoutInfo = compileLayout(visualLayout)
|
|
||||||
|
|
||||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]
|
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
|
function getDistance(a: CompiledLayoutKey, b: CompiledLayoutKey) {
|
||||||
const x1 = a.pos[0] + margin
|
const x1 = a.pos[0] + margin;
|
||||||
const y1 = a.pos[1] + margin
|
const y1 = a.pos[1] + margin;
|
||||||
const x1b = x1 + a.size[0] - margin
|
const x1b = x1 + a.size[0] - margin;
|
||||||
const y1b = y1 + a.size[1] - margin
|
const y1b = y1 + a.size[1] - margin;
|
||||||
const x2 = b.pos[0] + margin
|
const x2 = b.pos[0] + margin;
|
||||||
const y2 = b.pos[1] + margin
|
const y2 = b.pos[1] + margin;
|
||||||
const x2b = x2 + b.size[0] - margin
|
const x2b = x2 + b.size[0] - margin;
|
||||||
const y2b = y2 + b.size[1] - margin
|
const y2b = y2 + b.size[1] - margin;
|
||||||
|
|
||||||
const left = x2b < x1
|
const left = x2b < x1;
|
||||||
const right = x1b < x2
|
const right = x1b < x2;
|
||||||
const bottom = y2b < y1
|
const bottom = y2b < y1;
|
||||||
const top = y1b < y2
|
const top = y1b < y2;
|
||||||
|
|
||||||
return top && left
|
return top && left
|
||||||
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
|
? Math.sqrt((x1 - x2b) ** 2 + (y1b - y2) ** 2)
|
||||||
: left && bottom
|
: left && bottom
|
||||||
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
|
? Math.sqrt((x1 - x2b) ** 2 + (y1 - y2b) ** 2)
|
||||||
: bottom && right
|
: bottom && right
|
||||||
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
|
? Math.sqrt((x1b - x2) ** 2 + (y1 - y2b) ** 2)
|
||||||
: right && top
|
: right && top
|
||||||
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
|
? Math.sqrt((x1b - x2) ** 2 + (y1b - y2) ** 2)
|
||||||
: left
|
: left
|
||||||
? x1 - x2b
|
? x1 - x2b
|
||||||
: right
|
: right
|
||||||
? x2 - x1b
|
? x2 - x1b
|
||||||
: bottom
|
: bottom
|
||||||
? y1 - y2b
|
? y1 - y2b
|
||||||
: top
|
: top
|
||||||
? y2 - y1b
|
? y2 - y1b
|
||||||
: 0
|
: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function navigate(event: KeyboardEvent) {
|
function navigate(event: KeyboardEvent) {
|
||||||
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey) return
|
if (event.altKey || event.ctrlKey || event.shiftKey || event.metaKey)
|
||||||
|
return;
|
||||||
|
|
||||||
let wantedAngle: number
|
let wantedAngle: number;
|
||||||
const angleThreshold = Math.PI
|
const angleThreshold = Math.PI;
|
||||||
|
|
||||||
if (event.key === "ArrowUp") wantedAngle = Math.PI
|
if (event.key === "ArrowUp") wantedAngle = Math.PI;
|
||||||
else if (event.key === "ArrowDown") wantedAngle = 0
|
else if (event.key === "ArrowDown") wantedAngle = 0;
|
||||||
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2
|
else if (event.key === "ArrowRight") wantedAngle = Math.PI / 2;
|
||||||
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2
|
else if (event.key === "ArrowLeft") wantedAngle = -Math.PI / 2;
|
||||||
else return
|
else return;
|
||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault();
|
||||||
if (!focusKey) (groupParent.firstChild as SVGGElement).focus()
|
if (!focusKey) (groupParent.firstChild as SVGGElement).focus();
|
||||||
const [focusX, focusY] = getCenter(focusKey)
|
const [focusX, focusY] = getCenter(focusKey);
|
||||||
|
|
||||||
let bestDistance = Infinity
|
let bestDistance = Infinity;
|
||||||
let bestCandidate = 0
|
let bestCandidate = 0;
|
||||||
let isOptimalAngle = false
|
let isOptimalAngle = false;
|
||||||
|
|
||||||
for (const [i, key] of layoutInfo.keys.entries()) {
|
for (const [i, key] of layoutInfo.keys.entries()) {
|
||||||
if (key === focusKey) continue
|
if (key === focusKey) continue;
|
||||||
const [keyX, keyY] = getCenter(key)
|
const [keyX, keyY] = getCenter(key);
|
||||||
const deltaX = keyX - focusX
|
const deltaX = keyX - focusX;
|
||||||
const deltaY = keyY - focusY
|
const deltaY = keyY - focusY;
|
||||||
const angle = Math.atan2(deltaX, deltaY)
|
const angle = Math.atan2(deltaX, deltaY);
|
||||||
const distance = getDistance(key, focusKey)
|
const distance = getDistance(key, focusKey);
|
||||||
|
|
||||||
const angleDelta = Math.abs(wantedAngle - angle)
|
const angleDelta = Math.abs(wantedAngle - angle);
|
||||||
|
|
||||||
if (isOptimalAngle ? angleDelta > Number.EPSILON : angleDelta >= angleThreshold) continue
|
if (
|
||||||
if (distance > bestDistance) continue
|
isOptimalAngle
|
||||||
|
? angleDelta > Number.EPSILON
|
||||||
|
: angleDelta >= angleThreshold
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (distance > bestDistance) continue;
|
||||||
|
|
||||||
bestDistance = distance
|
bestDistance = distance;
|
||||||
bestCandidate = i
|
bestCandidate = i;
|
||||||
isOptimalAngle = angleDelta <= Number.EPSILON
|
isOptimalAngle = angleDelta <= Number.EPSILON;
|
||||||
}
|
}
|
||||||
|
|
||||||
const node = groupParent.children.item(bestCandidate)
|
const node = groupParent.children.item(bestCandidate);
|
||||||
if (node instanceof SVGGElement) {
|
if (node instanceof SVGGElement) {
|
||||||
node.focus()
|
node.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function edit(index: number) {
|
function edit(index: number) {
|
||||||
const keyInfo = layoutInfo.keys[index]
|
const keyInfo = layoutInfo.keys[index];
|
||||||
const clickedGroup = groupParent.children.item(index) as SVGGElement
|
if (!keyInfo) return;
|
||||||
const nextAction = get(layout)[get(activeLayer)][keyInfo.id]
|
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||||
const currentAction = get(deviceLayout)[get(activeLayer)][keyInfo.id]
|
const nextAction =
|
||||||
const component = new ActionSelector({
|
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
|
||||||
|
const currentAction =
|
||||||
|
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
|
||||||
|
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),
|
||||||
|
profile: get(activeProfile),
|
||||||
|
action,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
closed();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement
|
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||||
const backdrop = document.querySelector("dialog") as HTMLDialogElement
|
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
|
||||||
const dialogRect = dialog.getBoundingClientRect()
|
const dialogRect = dialog.getBoundingClientRect();
|
||||||
const groupRect = clickedGroup.getBoundingClientRect()
|
const groupRect = clickedGroup.getBoundingClientRect();
|
||||||
|
|
||||||
const scale = 0.5
|
const scale = 0.5;
|
||||||
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
|
const dialogScale = `${1 - scale * (1 - groupRect.width / dialogRect.width)} ${
|
||||||
1 - scale * (1 - groupRect.height / dialogRect.height)
|
1 - scale * (1 - groupRect.height / dialogRect.height)
|
||||||
}`
|
}`;
|
||||||
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
|
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
|
||||||
scale * (groupRect.y - dialogRect.y)
|
scale * (groupRect.y - dialogRect.y)
|
||||||
}px`
|
}px`;
|
||||||
|
|
||||||
const duration = 150
|
const duration = 150;
|
||||||
const options = {duration, easing: "ease"}
|
const options = { duration, easing: "ease" };
|
||||||
const dialogAnimation = dialog.animate(
|
const dialogAnimation = dialog.animate(
|
||||||
[
|
[
|
||||||
{scale: dialogScale, translate: dialogTranslate},
|
{ scale: dialogScale, translate: dialogTranslate },
|
||||||
{translate: "0 0", scale: "1"},
|
{ translate: "0 0", scale: "1" },
|
||||||
],
|
],
|
||||||
options,
|
options,
|
||||||
)
|
);
|
||||||
const backdropAnimation = backdrop.animate([{opacity: 0}, {opacity: 1}], options)
|
const backdropAnimation = backdrop.animate(
|
||||||
|
[{ opacity: 0 }, { opacity: 1 }],
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
async function closed() {
|
async function closed() {
|
||||||
dialogAnimation.reverse()
|
dialogAnimation.reverse();
|
||||||
backdropAnimation.reverse()
|
backdropAnimation.reverse();
|
||||||
|
|
||||||
await dialogAnimation.finished
|
await dialogAnimation.finished;
|
||||||
|
|
||||||
component.$destroy()
|
unmount(component);
|
||||||
}
|
}
|
||||||
|
|
||||||
component.$on("close", closed)
|
|
||||||
component.$on("select", ({detail}) => {
|
|
||||||
changes.update(changes => {
|
|
||||||
changes.push({
|
|
||||||
type: ChangeType.Layout,
|
|
||||||
id: keyInfo.id,
|
|
||||||
layer: get(activeLayer),
|
|
||||||
action: detail,
|
|
||||||
})
|
|
||||||
return changes
|
|
||||||
})
|
|
||||||
closed()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let focusKey: CompiledLayoutKey
|
let focusKey: CompiledLayoutKey;
|
||||||
let groupParent: SVGElement
|
let groupParent: SVGElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={navigate} />
|
<svelte:window on:keydown={navigate} />
|
||||||
@@ -180,16 +198,17 @@
|
|||||||
class="print"
|
class="print"
|
||||||
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
|
viewBox="0 0 {layoutInfo.size[0] * scale} {layoutInfo.size[1] * scale}"
|
||||||
bind:this={groupParent}
|
bind:this={groupParent}
|
||||||
|
transition:fly={{ y: 48, easing: expoOut }}
|
||||||
>
|
>
|
||||||
{#each layoutInfo.keys as key, i}
|
{#each layoutInfo.keys as key, i}
|
||||||
<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);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -198,9 +217,9 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
svg {
|
svg {
|
||||||
overflow: visible;
|
|
||||||
grid-area: "d";
|
grid-area: "d";
|
||||||
width: calc(min(100%, 35cm));
|
width: calc(min(100%, 35cm));
|
||||||
max-height: calc(100% - 170px);
|
max-height: calc(100% - 170px);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,30 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {getContext} from "svelte"
|
import type { Writable } from "svelte/store";
|
||||||
import type {Writable} from "svelte/store"
|
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||||
import type {VisualLayoutConfig} from "$lib/components/layout/visual-layout"
|
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
|
||||||
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
|
import { layout } from "$lib/undo-redo.js";
|
||||||
import {layout} from "$lib/undo-redo.js"
|
import { osLayout } from "$lib/os-layout.js";
|
||||||
import {osLayout} from "$lib/os-layout.js"
|
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
import { actionTooltip } from "$lib/title";
|
||||||
import {action} from "$lib/title"
|
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
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 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],
|
||||||
|
[number, number],
|
||||||
|
];
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each positions as position, layer}
|
{#each positions as position, layer}
|
||||||
{@const {action: actionId, isApplied} = $layout[layer][key.id] ?? {action: 0, isApplied: true}}
|
{@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
|
||||||
{@const {code, icon, id, display, title, keyCode, variant} = KEYMAP_CODES[actionId] ?? {code: actionId}}
|
key.id
|
||||||
|
] ?? {
|
||||||
|
action: 0,
|
||||||
|
isApplied: true,
|
||||||
|
}}
|
||||||
|
{@const { code, icon, id, display, title, keyCode, variant } =
|
||||||
|
$KEYMAP_CODES.get(actionId) ?? { code: actionId }}
|
||||||
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
|
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
|
||||||
{@const tooltip =
|
{@const tooltip =
|
||||||
(title ?? id ?? `0x${code.toString(16)}`) +
|
(title ?? id ?? `0x${code.toString(16)}`) +
|
||||||
@@ -36,6 +57,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"
|
||||||
@@ -44,15 +66,15 @@
|
|||||||
y={pos[1] + middle[1]}
|
y={pos[1] + middle[1]}
|
||||||
font-size={fontSizeMultiplier * (hasIcon ? iconFontSize : fontSize)}
|
font-size={fontSizeMultiplier * (hasIcon ? iconFontSize : fontSize)}
|
||||||
font-family={hasIcon ? "Material Symbols Rounded" : undefined}
|
font-family={hasIcon ? "Material Symbols Rounded" : undefined}
|
||||||
opacity={isActive ? 1 : inactiveOpacity}
|
opacity={isActive ? 1 : `var(--inactive-opacity, ${inactiveOpacity})`}
|
||||||
style:scale={isActive ? 1 : inactiveScale}
|
style:scale={isActive ? 1 : `var(--inactive-scale, ${inactiveScale})`}
|
||||||
style:translate={isActive
|
style:translate={isActive
|
||||||
? "0 0 0"
|
? "0 0 0"
|
||||||
: `${direction[0].toPrecision(2)}px ${direction[1].toPrecision(2)}px 0`}
|
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
|
||||||
style:rotate="{rotate}deg"
|
style:rotate="{rotate}deg"
|
||||||
use:action={{title: tooltip}}
|
{@attach actionTooltip(tooltip)}
|
||||||
>
|
>
|
||||||
{#if code !== 0}
|
{#if code !== 0 && code != 1023}
|
||||||
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isApplied}
|
{#if !isApplied}
|
||||||
@@ -66,18 +88,27 @@
|
|||||||
$transition: 200ms;
|
$transition: 200ms;
|
||||||
|
|
||||||
text {
|
text {
|
||||||
will-change: translate, scale;
|
|
||||||
user-select: none;
|
|
||||||
transform-origin: center;
|
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
transition:
|
transition:
|
||||||
fill #{$focus-transition} ease,
|
fill #{$focus-transition} ease,
|
||||||
opacity #{$transition} ease,
|
opacity #{$transition} ease,
|
||||||
translate #{$transition} ease,
|
translate #{$transition} ease,
|
||||||
scale #{$transition} ease;
|
scale #{$transition} ease;
|
||||||
|
will-change: translate, scale;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
--inactive-opacity: 0.8;
|
||||||
|
--inactive-scale: 0.7;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
text:focus-within {
|
text:focus-within {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
text.hidden {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,20 +1,51 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type {CompiledLayoutKey} from "$lib/serialization/visual-layout"
|
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
|
||||||
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>("visual-layout-config")
|
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||||
export let i: number
|
"visual-layout-config",
|
||||||
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 class="key-group" on:click on:keypress on:focusin role="button" tabindex={i + 1}>
|
<g
|
||||||
|
class="key-group"
|
||||||
|
class:highlight={$highlight?.has(key.id) === true}
|
||||||
|
class:faded={$highlight?.has(key.id) === false}
|
||||||
|
{onclick}
|
||||||
|
{onkeypress}
|
||||||
|
{onfocusin}
|
||||||
|
role="button"
|
||||||
|
tabindex={i + 1}
|
||||||
|
>
|
||||||
{#if key.shape === "square"}
|
{#if key.shape === "square"}
|
||||||
<rect
|
<rect
|
||||||
x={posX + margin}
|
x={posX + margin}
|
||||||
@@ -33,6 +64,7 @@
|
|||||||
[-1, 1],
|
[-1, 1],
|
||||||
[-1, -1],
|
[-1, -1],
|
||||||
[1, -1],
|
[1, -1],
|
||||||
|
[1, 1],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{:else if key.shape === "quarter-circle"}
|
{:else if key.shape === "quarter-circle"}
|
||||||
@@ -44,15 +76,23 @@
|
|||||||
{@const multiplier = 1.25}
|
{@const multiplier = 1.25}
|
||||||
|
|
||||||
{@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
|
{@const rotateRad = (key.rotate + 45) * (Math.PI / 180)}
|
||||||
{@const rotX = Math.round((Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
|
{@const rotX =
|
||||||
{@const rotY = Math.round((Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100) / 100}
|
Math.round(
|
||||||
|
(Math.abs(Math.cos(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
|
||||||
|
) / 100}
|
||||||
|
{@const rotY =
|
||||||
|
Math.round(
|
||||||
|
(Math.abs(Math.sin(rotateRad - Math.PI / 2)) + Number.EPSILON) * 100,
|
||||||
|
) / 100}
|
||||||
|
|
||||||
{@const rc = r1 - (r1 - r2) / 2}
|
{@const rc = r1 - (r1 - r2) / 2}
|
||||||
{@const middleX = Math.cos(rotateRad) * rc}
|
{@const middleX = Math.cos(rotateRad) * rc}
|
||||||
{@const middleY = Math.sin(rotateRad) * rc}
|
{@const middleY = Math.sin(rotateRad) * rc}
|
||||||
<path
|
<path
|
||||||
style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
|
style:transform="rotateZ({key.rotate}deg) translate({innerMargin}px, {innerMargin}px)"
|
||||||
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(p1 - p2)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
|
d="M{posX + p1},{posY} a{r1},{r1} 0 0,1 {-p1},{p1} l0,{-(
|
||||||
|
p1 - p2
|
||||||
|
)} a{r2},{r2} 0 0,0 {p2},{-p2}z"
|
||||||
/>
|
/>
|
||||||
<KeyText
|
<KeyText
|
||||||
{key}
|
{key}
|
||||||
@@ -64,6 +104,7 @@
|
|||||||
[-rotY, -rotX],
|
[-rotY, -rotX],
|
||||||
[-rotX, -rotY],
|
[-rotX, -rotY],
|
||||||
[rotX, rotY],
|
[rotX, rotY],
|
||||||
|
[rotY, rotX],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -74,14 +115,14 @@
|
|||||||
$transition: 200ms;
|
$transition: 200ms;
|
||||||
|
|
||||||
rect {
|
rect {
|
||||||
transform-origin: center;
|
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
path,
|
path,
|
||||||
g {
|
g {
|
||||||
transform-origin: top left;
|
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
|
|
||||||
path,
|
path,
|
||||||
@@ -97,15 +138,17 @@
|
|||||||
stroke-opacity: 0.3;
|
stroke-opacity: 0.3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.faded,
|
||||||
g:hover {
|
g:hover {
|
||||||
cursor: default;
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity #{$transition} ease;
|
transition: opacity #{$transition} ease;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
g.highlight,
|
||||||
g:focus-within {
|
g:focus-within {
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
|
||||||
> path,
|
> path,
|
||||||
> rect {
|
> rect {
|
||||||
|
|||||||
@@ -1,114 +1,117 @@
|
|||||||
<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 { actionTooltip } from "$lib/title";
|
||||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"
|
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||||
import {getContext} from "svelte"
|
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||||
import type {Writable} from "svelte/store"
|
import { fade, fly } from "svelte/transition";
|
||||||
import type {VisualLayout} from "$lib/serialization/visual-layout"
|
import { restoreFromFile } from "$lib/backup/backup";
|
||||||
|
import type { CompiledLayout } from "$lib/assets/layouts/layout.d.ts";
|
||||||
|
|
||||||
$: device = $serialPort?.device ?? "ONE"
|
const layouts: Record<string, (() => Promise<CompiledLayout>) | undefined> = {
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer")
|
ONE: () =>
|
||||||
|
import("$lib/assets/layouts/one.layout.yml").then(
|
||||||
const layers = [
|
(it) => it.default as CompiledLayout,
|
||||||
["Numeric Layer", "123", 1],
|
),
|
||||||
["Primary Layer", "abc", 0],
|
TWO: () =>
|
||||||
["Function Layer", "function", 2],
|
import("$lib/assets/layouts/one.layout.yml").then(
|
||||||
] as const
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
const layouts = {
|
LITE: () =>
|
||||||
ONE: () => import("$lib/assets/layouts/one.yml").then(it => it.default as VisualLayout),
|
import("$lib/assets/layouts/lite.layout.yml").then(
|
||||||
LITE: () => import("$lib/assets/layouts/lite.yml").then(it => it.default as VisualLayout),
|
(it) => it.default as CompiledLayout,
|
||||||
X: () => import("$lib/assets/layouts/generic/103-key.yml").then(it => it.default as VisualLayout),
|
),
|
||||||
}
|
X: () =>
|
||||||
|
import("$lib/assets/layouts/103-key.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
ZERO: () =>
|
||||||
|
import("$lib/assets/layouts/103-key.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
M4G: () =>
|
||||||
|
import("$lib/assets/layouts/m4g.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
M4GR: () =>
|
||||||
|
import("$lib/assets/layouts/m4gr.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
T4G: () =>
|
||||||
|
import("$lib/assets/layouts/t4g.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<fieldset>
|
{#if $serialPort}
|
||||||
{#each layers as [title, icon, value]}
|
{#await layouts[$serialPort.device]?.() then layoutInfo}
|
||||||
<button
|
<fieldset transition:fade>
|
||||||
class="icon"
|
<div class="layers">
|
||||||
use:action={{title, shortcut: `alt+${value + 1}`}}
|
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
|
||||||
on:click={() => ($activeLayer = value)}
|
<label>
|
||||||
class:active={$activeLayer === value}
|
<input
|
||||||
>
|
type="radio"
|
||||||
{icon}
|
onclick={() => ($activeLayer = layer)}
|
||||||
</button>
|
name="layer"
|
||||||
{/each}
|
value={layer}
|
||||||
</fieldset>
|
checked={$activeLayer === layer}
|
||||||
|
/>
|
||||||
|
{String.fromCodePoint(
|
||||||
|
"A".codePointAt(0)! + $activeProfile,
|
||||||
|
)}{layer + 1}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if $deviceMeta?.factoryDefaults?.layout}
|
||||||
|
<button
|
||||||
|
{@attach actionTooltip("Reset Layout")}
|
||||||
|
transition:fly={{ x: -8 }}
|
||||||
|
class="icon reset-layout"
|
||||||
|
onclick={() =>
|
||||||
|
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
|
||||||
|
>reset_wrench</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
{#await layouts[device]() then visualLayout}
|
{#if layoutInfo}
|
||||||
<GenericLayout {visualLayout} />
|
<GenericLayout {layoutInfo} />
|
||||||
{/await}
|
{/if}
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin-bottom: 96px;
|
max-height: 20cm;
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
position: relative;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
|
padding: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button.icon {
|
.layers {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
z-index: 1;
|
gap: 2px;
|
||||||
|
|
||||||
font-size: 24px;
|
margin-inline: auto;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
transition: all 250ms ease;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
aspect-ratio: 1;
|
|
||||||
|
|
||||||
font-size: 32px;
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
outline: 8px solid var(--md-sys-color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child,
|
|
||||||
&:last-child {
|
|
||||||
aspect-ratio: unset;
|
|
||||||
height: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
padding-inline: 4px 16px;
|
|
||||||
border-radius: 16px 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
padding-inline: 16px 4px;
|
|
||||||
border-radius: 0 16px 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
font-weight: 900;
|
|
||||||
color: var(--md-sys-color-on-tertiary);
|
|
||||||
background: var(--md-sys-color-tertiary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export interface VisualLayoutConfig {
|
export interface VisualLayoutConfig {
|
||||||
scale: number
|
scale: number;
|
||||||
inactiveScale: number
|
inactiveScale: number;
|
||||||
inactiveOpacity: number
|
inactiveOpacity: number;
|
||||||
strokeWidth: number
|
strokeWidth: number;
|
||||||
margin: number
|
margin: number;
|
||||||
fontSize: number
|
fontSize: number;
|
||||||
iconFontSize: number
|
iconFontSize: number;
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/lib/components/verbose-action.ts
Normal file
9
src/lib/components/verbose-action.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
|
|
||||||
|
export function isVerbose(info: KeyInfo) {
|
||||||
|
return (
|
||||||
|
info.id?.length !== 1 &&
|
||||||
|
info.title &&
|
||||||
|
(!info.id || /F\d{1,2}/.test(info.id) === false)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +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 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,
|
||||||
const dispatch = createEventDispatcher()
|
chord,
|
||||||
|
onabort,
|
||||||
|
onconfirm,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
abortTitle: string;
|
||||||
|
confirmTitle: string;
|
||||||
|
chord: Chord & { deleted: boolean };
|
||||||
|
onabort: () => void;
|
||||||
|
onconfirm: () => void;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
@@ -15,9 +27,23 @@
|
|||||||
{#if message}
|
{#if message}
|
||||||
<p>{@html message}</p>
|
<p>{@html message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<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")}>{confirmTitle}</button>
|
<button class="primary" onclick={onconfirm}>{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 {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
font-size: 2em;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: inline;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,28 +1,30 @@
|
|||||||
<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();
|
||||||
})
|
});
|
||||||
|
|
||||||
let modal: HTMLDialogElement
|
let modal: HTMLDialogElement;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<dialog bind:this={modal}>
|
<dialog bind:this={modal}>
|
||||||
<slot />
|
{@render children()}
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
dialog {
|
dialog {
|
||||||
|
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 38px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
max-width: 512px;
|
max-width: 512px;
|
||||||
|
|
||||||
color: var(--md-sys-color-on-background);
|
color: var(--md-sys-color-on-background);
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
border: none;
|
|
||||||
border-radius: 38px;
|
|
||||||
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog::backdrop {
|
dialog::backdrop {
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Dialog from "$lib/dialogs/Dialog.svelte"
|
|
||||||
import type {Change, ChordChange, LayoutChange, SettingChange} from "$lib/undo-redo"
|
|
||||||
import {ChangeType, chords} from "$lib/undo-redo"
|
|
||||||
import ActionString from "$lib/components/ActionString.svelte"
|
|
||||||
import LL from "../../i18n/i18n-svelte"
|
|
||||||
import {KEYMAP_IDS} from "$lib/serial/keymap-codes"
|
|
||||||
|
|
||||||
export let changes: Change[] = [
|
|
||||||
{type: ChangeType.Layout, layer: 0, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Layout, layer: 1, id: 1, action: 1},
|
|
||||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
|
||||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
|
||||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
|
||||||
{type: ChangeType.Setting, id: 0, setting: 2},
|
|
||||||
{type: ChangeType.Chord, id: [1], actions: [55], phrase: [55, 63, 37, 36]},
|
|
||||||
{
|
|
||||||
type: ChangeType.Chord,
|
|
||||||
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
|
||||||
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
|
||||||
phrase: [55, 63, 37, 36],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: ChangeType.Chord,
|
|
||||||
id: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
|
||||||
actions: [KEYMAP_IDS.get("y")!.code, KEYMAP_IDS.get("r")!.code, KEYMAP_IDS.get("t")!.code],
|
|
||||||
phrase: [],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
$: existingChords = new Set($chords.map(it => JSON.stringify(it.id)))
|
|
||||||
|
|
||||||
$: layoutChanges = Array.from(
|
|
||||||
{length: 3},
|
|
||||||
(_, i) => changes.filter(it => it.type === ChangeType.Layout && it.layer === i) as LayoutChange[],
|
|
||||||
)
|
|
||||||
$: settingChanges = changes.filter(it => it.type === ChangeType.Setting) as SettingChange[]
|
|
||||||
$: chordChanges = {
|
|
||||||
added: changes.filter(
|
|
||||||
it =>
|
|
||||||
it.type === ChangeType.Chord && it.phrase.length > 0 && !existingChords.has(JSON.stringify(it.id)),
|
|
||||||
) as ChordChange[],
|
|
||||||
changed: changes.filter(
|
|
||||||
it => it.type === ChangeType.Chord && it.phrase.length > 0 && existingChords.has(JSON.stringify(it.id)),
|
|
||||||
) as ChordChange[],
|
|
||||||
deleted: changes.filter(it => it.type === ChangeType.Chord && it.phrase.length === 0) as ChordChange[],
|
|
||||||
}
|
|
||||||
$: totalChordChanges = Object.values(chordChanges).reduce((acc, curr) => acc + curr.length, 0)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<h1>{$LL.changes.TITLE()}</h1>
|
|
||||||
<h2>
|
|
||||||
<label><input type="checkbox" class="checkbox" />{$LL.changes.ALL_CHANGES()}</label>
|
|
||||||
</h2>
|
|
||||||
<ul>
|
|
||||||
{#if layoutChanges.some(it => it.length > 0)}
|
|
||||||
<li>
|
|
||||||
<h3>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="checkbox" />
|
|
||||||
{$LL.changes.layout.TITLE(layoutChanges.reduce((acc, curr) => acc + curr.length, 0))}
|
|
||||||
</label>
|
|
||||||
</h3>
|
|
||||||
<ul>
|
|
||||||
{#each layoutChanges
|
|
||||||
.map((it, i) => /** @type {const} */ ([it, i + 1]))
|
|
||||||
.filter(([it]) => it.length > 0) as [changes, layer]}
|
|
||||||
<li>
|
|
||||||
<h4>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="checkbox" />
|
|
||||||
{$LL.changes.layout.LAYER({changes: changes.length, layer})}
|
|
||||||
</label>
|
|
||||||
</h4>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if settingChanges.length > 0}
|
|
||||||
<li>
|
|
||||||
<h3>
|
|
||||||
<label
|
|
||||||
><input type="checkbox" class="checkbox" />{$LL.changes.settings.TITLE(
|
|
||||||
settingChanges.length,
|
|
||||||
)}</label
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if totalChordChanges > 0}
|
|
||||||
<li>
|
|
||||||
<h3>
|
|
||||||
<label
|
|
||||||
><input type="checkbox" class="checkbox" />{$LL.changes.chords.TITLE(totalChordChanges)}</label
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
<ul>
|
|
||||||
{#each Object.entries(chordChanges) as [category, changes]}
|
|
||||||
{#if changes.length > 0}
|
|
||||||
<li>
|
|
||||||
<h4>
|
|
||||||
<label
|
|
||||||
><input type="checkbox" class="checkbox" />
|
|
||||||
{#if category === "added"}
|
|
||||||
{$LL.changes.chords.NEW_CHORDS(changes.length)}
|
|
||||||
{:else if category === "changed"}
|
|
||||||
{$LL.changes.chords.CHANGED_CHORDS(changes.length)}
|
|
||||||
{:else if category === "deleted"}
|
|
||||||
{$LL.changes.chords.DELETED_CHORDS(changes.length)}
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
</h4>
|
|
||||||
<ul>
|
|
||||||
{#each changes as change}
|
|
||||||
<li>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="checkbox" />
|
|
||||||
<ActionString display="keys" actions={change.actions} />
|
|
||||||
<ActionString actions={change.phrase} />
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
h1 {
|
|
||||||
font-size: 2em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-inline-start: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-inline-start: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,31 +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,
|
||||||
|
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,
|
||||||
|
chord,
|
||||||
|
onabort: () => resolvePromise(false),
|
||||||
|
onconfirm: () => resolvePromise(true),
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
let resolvePromise: (value: boolean) => void
|
const result = await resultPromise;
|
||||||
const resultPromise = new Promise<boolean>(resolve => {
|
unmount(dialog);
|
||||||
resolvePromise = resolve
|
|
||||||
})
|
|
||||||
|
|
||||||
dialog.$on("abort", () => resolvePromise(false))
|
return result;
|
||||||
dialog.$on("confirm", () => resolvePromise(true))
|
|
||||||
|
|
||||||
const result = await resultPromise
|
|
||||||
dialog.$destroy()
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Material Symbols Rounded";
|
|
||||||
font-weight: 100 700;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-weight: 100 700;
|
||||||
src: url("$lib/assets/icons.min.woff2") format("woff2");
|
src: url("$lib/assets/icons.min.woff2") format("woff2");
|
||||||
|
font-family: "Material Symbols Rounded";
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
direction: ltr;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
/* stylelint-disable-next-line */
|
/* stylelint-disable-next-line */
|
||||||
font-family: "Material Symbols Rounded";
|
font-family: "Material Symbols Rounded";
|
||||||
font-size: 24px;
|
|
||||||
font-feature-settings: "liga";
|
|
||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
"FILL" var(--icon-fill, 0),
|
"FILL" var(--icon-fill, 0),
|
||||||
"wght" var(--icon-weigth, 400),
|
"wght" var(--icon-weigth, 400),
|
||||||
"GRAD" var(--icon-grade, 0);
|
"GRAD" var(--icon-grade, 0);
|
||||||
font-weight: normal;
|
font-feature-settings: "liga";
|
||||||
font-style: normal;
|
|
||||||
line-height: 1;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
|
|
||||||
|
direction: ltr;
|
||||||
|
user-select: none;
|
||||||
|
text-transform: none;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
transition: font-variation-settings 250ms ease;
|
transition: font-variation-settings 250ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,27 +66,30 @@
|
|||||||
|
|
||||||
/* noto-sans-mono-latin-ext-wght-normal */
|
/* noto-sans-mono-latin-ext-wght-normal */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Mono Variable";
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-weight: 100 900;
|
||||||
font-stretch: 62.5% 100%;
|
font-stretch: 62.5% 100%;
|
||||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1E00-1EFF, U+2020,
|
font-family: "Noto Sans Mono Variable";
|
||||||
U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
|
||||||
|
U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||||
|
U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* noto-sans-mono-latin-wght-normal */
|
/* noto-sans-mono-latin-wght-normal */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Mono";
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-weight: 100 900;
|
||||||
font-stretch: 62.5% 100%;
|
font-stretch: 62.5% 100%;
|
||||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0300-0301,
|
font-family: "Noto Sans Mono";
|
||||||
U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212,
|
font-display: swap;
|
||||||
U+2215, U+FEFF, U+FFFD;
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
|
||||||
|
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user