90 Commits

Author SHA1 Message Date
b7c8ebfb3c feat: adjust chunking 2026-01-30 18:13:29 +01:00
632297d266 feat: change timing 2026-01-30 18:06:43 +01:00
0ee7e02c53 feat: test timeout 2026-01-30 17:43:16 +01:00
f618ffbada feat: wait ready 2026-01-30 17:38:46 +01:00
afa0d9ffd7 feat: goto terminal 2026-01-30 17:31:35 +01:00
cda2a527d9 feat: change update chunking 2026-01-30 17:26:31 +01:00
1ca2a70bc1 feat: changes 2026-01-30 17:04:36 +01:00
a16c79575f feat: update workflow 2026-01-29 14:21:04 +01:00
5371b9d305 feat: cv2 2026-01-29 14:16:37 +01:00
b9c6c05819 2.7.0 2026-01-28 18:19:03 +01:00
16bf766de9 feat: ccos emulator 2026-01-28 18:08:11 +01:00
ee8d400ad7 feat: hide cc0 2026-01-28 16:39:08 +01:00
9a1c2b5bf6 refactor: cleanup 2026-01-28 16:37:47 +01:00
1d1fcb72e3 fix: m0 should not have profiles
refactor: remove old editor/chat/learn links
2026-01-28 16:14:52 +01:00
ee3f84645d feat: support autospace v2 2026-01-20 17:17:55 +01:00
82dd08f2a2 feat: update stuff 2025-12-18 16:29:30 +01:00
9f65b4bb6c fix: action selector
update dependencies
2025-12-18 15:35:33 +01:00
e08dda40d9 feat: new left/right graphic 2025-12-17 19:58:05 +01:00
a403bf1ac0 improve cv2 2025-12-17 19:42:15 +01:00
1aff1703ac feat: new chord editor prototype 2025-12-17 17:34:32 +01:00
fe42dcd2ab fix: crash when saving empty chords 2025-12-12 17:41:54 +01:00
b13c34ca15 fix: oops 2025-12-12 15:19:28 +01:00
4023ab9bd5 feat: better handling of corrupted updates 2025-12-12 15:18:24 +01:00
2893afa2ba feat: qol improvements 2025-12-11 20:51:32 +01:00
7beab5ac07 fix: autospace cursor wonkyness 2025-11-28 17:33:55 +01:00
6895fa4a82 feat: cookbook 2025-11-28 14:38:51 +01:00
245dd97532 feat: 4th layer support 2025-11-12 18:21:22 +01:00
d84495894a fix: zero/engine backups should be x backups 2025-10-29 19:52:43 +01:00
1de52f7f81 feat: wasm zero 2025-10-29 18:51:03 +01:00
45682f0d1a 2.6.0 2025-10-22 17:22:34 +02:00
5f0bc45851 fix: issue with importing M4G backups 2025-10-22 16:59:41 +02:00
c6f1f3f6fc feat: add t4g vid 2025-10-22 16:58:04 +02:00
32c2ce2f45 2.5.0 2025-10-02 13:59:05 +02:00
c6e2f59b05 refactor: rename auto-backup to fast connect 2025-10-02 13:58:01 +02:00
2a872bafac v2.4.0 2025-09-23 14:14:11 +02:00
a940d1b480 feat: add upgrade flow for pre-2.0.0 (non-OTA) devices 2025-09-23 14:08:14 +02:00
f3b1d76666 feat: t4g support 2025-09-02 18:41:46 +02:00
0b2695a380 refactor: swap stylelint css order plugin for prettier plugin 2025-07-31 13:41:39 +02:00
048dee0a6d fix: can't select empty chord outputs
update dependencies
use new title popover
2025-07-29 20:18:13 +02:00
977bdf3043 fix: revamp reset popup 2025-07-29 18:59:11 +02:00
9ca30f412e feat: update compound calculation
feat: add "will my compound break" page
2025-07-29 16:40:54 +02:00
f2a18cafe8 fix: use semver sort for ccos updates 2025-07-29 14:55:02 +02:00
b27182dc35 fix: can't change profiles B/C
fix: only add profiles starting beta.4
2025-07-11 17:56:42 +02:00
74ce6af318 feat: profile support 2025-07-11 16:27:19 +02:00
782f1fc38b feat: autospace toggle 2025-06-13 20:29:05 +02:00
Shane O'Donnell
087ff36d5d Fix stale search (#189)
Fixes an issue where, after chord list is updated (e.g. by a deletion),
the search results continue to use the old list and indices, resulting
in incorrect search results.
2025-06-13 13:22:25 +02:00
bd1c6147fd 2.3.0 2025-05-09 19:38:39 +02:00
891abda0fb refactor: remove wip esptool section 2025-05-07 21:26:19 +02:00
3611f65e24 fix: build failure 2025-05-06 16:36:50 +02:00
f76882a09c feat: new settings page design 2025-05-06 16:34:34 +02:00
ff7e4f7b2e feat: add version changelog support 2025-05-06 15:04:44 +02:00
1c1c86241f fix: M4G isn't listed in the device manager 2025-05-02 17:40:04 +02:00
dc8b3c3d66 fix: update product ids 2025-05-02 13:21:35 +02:00
Aleksandr Iushmanov
65911419b0 Remove unused direction with incomplete type definition. (#184) 2025-04-27 23:14:21 +02:00
Aleksandr Iushmanov
ccfb09e261 exclude openssl and i18n from npm run format; + npm run format (#183) 2025-04-27 15:43:16 +02:00
b841469505 feat: add icons 2025-04-25 21:42:07 +02:00
bc06e8ee80 feat: color picker for hsv settings 2025-04-23 15:56:58 +02:00
24fc861ef4 fix: commit is not being sent when only settings or layout change 2025-04-22 19:56:24 +02:00
5801e5fbbe feat: ota progress bar
fix: can't set settings with inverse/scale
2025-04-22 19:14:51 +02:00
92b52e08f7 fix: progress bar is broken
fixes #175
2025-04-22 15:19:00 +02:00
4192210d27 fix: use different icons for consumer control
fixes #174
2025-04-22 14:30:21 +02:00
Aleksandr Iushmanov
0e5640a1ee [#167] Expand textarea for sentence input; use untrack to break recursive reactivity loops hanging the page on long sentences; Use better error message instead of ERROR (#182) 2025-04-22 14:25:44 +02:00
7f27499003 ci: update gh actions 2025-04-17 13:55:27 +02:00
Aleksandr Iushmanov
b6ded5f94c Remove unused CSS selectors. (#181) 2025-04-17 13:13:53 +02:00
Aleksandr Iushmanov
63d0ad7ae8 Use modern compiler for css processing in vite (to remove SASS 2.0.0 warnings on deprecated JS API usage); (#180)
Resolve some of SASS deprecation warnings;
Add note to readme about icons generation
2025-04-08 11:51:36 +02:00
Aleksandr Iushmanov
1c8f53caf6 Allow adding arrows as chord actions when shift is pressed (#179) 2025-04-06 17:41:14 +02:00
1d60b12d43 fix: settings not showing for older devices 2025-04-04 18:15:07 +02:00
e85a731410 feat: fetch settings from build meta 2025-04-04 18:03:09 +02:00
050af564ab ci: add pull request workflows 2025-03-31 14:00:32 +02:00
6545124aa2 refactor: update dependencies 2025-03-31 13:50:59 +02:00
b93724add3 Merge pull request #178 from poweroftrue/master
Improve chord loading speed
2025-03-31 13:13:17 +02:00
Mostafa Dahab
e1092113f6 Improve chord loading speed
Signed-off-by: Mostafa Dahab <mostafa@dahab.io>
2025-03-30 16:29:58 +03:00
Izeren
0bb4bbe838 [#169] Fix autoConnect feature. Use value form persistent storage when it is available. (#170)
Authored-by: izeren <yushalnik@bk.ru>
2025-02-24 11:30:19 +01:00
089812c555 feat: better connection error messages
resolve #159
fixes #158
fixes #153
2025-02-14 16:31:43 +01:00
45c5f21cc4 fix: search shows 1 chord when there are 0
fixes #151
2025-02-14 15:42:19 +01:00
fb5959998a feat: sentence trainer custom prompt
resolves #162
2025-02-14 15:31:17 +01:00
f319714489 fix: duplicate chords crash
fix: duplicate confirm dialog does not show affected chord
fixes #137
fixes #163
2025-02-14 15:17:22 +01:00
fb1f5b7ec7 fix: can't type in chords 2025-02-14 14:55:06 +01:00
ac16cfd3bf feat: use factory default meta
feat: clear chords button
resolves #64
2025-02-14 14:52:07 +01:00
9d5b0e01d2 feat: add voicebox shortcut
resolves #160
2025-02-14 14:04:45 +01:00
e7517f821d fix: action search does not work until selecting a filter
fixes #149
2025-02-14 13:38:47 +01:00
762f73063a fix: vocabulary is eating spaces
fixes #115
2025-02-14 13:33:44 +01:00
7ca9e04dd3 feat: use version meta
fixes #150
2025-02-13 16:17:46 +01:00
4d73dad780 feat: learn chat message 2025-02-13 13:53:01 +01:00
5419824c06 feat: re-add chat
fixes #161
2025-02-13 13:33:12 +01:00
075d05dd0b feat: better update page
feat: hide manual update steps as "unsafe" if OTA is available

resolves #155
2025-02-12 16:00:50 +01:00
9266702cbb feat: add sentence wpm stage 2025-01-16 20:41:00 +01:00
77e2d2b20e feat: sentence trainer idle timeout 2025-01-16 17:50:52 +01:00
7819f546a6 fix: package manager 2025-01-16 17:15:25 +01:00
e37b38085d feat: sentence trainer prototype
feat: layout learner prototype
2025-01-16 17:12:56 +01:00
182 changed files with 11043 additions and 8839 deletions

View File

@@ -1,6 +1,12 @@
name: Build name: Build
on: [push] on:
push:
branches:
- master
tags:
- v*
pull_request:
jobs: jobs:
build: build:
@@ -8,9 +14,9 @@ jobs:
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
@@ -20,11 +26,11 @@ jobs:
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with: with:
version: 9 version: 10
- name: 🐉 Use Node.js 22.4.x - name: 🐉 Use Node.js 22.14.x
uses: actions/setup-node@v3 uses: actions/setup-node@v4
with: with:
node-version: 22.4.x node-version: 22.14.x
cache: "pnpm" cache: "pnpm"
- name: ⏬ Install Node dependencies - name: ⏬ Install Node dependencies
run: pnpm install run: pnpm install
@@ -41,10 +47,13 @@ jobs:
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Publish Stable - name: Publish Stable
if: ${{ github.ref == 'refs/tags/v*' }} if: ${{ github.ref == 'refs/tags/v*' && !github.event.pull_request.head.repo.fork }}
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/ run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
- name: Publish Branch - name: Publish Branch
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/} if: ${{ !github.event.pull_request.head.repo.fork }}
env:
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/$BRANCH_NAME
- name: Publish Commit - name: Publish Commit
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }} run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}

View File

@@ -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

View File

@@ -1,4 +1,4 @@
{ {
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }

View File

@@ -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": {

View File

@@ -35,3 +35,9 @@ 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
```

24
flake.lock generated
View File

@@ -5,11 +5,11 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1710146030, "lastModified": 1731533236,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1722415718, "lastModified": 1743259260,
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=", "narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce", "rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -36,11 +36,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1718428119, "lastModified": 1736320768,
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=", "narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5", "rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -62,11 +62,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1722391647, "lastModified": 1743388531,
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=", "narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8", "rev": "011de3c895927300651d9c2cb8e062adf17aa665",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@@ -14,7 +14,13 @@
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (
system: system:
let let
overlays = [ (import rust-overlay) ]; overlays = [
(import rust-overlay)
(final: prev: {
nodejs = prev.nodejs_22;
corepack = prev.corepack_22;
})
];
pkgs = import nixpkgs { inherit system overlays; }; pkgs = import nixpkgs { inherit system overlays; };
rust-bin = pkgs.rust-bin.stable.latest.default.override { rust-bin = pkgs.rust-bin.stable.latest.default.override {
extensions = [ extensions = [
@@ -46,8 +52,8 @@
]; ];
packages = packages =
(with pkgs; [ (with pkgs; [
nodejs_22 nodejs
nodePackages.pnpm pnpm
rust-bin rust-bin
fontMin fontMin
]) ])
@@ -59,7 +65,7 @@
openssl_3 openssl_3
glib glib
gtk3 gtk3
libsoup libsoup_2_4
webkitgtk webkitgtk
librsvg librsvg
# serial plugin # serial plugin
@@ -70,7 +76,7 @@
devShell = pkgs.mkShell { devShell = pkgs.mkShell {
buildInputs = packages; buildInputs = packages;
shellHook = '' shellHook = ''
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH #export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
''; '';
}; };
} }

View File

@@ -4,7 +4,9 @@ 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", "deployed_code_update",
"difference",
"adjust", "adjust",
"add", "add",
"piano", "piano",
@@ -33,6 +35,7 @@ const config = {
"abc", "abc",
"function", "function",
"cloud_done", "cloud_done",
"counter_4",
"backup", "backup",
"cloud_download", "cloud_download",
"cloud_off", "cloud_off",
@@ -42,7 +45,26 @@ 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", "shopping_bag",
"filter_list", "filter_list",
@@ -58,24 +80,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", "replay",
"clock_loader_80",
"reply", "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",
@@ -89,6 +124,7 @@ const config = {
"sentiment_sad", "sentiment_sad",
"sentiment_content", "sentiment_content",
"sentiment_worried", "sentiment_worried",
"construction",
"timer", "timer",
"target", "target",
"download", "download",
@@ -113,15 +149,29 @@ const config = {
"developer_board", "developer_board",
"developer_board_off", "developer_board_off",
"memory", "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",
@@ -134,6 +184,8 @@ const config = {
routine: "e20c", routine: "e20c",
experiment: "e686", experiment: "e686",
dictionary: "f539", dictionary: "f539",
visibility_off: "e8f5",
file_save: "f17f",
}, },
}; };

View File

@@ -1,11 +1,11 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "2.2.3", "version": "2.7.0",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=22.4", "node": ">=22.14",
"pnpm": ">=9.4" "pnpm": ">=10.7"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -25,6 +25,7 @@
"build:tauri": "tauri build", "build:tauri": "tauri build",
"tauri": "tauri", "tauri": "tauri",
"test": "vitest run --coverage", "test": "vitest run --coverage",
"test:chord-sync": "vitest chord-sync",
"preview": "vite preview", "preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"minify-icons": "node src/tools/minify-icon-font.js", "minify-icons": "node src/tools/minify-icon-font.js",
@@ -34,64 +35,73 @@
"typesafe-i18n": "typesafe-i18n" "typesafe-i18n": "typesafe-i18n"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.18.2", "@codemirror/autocomplete": "^6.20.0",
"@codemirror/commands": "^6.7.1", "@codemirror/collab": "^6.1.1",
"@codemirror/lang-javascript": "^6.2.2", "@codemirror/commands": "^6.10.1",
"@codemirror/language": "^6.10.3", "@codemirror/lang-javascript": "^6.2.4",
"@codemirror/state": "^6.4.1", "@codemirror/language": "^6.12.1",
"@codemirror/view": "^6.34.1", "@codemirror/lint": "^6.9.2",
"@fontsource-variable/material-symbols-rounded": "^5.1.3", "@codemirror/merge": "^6.11.2",
"@fontsource-variable/noto-sans-mono": "^5.1.0", "@codemirror/search": "^6.6.0",
"@lezer/highlight": "^1.2.1", "@codemirror/state": "^6.5.3",
"@codemirror/view": "^6.39.9",
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
"@fontsource-variable/noto-sans-mono": "^5.2.10",
"@lezer/common": "^1.5.0",
"@lezer/generator": "^1.8.0",
"@lezer/highlight": "^1.2.3",
"@lezer/lr": "^1.4.7",
"@material/material-color-utilities": "^0.3.0", "@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2", "@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.0", "@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.0", "@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.7.5", "@sveltejs/kit": "^2.49.3",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^6.2.3",
"@tauri-apps/api": "^1.6.0", "@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0", "@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.5", "@types/dom-view-transitions": "^1.0.6",
"@types/flexsearch": "^0.7.6", "@types/js-yaml": "^4.0.9",
"@types/w3c-web-serial": "^1.0.7", "@types/semver": "^7.7.1",
"@types/w3c-web-usb": "^1.0.10", "@types/w3c-web-serial": "^1.0.8",
"@types/wicg-file-system-access": "^2023.10.5", "@types/w3c-web-usb": "^1.0.13",
"@vite-pwa/sveltekit": "^0.6.6", "@types/wicg-file-system-access": "^2023.10.7",
"autoprefixer": "^10.4.20", "@vite-pwa/sveltekit": "^1.1.0",
"codemirror": "^6.0.1", "autoprefixer": "^10.4.23",
"cypress": "^13.13.2", "codemirror": "^6.0.2",
"cypress": "^14.5.3",
"d3": "^7.9.0", "d3": "^7.9.0",
"esptool-js": "^0.4.7", "esptool-js": "^0.5.7",
"flexsearch": "^0.7.43", "flexsearch": "^0.8.212",
"fontkit": "^2.0.4", "fontkit": "^2.0.4",
"glob": "^11.0.0", "glob": "^11.0.3",
"jsdom": "^25.0.1", "js-yaml": "^4.1.1",
"matrix-js-sdk": "^34.9.0", "jsdom": "^26.1.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.3.3", "prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.2.7", "prettier-plugin-css-order": "^2.2.0",
"rxjs": "^7.8.1", "prettier-plugin-svelte": "^3.4.1",
"sass": "^1.80.6", "rxjs": "^7.8.2",
"socket.io-client": "^4.8.1", "sass": "^1.97.2",
"stylelint": "^16.10.0", "semver": "^7.7.3",
"stylelint-config-clean-order": "^6.1.0", "socket.io-client": "^4.8.3",
"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": "^14.1.0", "stylelint-config-recommended-scss": "^16.0.2",
"stylelint-config-standard-scss": "^13.1.0", "stylelint-config-standard-scss": "^16.0.0",
"svelte": "5.1.9", "svelte": "5.46.1",
"svelte-check": "^4.0.5", "svelte-check": "^4.3.5",
"svelte-preprocess": "^6.0.3", "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.6.3", "typescript": "^5.9.3",
"vite": "^5.4.10", "vite": "^7.3.1",
"vite-plugin-mkcert": "^1.17.6", "vite-plugin-mkcert": "^1.17.9",
"vite-plugin-pwa": "^0.20.5", "vite-plugin-pwa": "^1.2.0",
"vitest": "^2.1.4", "vitest": "^4.0.16",
"web-serial-polyfill": "^1.0.15", "web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0" "workbox-window": "^7.4.0"
}, },
"type": "module" "type": "module"
} }

5842
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
onlyBuiltDependencies:
- svelte-preprocess

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "2.2.3" 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"

View File

@@ -6,7 +6,7 @@
"devPath": "http://localhost:5173", "devPath": "http://localhost:5173",
"distDir": "../build" "distDir": "../build"
}, },
"package": { "productName": "amacc1ng", "version": "2.2.3" }, "package": { "productName": "amacc1ng", "version": "2.7.0" },
"tauri": { "tauri": {
"allowlist": { "all": false }, "allowlist": { "all": false },
"bundle": { "bundle": {

View File

@@ -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",
@@ -18,10 +18,10 @@ const de = {
}, },
backup: { backup: {
TITLE: "Backup", TITLE: "Backup",
AUTO_BACKUP: "Auto-backup", AUTO_BACKUP: "Beschleunigtes Verbinden",
DISCLAIMER: DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.", "<b>Nicht auf öffentlichen oder geteilten Computern einschalten.</b> Gerätedaten werden für schnelleren Zugriff lokal zwischengespeichert.",
DOWNLOAD: "Alles", DOWNLOAD: "Komplettes Profil",
RESTORE: "Wiederherstellen", RESTORE: "Wiederherstellen",
}, },
modal: { modal: {

View File

@@ -7,17 +7,17 @@ const en = {
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: "Backup", TITLE: "Backup",
AUTO_BACKUP: "Auto-backup", AUTO_BACKUP: "Fast Connect",
DISCLAIMER: DISCLAIMER:
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.", "<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: "Everything", DOWNLOAD: "Full profile",
RESTORE: "Restore", RESTORE: "Restore",
}, },
sync: { sync: {

View File

@@ -4,7 +4,7 @@
import { expoIn, expoOut } from "svelte/easing"; import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte"; import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } = let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
$props(); $props();
let inDirection = $state(0); let inDirection = $state(0);

View 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>

View File

@@ -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
1 s + Dup say
2 y + b by
3 y + Dup why
4 n + Dup no
5 n + f find
6 l + f life
7 l + p people
8 l + s + p spell
9 l + n line
10 l + n + p plant
11 t + h that
12 t + n + h than
13 t + n + p plant
14 t + l + f left
15 a + f after
16 a + d add
17 a + d + f had
18 a + h has
19 a + s as
20 a + s + h has
21 a + y + d day
22 a + y + s say
23 a + n an
24 a + n + d and
25 a + n + d + f hand
26 a + n + h hand
27 a + n + y any
28 a + l all
29 a + l + d land
30 a + l + p plant
31 a + l + s last
32 a + l + y + p play
33 a + l + n + d land
34 a + t at
35 a + t + h that
36 a + t + n + h than
37 a + t + l + s last
38 a + t + l + n + p plant
39 - + ? question
40 w + h who
41 w + s saw
42 w + y + h why
43 w + t without
44 w + t + h watch
45 w + t + n went
46 w + a was
47 w + a + h what
48 w + a + s saw
49 w + a + y way
50 w + a + y + Dup away
51 w + a + n want
52 w + a + l + f walk
53 w + a + l + y always
54 w + a + l + y + s always
55 w + a + t watch
56 w + a + t + h watch
57 w + a + t + n want
58 g + b begin
59 g + Dup question
60 g + h here
61 g + p page
62 g + l + n long
63 g + t get
64 k + q quick
65 k + f and
66 k + l + y + q quickly
67 k + t + l talk
68 k + a + b back
69 k + a + s ask
70 k + a + t + l talk
71 k + w + a + l walk
72 m + f form
73 m + y my
74 m + t + Dup mountain
75 m + a + f family
76 m + a + s small
77 m + a + y may
78 m + a + n man
79 m + a + n + y many
80 m + a + l almost
81 m + a + l + s small
82 m + a + l + s + Dup small
83 c + b because
84 c + Dup sea
85 c + h head
86 c + a + n can
87 c + a + n + h change
88 c + a + l call
89 c + a + l + p place
90 c + k + a + b back
91 u + p us
92 u + y you
93 u + j just
94 u + j + s just
95 u + t + b but
96 u + t + p put
97 u + t + s + d study
98 u + t + n until
99 u + t + j + s just
100 u + a + l last
101 u + k + q quick
102 u + k + l + y + q quickly
103 u + m + h much
104 u + m + n number
105 u + m + t + s must
106 u + m + c + h much
107 u + c + s + h such
108 u + c + t cut
109 u + c + t + n country
110 ' + a + s say
111 ' + a + n any
112 ' + m + a + n many
113 o + Dup off
114 o + f of
115 o + f + f food
116 o + d do
117 o + s so
118 o + y + b boy
119 o + n on
120 o + n + s + Dup soon
121 o + l line
122 o + l + Dup oil
123 o + l + d old
124 o + l + y only
125 o + l + n + y only
126 o + t to
127 o + t + b both
128 o + t + Dup too
129 o + t + s stop
130 o + t + s + p stop
131 o + t + n not
132 o + t + n + f often
133 o + t + n + d don't
134 o + t + n + p point
135 o + a + n + h another
136 o + a + l also
137 o + a + l + s also
138 o + w own
139 o + w + h how
140 o + w + s + h show
141 o + w + n now
142 o + w + n + d down
143 o + w + l + f follow
144 o + w + t two
145 o + g go
146 o + g + Dup good
147 o + g + n + s song
148 o + g + l + n long
149 o + g + t got
150 o + g + a + l along
151 o + g + a + l + n along
152 o + k + b book
153 o + k + n know
154 o + k + l look
155 o + k + t took
156 o + k + t + Dup took
157 o + k + w + n know
158 o + v + a + b above
159 o + v + k move
160 o + m + s some
161 o + m + t + s most
162 o + m + a + l almost
163 o + m + a + l + s almost
164 o + m + a + t + l + s almost
165 o + c + f food
166 o + c + l + s + h school
167 o + u our
168 o + u + f four
169 o + u + s + h should
170 o + u + y you
171 o + u + n + f found
172 o + u + n + f + f found
173 o + u + n + s sound
174 o + u + n + s + d sound
175 o + u + l + s + d + f should
176 o + u + l + s + h should
177 o + u + t out
178 o + u + a + b about
179 o + u + a + t + b about
180 o + u + w + l + d would
181 o + u + g + n + y young
182 o + u + g + t + h thought
183 o + u + m + t + n mountain
184 o + u + m + t + n + Dup mountain
185 o + u + c + l + d could
186 o + ' + t + n + d don't
187 o + o + l oil
188 o + o + t + n into
189 o + o + t + n + p point
190 o + o + u + w + t + h without
191 o + o + u + m + a + t + n mountain
192 i + f if
193 i + f + f different
194 i + d did
195 i + RH_Thumb_1_Center different
196 i + s is
197 i + s + d side
198 i + s + h his
199 i + n in
200 i + n + f + f find
201 i + l list
202 i + l + n line
203 i + t it
204 i + t + s + Dup still
205 i + t + s + h this
206 i + t + n into
207 i + t + l + s list
208 i + t + l + s + Dup still
209 i + a + s said
210 i + a + s + d said
211 i + a + n animal
212 i + w + h which
213 i + w + h + Dup which
214 i + w + l will
215 i + w + l + Dup will
216 i + w + l + h while
217 i + w + t + h with
218 i + g give
219 i + g + b big
220 i + g + h high
221 i + g + n + h night
222 i + g + l + h light
223 i + g + t + n + h thing
224 i + g + t + l + h light
225 i + g + a + n again
226 i + g + a + n + Dup again
227 i + k + n + d kind
228 i + k + l like
229 i + k + t + n + h think
230 i + v + l live
231 i + m + h him
232 i + m + p important
233 i + m + s miss
234 i + m + s + Dup miss
235 i + m + l mile
236 i + m + t time
237 i + m + t + h might
238 i + m + a + n animal
239 i + m + a + n + Dup animal
240 i + m + a + l + n another
241 i + m + g + t + h might
242 i + c + p picture
243 i + c + t + y city
244 i + u + t + l + n until
245 i + u + w + t + h without
246 i + u + k + q quick
247 i + u + k + l + y + q quickly
248 i + u + c + k + q quick
249 i + u + c + k + l + y + q quickly
250 i + ' + t it's
251 i + ' + t + s it's
252 e + b be
253 e + Dup earth
254 e + x example
255 e + f + b before
256 e + h he
257 e + h + Dup here
258 e + s state
259 e + s + Dup see
260 e + s + h she
261 e + y + Dup eye
262 e + n name
263 e + n + b been
264 e + n + Dup need
265 e + n + d end
266 e + l + h help
267 e + l + h + f help
268 e + l + s + p spell
269 e + t the
270 e + t + Dup eat
271 e + t + f feet
272 e + t + h there
273 e + t + s set
274 e + t + s + h these
275 e + t + y + h they
276 e + t + n + x next
277 e + t + n + h then
278 e + t + l let
279 e + t + l + Dup tell
280 e + t + l + f left
281 e + a at
282 e + a + f father
283 e + a + d + f head
284 e + a + h hear
285 e + a + p paper
286 e + a + s sea
287 e + a + y year
288 e + a + n name
289 e + a + t eat
290 e + a + t + s + Dup state
291 e + w we
292 e + w + f few
293 e + w + n new
294 e + w + n + h when
295 e + w + l well
296 e + w + l + Dup well
297 e + w + t + b between
298 e + w + t + n went
299 e + w + t + n + b between
300 e + g + h here
301 e + g + t get
302 e + g + a + p page
303 e + g + a + n + b began
304 e + k keep
305 e + k + p keep
306 e + k + t take
307 e + k + a + t take
308 e + v + y every
309 e + v + n even
310 e + v + a + h have
311 e + v + a + l leave
312 e + v + a + l + Dup leave
313 e + m me
314 e + m + s seem
315 e + m + s + Dup seem
316 e + m + n men
317 e + m + t + h them
318 e + m + a make
319 e + m + a + d made
320 e + m + a + s same
321 e + m + a + n mean
322 e + m + a + l + p + x example
323 e + m + c + a came
324 e + c + s second
325 e + c + t + n + s sentence
326 e + c + a came
327 e + c + a + f face
328 e + c + a + h each
329 e + c + a + l + p place
330 e + LH_Thumb_1_Center + a make
331 e + u + s use
332 e + u + s + q question
333 e + u + n + d under
334 e + u + t + q quite
335 e + u + c + a + s + b because
336 e + o + p open
337 e + o + s + d does
338 e + o + n one
339 e + o + n + p open
340 e + o + l + b below
341 e + o + l + h hello
342 e + o + l + h + Dup hello
343 e + o + l + p people
344 e + o + t + f often
345 e + o + t + h other
346 e + o + t + s + h those
347 e + o + t + n + f often
348 e + o + w + l + b below
349 e + o + g + t + h together
350 e + o + v over
351 e + o + v + a + b above
352 e + o + v + k move
353 e + o + m move
354 e + o + m + h home
355 e + o + m + s some
356 e + o + m + t + s sometime
357 e + o + m + t + s + h something
358 e + o + m + g + t + n + s + h something
359 e + o + m + c come
360 e + o + c come
361 e + o + c + n once
362 e + o + c + n + s + d second
363 e + o + c + l + s close
364 e + o + u + s + h house
365 e + o + u + n enough
366 e + o + u + g + n + h enough
367 e + i + s + d side
368 e + i + l + f life
369 e + i + l + n line
370 e + i + t + q quite
371 e + i + t + l little
372 e + i + t + l + Dup little
373 e + i + a + d idea
374 e + i + w + l + h while
375 e + i + w + t + h white
376 e + i + g give
377 e + i + g + p give
378 e + i + g + n + b begin
379 e + i + k + l like
380 e + i + v + l live
381 e + i + m + l mile
382 e + i + m + t time
383 e + i + u + t + q quite
384 e + i + u + u + t + n + s + q question
385 r + e + h her
386 r + e + t + Dup tree
387 r + e + t + h there
388 r + e + t + h + Dup three
389 r + e + t + l letter
390 r + e + a are
391 r + e + a + d read
392 r + e + a + h hear
393 r + e + a + p paper
394 r + e + a + n near
395 r + e + a + l + y really
396 r + e + a + l + y + Dup really
397 r + e + a + t + f after
398 r + e + a + t + h earth
399 r + e + a + t + RH_Thumb_1_Center father
400 r + e + a + t + l learn
401 r + e + w were
402 r + e + w + Dup were
403 r + e + w + h where
404 r + e + w + a + n + s answer
405 r + e + w + a + t water
406 r + e + g + h here
407 r + e + g + a + l large
408 r + e + g + a + t great
409 r + e + v + y very
410 r + e + v + y + Dup every
411 r + e + v + n never
412 r + e + u + n + d under
413 r + e + u + m + n + b number
414 r + e + o + f + b before
415 r + e + o + t + h other
416 r + e + o + g + t + h together
417 r + e + o + v over
418 r + e + o + m more
419 r + e + o + m + t + h mother
420 r + e + i + t + h their
421 r + e + i + t + n + f + f different
422 r + e + i + v river
423 r + e + i + c + l + d + f children
424 r + e + i + u + c + t + p picture
425 r + f for
426 r + h her
427 r + y your
428 r + n near
429 r + l learn
430 r + t + Dup tree
431 r + t + f father
432 r + t + s + Dup start
433 r + t + y try
434 r + t + l letter
435 r + t + l + Dup letter
436 r + a are
437 r + a + f far
438 r + a + d read
439 r + a + d + f hard
440 r + a + h hard
441 r + a + p part
442 r + a + l + y really
443 r + a + l + y + Dup really
444 r + a + t + p part
445 r + a + t + s + Dup start
446 r + w were
447 r + w + h where
448 r + w + t water
449 r + w + a + n + s answer
450 r + g + t great
451 r + g + a + l large
452 r + v + y very
453 r + v + n never
454 r + v + l later
455 r + m + f form
456 r + c + a car
457 r + c + a + y carry
458 r + c + a + y + Dup carry
459 r + u + n run
460 r + u + t + h through
461 r + u + t + n turn
462 r + ' + t + s story
463 r + o or
464 r + o + f for
465 r + o + t + y + s story
466 r + o + a + n + h another
467 r + o + w work
468 r + o + w + d word
469 r + o + w + l + d world
470 r + o + g grow
471 r + o + LeftDoubleClick grow
472 r + o + k + w work
473 r + o + m more
474 r + o + m + f from
475 r + o + m + t + h mother
476 r + o + u our
477 r + o + u + f four
478 r + o + u + y your
479 r + o + u + a around
480 r + o + u + a + n around
481 r + o + u + a + n + d around
482 r + o + u + g group
483 r + o + u + g + p group
484 r + o + u + g + t + h through
485 r + o + u + c + t + n country
486 r + o + u + c + t + n + y country
487 r + i + t + h their
488 r + i + t + s + f first
489 r + i + a air
490 r + i + w + t write
491 r + i + g + h right
492 r + i + g + l girl
493 r + i + g + t + h right
494 r + i + v river
495 r + i + v + Dup river
496 r + i + m + t + n + p important
497 r + i + c + l + h children

View File

@@ -16,4 +16,7 @@ export interface ActionInfo {
variant: "left" | "right"; variant: "left" | "right";
variantOf: number; variantOf: number;
keyCode: string; keyCode: string;
printable?: boolean;
separator?: boolean;
breaking?: boolean;
} }

19
src/lib/assets/layouts/layout.d.ts vendored Normal file
View 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;
}

View 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

View File

@@ -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]

View File

@@ -6,15 +6,9 @@ import type {
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 { import { changes, ChangeType, layout, settings } from "$lib/undo-redo.js";
changes,
ChangeType,
chords,
layout,
settings,
} from "$lib/undo-redo.js";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { serialPort } from "../serial/connection"; import { activeProfile, deviceChords, serialPort } from "../serial/connection";
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout"; import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords"; import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
@@ -50,11 +44,9 @@ 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 [ layout: (get(layout)[get(activeProfile)]?.map((it) =>
number[], it.map((it) => it.action),
number[], ) ?? []) as [number[], number[], number[]],
number[],
],
}; };
} }
@@ -62,7 +54,7 @@ export function createChordBackup(): CharaChordFile {
return { return {
charaVersion: 1, charaVersion: 1,
type: "chords", type: "chords",
chords: get(chords).map((it) => [it.actions, it.phrase]), chords: get(deviceChords).map((it) => [it.actions, it.phrase]),
}; };
} }
@@ -70,26 +62,30 @@ export function createSettingsBackup(): CharaSettingsFile {
return { return {
charaVersion: 1, charaVersion: 1,
type: "settings", type: "settings",
settings: get(settings).map((it) => it.value), settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [],
}; };
} }
export async function restoreBackup(event: Event) { export async function restoreBackup(
event: Event,
only?: "chords" | "layout" | "settings",
) {
const input = (event.target as HTMLInputElement).files![0]; const input = (event.target as HTMLInputElement).files![0];
if (!input) return; if (!input) return;
const text = await input.text(); 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 {
} }
} }
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) {
@@ -97,9 +93,15 @@ export function restoreFromFile(
const recent = file.history[0]; const recent = file.history[0];
if (!recent) return; if (!recent) return;
let backupDevice = recent[1].device; let backupDevice = recent[1].device;
if (backupDevice === "TWO") backupDevice = "ONE"; if (backupDevice === "TWO" || backupDevice === "M4G")
backupDevice = "ONE";
else if (backupDevice === "ZERO" || backupDevice === "ENGINE")
backupDevice = "X";
let currentDevice = get(serialPort)?.device; let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO") currentDevice = "ONE"; if (currentDevice === "TWO" || currentDevice === "M4G")
currentDevice = "ONE";
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
currentDevice = "X";
if (backupDevice !== currentDevice) { if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device"); alert("Backup is incompatible with this device");
@@ -107,34 +109,46 @@ export function restoreFromFile(
} }
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"
? getChangesFromLayoutFile(recent[1])
: []),
...(!only || only === "settings"
? getChangesFromSettingsFile(recent[2])
: []),
]);
return changes; return changes;
}); });
break; 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: {
@@ -148,7 +162,9 @@ export function restoreFromFile(
export function getChangesFromChordFile(file: CharaChordFile) { export function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = []; const changes: Change[] = [];
const existingChords = new Set( const existingChords = new Set(
get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])), get(deviceChords).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]))) {
@@ -167,12 +183,13 @@ export function getChangesFromChordFile(file: CharaChordFile) {
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),
}); });
} }
} }
@@ -183,12 +200,13 @@ 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),
}); });
} }
} }

View 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>;
}

View 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;

View 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
View 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}`);
}

View File

@@ -10,14 +10,18 @@
replay, replay,
cursor = false, cursor = false,
keys = false, keys = false,
paused = false,
children, children,
ondone, ondone,
ontick,
}: { }: {
replay: ReplayPlayer | Replay; replay: ReplayPlayer | Replay;
cursor?: boolean; cursor?: boolean;
keys?: boolean; keys?: boolean;
paused?: boolean;
children?: Snippet; children?: Snippet;
ondone?: () => void; ondone?: () => void;
ontick?: (time: number) => void;
} = $props(); } = $props();
let replayPlayer: ReplayPlayer | undefined = $state(); let replayPlayer: ReplayPlayer | undefined = $state();
@@ -45,6 +49,10 @@
$effect(() => { $effect(() => {
if (!svg || !text) return; if (!svg || !text) return;
if (paused) {
text.textContent = finalText ?? "";
return;
}
const player = const player =
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay); replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
replayPlayer = player; replayPlayer = player;
@@ -63,6 +71,7 @@
const unsubscribePlayer = player.subscribe(apply); const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer; textRenderer = renderer;
player.onTick = ontick;
player.onDone = ondone; player.onDone = ondone;
player.start(); player.start();
apply(); apply();
@@ -70,8 +79,11 @@
renderer.animated = true; renderer.animated = true;
}); });
return () => { return () => {
textRenderer = undefined;
replayPlayer = undefined;
unsubscribePlayer(); unsubscribePlayer();
player?.destroy(); player.destroy();
renderer.destroy();
}; };
}); });
@@ -88,7 +100,7 @@
{#key replay} {#key replay}
<svg bind:this={svg}></svg> <svg bind:this={svg}></svg>
{#if browser} {#if browser}
<span use:innerText={text}></span> <span use:innerText={text} style:opacity={paused ? 1 : 0}></span>
{:else if !(replay instanceof ReplayPlayer)} {:else if !(replay instanceof ReplayPlayer)}
{finalText} {finalText}
{/if} {/if}
@@ -104,7 +116,6 @@
} }
span { span {
opacity: 0;
white-space: pre-wrap; white-space: pre-wrap;
overflow-wrap: break-word; overflow-wrap: break-word;
} }
@@ -113,15 +124,15 @@
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
font-family: inherit;
font-size: inherit;
color: inherit; color: inherit;
font-size: inherit;
font-family: inherit;
user-select: none; user-select: none;
} }
svg > :global(text) { svg > :global(text) {
font-family: inherit;
font-size: inherit; font-size: inherit;
font-family: inherit;
fill: currentColor; fill: currentColor;
dominant-baseline: middle; dominant-baseline: middle;
} }

View File

@@ -83,24 +83,24 @@
<style> <style>
section { section {
display: grid;
position: relative; position: relative;
margin: 1em; margin: 1em;
margin-bottom: 0; margin-bottom: 0;
display: grid;
height: 3em; height: 3em;
font-size: 2em; font-size: 2em;
} }
.rating { .rating {
font-weight: bold;
font-style: italic; font-style: italic;
font-weight: bold;
text-transform: uppercase; text-transform: uppercase;
} }
.tile { .tile {
border-radius: 0.1em;
width: 100%; width: 100%;
height: 0.2em; height: 0.2em;
border-radius: 0.1em;
} }
kbd { kbd {
@@ -112,19 +112,19 @@
} }
.chord { .chord {
will-change: transform, opacity, scale; display: flex;
position: absolute; position: absolute;
top: 0; top: 0;
left: 50%; left: 50%;
display: flex;
flex-direction: column; flex-direction: column;
margin-inline-end: 1em;
padding-inline: 0.1em;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
transition: transition:
opacity 0.3s ease, opacity 0.3s ease,
translate 0.3s ease, translate 0.3s ease,
scale 0.3s ease; scale 0.3s ease;
will-change: transform, opacity, scale;
margin-inline-end: 1em;
padding-inline: 0.1em;
} }
</style> </style>

View 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>

View 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>

View File

@@ -12,7 +12,10 @@ export class ReplayPlayer {
startTime = performance.now(); startTime = performance.now();
private animationFrameId: number | null = null; private animationFrameId: ReturnType<typeof requestAnimationFrame> | null =
null;
private timeoutId: ReturnType<typeof setTimeout> | null = null;
timescale = 1; timescale = 1;
@@ -20,6 +23,8 @@ export class ReplayPlayer {
onDone?: () => void; onDone?: () => void;
onTick?: (time: number) => void;
constructor( constructor(
readonly replay: Replay, readonly replay: Replay,
plugins: ReplayPlugin[] = [], plugins: ReplayPlugin[] = [],
@@ -47,6 +52,7 @@ export class ReplayPlayer {
} }
const now = performance.now() - this.startTime; const now = performance.now() - this.startTime;
this.onTick?.(now);
while ( while (
this.replayCursor < this.replay.keys.length && this.replayCursor < this.replay.keys.length &&
@@ -131,7 +137,7 @@ export class ReplayPlayer {
} }
return this; return this;
} }
setTimeout(() => { this.timeoutId = setTimeout(() => {
this.startTime = performance.now(); this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this)); this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}, delay); }, delay);
@@ -139,6 +145,9 @@ export class ReplayPlayer {
} }
destroy() { destroy() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.animationFrameId) { if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId); cancelAnimationFrame(this.animationFrameId);
} }

View File

@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
} }
} }
} }
console.log(this.tokens);
clearTimeout(this.timeout); clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) { if (replay.stepper.held.size === 0) {

View 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);
}
}

View File

@@ -56,7 +56,7 @@ export class ReplayRecorder {
finish(trim = true, round = true) { finish(trim = true, round = true) {
return { return {
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round), start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round) ?? 0,
finish: maybeRound( finish: maybeRound(
trim trim
? Math.max(...this.replay.map((it) => it[2] + it[3])) ? Math.max(...this.replay.map((it) => it[2] + it[3]))
@@ -74,6 +74,6 @@ export class ReplayRecorder {
] as const, ] as const,
) )
.sort((a, b) => a[2] - b[2]), .sort((a, b) => a[2] - b[2]),
}; } satisfies Replay;
} }
} }

View File

@@ -36,6 +36,7 @@ export class TextRenderer {
); );
this.cursorNode.setAttribute("x", "0"); this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0"); this.cursorNode.setAttribute("y", "0");
this.cursorNode.setAttribute("class", "cursor");
this.svg.appendChild(this.cursorNode); this.svg.appendChild(this.cursorNode);
} }
@@ -278,6 +279,18 @@ export class TextRenderer {
} }
} }
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) { private isShiny(char: TextToken, index: number) {
return ( return (
this.shiny?.includes(index) || this.shiny?.includes(index) ||

View File

@@ -1,71 +0,0 @@
<script lang="ts">
import type { RoomMember } from "matrix-js-sdk";
import { matrixClient, memberColor } from "./chat";
import { theme } from "$lib/preferences";
import { hexFromArgb } from "@material/material-color-utilities";
let { members }: { members: RoomMember[] } = $props();
</script>
<div class="member-list">
{#each members as member (member.userId)}
{@const avatar = member.getMxcAvatarUrl()}
<div class="member">
{#if avatar}
<img
class="avatar"
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
alt={member.name}
width="32"
height="32"
/>
{:else}
{@const color = memberColor(member, $theme)}
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
<div
style:background={hexFromArgb(modeColor.color)}
style:color={hexFromArgb(modeColor.onColor)}
class="avatar avatar-placeholder icon"
>
person
</div>
{/if}
<span>{member.name}</span>
</div>
{/each}
</div>
<style lang="scss">
.avatar {
border-radius: 50%;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
}
.member {
display: flex;
align-items: center;
gap: 0.5rem;
}
.member-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 8px;
overflow-y: auto;
height: 100%;
}
span {
word-break: break-all;
}
</style>

View File

@@ -1,251 +0,0 @@
<script lang="ts">
import type {
EventTimeline,
MatrixEvent,
MsgType,
Room,
RoomEvent,
RoomMember,
RoomMemberEvent,
} from "matrix-js-sdk";
import { onDestroy, onMount, tick } from "svelte";
import { matrixClient } from "./chat";
import MatrixEventComponent from "./events/MatrixEvent.svelte";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
import { type Socket, io } from "socket.io-client";
import { SvelteMap } from "svelte/reactivity";
let { timeline }: { timeline: EventTimeline } = $props();
const excludeEvents = ["m.reaction", "m.room.redaction"];
let events = $state(
timeline
.getEvents()
.filter((it) => !excludeEvents.includes(it.getType()))
.reverse(),
);
let recorder = $state(new ReplayRecorder());
let showCursor = $state(false);
let timelineElement: HTMLElement = $state()!;
async function onTimeline(
event: MatrixEvent,
room?: Room,
toStartOfTimeline?: boolean,
) {
if (room?.roomId !== timeline.getRoomId()) return;
const sender = event.getSender();
if (sender) {
live.delete(sender);
}
if (excludeEvents.includes(event.getType())) return;
if (toStartOfTimeline) {
events.push(event);
} else {
const needScroll = timelineElement.scrollTop < 20;
events.unshift(event);
if (needScroll) {
await tick();
timelineElement.scroll({
top: 0,
behavior: "smooth",
});
}
}
}
let typing = $state<string[]>([]);
function onTyping(event: MatrixEvent, member: RoomMember) {
typing = event.event.content?.["user_ids"] ?? [];
}
async function send() {
const roomId = timeline.getRoomId();
if (!roomId) return;
const finalText = recorder.player.stepper.text
.map((token) => token.text)
.join("");
const finalRecording = recorder.finish();
if (!finalText) return;
recorder = new ReplayRecorder();
await $matrixClient.sendMessage(roomId, {
msgtype: "m.text" as MsgType.Text,
body: finalText,
// @ts-expect-error
"m.replay": finalRecording,
});
}
function onKey(event: KeyboardEvent) {
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
send();
return;
} else {
recorder.next(event);
}
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
recorder = new ReplayRecorder();
} else {
socket.emit("message", {
timeStamp: event.timeStamp,
type: event.type,
key: event.key,
code: event.code,
username: $matrixClient.getUserId(),
});
}
}
let socket: Socket = $state()!;
let live = new SvelteMap<string, ReplayRecorder>();
onMount(() => {
socket = io("https://srv.charachorder.io");
socket.emit("join", timeline.getRoomId());
socket.on("message", async ({ message }) => {
let userRecorder = live.get(message.username);
if (!userRecorder) {
userRecorder = new ReplayRecorder();
live.set(message.username, userRecorder);
}
await tick();
userRecorder.next(message);
if (userRecorder.player.stepper.text.length === 0) {
live.delete(message.username);
}
});
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
});
onDestroy(() => {
socket?.disconnect();
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
});
</script>
<section>
<div bind:this={timelineElement} class="timeline">
{#each live.entries() as [userId, recorder] (userId)}
{@const roomId = timeline.getRoomId()}
{#if roomId}
{@const room = $matrixClient.getRoom(roomId)}
{@const member = room?.getMember(userId)}
{#if member}
<MatrixEventComponent sender={member} replay={recorder.player} />
{/if}
{/if}
{/each}
{#each events as event, i (event.event["event_id"])}
{@const prev = events[i + 1]}
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
{/each}
</div>
<div class="static-elements">
<div class="indicators"></div>
<div class="input-box">
<button class="icon">add</button>
<div
role="textbox"
tabindex="0"
class="input"
onkeydown={onKey}
onkeyup={onKey}
onfocusin={() => (showCursor = true)}
onfocusout={() => (showCursor = false)}
>
<CharRecorder replay={recorder.player} cursor={showCursor} />
</div>
<button class="icon" onclick={send}>send</button>
</div>
</div>
</section>
<style lang="scss">
$border-radius: 16px;
h2 {
height: min-content;
}
.input {
border: 1px solid var(--md-sys-color-outline);
flex-grow: 1;
cursor: text;
padding: 0.5em;
font-size: 1rem;
border-radius: $border-radius;
text-wrap: wrap;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-word;
&:focus-visible {
outline: none;
}
}
.input-box {
display: flex;
gap: 4px;
padding-block: 8px;
flex-shrink: 0;
width: 100%;
}
.static-elements {
position: relative;
width: 100%;
}
.timeline {
contain: content;
height: auto;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
overflow-x: hidden;
flex-grow: 1;
width: 100%;
}
.back-to-present {
position: fixed;
bottom: 0;
}
.scroll-controls {
position: sticky;
bottom: 0;
min-height: 16px;
background: linear-gradient(
to bottom,
transparent,
var(--md-sys-color-background)
);
}
section {
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: flex-end;
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,109 +0,0 @@
import { derived, writable, type Writable } from "svelte/store";
import type {
ClientEvent,
LoginResponse,
MatrixClient,
RoomMember,
} from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage";
import {
themeFromSourceColor,
argbFromHex,
type CustomColorGroup,
} from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences";
import { MatrixRx } from "./matrix-rx/client";
export const matrixClient: Writable<MatrixClient> = writable();
export const isLoggedIn: Writable<boolean> = writable(false);
export const matrix = derived(
[matrixClient, isLoggedIn],
([matrixClient, isLoggedIn]) =>
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
);
export const currentRoomId = persistentWritable<string | null>(
"currentRoomId",
null,
);
function getStoredLogin(): LoginResponse | undefined {
try {
return JSON.parse(localStorage.getItem("matrix-login")!);
} catch {
return undefined;
}
}
export function storeLogin(response: LoginResponse) {
localStorage.setItem("matrix-login", JSON.stringify(response));
}
export async function initMatrixClient() {
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
"matrix-js-sdk"
);
const storedLogin = getStoredLogin();
const store = new IndexedDBStore({
dbName: "matrix",
indexedDB: window.indexedDB,
});
const cryptoStore = new IndexedDBCryptoStore(
window.indexedDB,
"matrix-crypto",
);
const client = createClient({
baseUrl: import.meta.env.VITE_MATRIX_URL,
userId: storedLogin?.user_id,
accessToken: storedLogin?.access_token,
timelineSupport: true,
store,
cryptoStore,
});
console.log("store");
await store.startup();
console.log("cryptoStore");
await cryptoStore.startup();
console.log("client");
await client.startClient();
client.once("sync" as ClientEvent.Sync, () => {
isLoggedIn.set(client.isLoggedIn());
});
const loginToken = new URLSearchParams(window.location.search).get(
"loginToken",
);
if (loginToken) {
storeLogin(await client.loginWithToken(loginToken));
window.history.replaceState({}, document.title, window.location.pathname);
isLoggedIn.set(client.isLoggedIn());
}
matrixClient.set(client);
console.log("done");
}
export function memberColor(
member: RoomMember,
theme: UserTheme,
): CustomColorGroup {
let hash = 0;
member.userId.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += value.toString(16).padStart(2, "0");
}
return themeFromSourceColor(argbFromHex(theme.color), [
{ value: argbFromHex(color), name: "member", blend: true },
]).customColors.find((c) => c.color.name === "member")!;
}

View File

@@ -1,357 +0,0 @@
<script lang="ts">
import type {
EventTimeline,
MatrixEvent,
MatrixEventEvent,
Relations,
RelationsEvent,
RoomMember,
} from "matrix-js-sdk";
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
import { matrixClient, memberColor } from "../chat";
import { theme } from "$lib/preferences";
import { hexFromArgb } from "@material/material-color-utilities";
import { fade } from "svelte/transition";
import type { Replay } from "$lib/charrecorder/core/types";
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
import { onDestroy, onMount } from "svelte";
import { writable } from "svelte/store";
let {
event,
prev,
sender,
replay: replayPlayer,
timeline,
}: {
event?: MatrixEvent;
prev?: MatrixEvent;
sender?: RoomMember | null;
replay?: Replay | ReplayPlayer;
timeline?: EventTimeline;
} = $props();
let toolbarHover = $state(false);
let mainHover = $state(false);
let hover = $derived(toolbarHover || mainHover);
let replay: Replay | undefined = $state();
let reactions: Relations | undefined = $state(
timeline && event?.event.event_id
? timeline
.getTimelineSet()
.relations.getChildEventsForEvent(
event.event.event_id,
"m.annotation",
"m.reaction",
)
: undefined,
);
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
function createRelations() {
if (!timeline || !event?.event.event_id) return;
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
reactions?.off(
"Relations.remove" as RelationsEvent.Remove,
createRelations,
);
reactions = timeline
.getTimelineSet()
.relations.getChildEventsForEvent(
event.event.event_id,
"m.annotation",
"m.reaction",
);
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
reactions?.on(
"Relations.redaction" as RelationsEvent.Redaction,
createRelations,
);
annotations.set(
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
);
console.log("create");
}
onMount(() => {
createRelations();
event?.on(
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
createRelations,
);
});
onDestroy(() => {
event?.off(
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
createRelations,
);
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
reactions?.off(
"Relations.remove" as RelationsEvent.Remove,
createRelations,
);
reactions?.off(
"Relations.redaction" as RelationsEvent.Redaction,
createRelations,
);
});
</script>
<div
class="event"
role="log"
onmouseover={() => (mainHover = true)}
onfocus={() => (mainHover = true)}
onmouseout={() => (mainHover = false)}
onblur={() => (mainHover = false)}
>
{#if event && hover}
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
{/if}
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
{@const color = memberColor(sender, $theme)}
{@const avatarMxc = sender.getMxcAvatarUrl()}
{#if avatarMxc}
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
<img
class="avatar"
src={avatar}
alt={sender.name}
width="32"
height="32"
/>
{:else}
<div
class="avatar avatar-placeholder icon"
style:background={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
style:color={hexFromArgb(
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
)}
>
person
</div>
{/if}
<div
class="sender"
style:color={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
>
<strong>{sender.name}</strong>
{#if replay || replayPlayer}
<div class="dots">
{#each new Array(3) as _, i}
<div
style:animation-delay={i * 0.2 + "s"}
style:background={hexFromArgb(
$theme.mode === "dark" ? color.dark.color : color.light.color,
)}
class="dot"
></div>
{/each}
</div>
{/if}
</div>
{/if}
<div class="content">
{#if event}
{#if event.getType() === "m.room.message"}
<MatrixMessageEvent {event} bind:replay />
{:else}
<details>
<summary>{event.getType()}</summary>
<pre>{JSON.stringify(event.event, null, 2)}</pre>
</details>
{/if}
{/if}
{#if replayPlayer}
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
{/if}
</div>
{#if event && hover}
<div
role="toolbar"
tabindex="0"
class="toolbar"
transition:fade={{ duration: 100 }}
onmouseover={() => (toolbarHover = true)}
onfocus={() => (toolbarHover = true)}
onmouseout={() => (toolbarHover = false)}
onblur={() => (toolbarHover = false)}
>
<button class="icon">add_reaction</button>
<button class="icon">reply</button>
{#if event.event.content?.["m.replay"]}
{#if replay}
<button class="icon" onclick={() => (replay = undefined)}>stop</button
>
{:else}
<button
class="icon"
onclick={() => (replay = event.event.content?.["m.replay"])}
>replay</button
>
{/if}
{/if}
<button class="icon">more_horiz</button>
</div>
{/if}
{#if $annotations && $annotations.length > 0}
<div class="reactions">
{#each $annotations as [reaction, events]}
<button class="reaction"
>{reaction} <span class="count">{events.size}</span></button
>
{/each}
</div>
{/if}
</div>
<style lang="scss">
details {
opacity: 0.5;
word-wrap: break-word;
}
pre {
text-wrap: wrap;
word-wrap: break-word;
}
.toolbar {
position: absolute;
top: -26px;
right: 0;
background: var(--md-sys-color-secondary-container);
color: var(--md-sys-color-on-secondary-container);
padding: 4px;
border-radius: 4px;
display: flex;
z-index: 100;
button {
font-size: 16px;
width: 24px;
height: 24px;
}
}
.dots {
display: flex;
gap: 2px;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1s infinite;
}
.sender,
.avatar {
margin-block: 2px 4px;
}
.avatar {
grid-area: avatar;
width: 32px;
height: 32px;
border-radius: 50%;
translate: 0 2px;
}
div.avatar {
display: flex;
justify-content: center;
align-items: center;
}
.sender {
display: flex;
grid-area: sender;
align-items: center;
gap: 8px;
}
.reactions {
grid-area: reactions;
margin-top: 2px;
display: flex;
gap: 4px;
}
.reaction {
border: 1px solid var(--md-sys-color-outline);
padding: 6px;
border-radius: 6px;
height: 24px;
display: flex;
font-size: 12px;
> .count {
font-size: 10px;
}
}
.event {
display: grid;
position: relative;
padding-inline: 0.5em;
margin-inline: 0.5em;
padding-block: 0.25em;
border-radius: 4px;
grid-template-areas:
"avatar sender date"
"avatar content content"
"none reactions reactions";
grid-template-columns: 32px 1fr auto;
}
.content {
grid-area: content;
text-wrap: wrap;
word-wrap: break-word;
}
.reactions,
.content,
.sender {
margin-inline: 8px;
}
.backdrop {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0.25;
background: var(--md-sys-color-surface-variant);
border-radius: 8px;
}
</style>

View File

@@ -1,56 +0,0 @@
<script lang="ts">
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
import type { Replay } from "$lib/charrecorder/core/types";
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { fade } from "svelte/transition";
import { matrixClient } from "../chat";
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
$props();
</script>
<div>
{#if event.event.content?.msgtype === "m.image"}
<img
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
alt={event.event.content["body"]}
/>
{:else}
<span class="content" style:opacity={replay && 0}
>{event.event.content?.["body"]}</span
>
{/if}
{#if replay}
<div class="replay" out:fade>
<CharRecorder
{replay}
cursor={true}
keys={true}
ondone={() => (replay = undefined)}
/>
</div>
{/if}
</div>
<style lang="scss">
div {
position: relative;
min-height: 1.5em;
}
img {
max-width: 100%;
max-height: 16em;
border-radius: 8px;
}
.content {
transition: opacity 0.2s;
}
.replay {
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -1,71 +0,0 @@
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
import {
filter,
map,
type Observable,
of,
distinctUntilChanged,
merge,
} from "rxjs";
import { fromMatrixClientEvent } from "./events";
function roomListDistinct(prev: Room[], curr: Room[]) {
if (prev.length !== curr.length) return false;
for (let i = 0; i < prev.length; i++) {
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
}
return true;
}
export class MatrixRx {
topLevelRooms$: Observable<Room[]>;
topLevelSpaces$: Observable<Room[]>;
topLevelChats$: Observable<Room[]>;
constructor(private client: MatrixClient) {
this.topLevelRooms$ = merge(
of([]),
fromMatrixClientEvent(client, "Room"),
fromMatrixClientEvent(client, "deleteRoom"),
fromMatrixClientEvent(client, "Room.myMembership"),
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
filter(
([_room, prev, curr]) =>
prev.getStateEvents("m.space.parent").length !==
curr.getStateEvents("m.space.parent").length,
),
),
).pipe(
map(() =>
this.client.getVisibleRooms().filter(
(room) =>
room.getMyMembership() !== "leave" &&
room
.getLiveTimeline()
.getState("f" as Direction.Forward)
?.getStateEvents("m.space.parent").length === 0,
),
),
distinctUntilChanged(roomListDistinct),
);
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
distinctUntilChanged(roomListDistinct),
);
this.topLevelChats$ = this.topLevelRooms$.pipe(
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
distinctUntilChanged(roomListDistinct),
);
}
}
export class SpaceRx {
constructor(
private client: MatrixClient,
private space: Room,
) {}
}

View File

@@ -1,11 +0,0 @@
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
import { fromEvent, type Observable } from "rxjs";
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
client: MatrixClient,
eventName: `${T}`, // hack so we can use strings instead of enums
): Observable<Parameters<ClientEventHandlerMap[T]>> {
return fromEvent(client, eventName) as Observable<
Parameters<ClientEventHandlerMap[T]>
>;
}

View File

@@ -1,85 +0,0 @@
import type {
MatrixClient,
MatrixEvent,
Room,
Direction,
RoomState,
RoomStateEventHandlerMap,
EventType,
} from "matrix-js-sdk";
import { fromMatrixClientEvent } from "./events";
import {
map,
filter,
merge,
startWith,
Observable,
of,
fromEvent,
concat,
defer,
} from "rxjs";
export function matrixRoom$(
client: MatrixClient,
roomId: string | undefined,
): Observable<Room | undefined> {
return merge([
fromMatrixClientEvent(client, "Room").pipe(
filter(([room]) => room.roomId === roomId),
),
fromMatrixClientEvent(client, "deleteRoom").pipe(
filter(([id]) => id === roomId),
),
]).pipe(
startWith([]),
map(() => client.getRoom(roomId) ?? undefined),
);
}
export function roomTimeline$(
client: MatrixClient,
room: Room | undefined,
): Observable<MatrixEvent[] | undefined> {
if (!room) return of(undefined);
const eventTimeline = room.getLiveTimeline();
return fromMatrixClientEvent(client, "Room.timeline").pipe(
filter(
([, eventRoom]) =>
eventRoom !== undefined && eventRoom.roomId === room.roomId,
),
startWith([]),
map(() => eventTimeline.getEvents()),
);
}
export function roomCurrentStateEvents$(
client: MatrixClient,
room: Room,
eventType: EventType | string,
): Observable<MatrixEvent[]> {
return concat(
defer(() =>
of(
room
.getLiveTimeline()
.getState("f" as Direction.Forward)
?.getStateEvents(eventType) ?? [],
),
),
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
filter(([room]) => room.roomId === room.roomId),
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
),
);
}
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
state: RoomState,
eventName: `${T}`,
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
return fromEvent(state, eventName) as Observable<
Parameters<RoomStateEventHandlerMap[T]>
>;
}

View File

@@ -1,19 +0,0 @@
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
import { filter, map, of, startWith, type Observable } from "rxjs";
import { fromMatrixClientEvent } from "./events";
export function roomTimeline(
client: MatrixClient,
roomId: string | undefined,
): Observable<MatrixEvent[]> {
if (!roomId) return of([]);
const room = client.getRoom(roomId);
if (!room) return of([]);
const eventTimeline = room.getLiveTimeline();
return fromMatrixClientEvent(client, "Room.timeline").pipe(
filter(([, room]) => room?.roomId === roomId),
startWith([]),
map(() => eventTimeline.getEvents()),
);
}

View 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>

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import {
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import type { ParseResult } from "./parse-meta";
import { actionTooltip } from "$lib/title";
import LL from "$i18n/i18n-svelte";
import ProgressButton from "$lib/ProgressButton.svelte";
import type { EditorView } from "codemirror";
import { createSaveTask } from "./save-chords";
import { goto } from "$app/navigation";
let { parsed, view }: { parsed: ParseResult; view: EditorView } = $props();
$inspect(parsed);
let added = $derived(
parsed.chords.reduce(
(acc, chord) =>
acc +
(chord.phrase && chord.phrase.originalValue === undefined ? 1 : 0),
0,
),
);
let changed = $derived(
parsed.chords.reduce(
(acc, chord) =>
acc +
(chord.phrase?.originalValue !== undefined &&
chord.phrase.originalValue !== chord.phrase.value
? 1
: 0),
0,
),
);
let error: Error | undefined = $state(undefined);
async function save() {
const port = $serialPort;
if (!view || !port) return;
error = undefined;
const task = createSaveTask(view);
const total = task.remove.length + task.set.length;
$syncStatus = "uploading";
$syncProgress = { current: 0, max: total };
let progressCount = 0;
for (const input of task.remove) {
try {
await port.deleteChord({ actions: input });
} catch (e) {
error = e as Error;
}
progressCount++;
$syncProgress = { current: progressCount, max: total };
}
for (const [input, phrase] of task.set) {
try {
await port.setChord({ actions: input, phrase });
} catch (e) {
error = e as Error;
}
progressCount++;
$syncProgress = { current: progressCount, max: total };
}
if (error !== undefined) {
goto("/terminal");
}
await sync();
}
let removed = $derived(parsed.removed.length);
</script>
<div class="container">
{#if added + changed + removed !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
<div {@attach actionTooltip($LL.saveActions.SAVE())}>
<ProgressButton
disabled={$syncStatus !== "done"}
working={$syncStatus === "uploading" || $syncStatus === "downloading"}
progress={$syncProgress && $syncStatus === "uploading"
? $syncProgress.current / $syncProgress.max
: 0}
style="--height: 36px"
error={error !== undefined
? (error.message ?? error.toString())
: undefined}
onclick={save}
>
<span class="icon">save</span>
{$LL.saveActions.SAVE()}
</ProgressButton>
</div>
{/if}
<div>
{#if added}
<span class="added">+{added}</span>
{/if}
{#if changed}
<span class="changed">~{changed}</span>
{/if}
{#if removed}
<span class="removed">-{removed}</span>
{/if}
</div>
{#if parsed.aliases.size > 0}
<div class="section">
<span class="icon">content_copy</span>
<span>{parsed.aliases.size}</span>
</div>
{/if}
</div>
<style lang="scss">
.icon {
font-size: 16px;
}
.container {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 32px;
}
.section {
display: flex;
align-items: center;
gap: 8px;
}
.added {
color: var(--md-sys-color-success);
}
.changed {
color: var(--md-sys-color-warning);
}
.removed {
color: var(--md-sys-color-error);
}
</style>

View File

@@ -0,0 +1,156 @@
import { linter, type Diagnostic } from "@codemirror/lint";
import { parsedChordsField } from "./parsed-chords-plugin";
export function actionLinter(config?: Parameters<typeof linter>[1]) {
const finalConfig: Parameters<typeof linter>[1] = {
...config,
needsRefresh(update) {
return (
update.startState.field(parsedChordsField) !==
update.state.field(parsedChordsField)
);
},
};
return linter((view) => {
console.log("lint");
const diagnostics: Diagnostic[] = [];
const parsed = view.state.field(parsedChordsField);
for (const chord of parsed.chords) {
if (chord.disabled) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-ignored",
message: `Chord disabled`,
});
}
if (chord.compounds) {
for (const compound of chord.compounds) {
if (compound.actions.length === 0 && compound.parent) {
const replacement = view.state.doc.sliceString(
compound.parent.range[0],
compound.parent.input!.range[1],
);
diagnostics.push({
from: compound.range[0],
to: compound.range[1],
severity: "warning",
message: `Compound literal can be replaced with "${replacement}"`,
actions: [
{
name: "Replace",
apply(view, from, to) {
view.dispatch({
changes: {
from,
to,
insert: replacement + "|",
},
});
},
},
],
});
}
}
const lastCompound = chord.compounds.at(-1);
if (lastCompound) {
const from = chord.range[0];
const to = lastCompound.range[1];
if (lastCompound.parent) {
diagnostics.push({
from,
to,
severity: "info",
markClass: "chord-child",
message: `Child of ${view.state.doc.sliceString(lastCompound.parent.range[0], lastCompound.parent.range[1])}`,
actions: [
{
name: "Select Parent",
apply(view) {
view.dispatch({
selection: {
anchor: lastCompound.parent!.range[0],
},
scrollIntoView: true,
});
},
},
],
});
} else {
diagnostics.push({
from,
to,
severity: "warning",
message: `Orphan compound`,
});
}
}
}
if (chord.children) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-parent",
message: `Parent of ${chord.children.length} compound(s)`,
actions: chord.children.map((child) => ({
name: `Go to ${view.state.doc.sliceString(child.range[0], child.range[1])}`,
apply(view) {
view.dispatch({
selection: {
anchor: child.range[0],
},
scrollIntoView: true,
});
},
})),
});
}
if (chord.phrase) {
if (!chord.phrase.originalValue) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-new",
message: `New Chord`,
});
} else if (chord.phrase.originalValue !== chord.phrase.value) {
diagnostics.push({
from: chord.range[0],
to: chord.range[1],
severity: "info",
markClass: "chord-unchanged",
message: `Phrase changed`,
});
}
if (chord.aliases) {
diagnostics.push({
from: chord.phrase.range[0],
to: chord.phrase.range[1],
severity: "warning",
markClass: "chord-alias",
message: `Alias of ${chord.aliases.length} chord(s)`,
actions: chord.aliases.map((alias) => ({
name: `Go to ${view.state.doc.sliceString(alias.range[0], alias.input?.range[1] ?? alias.range[1])}`,
apply(view) {
view.dispatch({
selection: {
anchor: alias.range[0],
},
scrollIntoView: true,
});
},
})),
});
}
}
}
return diagnostics;
}, finalConfig);
}

View File

@@ -0,0 +1,10 @@
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
import { derived } from "svelte/store";
import { reactiveStateField } from "./store-state-field";
const actionMeta = derived([KEYMAP_IDS, KEYMAP_CODES], ([ids, codes]) => ({
ids,
codes,
}));
export const actionMetaPlugin = reactiveStateField(actionMeta);

View File

@@ -0,0 +1,102 @@
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { mount, unmount } from "svelte";
import Action from "$lib/components/Action.svelte";
import type { Range } from "@codemirror/state";
import { parsedChordsField } from "./parsed-chords-plugin";
import { iterActions } from "./parse-meta";
import type { KeyInfo } from "$lib/serial/keymap-codes";
export class ActionWidget extends WidgetType {
component?: {};
constructor(readonly info: KeyInfo) {
super();
}
toDOM() {
if (this.component) {
unmount(this.component);
}
const element = document.createElement("span");
element.style.paddingInline = "2px";
this.component = mount(Action, {
target: element,
props: {
action: this.info,
display: "keys",
inText: true,
withPopover: false,
},
});
return element;
}
override destroy() {
if (this.component) {
unmount(this.component);
}
}
}
function actionWidgets(view: EditorView) {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
for (const chord of view.state.field(parsedChordsField).chords) {
if (chord.range[1] < from || chord.range[0] > to) continue;
iterActions(chord, (action) => {
if (
view.state.selection.ranges.some(
(r) => r.from <= action.range[1] && r.to > action.range[0],
)
) {
return;
}
if (action.info && action.explicit) {
const deco = Decoration.replace({
widget: new ActionWidget(action.info),
});
widgets.push(deco.range(action.range[0], action.range[1]));
}
});
}
}
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 ||
update.selectionSet ||
update.startState.field(parsedChordsField) !=
update.state.field(parsedChordsField)
)
this.decorations = actionWidgets(update.view);
}
},
{
decorations(instance) {
return instance.decorations;
},
provide(plugin) {
return EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
);
},
},
);

View File

@@ -0,0 +1,263 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
import {
composeChordInput,
hasConcatenator,
hashChord,
willBeValidChordInput,
} from "$lib/serial/chord";
import type {
ActionMeta,
ChordMeta,
MetaRange,
ParseResult,
} from "./parse-meta";
import type { Tree } from "@lezer/common";
function parseChordMeta(
tree: Tree,
ids: Map<string, KeyInfo>,
codes: Map<number, KeyInfo>,
sliceString: (from: number, to: number) => string,
): ChordMeta[] {
console.time("parseChordTree");
const result: ChordMeta[] = [];
let current: ChordMeta = { range: [0, 0], valid: false };
let actions: ActionMeta[] = [];
let actionRange: MetaRange | undefined = undefined;
tree.cursor().iterate(
(node) => {
if (node.name === "Action") {
actionRange = [node.from, node.to];
} else if (node.name === "ChordPhrase") {
current.phrase = {
range: [node.from, node.to],
value: [],
valid: true,
actions: [],
hasConcatenator: false,
};
} else if (node.name === "Chord") {
current = { range: [node.from, node.to], valid: false };
} else if (node.name === "ActionString") {
actions = [];
} else if (node.name === "HexNumber") {
const hexString = sliceString(node.from, node.to);
const code = Number.parseInt(hexString, 16);
const parentNode = node.node.parent;
if (parentNode?.type.name === "CompoundLiteral") {
current.compounds ??= [];
current.compounds.push({
range: [parentNode.from, parentNode.to],
value: code,
actions: [],
valid: true, // TODO: validate compound literal
});
} else {
const valid = !(Number.isNaN(code) || code < 0 || code > 1023);
actions.push({
code,
info: codes.get(code),
explicit: true,
valid,
range: actionRange!,
});
}
} else if (
node.name === "ActionId" ||
node.name === "SingleLetter" ||
node.name === "EscapedLetter"
) {
const id = sliceString(node.from, node.to);
const info = ids.get(id);
const value: ActionMeta = {
code: info?.code ?? Number.NaN,
info,
valid: info !== undefined,
range: actionRange!,
};
if (node.name === "ActionId") {
value.explicit = true;
}
actions.push(value);
}
},
(node) => {
if (node.name === "Chord") {
result.push(current);
if (current.phrase) {
current.phrase.actions = actions;
current.phrase.value = actions.map(({ code }) => code);
current.phrase.valid = actions.every(({ valid }) => valid);
current.phrase.hasConcatenator = hasConcatenator(
current.phrase.value,
codes,
);
}
current.valid =
(current.phrase?.valid ?? false) && (current.input?.valid ?? false);
if (!current.valid) {
current.disabled = true;
}
} else if (node.name === "CompoundInput") {
const lastCompound = current.compounds?.at(-1);
current.compounds ??= [];
current.compounds.push({
range: [node.from, node.to],
value: hashChord(
composeChordInput(
actions.map(({ code }) => code),
lastCompound?.value,
),
),
actions,
valid:
willBeValidChordInput(actions.length, lastCompound !== undefined) &&
actions.every(({ valid }) => valid),
});
} else if (node.name === "ChordInput") {
const lastCompound = current.compounds?.at(-1);
current.input = {
range: [node.from, node.to],
value: composeChordInput(
actions.map(({ code }) => code),
lastCompound?.value,
),
valid:
willBeValidChordInput(actions.length, lastCompound !== undefined) &&
actions.every(({ valid }) => valid),
actions,
};
}
},
);
console.timeEnd("parseChordTree");
return result;
}
function resolveChordOverrides(chords: ChordMeta[]): Map<string, ChordMeta> {
console.time("resolveOverrides");
const seen = new Map<string, ChordMeta>();
for (const info of chords) {
if (!info.input || info.disabled) continue;
const key = JSON.stringify(info.input.value);
const override = seen.get(key);
if (override) {
override.overrides ??= [];
override.overrides.push(info);
info.overriddenBy = override;
info.disabled = true;
} else {
seen.set(key, info);
}
}
console.timeEnd("resolveOverrides");
return seen;
}
function resolveChordAliases(chords: ChordMeta[]): Map<string, ChordMeta[]> {
console.time("resolveAliases");
const aliases = new Map<string, ChordMeta[]>();
for (const info of chords) {
if (!info.phrase) continue;
const key = JSON.stringify(info.phrase.value);
const list = aliases.get(key) ?? [];
list.push(info);
aliases.set(key, list);
}
for (const [key, value] of aliases) {
if (value.length <= 1) {
aliases.delete(key);
} else {
for (const info of value) {
info.aliases = value.filter((i) => i !== info);
}
}
}
console.timeEnd("resolveAliases");
return aliases;
}
function resolveCompoundParents(chords: ChordMeta[]): Map<number, ChordMeta> {
console.time("resolveCompoundParents");
const compounds = new Map<number, ChordMeta>();
for (const chord of chords) {
if (chord.input && !chord.disabled) {
compounds.set(hashChord(chord.input.value), chord);
}
}
for (const chord of chords) {
if (chord.compounds) {
for (const compound of chord.compounds) {
const parent = compounds.get(compound.value);
if (parent) {
compound.parent = parent;
}
}
const lastCompound = chord.compounds?.at(-1);
if (lastCompound && lastCompound.parent) {
lastCompound.parent.children ??= [];
lastCompound.parent.children.push(chord);
}
}
}
console.timeEnd("resolveCompoundParents");
return compounds;
}
export function resolveChanges(
chords: ChordMeta[],
inputs: Map<string, ChordMeta>,
deviceChords: CharaChordFile["chords"],
): [CharaChordFile["chords"], Map<string, ChordMeta>] {
console.time("resolveChanges");
const removed: CharaChordFile["chords"] = [];
const exact = new Map<string, ChordMeta>();
for (const chord of chords) {
if (chord.input && chord.phrase && !chord.disabled) {
exact.set(
JSON.stringify([chord.input.value, chord.phrase?.value ?? []]),
chord,
);
}
}
for (const deviceChord of deviceChords) {
const exactMatch = exact.get(JSON.stringify(deviceChord));
if (exactMatch) {
exactMatch.phrase!.originalValue = exactMatch.phrase!.value;
continue;
}
const byInput = inputs.get(JSON.stringify(deviceChord[0]));
if (byInput) {
byInput.phrase!.originalValue = deviceChord[1];
continue;
}
removed.push(deviceChord);
}
console.timeEnd("resolveChanges");
return [removed, exact];
}
export function parseCharaChords(
tree: Tree,
ids: Map<string, KeyInfo>,
codes: Map<number, KeyInfo>,
deviceChords: CharaChordFile["chords"],
sliceString: (from: number, to: number) => string,
): ParseResult {
console.time("parseTotal");
const chords = parseChordMeta(tree, ids, codes, sliceString);
const inputs = resolveChordOverrides(chords);
const aliases = resolveChordAliases(chords);
const compounds = resolveCompoundParents(chords);
const [removed, exact] = resolveChanges(chords, inputs, deviceChords);
console.timeEnd("parseTotal");
return { chords, removed, aliases, compounds, inputs, exact };
}

View File

@@ -0,0 +1,41 @@
import { hoverTooltip } from "@codemirror/view";
import { parsedChordsField } from "./parsed-chords-plugin";
import { type ActionMeta, iterActions } from "./parse-meta";
import { mount, unmount } from "svelte";
import ActionTooltip from "$lib/components/action/ActionTooltip.svelte";
function inRange(pos: number, side: 1 | -1, range: [number, number]) {
if (side < 0) {
return pos > range[0] && pos <= range[1];
} else {
return pos >= range[0] && pos < range[1];
}
}
export const actionHover = hoverTooltip((view, pos, side) => {
const chord = view.state
.field(parsedChordsField)
.chords.find((chord) => inRange(pos, side, chord.range));
if (!chord) return null;
let action = iterActions<ActionMeta>(chord, (action) =>
inRange(pos, side, action.range) ? action : undefined,
);
if (!action?.info) return null;
return {
pos: action.range[0],
end: action.range[1],
create() {
const dom = document.createElement("div");
const element = mount(ActionTooltip, {
target: dom,
props: { info: action.info, valid: true },
});
return {
dom,
destroy() {
unmount(element);
},
};
},
};
});

View File

@@ -0,0 +1,39 @@
import {
EditorView,
ViewPlugin,
ViewUpdate,
type PluginValue,
} from "@codemirror/view";
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
export function actionAutocompletePlugin(
query: (query: string | undefined) => void,
) {
return ViewPlugin.fromClass(
class implements PluginValue {
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
query(this.resolveAutocomplete(update.state));
}
resolveAutocomplete(state: EditorState): string | undefined {
if (state.selection.ranges.length !== 1) return;
const from = state.selection.ranges[0]!.from;
const to = state.selection.ranges[0]!.to;
if (from !== to) return;
const tree = syntaxTree(state);
const node = tree.resolveInner(from, -1).parent;
if (node?.name !== "ExplicitAction") return;
if (node.getChild("ExplicitDelimEnd")) return;
const queryNode = node.getChild("ExplicitDelimStart")?.nextSibling;
return (
(queryNode
? state.doc.sliceString(queryNode.from, queryNode.to)
: undefined) || undefined
);
}
},
);
}

View File

@@ -0,0 +1,44 @@
import { EditorView, showPanel, type Panel } from "@codemirror/view";
import { parsedChordsField } from "./parsed-chords-plugin";
import { mount, unmount } from "svelte";
import ChangesPanel from "./ChangesPanel.svelte";
function changesPanelFunc(view: EditorView): Panel {
let dom = document.createElement("div");
dom.style.display = "contents";
let viewState = $state.raw(view);
let parsed = $state.raw(view.state.field(parsedChordsField));
let component: {};
return {
dom,
mount() {
component = mount(ChangesPanel, {
target: dom,
props: {
get parsed() {
return parsed;
},
get view() {
return viewState;
},
},
});
},
update: (update) => {
if (
update.startState.field(parsedChordsField) !==
update.state.field(parsedChordsField)
) {
console.log("update changes panel");
parsed = update.state.field(parsedChordsField);
}
},
destroy() {
unmount(component);
},
};
}
export function changesPanel() {
return showPanel.of(changesPanelFunc);
}

View File

@@ -0,0 +1,159 @@
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 =
"&emsp;⇛" + (this.hasConcatenator ? "" : "&emsp;");
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);
}*/
this.element = document.createElement("div");
this.element.style.breakAfter = "column";
}
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 },
});
}
},
},
},
);

View File

@@ -0,0 +1,44 @@
import type { CharaChordFile } from "$lib/share/chara-file";
import { StateEffect, StateField } from "@codemirror/state";
import { actionMetaPlugin } from "./action-meta-plugin";
import { syncCharaChords } from "./chord-sync";
import type { EditorView } from "@codemirror/view";
const chordSyncEffect = StateEffect.define<CharaChordFile["chords"]>();
export function editorSyncChords(
view: EditorView,
newDeviceChords: CharaChordFile["chords"],
) {
const { ids, codes } = view.state.field(actionMetaPlugin.field);
const oldDeviceChords = view.state.field(deviceChordField);
const changes = syncCharaChords(
oldDeviceChords,
newDeviceChords,
ids,
codes,
view.state.doc.toString(),
);
view.dispatch({
effects: chordSyncEffect.of(newDeviceChords),
changes,
});
}
export const deviceChordField = StateField.define<CharaChordFile["chords"]>({
create() {
return [];
},
update(value, transaction) {
return (
transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ??
value
);
},
toJSON(value) {
return value;
},
fromJSON(value) {
return value;
},
});

View File

@@ -0,0 +1,135 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
import { describe, it, expect } from "vitest";
import { parseCharaChords } from "./action-serializer";
import { parser } from "./chords.grammar";
import { syncCharaChords } from "./chord-sync";
import { Text } from "@codemirror/state";
const asciiInfo: KeyInfo[] = Array.from(
{ length: 0x7f - 0x20 },
(_, i) =>
({
code: i + 0x20,
id: String.fromCharCode(i + 0x20),
}) satisfies KeyInfo,
);
const asciiCodes = new Map<number, KeyInfo>(
asciiInfo.map((info) => [info.code, info]),
);
const asciiIds = new Map<string, KeyInfo>(
asciiInfo.map((info) => [info.id!, info]),
);
function chords(...strings: string[]): string {
return strings.join("\n");
}
function backup(doc: string): CharaChordFile["chords"] {
const tree = parser.parse(doc);
const result = parseCharaChords(tree, asciiIds, asciiCodes, [], (from, to) =>
doc.slice(from, to),
);
return result.chords
.filter((chord) => !chord.disabled)
.map((chord) => [chord.input?.value ?? [], chord.phrase?.value ?? []]);
}
function expectSync(options: {
org: string[];
mod: string[];
cur: string[];
exp: string[];
}) {
expect(
syncCharaChords(
backup(chords(...options.org)),
backup(chords(...options.mod)),
asciiIds,
asciiCodes,
chords(...options.cur),
)
.apply(Text.of(options.cur))
.toString()
.replace(/\n$/, ""),
).toEqual(chords(...options.exp));
}
describe("chord sync", function () {
it("should not do anything when no changes happened", function () {
expectSync({
org: ["abc=>def", "def=>ghi", "jkl=>mno"],
mod: ["abc=>def", "def=>ghi", "jkl=>mno"],
cur: ["abc=>def", "def=>ghi", "jkl=>mno"],
exp: ["abc=>def", "def=>ghi", "jkl=>mno"],
});
});
it("should not touch the doc if device chords are unchanged", function () {
expectSync({
org: ["abc=>def", "def=>ghi", "jkl=>mno"],
mod: ["abc=>def", "def=>ghi", "jkl=>mno"],
cur: ["ab=>def", "def=>gh"],
exp: ["ab=>def", "def=>gh"],
});
});
it("should apply removals to unchanged chords only", function () {
expectSync({
org: ["abc=>def", "def=>ghi", "jkl=>mno", "mno=>pqr"],
mod: ["abc=>def"],
cur: ["abc=>def", "def=>ghij", "jkl=>mno", "mno=>pqr"],
exp: ["abc=>def", "def=>ghij"],
});
});
it("should keep user modifications over device modifications", function () {
expectSync({
org: ["abc=>def", "def=>ghi", "jkl=>mno", "mno=>pqr"],
mod: ["abc=>def", "def=>ghijk", "jkl=>mnop", "mno=>pqr"],
cur: ["abc=>def", "def=>ghij", "jkl=>mno", "mno=>pqr"],
exp: ["abc=>def", "def=>ghij", "jkl=>mnop", "mno=>pqr"],
});
});
it("should handle complex changes", function () {
expectSync({
org: [
"unchanged=>unchanged",
"usermod=>usermod",
"devmod=>devmod",
"userremoval=>userremoval",
"devremoval=>devremoval",
"devremusermod=>devremusermod",
],
mod: [
"unchanged=>unchanged",
"devadd=>devadd",
"usermod=>usermod",
"userremoval=>userremoval",
"devmod=>devmod1",
"sameadd=>sameadd",
],
cur: [
"useradd1=>useradd1",
"unchanged=>unchanged",
"usermod=>use",
"devremusermod=>xyz",
"devmod=>devmod",
"sameadd=>sameadd",
"devremoval=>devremoval",
"useradd=>useradd",
],
exp: [
"devadd=>devadd",
"useradd1=>useradd1",
"unchanged=>unchanged",
"usermod=>use",
"devremusermod=>xyz",
"devmod=>devmod1",
"sameadd=>sameadd",
"useradd=>useradd",
],
});
});
});

View File

@@ -0,0 +1,130 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
import { ChangeSet, type ChangeSpec } from "@codemirror/state";
import { parseCharaChords } from "./action-serializer";
import { parser } from "./chords.grammar";
import type { CharaChordFile } from "$lib/share/chara-file";
import { splitCompound } from "$lib/serial/chord";
function canUseIdAsString(info: KeyInfo): boolean {
return !!info.id && /^[^>\n]+$/.test(info.id);
}
export function actionToValue(code: number, info?: KeyInfo) {
if (info && info.id?.length === 1)
return /^[<>|\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
if (!info || !canUseIdAsString(info))
return `<0x${code.toString(16).padStart(2, "0")}>`;
return `<${info.id}>`;
}
function canonicalInputSorting(input: number[], phrase: number[]): number[] {
const tail = [...input];
const prefix = phrase.filter((code) => {
const index = tail.indexOf(code);
if (index !== -1) {
tail.splice(index, 1);
return true;
}
return false;
});
return [...prefix, ...tail];
}
export interface ChangeType {
from: number;
to: number;
insert: string;
}
export function syncCharaChords(
originalDeviceChords: CharaChordFile["chords"],
newDeviceChords: CharaChordFile["chords"],
ids: Map<string, KeyInfo>,
codes: Map<number, KeyInfo>,
doc: string,
): ChangeSet {
const tree = parser.parse(doc);
const result = parseCharaChords(
tree,
ids,
codes,
originalDeviceChords,
(from, to) => doc.slice(from, to),
);
const exactChords = new Map<string, number>();
for (const chord of originalDeviceChords) {
const key = JSON.stringify(chord);
const count = exactChords.get(key) ?? 0;
exactChords.set(key, count + 1);
}
const changes: ChangeType[] = [];
const inputModified = new Set<string>();
for (const chord of newDeviceChords) {
const key = JSON.stringify(chord);
const count = exactChords.get(key) ?? 0;
if (count > 0) {
exactChords.set(key, count - 1);
continue;
}
const inputKey = JSON.stringify(chord[0]);
inputModified.add(inputKey);
const byInput = result.inputs.get(inputKey);
if (byInput) {
if (
byInput.phrase?.originalValue &&
byInput.phrase.originalValue === byInput.phrase.value
) {
changes.push({
from: byInput.phrase.range[0],
to: byInput.phrase.range[1],
insert: chord[1]
.map((code) => actionToValue(code, codes.get(code)))
.join(""),
});
}
} else {
const [inputs, compound] = splitCompound(chord[0]);
const sortedInput = canonicalInputSorting(inputs, chord[1]);
changes.push({
from: 0,
to: 0,
insert:
(compound ? `|0x${compound.toString(16)}|` : "") +
sortedInput
.map((code) => actionToValue(code, codes.get(code)))
.join("") +
"=>" +
chord[1]
.map((code) => actionToValue(code, codes.get(code)))
.join("") +
"\n",
});
}
}
changes.push(
...exactChords
.entries()
.filter(([, count]) => count > 0)
.map(([key]) => result.exact.get(key))
.filter((chord) => chord !== undefined)
.filter(
(chord) =>
chord.input && !inputModified.has(JSON.stringify(chord.input.value)),
)
.map(
(chord) =>
({
from: chord.range[0],
to: chord.range[1],
insert: "",
}) satisfies ChangeSpec,
),
);
return ChangeSet.of(changes, doc.length);
}

View File

@@ -0,0 +1,54 @@
import { parser } from "./chords.grammar";
import {
LRLanguage,
LanguageSupport,
HighlightStyle,
} from "@codemirror/language";
import { styleTags, tags } from "@lezer/highlight";
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({})]);
}

View File

@@ -0,0 +1,43 @@
@top Program { Chord* }
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
EscapedSingleAction { Escape EscapedLetter }
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
ActionString { Action+ }
CompoundLiteral { CompoundDelim HexNumber CompoundDelim }
CompoundInput { ActionString CompoundDelim }
ChordInput { CompoundLiteral? CompoundInput* ActionString }
ChordPhrase { ActionString }
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
@skip {
Space
}
@tokens {
@precedence { HexNumber, ActionId }
@precedence { Space, Escape }
@precedence { Space, SingleLetter }
@precedence { Escape, SingleLetter }
@precedence { CompoundDelim, SingleLetter }
@precedence { ActionId, Space }
@precedence { EscapedLetter, Space }
Space {" "}
ExplicitDelimStart {"<"}
ExplicitDelimEnd {">"}
CompoundDelim {"|"}
PhraseDelim {"=>"}
Escape { "\\" }
HexNumber { "0x" $[a-fA-F0-9]+ }
ActionId { ![\n>]+ }
SingleLetter { ![\n<] }
EscapedLetter { ![\n] }
ChordDelim { ("\n" | @eof) }
}
@detectDelim

View 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
View File

@@ -0,0 +1,3 @@
declare module "*.grammar" {
export const parser: import("@lezer/lr").LRParser;
}

View File

@@ -0,0 +1,186 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
import type { CharaChordFile } from "$lib/share/chara-file";
import type { ChangeDesc } from "@codemirror/state";
export type MetaRange = [from: number, to: number];
function mapMetaRange(range: MetaRange, change: ChangeDesc): MetaRange {
const newFrom = change.mapPos(range[0]);
const newTo = change.mapPos(range[1]);
if (newFrom === range[0] && newTo === range[1]) {
return range;
}
return [newFrom, newTo];
}
export interface ActionMeta {
code: number;
info?: KeyInfo;
explicit?: boolean;
range: MetaRange;
valid: boolean;
}
function mapActionMeta(action: ActionMeta, change: ChangeDesc): ActionMeta {
const newRange = mapMetaRange(action.range, change);
if (newRange === action.range) {
return action;
}
return {
...action,
range: newRange,
};
}
function mapArray<T>(
array: T[],
change: ChangeDesc,
mapFn: (action: T, change: ChangeDesc) => T,
): T[] {
let changed = false;
const newArray = array.map((value) => {
const newValue = mapFn(value, change);
if (newValue !== value) {
changed = true;
return newValue;
}
return value;
});
if (changed) {
return newArray;
}
return array;
}
export interface ActionStringMeta<T> {
range: MetaRange;
value: T;
valid: boolean;
actions: ActionMeta[];
}
function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
actionString: T,
change: ChangeDesc,
) {
const newRange = mapMetaRange(actionString.range, change);
const newActions = mapArray(actionString.actions, change, mapActionMeta);
if (newRange === actionString.range && newActions === actionString.actions) {
return actionString;
}
return {
...actionString,
range: newRange,
actions: newActions,
};
}
export interface PhraseMeta extends ActionStringMeta<number[]> {
hasConcatenator: boolean;
originalValue?: number[];
}
export interface CompoundMeta extends ActionStringMeta<number> {
parent?: ChordMeta;
}
export interface InputMeta extends ActionStringMeta<number[]> {}
export interface ChordMeta {
range: MetaRange;
valid: boolean;
disabled?: boolean;
compounds?: CompoundMeta[];
input?: InputMeta;
phrase?: PhraseMeta;
children?: ChordMeta[];
overrides?: ChordMeta[];
aliases?: ChordMeta[];
overriddenBy?: ChordMeta;
}
export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta {
const newRange = mapMetaRange(chord.range, change);
const newCompounds = chord.compounds
? mapArray(chord.compounds, change, mapActionStringMeta)
: undefined;
const newInput = chord.input
? mapActionStringMeta(chord.input, change)
: undefined;
const newPhrase = chord.phrase
? mapActionStringMeta(chord.phrase, change)
: undefined;
if (
newRange === chord.range &&
newCompounds === chord.compounds &&
newInput === chord.input &&
newPhrase === chord.phrase
) {
return chord;
}
const newChord: ChordMeta = {
...chord,
range: newRange,
};
if (newCompounds) newChord.compounds = newCompounds;
if (newInput) newChord.input = newInput;
if (newPhrase) newChord.phrase = newPhrase;
return newChord;
}
export interface ParseResult {
chords: ChordMeta[];
removed: CharaChordFile["chords"];
aliases: Map<string, ChordMeta[]>;
compounds: Map<number, ChordMeta>;
inputs: Map<string, ChordMeta>;
exact: Map<string, ChordMeta>;
}
export function mapParseResult(
result: ParseResult,
change: ChangeDesc,
): ParseResult {
const newChords = mapArray(result.chords, change, mapChordMeta);
if (newChords === result.chords) {
return result;
}
return {
...result,
chords: newChords,
};
}
export function iterActions<T = void>(
chord: ChordMeta,
callback: (action: ActionMeta) => T | void,
): T | undefined {
if (chord.input) {
for (const action of chord.input.actions) {
const result = callback(action);
if (result !== undefined) {
return result;
}
}
}
if (chord.compounds) {
for (const compound of chord.compounds) {
for (const action of compound.actions) {
const result = callback(action);
if (result !== undefined) {
return result;
}
}
}
}
if (chord.phrase) {
for (const action of chord.phrase.actions) {
const result = callback(action);
if (result !== undefined) {
return result;
}
}
}
return undefined;
}

View File

@@ -0,0 +1,40 @@
import { StateField } from "@codemirror/state";
import { parseCharaChords } from "./action-serializer";
import { actionMetaPlugin } from "./action-meta-plugin";
import { syntaxTree } from "@codemirror/language";
import { deviceChordField } from "./chord-sync-plugin";
import { mapParseResult, type ParseResult } from "./parse-meta";
export const parsedChordsField = StateField.define<ParseResult>({
create() {
return {
chords: [],
removed: [],
aliases: new Map(),
compounds: new Map(),
inputs: new Map(),
exact: new Map(),
};
},
update(value, transaction) {
const tree = syntaxTree(transaction.state);
const ids = transaction.state.field(actionMetaPlugin.field).ids;
const codes = transaction.state.field(actionMetaPlugin.field).codes;
const deviceChords = transaction.state.field(deviceChordField);
if (
tree !== syntaxTree(transaction.startState) ||
ids !== transaction.startState.field(actionMetaPlugin.field).ids ||
codes !== transaction.startState.field(actionMetaPlugin.field).codes ||
deviceChords !== transaction.startState.field(deviceChordField)
) {
return parseCharaChords(
syntaxTree(transaction.state),
ids,
codes,
deviceChords,
(from, to) => transaction.state.doc.sliceString(from, to),
);
}
return mapParseResult(value, transaction.changes);
},
});

View File

@@ -0,0 +1,187 @@
import {
EditorView,
highlightActiveLine,
keymap,
lineNumbers,
ViewPlugin,
ViewUpdate,
} from "@codemirror/view";
import {
history,
historyField,
historyKeymap,
standardKeymap,
} from "@codemirror/commands";
import { debounceTime, mergeMap, Subject } from "rxjs";
import { EditorState, type EditorStateConfig } from "@codemirror/state";
import { lintGutter } from "@codemirror/lint";
import {
chordHighlightStyle,
chordLanguageSupport,
} from "./chords-grammar-plugin";
import { actionLinter } from "./action-linter";
import { actionAutocompletePlugin } from "./autocomplete";
import { delimPlugin } from "./chord-delim-plugin";
import { actionPlugin } from "./action-plugin";
import { syntaxHighlighting } from "@codemirror/language";
import { deviceChordField } from "./chord-sync-plugin";
import { actionMetaPlugin } from "./action-meta-plugin";
import { parsedChordsField } from "./parsed-chords-plugin";
import { changesPanel } from "./changes-panel.svelte";
import { searchKeymap } from "@codemirror/search";
import { actionHover } from "./action-tooltip";
const serializedFields = {
history: historyField,
deviceChords: deviceChordField,
};
export interface EditorConfig {
rawCode?: boolean;
storeName: string;
autocomplete(query: string | undefined): void;
}
export function createConfig(params: EditorConfig) {
return {
extensions: [
actionMetaPlugin.plugin,
deviceChordField,
parsedChordsField,
actionHover,
changesPanel(),
lintGutter(),
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
chordLanguageSupport(),
actionLinter({
delay: 100,
markerFilter(diagnostics) {
return diagnostics.filter((it) => it.from !== it.to);
},
}),
actionAutocompletePlugin(params.autocomplete),
persistentStatePlugin(params.storeName),
history(),
syntaxHighlighting(chordHighlightStyle),
highlightActiveLine(),
EditorView.theme({
".cm-line": {
borderBottom: "1px solid transparent",
caretColor: "var(--md-sys-color-on-surface)",
},
".cm-scroller": {
overflow: "auto",
width: "100%",
fontFamily: "inherit !important",
gap: "8px",
},
".cm-content": {
flex: 1,
},
".cm-cursor": {
borderColor: "var(--md-sys-color-on-surface)",
},
}),
keymap.of([...standardKeymap, ...historyKeymap, ...searchKeymap]),
],
} satisfies EditorStateConfig;
}
export async function loadPersistentState(
params: EditorConfig,
): Promise<EditorState> {
const stored = await getState(params.storeName);
const config = createConfig(params);
if (stored) {
try {
return EditorState.fromJSON(stored, config, serializedFields);
} catch (e) {
console.error("Failed to parse persistent state:", e);
}
}
return EditorState.create(config);
}
export function persistentStatePlugin(storeName: string) {
return ViewPlugin.fromClass(
class {
updateSubject = new Subject<void>();
subscription = this.updateSubject
.pipe(
debounceTime(500),
mergeMap(() =>
storeState(storeName, this.view.state.toJSON(serializedFields)),
),
)
.subscribe(() => {});
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {
if (update.state !== update.startState) {
this.updateSubject.next();
}
}
destroy() {
this.subscription.unsubscribe();
}
},
);
}
const dbName = "chord-state";
const dbVersion = 1;
const storeName = "state";
async function openDb(): Promise<IDBDatabase> {
const dbRequest = indexedDB.open(dbName, dbVersion);
return new Promise<IDBDatabase>((resolve, reject) => {
dbRequest.onsuccess = () => resolve(dbRequest.result);
dbRequest.onerror = () => reject(dbRequest.error);
dbRequest.onupgradeneeded = () => {
const db = dbRequest.result;
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName);
}
};
});
}
async function getState<T>(name: string): Promise<T | undefined> {
const db = await openDb();
try {
const readTransaction = db.transaction([storeName], "readonly");
const store = readTransaction.objectStore(storeName);
const itemRequest = store.get(name);
const result = await new Promise<T | undefined>((resolve) => {
itemRequest.onsuccess = () => resolve(itemRequest.result);
itemRequest.onerror = () => resolve(undefined);
});
return result;
} catch (e) {
console.error(e);
return undefined;
} finally {
db.close();
}
}
async function storeState<T>(name: string, state: T): Promise<void> {
const db = await openDb();
try {
const putTransaction = db.transaction([storeName], "readwrite");
const putStore = putTransaction.objectStore(storeName);
const putRequest = putStore.put(state, name);
await new Promise<void>((resolve, reject) => {
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
});
putTransaction.commit();
} catch (e) {
console.error(e);
} finally {
db.close();
}
}

View File

@@ -0,0 +1,58 @@
import type { EditorView } from "@codemirror/view";
import { parser } from "./chords.grammar";
import { parseCharaChords } from "./action-serializer";
import { actionMetaPlugin } from "./action-meta-plugin";
import { deviceChordField } from "./chord-sync-plugin";
import type { CharaChordFile } from "$lib/share/chara-file";
export interface SaveChordsTask {
remove: number[][];
set: [number[], number[]][];
}
export function createSaveTask(view: EditorView): SaveChordsTask {
const tree = parser.parse(view.state.doc.toString());
const { ids, codes } = view.state.field(actionMetaPlugin.field);
const deviceChords = view.state.field(deviceChordField);
const result = parseCharaChords(tree, ids, codes, deviceChords, (from, to) =>
view.state.doc.sliceString(from, to),
);
return {
remove: result.removed.map((chord) => chord[0]),
set: result.chords
.filter(
(chord) =>
!chord.disabled &&
(!chord.phrase ||
chord.phrase?.originalValue !== chord.phrase?.value),
)
.map((chord) => [chord.input?.value ?? [], chord.phrase?.value ?? []]),
};
}
export function applySaveTask(
backup: CharaChordFile["chords"],
task: SaveChordsTask,
): CharaChordFile["chords"] {
const newBackup = [...backup];
for (const input of task.remove) {
const index = newBackup.findIndex((chord) => {
return JSON.stringify(chord[0]) === JSON.stringify(input);
});
if (index !== -1) {
newBackup.splice(index, 1);
}
}
for (const [input, phrase] of task.set) {
const index = newBackup.findIndex((chord) => {
return JSON.stringify(chord[0]) === JSON.stringify(input);
});
if (index !== -1) {
newBackup[index] = [input, phrase];
} else {
newBackup.push([input, phrase]);
}
}
return newBackup;
}

View File

@@ -0,0 +1,35 @@
import { StateEffect, StateField } from "@codemirror/state";
import { EditorView, ViewPlugin } from "@codemirror/view";
import { get, type Readable } from "svelte/store";
export function reactiveStateField<T>(store: Readable<T>) {
const effect = StateEffect.define<T>();
const field = StateField.define<T>({
create() {
return get(store);
},
update(value, transaction) {
return (
transaction.effects.findLast((it) => it.is(effect))?.value ?? value
);
},
});
const plugin = ViewPlugin.fromClass(
class {
unsubscribe: () => void;
constructor(readonly view: EditorView) {
this.unsubscribe = store.subscribe((value) => {
setTimeout(() => {
view.dispatch({ effects: effect.of(value) });
});
});
}
destroy() {
this.unsubscribe();
}
},
);
return { field, plugin: [field, plugin] };
}

View File

@@ -0,0 +1,16 @@
a|.=<LEFT_SHIFT>=>t=t
;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>

View File

@@ -1,105 +1,228 @@
<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 { actionTooltip } from "$lib/title";
import ActionTooltip from "./action/ActionTooltip.svelte";
let { let {
action, action,
display, display,
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props(); ignoreIcon = false,
inText = false,
withPopover = true,
}: {
action: string | number | KeyInfo;
display: "inline-keys" | "keys" | "verbose";
ignoreIcon?: boolean;
inText?: boolean;
withPopover?: boolean;
} = $props();
let info = $derived( let retrievedInfo = $derived(
typeof action === "number" typeof action === "number"
? (KEYMAP_CODES.get(action) ?? { code: action }) ? $KEYMAP_CODES.get(action)
: action, : typeof action === "string"
? $KEYMAP_IDS.get(action)
: action,
); );
let info = $derived(
retrievedInfo ??
(typeof action === "number"
? ({ code: action } satisfies KeyInfo)
: typeof action === "string"
? ({ code: 1024, id: action } satisfies KeyInfo)
: action),
);
let icon = $derived(ignoreIcon ? undefined : info.icon);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode)); let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let hasPopover = $derived(
let tooltip = $derived( withPopover &&
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` + (!retrievedInfo || !info.id || info.title || info.description),
(info.title ?? "") +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: ""),
); );
</script> </script>
{#if display === "keys"} {#snippet popover()}
<ActionTooltip valid={!!retrievedInfo} {info} />
{/snippet}
{#snippet kbdText()}
{dynamicMapping ??
icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{/snippet}
{#snippet kbdSnippet(withPopover = true)}
<kbd <kbd
class:icon={!!info.icon} class:in-text={inText}
class:icon={!!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}
> >
{dynamicMapping ?? {@render kbdText()}
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
</kbd> </kbd>
{:else if display === "inline-keys"} {/snippet}
{#snippet inlineKbdSnippet()}
{#if !info.icon && dynamicMapping?.length === 1} {#if !info.icon && dynamicMapping?.length === 1}
<span <span
use:title={{ title: tooltip }} {@attach hasPopover ? actionTooltip(popover) : null}
class:in-text={inText}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"}>{dynamicMapping}</span class:right={info.variant === "right"}>{dynamicMapping}</span
> >
{:else if !info.icon && info.id?.length === 1} {:else if !icon && info.id?.length === 1}
<span <span
use:title={{ title: tooltip }} {@attach hasPopover ? actionTooltip(popover) : null}
class:in-text={inText}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"}>{info.id}</span class:right={info.variant === "right"}>{info.id}</span
> >
{: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={!!icon}
use:title={{ title: tooltip }} class:warn={!retrievedInfo}
> class:error={info.code > 1023}
{dynamicMapping ?? {@attach hasPopover ? actionTooltip(popover) : null}
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
> >
{@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-color: color-mix(
in srgb,
var(--md-sys-color-on-surface) 50%,
transparent
);
.left,
.right {
background-color: transparent;
&::before {
position: absolute;
inset: 0;
outline: 2px dashed
color-mix(in srgb, var(--bg-color), var(--md-sys-color-outline) 40%);
outline-offset: -2px;
border-radius: var(--border-radius);
content: "";
}
}
$cutoff: 60%;
.left { .left {
border-left-width: 3px; background-image: linear-gradient(
to right,
var(--bg-color) $cutoff,
transparent $cutoff
);
&::before {
clip-path: inset(0 0 0 $cutoff);
}
} }
.right { .right {
border-right-width: 3px; background-image: linear-gradient(
} to left,
var(--bg-color) $cutoff,
transparent $cutoff
);
.dynamic { &::before {
padding: 4px; clip-path: inset(0 $cutoff 0 0);
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>

View File

@@ -12,7 +12,7 @@
$props(); $props();
let key = $derived( let key = $derived(
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as (typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
| number | number
| KeyInfo, | KeyInfo,
); );
@@ -48,33 +48,33 @@
<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;
border-radius: 8px;
@media not (forced-colors: active) { @media not (forced-colors: active) {
color: inherit;
background: transparent;
border: none; border: none;
background: transparent;
color: inherit;
&:focus-visible { &:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none; outline: none;
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
} }
} }
@media (forced-colors: active) { @media (forced-colors: active) {
border: 1px solid ButtonBorder;
margin-block: 4px; margin-block: 4px;
border: 1px solid ButtonBorder;
&:hover { &:hover {
color: ActiveText; color: ActiveText;
@@ -86,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;
} }

View 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>

View File

@@ -16,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>

View File

@@ -1,10 +1,15 @@
<script lang="ts"> <script lang="ts">
import { serialLog, serialPort } from "$lib/serial/connection"; import { serialLog, serialPort } from "$lib/serial/connection";
import { onMount } from "svelte";
import { slide } from "svelte/transition"; 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(0, value.trim()); $serialPort?.send(0, [value.trim()]);
value = ""; value = "";
io.scrollTo({ top: io.scrollHeight }); io.scrollTo({ top: io.scrollHeight });
} }
@@ -33,63 +38,61 @@
<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 {

View File

@@ -1,9 +1,14 @@
<script lang="ts"> <script lang="ts">
let { title, shortcut }: { title?: string; shortcut?: string } = $props(); import type { Snippet } from "svelte";
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}
@@ -20,8 +25,8 @@
:global(kbd.icon) { :global(kbd.icon) {
display: inline-flex; display: inline-flex;
font-size: inherit;
translate: 0 0.2em; translate: 0 0.2em;
font-size: inherit;
} }
} }
</style> </style>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import type { KeyInfo } from "$lib/serial/keymap-codes";
let { valid, info }: { valid: boolean; info: KeyInfo } = $props();
</script>
{#if valid}
{#if info.icon || info.display || !info.id}
&lt;<b>{info.id ?? `0x${info.code.toString(16)}`}</b>&gt;
{/if}
{#if info.title}
{info.title}
{/if}
{#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 />&nbsp;<i>Prevents prepended autospaces</i>
{/if}
{#if info.separator || info.breaking}
<br />&nbsp;<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}

View File

@@ -0,0 +1,370 @@
<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/chord-sync";
let {
currentAction = undefined,
nextAction = undefined,
queryFilter = undefined,
ignoreIcon,
autofocus = false,
onselect,
onclose,
}: {
currentAction?: number;
queryFilter?: string;
nextAction?: number;
autofocus?: boolean;
ignoreIcon?: 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);
});
let didClear = true;
$effect(() => {
if (queryFilter !== undefined || !didClear) {
searchBox.value = queryFilter ?? "";
search();
}
});
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[]],
),
);
didClear = searchBox.value === "";
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] (actions)}
{#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" {ignoreIcon}></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>

View File

@@ -1,14 +1,5 @@
<script lang="ts"> <script lang="ts">
import { import ActionList from "./ActionList.svelte";
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
let { let {
currentAction = undefined, currentAction = undefined,
@@ -21,327 +12,34 @@
onselect: (id: number) => void; onselect: (id: number) => void;
onclose: () => void; onclose: () => void;
} = $props(); } = $props();
onMount(() => {
searchBox.focus();
});
const index = new FlexSearch.Index({ tokenize: "full" });
createIndex();
async function createIndex() {
for (const [, action] of KEYMAP_CODES) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
}
async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[];
exact = KEYMAP_IDS.get(searchBox.value)?.code;
code = Number(searchBox.value);
}
function select(id?: number) {
if (id !== undefined) {
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: number[] = $state([]);
let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN);
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter = $state(new Set<number>());
</script> </script>
<svelte:window on:keydown={keyboardNavigation} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog <dialog
open open
onclick={(event) => { onclick={(event) => {
if (event.target === event.currentTarget) onclose(); if (event.target === event.currentTarget) onclose();
}} }}
> >
<div class="content"> <ActionList
<div class="search-row"> autofocus={true}
<input {currentAction}
type="search" {nextAction}
bind:this={searchBox} {onselect}
oninput={search} {onclose}
onkeypress={(event) => { />
if (event.key === "Enter") {
select(exact);
}
}}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button
>
<button
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon"
onclick={onclose}>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} 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}
{#if filter !== undefined || results.length > 0}
{@const resultValue =
results.length === 0
? Array.from(KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li>
{/each}
{/if}
</ul>
</div>
</dialog> </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);
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.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;
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
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;
}
@media (forced-colors: active) {
background: Mark;
}
} }
</style> </style>

View File

@@ -1,24 +1,22 @@
<script lang="ts"> <script lang="ts">
import { compileLayout } from "$lib/serialization/visual-layout";
import type {
VisualLayout,
CompiledLayoutKey,
} from "$lib/serialization/visual-layout";
import { deviceLayout } from "$lib/serial/connection"; import { deviceLayout } from "$lib/serial/connection";
import { dev } from "$app/environment"; import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"; import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte"; import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext, mount, unmount } from "svelte"; import { getContext, mount, unmount } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js"; import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo"; import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition"; import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing"; import { expoOut } from "svelte/easing";
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
@@ -30,8 +28,7 @@
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer"); console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
} }
let { visualLayout }: { visualLayout: VisualLayout } = $props(); let { layoutInfo }: { layoutInfo: CompiledLayout } = $props();
let layoutInfo = $state(compileLayout(visualLayout));
function getCenter(key: CompiledLayoutKey): [x: number, y: number] { function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]; return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
@@ -125,8 +122,10 @@
const keyInfo = layoutInfo.keys[index]; const keyInfo = layoutInfo.keys[index];
if (!keyInfo) return; if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement; const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id]; const nextAction =
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id]; get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const currentAction =
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, { const component = mount(ActionSelector, {
target: document.body, target: document.body,
props: { props: {
@@ -137,12 +136,15 @@
}, },
onselect(action) { onselect(action) {
changes.update((changes) => { changes.update((changes) => {
changes.push({ changes.push([
type: ChangeType.Layout, {
id: keyInfo.id, type: ChangeType.Layout,
layer: get(activeLayer), id: keyInfo.id,
action, layer: get(activeLayer),
}); profile: get(activeProfile),
action,
},
]);
return changes; return changes;
}); });
closed(); closed();
@@ -215,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>

View File

@@ -1,16 +1,19 @@
<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/serialization/visual-layout"; import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
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 { action } from "$lib/title"; import { actionTooltip } 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",
);
let { let {
key, key,
@@ -25,17 +28,24 @@
middle: [number, number]; middle: [number, number];
pos: [number, number]; pos: [number, number];
rotate: number; rotate: number;
positions: [[number, number], [number, number], [number, number]]; positions: [
[number, number],
[number, number],
[number, number],
[number, number],
];
} = $props(); } = $props();
</script> </script>
{#each positions as position, layer} {#each positions as position, layer}
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? { {@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
key.id
] ?? {
action: 0, action: 0,
isApplied: true, isApplied: true,
}} }}
{@const { code, icon, id, display, title, keyCode, variant } = {@const { code, icon, id, display, title, keyCode, variant } =
KEYMAP_CODES.get(actionId) ?? { code: actionId }} $KEYMAP_CODES.get(actionId) ?? { code: actionId }}
{@const dynamicMapping = keyCode && $osLayout.get(keyCode)} {@const dynamicMapping = keyCode && $osLayout.get(keyCode)}
{@const tooltip = {@const tooltip =
(title ?? id ?? `0x${code.toString(16)}`) + (title ?? id ?? `0x${code.toString(16)}`) +
@@ -47,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"
@@ -61,9 +72,9 @@
? "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}
@@ -77,15 +88,15 @@
$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) { @media (prefers-contrast: more) {
--inactive-opacity: 0.8; --inactive-opacity: 0.8;
@@ -96,4 +107,8 @@
text:focus-within { text:focus-within {
outline: none; outline: none;
} }
text.hidden {
opacity: 0.2;
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<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";
@@ -8,11 +8,14 @@
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
} from "svelte/elements"; } from "svelte/elements";
import { type Writable } from "svelte/store";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>( const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config", "visual-layout-config",
); );
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
let { let {
i, i,
key, key,
@@ -35,6 +38,8 @@
<g <g
class="key-group" class="key-group"
class:highlight={$highlight?.has(key.id) === true}
class:faded={$highlight?.has(key.id) === false}
{onclick} {onclick}
{onkeypress} {onkeypress}
{onfocusin} {onfocusin}
@@ -59,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"}
@@ -98,6 +104,7 @@
[-rotY, -rotX], [-rotY, -rotX],
[-rotX, -rotY], [-rotX, -rotY],
[rotX, rotY], [rotX, rotY],
[rotY, rotX],
]} ]}
/> />
{/if} {/if}
@@ -108,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,
@@ -131,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 {

View File

@@ -1,66 +1,83 @@
<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 { fade } from "svelte/transition"; import type { CompiledLayout } from "$lib/assets/layouts/layout.d.ts";
let device = $derived($serialPort?.device); const layouts: Record<string, (() => Promise<CompiledLayout>) | undefined> = {
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const;
const layouts = {
ONE: () => ONE: () =>
import("$lib/assets/layouts/one.yml").then( import("$lib/assets/layouts/one.layout.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as CompiledLayout,
), ),
TWO: () => TWO: () =>
import("$lib/assets/layouts/one.yml").then( import("$lib/assets/layouts/one.layout.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as CompiledLayout,
), ),
LITE: () => LITE: () =>
import("$lib/assets/layouts/lite.yml").then( import("$lib/assets/layouts/lite.layout.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as CompiledLayout,
), ),
X: () => X: () =>
import("$lib/assets/layouts/generic/103-key.yml").then( import("$lib/assets/layouts/103-key.layout.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as CompiledLayout,
),
ZERO: () =>
import("$lib/assets/layouts/103-key.layout.yml").then(
(it) => it.default as CompiledLayout,
), ),
M4G: () => M4G: () =>
import("$lib/assets/layouts/m4g.yml").then( import("$lib/assets/layouts/m4g.layout.yml").then(
(it) => it.default as VisualLayout, (it) => it.default as CompiledLayout,
), ),
M4GR: () => M4GR: () =>
import("$lib/assets/layouts/m4gr.yml").then( import("$lib/assets/layouts/m4gr.layout.yml").then(
(it) => it.default as VisualLayout, (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">
{#if device} {#if $serialPort}
{#await layouts[device]() then visualLayout} {#await layouts[$serialPort.device]?.() then layoutInfo}
<fieldset transition:fade> <fieldset transition:fade>
{#each layers as [title, icon, value]} <div class="layers">
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
<label>
<input
type="radio"
onclick={() => ($activeLayer = layer)}
name="layer"
value={layer}
checked={$activeLayer === layer}
/>
{String.fromCodePoint(
"A".codePointAt(0)! + $activeProfile,
)}{layer + 1}
</label>
{/each}
</div>
{#if $deviceMeta?.factoryDefaults?.layout}
<button <button
class="icon" {@attach actionTooltip("Reset Layout")}
use:action={{ title, shortcut: `alt+${value + 1}` }} transition:fly={{ x: -8 }}
onclick={() => ($activeLayer = value)} class="icon reset-layout"
class:active={$activeLayer === value} onclick={() =>
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
>reset_wrench</button
> >
{icon} {/if}
</button>
{/each}
</fieldset> </fieldset>
<GenericLayout {visualLayout} /> {#if layoutInfo}
<GenericLayout {layoutInfo} />
{/if}
{/await} {/await}
{/if} {/if}
</div> </div>
@@ -69,8 +86,8 @@
.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%;
@@ -78,62 +95,23 @@
} }
fieldset { fieldset {
position: relative;
display: flex; display: flex;
align-items: center; position: relative;
justify-content: center; justify-content: center;
align-items: center;
border: none;
padding: 8px; padding: 8px;
border: none;
} }
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%;
}
&:first-child,
&:last-child {
aspect-ratio: unset;
height: unset;
}
&:first-child {
margin-inline-end: -8px;
padding-inline: 4px 24px;
border-radius: 16px 0 0 16px;
}
&:last-child {
margin-inline-start: -8px;
padding-inline: 24px 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>

View 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)
);
}

View File

@@ -1,13 +1,14 @@
<script lang="ts"> <script lang="ts">
import Dialog from "$lib/dialogs/Dialog.svelte"; import Dialog from "$lib/dialogs/Dialog.svelte";
import ActionString from "$lib/components/ActionString.svelte"; import type { Chord } from "$lib/serial/chord";
import ChordActionEdit from "../../routes/(app)/config/chords/ChordActionEdit.svelte";
let { let {
title, title,
message, message,
abortTitle, abortTitle,
confirmTitle, confirmTitle,
actions = [], chord,
onabort, onabort,
onconfirm, onconfirm,
}: { }: {
@@ -15,7 +16,7 @@
message?: string; message?: string;
abortTitle: string; abortTitle: string;
confirmTitle: string; confirmTitle: string;
actions: number[]; chord: Chord & { deleted: boolean };
onabort: () => void; onabort: () => void;
onconfirm: () => void; onconfirm: () => void;
} = $props(); } = $props();
@@ -26,7 +27,20 @@
{#if message} {#if message}
<p>{@html message}</p> <p>{@html message}</p>
{/if} {/if}
<p><ActionString {actions} /></p> <p>
<ChordActionEdit
chord={{
...chord,
isApplied: false,
phraseChanged: false,
actionsChanged: false,
sortBy: "",
id: chord.actions,
}}
interactive={false}
onsubmit={() => {}}
/>
</p>
<div class="buttons"> <div class="buttons">
<button onclick={onabort}>{abortTitle}</button> <button onclick={onabort}>{abortTitle}</button>
<button class="primary" onclick={onconfirm}>{confirmTitle}</button> <button class="primary" onclick={onconfirm}>{confirmTitle}</button>

View 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>

View File

@@ -16,15 +16,15 @@
<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 {

View File

@@ -1,217 +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 as changes, i}
{@const layer = i + 1}
{#if changes.length > 0}
<li>
<h4>
<label>
<input type="checkbox" class="checkbox" />
{$LL.changes.layout.LAYER({
changes: changes.length,
layer,
})}
</label>
</h4>
</li>
{/if}
{/each}
</ul>
</li>
{/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>

View File

@@ -1,33 +1,34 @@
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte"; import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
import { mount, unmount } from "svelte";
import type { Chord } from "$lib/serial/chord";
export async function askForConfirmation( export async function askForConfirmation(
title: string, title: string,
message: string, message: string,
confirmTitle: string, confirmTitle: string,
abortTitle: string, abortTitle: string,
actions: number[], chord: Chord,
): Promise<boolean> { ): Promise<boolean> {
const dialog = new ConfirmDialog({ let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
const dialog = mount(ConfirmDialog, {
target: document.body, target: document.body,
props: { props: {
title, title,
message, message,
confirmTitle, confirmTitle,
abortTitle, abortTitle,
actions, chord,
onabort: () => resolvePromise(false),
onconfirm: () => resolvePromise(true),
}, },
}); });
let resolvePromise: (value: boolean) => void;
const resultPromise = new Promise<boolean>((resolve) => {
resolvePromise = resolve;
});
dialog.$on("abort", () => resolvePromise(false));
dialog.$on("confirm", () => resolvePromise(true));
const result = await resultPromise; const result = await resultPromise;
dialog.$destroy(); unmount(dialog);
return result; return result;
} }

View 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;
}

View File

@@ -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;
} }

View File

@@ -66,28 +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, font-family: "Noto Sans Mono Variable";
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, 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; 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, font-family: "Noto Sans Mono";
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, font-display: swap;
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

39
src/lib/hover-popover.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>();
export function tooltip(
target: HTMLElement | undefined,
shortcut?: string,
): Attachment<HTMLElement> {
return (node: HTMLElement) => {
function show() {
if (!target) return;
target.showPopover({ source: node });
}
function hide() {
if (!target) return;
target.hidePopover();
}
node.addEventListener("mouseenter", show);
node.addEventListener("focus", show);
node.addEventListener("mouseleave", hide);
node.addEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
return () => {
node.removeEventListener("mouseenter", show);
node.removeEventListener("focus", show);
node.removeEventListener("mouseleave", hide);
node.removeEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
};
};
}

View File

@@ -1,101 +0,0 @@
import { osLayout } from "$lib/os-layout";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { persistentWritable } from "$lib/storage";
import { type ChordInfo, chords } from "$lib/undo-redo";
import { derived } from "svelte/store";
export const words = derived(
[chords, osLayout],
([chords, layout]) =>
new Map<string, ChordInfo>(
chords
.map((chord) => ({
chord,
output: chord.phrase.map((action) =>
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
),
}))
.filter(({ output }) => output.every((it) => !!it))
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
),
);
interface Score {
lastTyped: number;
score: number;
total: number;
}
export const scores = persistentWritable<Record<string, Score>>("scores", {});
export const learnConfigDefault = {
maxScore: 3,
minScore: -3,
scoreBlend: 0.5,
weakRate: 0.8,
weakBoost: 0.5,
maxWeak: 3,
newRate: 0.3,
initialNewRate: 0.9,
initialCount: 10,
};
export const learnConfigStored = persistentWritable<
Partial<typeof learnConfigDefault>
>("learn-config", {});
export const learnConfig = derived(learnConfigStored, (config) => ({
...learnConfigDefault,
...config,
}));
let lastWord: string | undefined;
function shuffle<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j]!, array[i]!];
}
return array;
}
function randomLog2<T>(array: T[], max = array.length): T | undefined {
return array[
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
];
}
export const nextWord = derived(
[words, scores, learnConfig],
([words, scores, config]) => {
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
values.sort(([, a], [, b]) => a.score - b.score);
const weakCount =
(values.findIndex(([, { score }]) => score > 0) + 1 ||
values.length + 1) - 1;
const weak = randomLog2(values, weakCount);
if (weak && Math.random() / weakCount < config.weakRate) {
lastWord = weak[0];
return weak[0];
}
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
const recent = randomLog2(values);
const newRate =
values.length < config.initialCount
? config.initialNewRate
: config.newRate;
if (
recent &&
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
Math.random() > newRate)
) {
lastWord = recent[0];
return recent[0];
}
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
const word = newWord || recent?.[0] || weak?.[0];
lastWord = word;
return word;
},
);

View File

@@ -1,11 +0,0 @@
import { persistentWritable } from "$lib/storage";
interface ChordStats {
level: number;
lastUprank: number;
}
export const chordStats = persistentWritable<Record<string, ChordStats>>(
"chord-stats",
{},
);

View File

@@ -0,0 +1,157 @@
import type { RawVersionMeta, SettingsMeta, VersionMeta } from "./types/meta";
import type { Listing } from "./types/listing";
import type { KeymapCategory } from "./types/actions";
import { browser } from "$app/environment";
let lock: Promise<void> | undefined = undefined;
export async function getMeta(
device: string,
version: string,
fetch: typeof window.fetch = window.fetch,
): Promise<VersionMeta> {
while (lock) await lock;
let resolveLock!: () => void;
lock = new Promise((resolve) => (resolveLock = resolve));
try {
if (!browser) return fetchMeta(device, version, fetch);
const dbRequest = indexedDB.open("version-meta", 6);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
dbRequest.onsuccess = () => resolve(dbRequest.result);
dbRequest.onerror = () => reject(dbRequest.error);
dbRequest.onupgradeneeded = () => {
const db = dbRequest.result;
if (db.objectStoreNames.contains("meta")) {
db.deleteObjectStore("meta");
}
db.createObjectStore("meta", { keyPath: ["device", "version"] });
};
});
console.log("upgrading version meta db");
try {
const readTransaction = db.transaction(["meta"], "readonly");
const store = readTransaction.objectStore("meta");
const itemRequest = store.get([device, version]);
const item = await new Promise<VersionMeta | undefined>((resolve) => {
itemRequest.onsuccess = () => resolve(itemRequest.result);
itemRequest.onerror = () => resolve(undefined);
});
if (item) return item;
const meta = await fetchMeta(device, version);
const putTransaction = db.transaction(["meta"], "readwrite");
const putStore = putTransaction.objectStore("meta");
const putRequest = putStore.put(meta);
await new Promise<void>((resolve, reject) => {
putRequest.onsuccess = () => resolve();
putRequest.onerror = () => reject(putRequest.error);
});
putTransaction.commit();
return meta;
} finally {
db.close();
}
} catch (error) {
console.error(error);
} finally {
resolveLock();
lock = undefined;
}
return fetchMeta(device, version, fetch);
}
async function fetchMeta(
device: string,
version: string,
fetch: typeof window.fetch = window.fetch,
): Promise<VersionMeta> {
const path = `${import.meta.env.VITE_FIRMWARE_URL}/${device}/${version}`;
const files: Listing[] = await fetch(`${path}/`)
.then((res) => res.json())
.catch(() => []);
const meta: Partial<RawVersionMeta> | undefined = files.some(
(entry) => entry.type === "file" && entry.name === "meta.json",
)
? await fetch(`${path}/meta.json`).then((res) => res.json())
: undefined;
return {
version: meta?.version ?? version,
device: meta?.target ?? device,
date: new Date(meta?.git_date ?? files[0]?.mtime ?? ""),
path,
commit: meta?.git_commit ?? undefined,
dirty: meta?.git_is_dirty ?? false,
public: meta?.public_build ?? !version.includes("+"),
developmentBuild: (meta?.development_mode ?? 0) === 1,
factoryDefaults: meta?.factory_defaults
? {
layout: await fetch(`${path}/${meta.factory_defaults.layout}`).then(
(it) => it.json(),
),
settings: await fetch(
`${path}/${meta.factory_defaults.settings}`,
).then((it) => it.json()),
chords: Object.fromEntries(
await Promise.all(
Object.entries(meta.factory_defaults.chords).map(
async ([name, file]) => [
name,
await fetch(`${path}/${file}`).then((it) => it.json()),
],
),
),
),
}
: undefined,
settings: await (meta?.settings
? fetch(`${path}/${meta.settings}`).then((it) => it.json())
: import("$lib/assets/settings.yml")
.then((it) => (it as any).default)
.then((settings: SettingsMeta[]) => {
if (!device.startsWith("lite_")) {
settings = settings.filter((it) => it.name !== "leds");
}
return settings;
})),
changelog: await (meta?.changelog
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
: {}),
actions: await (meta?.actions
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
: Promise.all<KeymapCategory[]>(
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
async (load) => load().then((it) => (it as any).default),
),
)),
recipes: await (meta?.recipes
? fetch(`${path}/${meta.recipes}`).then((it) => it.json())
: undefined),
update: {
uf2:
meta?.update?.uf2 ??
files.find(
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
)?.name ??
undefined,
ota:
meta?.update?.ota ??
files.find(
(entry) => entry.type === "file" && entry.name === "firmware.bin",
)?.name ??
undefined,
esptool: meta?.update?.esptool ?? undefined,
js: meta?.update?.js ?? undefined,
wasm: meta?.update?.wasm ?? undefined,
dll: meta?.update?.dll ?? undefined,
so: meta?.update?.so ?? undefined,
},
spiFlash: meta?.spi_flash ?? undefined,
};
}

Some files were not shown because too many files have changed in this diff Show More