1 Commits

Author SHA1 Message Date
John de St Germain
29756834f8 Fix misspelling 2024-05-13 09:20:46 -05:00
225 changed files with 16362 additions and 22875 deletions

View File

@@ -2,56 +2,49 @@ name: Build
on: on:
push: push:
branches:
- master
tags: tags:
- v* - "v*"
pull_request: workflow_dispatch:
jobs: jobs:
build: CI:
name: 🔨🚀 Build and deploy name: 🔨🚀 Build and deploy
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: 🚚 Checkout - name: 🚚 Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- name: 🐍 Use Python 3.x - name: 🐍 Use Python 3.x
uses: actions/setup-python@v5 uses: actions/setup-python@v3.1.4
with: with:
python-version: 3.x python-version: 3.x
cache: pip cache: pip
- name: ⏬ Install Python dependencies - name: ⏬ Install Python dependencies
run: pip install -r requirements.txt run: pip install -r requirements.txt
- name: Install pnpm - name: 🐉 Use Node.js 18.16.x
uses: pnpm/action-setup@v4 uses: actions/setup-node@v3
with: with:
version: 10 node-version: 18.16.x
- name: 🐉 Use Node.js 22.14.x cache: "npm"
uses: actions/setup-node@v4
with:
node-version: 22.14.x
cache: "pnpm"
- name: ⏬ Install Node dependencies - name: ⏬ Install Node dependencies
run: pnpm install run: npm ci
- name: 🔥 Optimize icon font - name: 🔥 Optimize icon font
run: pnpm minify-icons run: npm run minify-icons
- name: 🔨 Build site - name: 🔨 Build site
run: pnpm build run: npm run build
- name: Setup SSH - name: 📦 Upload build artifacts
run: | uses: actions/upload-artifact@v3.1.2
install -m 600 -D /dev/null ~/.ssh/id_rsa with:
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_rsa name: build
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts path: build
- name: Disable jekyll
- name: Publish Stable run: touch build/.nojekyll
if: ${{ github.ref == 'refs/tags/v*' && !github.event.pull_request.head.repo.fork }} - name: Custom domain
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/ run: echo 'manager.charachorder.com' > build/CNAME
- run: git config user.name github-actions
- name: Publish Branch - run: git config user.email github-actions@github.com
if: ${{ !github.event.pull_request.head.repo.fork }} - run: git --work-tree build add --all
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/} - run: git commit -m "Automatic Deploy action run by github-actions"
- name: Publish Commit - run: git push origin HEAD:gh-pages --force
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}

View File

@@ -7,8 +7,6 @@ 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", "prettier-plugin-css-order"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }

View File

@@ -3,6 +3,7 @@
"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

@@ -29,15 +29,9 @@ You may need to run through some additional setup to get Rust running inside Int
- Python >=3.10 - Python >=3.10
- Rust Stable (For Tauri Development) - Rust Stable (For Tauri Development)
I know, python in JS projects is extremely annoying. Unfortunately, I know, python in JS projects is extremely annoying, unfortunately,
it seems to be the only platform that offers a functional it seems to be the only platform that offers a functional
way to subset variable woff2 fonts with ligatures. way to subset variable woff2 fonts with ligatures.
In other words, either have python as a development dependency or In other words, either have python as a development dependency or
serve a 3.5MB icons font of which 99.5% is completely unused. serve a 3.5MB icons font of which 99.5% is completely unused.
To generate the icons use the following command:
```shell
npm run minify-icons
```

58
flake.lock generated
View File

@@ -5,11 +5,29 @@
"systems": "systems" "systems": "systems"
}, },
"locked": { "locked": {
"lastModified": 1731533236, "lastModified": 1689068808,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "narHash": "sha256-6ixXo3wt24N/melDWjq70UuHQLxGV8jZvooRanIHXw0=",
"owner": "numtide", "owner": "numtide",
"repo": "flake-utils", "repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "rev": "919d646de7be200f3bf08cb76ae1f09402b6f9b4",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1681202837,
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -20,11 +38,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1743259260, "lastModified": 1689752456,
"narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=", "narHash": "sha256-VOChdECcEI8ixz8QY+YC4JaNEFwQd1V8bA0G4B28Ki0=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f", "rev": "7f256d7da238cb627ef189d56ed590739f42f13b",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -36,11 +54,11 @@
}, },
"nixpkgs_2": { "nixpkgs_2": {
"locked": { "locked": {
"lastModified": 1736320768, "lastModified": 1681358109,
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=", "narHash": "sha256-eKyxW4OohHQx9Urxi7TQlFBTDWII+F+x2hklDOQPB50=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8", "rev": "96ba1c52e54e74c3197f4d43026b3f3d92e83ff9",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -59,14 +77,15 @@
}, },
"rust-overlay": { "rust-overlay": {
"inputs": { "inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1743388531, "lastModified": 1690942540,
"narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=", "narHash": "sha256-eafSSO3Y+/TFuy+CHKyolYfGvC33IAWNx4W2NA7LfZM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "011de3c895927300651d9c2cb8e062adf17aa665", "rev": "aa3994f054038262df55122dfa552b9eab71a994",
"type": "github" "type": "github"
}, },
"original": { "original": {
@@ -89,6 +108,21 @@
"repo": "default", "repo": "default",
"type": "github" "type": "github"
} }
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

View File

@@ -4,41 +4,19 @@
rust-overlay.url = "github:oxalica/rust-overlay"; rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = outputs = {
{
self, self,
nixpkgs, nixpkgs,
flake-utils, flake-utils,
rust-overlay, rust-overlay,
}: }:
flake-utils.lib.eachDefaultSystem ( flake-utils.lib.eachDefaultSystem (system: let
system: overlays = [(import rust-overlay)];
let
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 = ["rust-src" "rust-std" "clippy" "rust-analyzer"];
"rust-src"
"rust-std"
"clippy"
"rust-analyzer"
];
}; };
fontMin = pkgs.python311.withPackages ( fontMin = pkgs.python311.withPackages (ps: with ps; [brotli fonttools] ++ (with fonttools.optional-dependencies; [woff]));
ps:
with ps;
[
brotli
fonttools
]
++ (with fonttools.optional-dependencies; [ woff ])
);
tauriPkgs = nixpkgs.legacyPackages.${system}; tauriPkgs = nixpkgs.legacyPackages.${system};
libraries = with tauriPkgs; [ libraries = with tauriPkgs; [
webkitgtk webkitgtk
@@ -52,8 +30,7 @@
]; ];
packages = packages =
(with pkgs; [ (with pkgs; [
nodejs nodejs_18
pnpm
rust-bin rust-bin
fontMin fontMin
]) ])
@@ -65,20 +42,18 @@
openssl_3 openssl_3
glib glib
gtk3 gtk3
libsoup_2_4 libsoup
webkitgtk webkitgtk
librsvg librsvg
# serial plugin # serial plugin
udev udev
]); ]);
in in {
{
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,8 +4,6 @@ const config = {
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2", "node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
outputPath: "src/lib/assets/icons.min.woff2", outputPath: "src/lib/assets/icons.min.woff2",
icons: [ icons: [
"rocket_launch",
"deployed_code_update",
"adjust", "adjust",
"add", "add",
"piano", "piano",
@@ -20,8 +18,6 @@ const config = {
"update", "update",
"offline_pin", "offline_pin",
"warning", "warning",
"dangerous",
"check",
"cable", "cable",
"person", "person",
"sync", "sync",
@@ -34,7 +30,6 @@ const config = {
"abc", "abc",
"function", "function",
"cloud_done", "cloud_done",
"counter_4",
"backup", "backup",
"cloud_download", "cloud_download",
"cloud_off", "cloud_off",
@@ -44,25 +39,7 @@ 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",
"open_in_browser",
"chevron_backward",
"chevron_forward",
"bookmark",
"drag_pan",
"markdown_copy",
"sort", "sort",
"shopping_bag", "shopping_bag",
"filter_list", "filter_list",
@@ -78,37 +55,22 @@ const config = {
"light_mode", "light_mode",
"palette", "palette",
"translate", "translate",
"smart_toy",
"visibility_off",
"play_arrow", "play_arrow",
"extension", "extension",
"upload_file", "upload_file",
"file_export",
"file_save",
"commit", "commit",
"bug_report", "bug_report",
"delete", "delete",
"remove_selection", "remove_selection",
"bolt", "bolt",
"thunderstorm",
"join_inner",
"uppercase",
"undo", "undo",
"redo", "redo",
"replay",
"clock_loader_80",
"reply",
"navigate_before", "navigate_before",
"navigate_next", "navigate_next",
"library_add",
"reset_wrench",
"reset_settings",
"delete_sweep",
"print", "print",
"restore_from_trash", "restore_from_trash",
"history", "history",
"history_toggle_off", "history_toggle_off",
"text_to_speech",
"sentiment_satisfied", "sentiment_satisfied",
"sentiment_dissatisfied", "sentiment_dissatisfied",
"sentiment_very_satisfied", "sentiment_very_satisfied",
@@ -122,7 +84,6 @@ const config = {
"sentiment_sad", "sentiment_sad",
"sentiment_content", "sentiment_content",
"sentiment_worried", "sentiment_worried",
"construction",
"timer", "timer",
"target", "target",
"download", "download",
@@ -130,46 +91,18 @@ const config = {
"upload_2", "upload_2",
"stat_minus_2", "stat_minus_2",
"stat_2", "stat_2",
"send",
"more_horiz",
"add_reaction",
"stop",
"description", "description",
"add_circle", "add_circle",
"refresh", "refresh",
"tune",
"edit_document",
"chat",
"account_circle",
"experiment",
"code",
"dictionary",
"developer_board",
"developer_board_off",
"memory",
"gamepad_circle_up",
"gamepad_circle_left",
"gamepad_circle_down",
"gamepad_circle_right",
"trail_length_medium",
"blur_short",
"combine_columns",
"animation",
"text_select_move_back_word",
], ],
codePoints: { codePoints: {
speed: "e9e4", speed: "e9e4",
arrow_split: "e985", arrow_split: "e985",
arrow_circle_down: "f181", arrow_circle_down: "f181",
arrow_circle_up: "f182", arrow_circle_up: "f182",
gamepad_circle_up: "eecd",
gamepad_circle_right: "eece",
gamepad_circle_left: "eecf",
gamepad_circle_down: "eed0",
counter_1: "f784", counter_1: "f784",
counter_2: "f783", counter_2: "f783",
counter_3: "f782", counter_3: "f782",
counter_4: "f781",
ios_share: "e6b8", ios_share: "e6b8",
light_mode: "e518", light_mode: "e518",
upload_file: "e9fc", upload_file: "e9fc",
@@ -179,11 +112,6 @@ const config = {
upload_2: "ff52", upload_2: "ff52",
stat_minus_2: "e69c", stat_minus_2: "e69c",
stat_2: "e699", stat_2: "e699",
routine: "e20c",
experiment: "e686",
dictionary: "f539",
visibility_off: "e8f5",
file_save: "f17f",
}, },
}; };

11684
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,8 @@
{ {
"name": "charachorder-device-manager", "name": "charachorder-device-manager",
"version": "2.6.0", "version": "1.5.1",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"private": true, "private": true,
"engines": {
"node": ">=22.14",
"pnpm": ">=10.7"
},
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/CharaChorder/DeviceManager.git" "url": "https://github.com/CharaChorder/DeviceManager.git"
@@ -34,71 +30,52 @@
"typesafe-i18n": "typesafe-i18n" "typesafe-i18n": "typesafe-i18n"
}, },
"devDependencies": { "devDependencies": {
"@codemirror/autocomplete": "^6.20.0", "@codemirror/autocomplete": "^6.15.0",
"@codemirror/commands": "^6.10.1", "@codemirror/commands": "^6.3.3",
"@codemirror/lang-javascript": "^6.2.4", "@codemirror/lang-javascript": "^6.2.2",
"@codemirror/language": "^6.11.3", "@codemirror/language": "^6.10.1",
"@codemirror/merge": "^6.11.2", "@codemirror/state": "^6.4.1",
"@codemirror/state": "^6.5.2", "@fontsource-variable/material-symbols-rounded": "^5.0.27",
"@codemirror/view": "^6.39.4", "@fontsource-variable/noto-sans-mono": "^5.0.19",
"@fontsource-variable/material-symbols-rounded": "^5.2.30", "@material/material-color-utilities": "^0.2.7",
"@fontsource-variable/noto-sans-mono": "^5.2.10", "@modyfi/vite-plugin-yaml": "^1.1.0",
"@lezer/common": "^1.4.0", "@sveltejs/adapter-static": "^2.0.3",
"@lezer/generator": "^1.8.0", "@sveltejs/kit": "^1.30.4",
"@lezer/highlight": "^1.2.3", "@sveltejs/vite-plugin-svelte": "^2.5.3",
"@lezer/lr": "^1.4.5", "@tauri-apps/api": "^1.5.3",
"@material/material-color-utilities": "^0.3.0", "@tauri-apps/cli": "^1.5.11",
"@melt-ui/pp": "^0.3.2", "@types/dom-view-transitions": "^1.0.4",
"@melt-ui/svelte": "^0.86.6", "@types/flexsearch": "^0.7.6",
"@modyfi/vite-plugin-yaml": "^1.1.1", "@types/w3c-web-serial": "^1.0.6",
"@sveltejs/adapter-static": "^3.0.10", "@types/w3c-web-usb": "^1.0.10",
"@sveltejs/kit": "^2.49.2", "@vite-pwa/sveltekit": "^0.2.10",
"@sveltejs/vite-plugin-svelte": "^6.2.1", "autoprefixer": "^10.4.19",
"@tauri-apps/api": "^1.6.0", "codemirror": "^6.0.1",
"@tauri-apps/cli": "^1.6.0", "cypress": "^13.7.2",
"@types/dom-view-transitions": "^1.0.6", "flexsearch": "^0.7.43",
"@types/js-yaml": "^4.0.9", "fontkit": "^2.0.2",
"@types/semver": "^7.7.1", "glob": "^10.3.12",
"@types/w3c-web-serial": "^1.0.8", "jsdom": "^22.1.0",
"@types/w3c-web-usb": "^1.0.13",
"@types/wicg-file-system-access": "^2023.10.7",
"@vite-pwa/sveltekit": "^1.1.0",
"autoprefixer": "^10.4.23",
"codemirror": "^6.0.2",
"cypress": "^14.5.3",
"d3": "^7.9.0",
"esptool-js": "^0.5.7",
"flexsearch": "^0.8.212",
"fontkit": "^2.0.4",
"glob": "^11.0.3",
"js-yaml": "^4.1.1",
"jsdom": "^26.1.0",
"matrix-js-sdk": "^37.12.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.7.4", "prettier": "^3.2.5",
"prettier-plugin-css-order": "^2.1.2", "prettier-plugin-svelte": "^3.2.2",
"prettier-plugin-svelte": "^3.4.1", "sass": "^1.74.1",
"rxjs": "^7.8.2", "stylelint": "^15.11.0",
"sass": "^1.97.0", "stylelint-config-clean-order": "^5.4.2",
"semver": "^7.7.3",
"socket.io-client": "^4.8.1",
"stylelint": "^16.26.1",
"stylelint-config-html": "^1.1.0", "stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0", "stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^16.0.2", "stylelint-config-recommended-scss": "^13.1.0",
"stylelint-config-standard-scss": "^16.0.0", "stylelint-config-standard-scss": "^11.1.0",
"svelte": "5.37.1", "svelte": "^4.2.12",
"svelte-check": "^4.3.4", "svelte-check": "^3.6.9",
"svelte-preprocess": "^6.0.3", "svelte-preprocess": "^5.1.3",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2", "typesafe-i18n": "^5.26.2",
"typescript": "^5.8.3", "typescript": "^5.4.4",
"vite": "^7.0.6", "vite": "^4.5.3",
"vite-plugin-mkcert": "^1.17.9", "vite-plugin-mkcert": "^1.17.5",
"vite-plugin-pwa": "^1.0.2", "vite-plugin-pwa": "^0.17.5",
"vitest": "^4.0.16", "vitest": "^0.34.6"
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
}, },
"type": "module" "type": "module"
} }

9330
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "app" name = "app"
version = "2.6.0" version = "1.5.1"
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.6.0" }, "package": { "productName": "amacc1ng", "version": "1.5.1" },
"tauri": { "tauri": {
"allowlist": { "all": false }, "allowlist": { "all": false },
"bundle": { "bundle": {

2
src/env.d.ts vendored
View File

@@ -14,8 +14,6 @@ interface ImportMetaEnv {
readonly VITE_LEARN_URL: string; readonly VITE_LEARN_URL: string;
readonly VITE_LATEST_FIRMWARE: string; readonly VITE_LATEST_FIRMWARE: string;
readonly VITE_STORE_URL: string; readonly VITE_STORE_URL: string;
readonly VITE_MATRIX_URL: string;
readonly VITE_FIRMWARE_URL: string;
} }
interface ImportMeta { interface ImportMeta {

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: "Anwended", SAVE: "Speichern",
}, },
update: { update: {
TITLE: "Gerät aktualisieren", TITLE: "Gerät aktualisieren",
@@ -17,11 +17,11 @@ const de = {
RELOAD: "Neu laden", RELOAD: "Neu laden",
}, },
backup: { backup: {
TITLE: "Backup", TITLE: "Lokale Kopie",
AUTO_BACKUP: "Beschleunigtes Verbinden", INDIVIDUAL: "Einzeldateien",
DISCLAIMER: DISCLAIMER:
"<b>Nicht auf öffentlichen oder geteilten Computern einschalten.</b> Gerätedaten werden für schnelleren Zugriff lokal zwischengespeichert.", "Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Komplettes Profil", DOWNLOAD: "Alles herunterladen",
RESTORE: "Wiederherstellen", RESTORE: "Wiederherstellen",
}, },
modal: { modal: {
@@ -109,7 +109,7 @@ const de = {
}, },
configure: { configure: {
chords: { chords: {
TITLE: "Bibliothek", TITLE: "Akkorde",
HOLD_KEYS: "Akkord halten", HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord", NEW_CHORD: "Neuer Akkord",
DUPLICATE: "Akkord existiert bereits", DUPLICATE: "Akkord existiert bereits",
@@ -131,7 +131,7 @@ const de = {
TITLE: "Layout", TITLE: "Layout",
}, },
settings: { settings: {
TITLE: "Gerät", TITLE: "Einstellungen",
}, },
}, },
plugin: { plugin: {

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: "Apply", SAVE: "Save",
}, },
update: { update: {
TITLE: "Update your device", TITLE: "Update your device",
}, },
backup: { backup: {
TITLE: "Backup", TITLE: "Local backup",
AUTO_BACKUP: "Fast Connect", INDIVIDUAL: "Individual backups",
DISCLAIMER: DISCLAIMER:
"<b>Turn off if using a shared or public computer.</b> Caches your device's data locally for quick access next time you connect.", "A backup is made and stored in this browser, and always remains only on your computer.",
DOWNLOAD: "Full profile", DOWNLOAD: "Download Everything",
RESTORE: "Restore", RESTORE: "Restore",
}, },
sync: { sync: {
@@ -108,7 +108,7 @@ const en = {
}, },
configure: { configure: {
chords: { chords: {
TITLE: "Library", TITLE: "Chords",
HOLD_KEYS: "Hold chord", HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord", NEW_CHORD: "New chord",
DUPLICATE: "Chord already exists", DUPLICATE: "Chord already exists",
@@ -130,7 +130,7 @@ const en = {
TITLE: "Layout", TITLE: "Layout",
}, },
settings: { settings: {
TITLE: "Device", TITLE: "Settings",
}, },
}, },
plugin: { plugin: {

View File

@@ -1,55 +0,0 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { afterNavigate, beforeNavigate } from "$app/navigation";
import { expoIn, expoOut } from "svelte/easing";
import type { Snippet } from "svelte";
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
$props();
let inDirection = $state(0);
let outDirection = $state(0);
let outroEnd: undefined | (() => void) = $state(undefined);
let animationDone: Promise<void>;
let isNavigating = $state(false);
function routeIndex(route: string | undefined): number {
return routeOrder.findIndex((it) => route?.startsWith(it));
}
beforeNavigate((navigation) => {
const from = routeIndex(navigation.from?.url.pathname);
const to = routeIndex(navigation.to?.url.pathname);
if (from === -1 || to === -1 || from === to) return;
isNavigating = true;
inDirection = from > to ? -1 : 1;
outDirection = from > to ? 1 : -1;
animationDone = new Promise((resolve) => {
outroEnd = resolve;
});
});
afterNavigate(async () => {
await animationDone;
isNavigating = false;
});
</script>
{#if !isNavigating}
<main
in:fly={{ y: inDirection * 24, duration: 150, easing: expoOut }}
out:fly={{ y: outDirection * 24, duration: 150, easing: expoIn }}
onoutroend={outroEnd}
>
{@render children()}
</main>
{/if}
<style lang="scss">
main {
padding: 0;
}
</style>

View File

@@ -1,121 +0,0 @@
<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

@@ -0,0 +1,497 @@
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

@@ -9,193 +9,145 @@ actions:
This action is unique in this way. Technically it is "printable", but it is not visible. This action is unique in this way. Technically it is "printable", but it is not visible.
39: 39:
id: "'" id: "'"
keyCode: Quote
title: Single Quote title: Single Quote
44: 44:
id: "," id: ","
keyCode: Comma
title: Comma title: Comma
45: 45:
id: "-" id: "-"
keyCode: Minus
title: Minus title: Minus
46: 46:
id: "." id: "."
keyCode: Period
title: Period title: Period
47: 47:
id: "/" id: "/"
keyCode: Slash
title: Forward Slash title: Forward Slash
48: 48:
id: "0" id: "0"
keyCode: Digit0
title: Zero title: Zero
49: 49:
id: "1" id: "1"
keyCode: Digit1
title: One title: One
50: 50:
id: "2" id: "2"
keyCode: Digit2
title: Two title: Two
51: 51:
id: "3" id: "3"
keyCode: Digit3
title: Three title: Three
52: 52:
id: "4" id: "4"
keyCode: Digit4
title: Four title: Four
53: 53:
id: "5" id: "5"
keyCode: Digit5
title: Five title: Five
54: 54:
id: "6" id: "6"
keyCode: Digit6
title: Six title: Six
55: 55:
id: "7" id: "7"
keyCode: Digit7
title: Seven title: Seven
56: 56:
id: "8" id: "8"
keyCode: Digit8
title: Eight title: Eight
57: 57:
id: "9" id: "9"
keyCode: Digit9
title: Nine title: Nine
59: 59:
id: ";" id: ";"
keyCode: Semicolon
title: Semicolon title: Semicolon
61: 61:
id: "=" id: "="
keyCode: Equal
title: Equals title: Equals
91: 91:
id: "[" id: "["
keyCode: BracketLeft
title: Left Bracket title: Left Bracket
92: 92:
id: "\\" id: "\\"
keyCode: Backslash
title: Backslash title: Backslash
93: 93:
id: "]" id: "]"
keyCode: BracketRight
title: Right Bracket title: Right Bracket
96: 96:
id: "`" id: "`"
keyCode: Backquote
title: Backtick title: Backtick
97: 97:
id: "a" id: "a"
keyCode: KeyA
title: Lowercase a title: Lowercase a
98: 98:
id: "b" id: "b"
keyCode: KeyB
title: Lowercase b title: Lowercase b
99: 99:
id: "c" id: "c"
keyCode: KeyC
title: Lowercase c title: Lowercase c
100: 100:
id: "d" id: "d"
keyCode: KeyD
title: Lowercase d title: Lowercase d
101: 101:
id: "e" id: "e"
keyCode: KeyE
title: Lowercase e title: Lowercase e
102: 102:
id: "f" id: "f"
keyCode: KeyF
title: Lowercase f title: Lowercase f
103: 103:
id: "g" id: "g"
keyCode: KeyG
title: Lowercase g title: Lowercase g
104: 104:
id: "h" id: "h"
keyCode: KeyH
title: Lowercase h title: Lowercase h
105: 105:
id: "i" id: "i"
keyCode: KeyI
title: Lowercase i title: Lowercase i
106: 106:
id: "j" id: "j"
keyCode: KeyJ
title: Lowercase j title: Lowercase j
107: 107:
id: "k" id: "k"
keyCode: KeyK
title: Lowercase k title: Lowercase k
108: 108:
id: "l" id: "l"
keyCode: KeyL
title: Lowercase l title: Lowercase l
109: 109:
id: "m" id: "m"
keyCode: KeyM
title: Lowercase m title: Lowercase m
110: 110:
id: "n" id: "n"
keyCode: KeyN
title: Lowercase n title: Lowercase n
111: 111:
id: "o" id: "o"
keyCode: KeyO
title: Lowercase o title: Lowercase o
112: 112:
id: "p" id: "p"
keyCode: KeyP
title: Lowercase p title: Lowercase p
113: 113:
id: "q" id: "q"
keyCode: KeyQ
title: Lowercase q title: Lowercase q
114: 114:
id: "r" id: "r"
keyCode: KeyR
title: Lowercase r title: Lowercase r
115: 115:
id: "s" id: "s"
keyCode: KeyS
title: Lowercase s title: Lowercase s
116: 116:
id: "t" id: "t"
keyCode: KeyT
title: Lowercase t title: Lowercase t
117: 117:
id: "u" id: "u"
keyCode: KeyU
title: Lowercase u title: Lowercase u
118: 118:
id: "v" id: "v"
keyCode: KeyV
title: Lowercase v title: Lowercase v
119: 119:
id: "w" id: "w"
KeyCode: KeyW
title: Lowercase w title: Lowercase w
120: 120:
id: "x" id: "x"
keyCode: KeyX
title: Lowercase x title: Lowercase x
121: 121:
id: "y" id: "y"
keyCode: KeyY
title: Lowercase y title: Lowercase y
122: 122:
id: "z" id: "z"
keyCode: KeyZ
title: Lowercase z title: Lowercase z
127: 127:
id: "DEL" id: "DEL"
keyCode: Delete
title: Delete title: Delete

View File

@@ -70,9 +70,6 @@ actions:
title: Primary Keymap title: Primary Keymap
icon: counter_1 icon: counter_1
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
549: 549:
variantOf: 548 variantOf: 548
<<: *primary_keymap <<: *primary_keymap
@@ -83,9 +80,6 @@ actions:
title: Numeric Layer title: Numeric Layer
icon: counter_2 icon: counter_2
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
551: 551:
variantOf: 550 variantOf: 550
<<: *secondary_keymap <<: *secondary_keymap
@@ -96,31 +90,11 @@ actions:
title: Function Layer title: Function Layer
icon: counter_3 icon: counter_3
variant: left variant: left
description: |
Acts as a toggle if the same action is not assigned
to the target layer
553: 553:
variationOf: 552 variationOf: 552
<<: *tertiary_keymap <<: *tertiary_keymap
id: "KM_3_R" id: "KM_3_R"
variant: right variant: right
558:
id: HOLD_COMPOUND
title: Dynamic Library
icon: layers
description: |
Allows for the activation & creation of dynamic chord libraries.
When included as part of a chord output,
that chord's input becomes the seed for a dynamic chord library,
and that library is activated.
Any new chords created while a dynamic library is active are established one level above its seed.
559:
id: RELEASE_COMPOUND
title: Base Library
icon: layers_clear
description: |
Re-activates your base chord library,
and deactivates any currently active dynamic chord library.
576: 576:
id: ACTION_DELAY_1000 id: ACTION_DELAY_1000
icon: clock_loader_90 icon: clock_loader_90

View File

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

View File

@@ -422,8 +422,8 @@ actions:
title: Keyboard Non-US \ and | (US English) title: Keyboard Non-US \ and | (US English)
357: 357:
id: "COMPOSE" id: "COMPOSE"
icon: menu
title: Keyboard Application title: Keyboard Application
description: Officially supported by Win, Unix, and Boot
358: 358:
id: "POWER" id: "POWER"
keyCode: "Power" keyCode: "Power"
@@ -944,99 +944,99 @@ actions:
title: Keyboard Right GUI title: Keyboard Right GUI
488: 488:
id: "KSC_E8" id: "KSC_E8"
icon: play_pause
keyCode: "MediaPlayPause" keyCode: "MediaPlayPause"
title: Media Play Pause title: Media Play Pause
description: Not required to be supported by any OS. Possibly deprecated.
489: 489:
id: "KSC_E9" id: "KSC_E9"
icon: stop
keyCode: "MediaStop" keyCode: "MediaStop"
title: Media Stop CD title: Media Stop CD
description: Not required to be supported by any OS. Possibly deprecated.
490: 490:
id: "KSC_EA" id: "KSC_EA"
icon: skip_previous
keyCode: "MediaTrackPrevious" keyCode: "MediaTrackPrevious"
title: Media Previous Song title: Media Previous Song
description: Not required to be supported by any OS. Possibly deprecated.
491: 491:
id: "KSC_EB" id: "KSC_EB"
icon: skip_next
keyCode: "MediaTrackNext" keyCode: "MediaTrackNext"
title: Media Next Song title: Media Next Song
description: Not required to be supported by any OS. Possibly deprecated.
492: 492:
id: "KSC_EC" id: "KSC_EC"
icon: eject
keyCode: "Eject" keyCode: "Eject"
title: Media Eject CD title: Media Eject CD
description: MacOS only description: Not required to be supported by any OS. Possibly deprecated.
493: 493:
id: "KSC_ED" id: "KSC_ED"
icon: volume_up
keyCode: "AudioVolumeUp" keyCode: "AudioVolumeUp"
title: Media Volume Up title: Media Volume Up
description: Not required to be supported by any OS. Possibly deprecated.
494: 494:
id: "KSC_EE" id: "KSC_EE"
icon: volume_down
keyCode: "AudioVolumeDown" keyCode: "AudioVolumeDown"
title: Media Volume Down title: Media Volume Down
description: Not required to be supported by any OS. Possibly deprecated.
495: 495:
id: "KSC_EF" id: "KSC_EF"
icon: volume_off
keyCode: "AudioVolumeMute" keyCode: "AudioVolumeMute"
title: Media Mute title: Media Mute
description: Not required to be supported by any OS. Possibly deprecated.
496: 496:
id: "KSC_F0" id: "KSC_F0"
icon: language title: Media www
title: Media Browser description: Not required to be supported by any OS. Possibly deprecated.
497: 497:
id: "KSC_F1" id: "KSC_F1"
keyCode: "BrowserBack" keyCode: "BrowserBack"
title: Media Browser Back title: Media Back
description: Not required to be supported by any OS. Possibly deprecated.
498: 498:
id: "KSC_F2" id: "KSC_F2"
keyCode: "BrowserForward" keyCode: "BrowserForward"
title: Media Browser Forward title: Media Forward
description: Not required to be supported by any OS. Possibly deprecated.
499: 499:
id: "KSC_F3" id: "KSC_F3"
keyCode: "BrowserStop" keyCode: "BrowserStop"
title: Media Browser Stop title: Media Stop
description: Not supported on MacOS description: Not required to be supported by any OS. Possibly deprecated.
500: 500:
id: "KSC_F4" id: "KSC_F4"
icon: search
keyCode: "BrowserSearch" keyCode: "BrowserSearch"
title: Media Browser Search title: Media Find
description: Not required to be supported by any OS. Possibly deprecated.
501: 501:
id: "KSC_F5" id: "KSC_F5"
icon: brightness_high title: Media Scroll Up
title: Media Brightness Up description: Not required to be supported by any OS. Possibly deprecated.
502: 502:
id: "KSC_F6" id: "KSC_F6"
icon: brightness_low title: Media Scroll Down
title: Media Brightness Down description: Not required to be supported by any OS. Possibly deprecated.
503: 503:
id: "KSC_F7" id: "KSC_F7"
title: Media Edit title: Media Edit
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
504: 504:
id: "KSC_F8" id: "KSC_F8"
icon: bedtime
keyCode: "Sleep" keyCode: "Sleep"
title: Media System Sleep title: Media Sleep
description: Not required to be supported by any OS. Possibly deprecated. description: Not required to be supported by any OS. Possibly deprecated.
505: 505:
id: "KSC_F9" id: "KSC_F9"
icon: routine
keyCode: "WakeUp" keyCode: "WakeUp"
title: Media System Wake title: Media Coffee
description: Not supported on Windows description: Not required to be supported by any OS. Possibly deprecated.
506: 506:
id: "KSC_FA" id: "KSC_FA"
keyCode: "BrowserRefresh" keyCode: "BrowserRefresh"
title: Media Browser Refresh title: Media Refresh
description: Not required to be supported by any OS. Possibly deprecated.
507: 507:
id: "KSC_FB" id: "KSC_FB"
title: Media Calculator title: Media Calc
description: Not supported on MacOS description: Not required to be supported by any OS. Possibly deprecated.
508: 508:
id: "KSC_FC" id: "KSC_FC"
description: Not required to be supported by any OS. description: Not required to be supported by any OS.

View File

@@ -1,19 +0,0 @@
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

@@ -1,37 +0,0 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -1,37 +0,0 @@
name: M4G
col:
# Ring / Middle
- offset: [2, 0]
row:
- switch: { e: 26, n: 27, w: 28, s: 29 }
- switch: { e: 21, n: 22, w: 23, s: 24 }
- offset: [4, 0]
switch: { w: 66, n: 67, e: 68, s: 69 }
- switch: { w: 71, n: 72, e: 73, s: 74 }
- offset: [2, 0]
row:
- switch: { e: 41, n: 42, w: 43, s: 44 }
- switch: { e: 36, n: 37, w: 38, s: 39 }
- offset: [4, 0]
switch: { w: 81, n: 82, e: 83, s: 84 }
- switch: { w: 86, n: 87, e: 88, s: 89 }
# Pinkie / Index
- offset: [0, -3]
row:
- switch: { e: 31, n: 32, w: 33, s: 34 }
- offset: [4, 0]
switch: { e: 16, n: 17, w: 18, s: 19 }
- switch: { w: 61, n: 62, e: 63, s: 64 }
- offset: [4, 0]
switch: { w: 76, n: 77, e: 78, s: 79 }
# Thumbs
- row:
- offset: [5.5, 0.5]
switch: { e: 11, n: 12, w: 13, s: 14 }
- offset: [1, 0.5]
switch: { w: 56, n: 57, e: 58, s: 59 }
- row:
- offset: [4.5, -0.25]
switch: { e: 6, n: 7, w: 8, s: 9 }
- offset: [3, -0.25]
switch: { w: 51, n: 52, e: 53, s: 54 }

View File

@@ -1,10 +0,0 @@
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

@@ -13,7 +13,7 @@
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere", "GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use", "Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
"Chentry stands for character entry, or typing letter by letter on a chording enabled device", "Chentry stands for character entry, or typing letter by letter on a chording enabled device",
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more", "Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets", "You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated", "An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!", "Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",

View File

@@ -1,154 +1,118 @@
- name: spurring settings:
description: | 0x1:
"Chording only" mode which tells your device to output chords on a press title: Enable Serial Header
rather than a press & release. It also enables you to jump from one description: boolean 0 or 1, default is 0
chord to another without releasing everything and can be activated in 0x2:
GTM or by chording both mirror keys. It can provide significant speed title: Enable Serial Logging
gains with chording, but also takes away the flexibility of character description: boolean 0 or 1, default is 0
entry. 0x3:
items: title: Enable Serial Debugging
- id: 0x41 description: boolean 0 or 1, default is 0
name: enable 0x4:
range: [0, 1] title: Enable Serial Raw
- id: 0x43 description: boolean 0 or 1, default is 0
name: character counter timeout 0x5:
range: [0, 240000] title: Enable Serial Chord
step: 1000 description: boolean 0 or 1, default is 0
scale: 0.001 0x6:
unit: s title: Enable Serial Keyboard
- name: arpeggiates description: boolean 0 or 1, default is 0
description: | 0x7:
Allows chord modifiers to be hit after instead of with a chord, title: Enable Serial Mouse
and enables select keys to be placed before auto-spaces. description: boolean 0 or 1, default is 0
items: 0x11:
- id: 0x51 title: Enable USB HID Keyboard
name: enable description: boolean 0 or 1, default is 1
range: [0, 1] 0x12:
- id: 0x54 title: Enable Character Entry
name: timeout description: boolean 0 or 1
range: [0, 2550] 0x13:
step: 10 title: GUI-CTRL Swap Mode
unit: ms description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only)
- name: keyboard 0x14:
items: title: Key Scan Duration
- id: 0x11 description: scan rate described in milliseconds; default is 2ms = 500Hz
name: enable 0x15:
range: [0, 1] title: Key Debounce Press Duration
- id: 0x12 description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
name: character entry 0x16:
range: [0, 1] title: Key Debounce Release Duration
- id: 0x13 description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
name: command option swap 0x17:
range: [0, 1] title: Keyboard Output Character Microsecond Delays
description: | description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us
Swaps ⌥ and ⌘ to make transitioning between Mac and other systems easier. 0x21:
- id: 0x14 title: Enable USB HID Mouse
name: poll rate description: boolean 0 or 1; default is 1
range: [0, 255] 0x22:
unit: Hz title: Slow Mouse Speed
inverse: 1000 description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s
- id: 0x15 0x23:
name: debounce press title: Fast Mouse Speed
range: [0, 255] description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s
unit: ms 0x24:
- id: 0x16 title: Enable Active Mouse
name: debounce release description: boolean 0 or 1; moves mouse back and forth every 60s
range: [0, 255] 0x25:
unit: ms title: Mouse Scroll Speed
- id: 0x17 description: default is 1; polls at 1/4th the rate of the mouse move updates
name: output delay 0x26:
range: [0, 10200] title: Mouse Poll Duration
step: 40 description: poll rate described in milliseconds; default is 20ms = 50Hz
unit: µs 0x31:
- name: mouse title: Enable Chording
items: description: boolean 0 or 1
- id: 0x21 0x32:
name: enable title: Enable Chording Character Counter Timeout
range: [0, 1] description: boolean 0 or 1; default is 1
- id: 0x22 0x33:
name: slow speed title: Chording Character Counter Timeout Timer
range: [0, 255] description: 0-255 deciseconds; default is 40 or 4.0 seconds
unit: px 0x34:
- id: 0x23 title: Chord Detection Press Tolerance(ms)
name: fast speed description: 1-50 milliseconds
range: [0, 255] 0x35:
unit: px title: Chord Detection Release Tolerance(ms)
- id: 0x24 description: 1-50 milliseconds
name: caffeine 0x41:
range: [0, 1] title: Enable Spurring
description: | description: boolean 0 or 1; default is 1
Keeps computer alive by moving the mouse back and forth one pixel every 60s 0x42:
- id: 0x25 title: Enable Spurring Character Counter Timeout
name: scroll speed description: boolean 0 or 1; default is 1
range: [0, 255] 0x43:
unit: pg title: Spurring Character Counter Timeout Timer
- id: 0x26 description: 0-255 seconds; default is 240
name: poll rate 0x51:
range: [0, 255] title: Enable Arpeggiates
unit: Hz description: boolean 0 or 1; default is 1
inverse: 1000 0x54:
- name: chording title: Arpeggiate Tolerance
items: description: in milliseconds; default 800ms
- id: 0x31 0x61:
name: enable title: Enable Compound Chording (coming soon)
range: [0, 1] description: boolean 0 or 1; default is 0
- id: 0x33 0x64:
name: auto delete timeout title: Compound Tolerance
range: [0, 25500] description: in milliseconds; default 1500ms
step: 100 0x81:
- id: 0x34 title: LED Brightness
name: press tolerance description: 0-50 (CCL only); default is 5, which draws around 100 mA of current
description: | 0x82:
Scales with the number of chord inputs. title: LED Color Code
range: [0, 255] description: Color Codes to be listed (CCL only)
unit: ms 0x83:
- id: 0x35 title: Enable LED Key Highlight (coming soon)
name: release tolerance description: boolean 0 or 1 (CCL only)
description: | 0x84:
Scales with the number of chord inputs. title: Enable LEDs
range: [0, 255] description: boolean 0 or 1; default is 1 (CCL only)
unit: ms 0x91:
- name: leds title: Operating System
items: description: Operating system codes listed below
- id: 0x84 0x92:
name: enable title: Enable Realtime Feedback
range: [0, 1] description: boolean 0 or 1; default is 1
- id: 0x81 0x93:
name: brightness title: Enable CharaChorder Ready on startup
range: [0, 50] description: boolean 0 or 1; default is 1
- 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

@@ -14,7 +14,7 @@ import {
settings, settings,
} from "$lib/undo-redo.js"; } from "$lib/undo-redo.js";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { activeProfile, serialPort } from "../serial/connection"; import { 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,9 +50,11 @@ export function createLayoutBackup(): CharaLayoutFile {
charaVersion: 1, charaVersion: 1,
type: "layout", type: "layout",
device: get(serialPort)?.device, device: get(serialPort)?.device,
layout: (get(layout)[get(activeProfile)]?.map((it) => layout: get(layout).map((it) => it.map((it) => it.action)) as [
it.map((it) => it.action), number[],
) ?? []) as [number[], number[], number[]], number[],
number[],
],
}; };
} }
@@ -68,93 +70,66 @@ export function createSettingsBackup(): CharaSettingsFile {
return { return {
charaVersion: 1, charaVersion: 1,
type: "settings", type: "settings",
settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [], settings: get(settings).map((it) => it.value),
}; };
} }
export async function restoreBackup( export async function restoreBackup(event: Event) {
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), only); restoreFromFile(JSON.parse(text));
} else if (isCsvLayout(text)) { } else if (isCsvLayout(text)) {
restoreFromFile(csvLayoutToJson(text), only); restoreFromFile(csvLayoutToJson(text));
} else if (isCsvChords(text)) { } else if (isCsvChords(text)) {
restoreFromFile(csvChordsToJson(text), only); restoreFromFile(csvChordsToJson(text));
} 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) {
case "backup": { case "backup": {
const recent = file.history[0]; const recent = file.history[0];
if (!recent) return; if (!recent) return;
let backupDevice = recent[1].device; if (recent[1].device !== get(serialPort)?.device) {
if (backupDevice === "TWO" || backupDevice === "M4G")
backupDevice = "ONE";
else if (backupDevice === "ZERO" || backupDevice === "ENGINE")
backupDevice = "X";
let currentDevice = get(serialPort)?.device;
if (currentDevice === "TWO" || currentDevice === "M4G")
currentDevice = "ONE";
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
currentDevice = "X";
if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device"); alert("Backup is incompatible with this device");
throw new Error("Backup is incompatible with this device"); throw new Error("Backup is incompatible with this device");
} }
changes.update((changes) => { changes.update((changes) => {
changes.push([ changes.push(
...(!only || only === "chords" ...getChangesFromChordFile(recent[0]),
? getChangesFromChordFile(recent[0]) ...getChangesFromLayoutFile(recent[1]),
: []), ...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": {
if (!only || only === "chords") {
changes.update((changes) => { changes.update((changes) => {
changes.push(getChangesFromChordFile(file)); changes.push(...getChangesFromChordFile(file));
return changes; return changes;
}); });
}
break; break;
} }
case "layout": { case "layout": {
if (!only || only === "layout") {
changes.update((changes) => { changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file)); changes.push(...getChangesFromLayoutFile(file));
return changes; return changes;
}); });
}
break; break;
} }
case "settings": { case "settings": {
if (!only || only === "settings") {
changes.update((changes) => { changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file)); changes.push(...getChangesFromSettingsFile(file));
return changes; return changes;
}); });
}
break; break;
} }
default: { default: {
@@ -187,13 +162,12 @@ 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)[get(activeProfile)]?.[id]; const setting = get(settings)[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),
}); });
} }
} }
@@ -204,13 +178,12 @@ 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)[get(activeProfile)]?.[layer]?.[id]?.action !== action) { if (get(layout)[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,26 @@
e + b + a,babe
e + c + b,because
f + e + c + a,face
h + e + c + a,each
i + d + ',I'd
i + g + b,big
i + g + e,give
k + b + a,back
k + e + a,take
l + e + a,late
l + e + d + a,lead
l + f + e,feel
l + g + e + a,large
l + h + e,help
l + i + a,Lia
l + i + f,fill
l + i + f + e,life
l + i + g + b + a,gitlab
l + k + i + e,like
m + e + a,make
m + i + ',I'm
n + c + a,can
n + d + a,and
n + e + b,been
n + e + b + a,enable
n + e + d,end

View File

@@ -1,26 +0,0 @@
import type { Attachment } from "svelte/attachments";
import { browser } from "$app/environment";
import { persistentWritable } from "$lib/storage";
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
export function ccosKeyInterceptor() {
return ((element: Window) => {
const ccos = browser
? import("./ccos").then((module) => module.fetchCCOS(".test"))
: Promise.resolve(undefined);
function onEvent(event: KeyboardEvent) {
ccos.then((it) => it?.handleKeyEvent(event));
}
element.addEventListener("keydown", onEvent, true);
element.addEventListener("keyup", onEvent, true);
return () => {
ccos.then((it) => it?.destroy());
element.removeEventListener("keydown", onEvent, true);
element.removeEventListener("keyup", onEvent, true);
};
}) satisfies Attachment<Window>;
}

View File

@@ -1,37 +0,0 @@
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

@@ -1,111 +0,0 @@
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]),
);

View File

@@ -1,232 +0,0 @@
import { getMeta } from "$lib/meta/meta-storage";
import type { SerialPortLike } from "$lib/serial/device";
import type {
CCOSInEvent,
CCOSInitEvent,
CCOSKeyPressEvent,
CCOSKeyReleaseEvent,
CCOSOutEvent,
} from "./ccos-events";
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
const device = "zero_wasm";
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>();
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;
}
console.log("CCOS worker message", event.data);
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 = ".2.2.0-beta.12+266bdda",
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

@@ -1,151 +0,0 @@
<script lang="ts">
import { browser } from "$app/environment";
import { ReplayPlayer } from "./core/player.js";
import { ReplayStepper } from "./core/step.js";
import type { Replay } from "./core/types.js";
import { TextRenderer } from "./renderer/renderer.js";
import { setContext, type Snippet } from "svelte";
let {
replay,
cursor = false,
keys = false,
paused = false,
children,
ondone,
ontick,
}: {
replay: ReplayPlayer | Replay;
cursor?: boolean;
keys?: boolean;
paused?: boolean;
children?: Snippet;
ondone?: () => void;
ontick?: (time: number) => void;
} = $props();
let replayPlayer: ReplayPlayer | undefined = $state();
setContext("replay", {
get player() {
return replayPlayer;
},
});
let finalText = $derived(
replay instanceof ReplayPlayer
? undefined
: new ReplayStepper(replay.keys).text.map((token) => token.text).join(""),
);
let svg: SVGSVGElement | undefined = $state();
let text: Text = (browser ? document.createTextNode("") : undefined)!;
let textRenderer: TextRenderer | undefined = $state();
$effect(() => {
if (!textRenderer) return;
textRenderer.showCursor = cursor;
});
$effect(() => {
if (!svg || !text) return;
if (paused) {
text.textContent = finalText ?? "";
return;
}
const player =
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
replayPlayer = player;
const renderer = new TextRenderer(svg.parentNode as HTMLElement, svg, text);
const apply = () => {
text.textContent =
finalText ??
(player.stepper.text.map((token) => token.text).join("") || "n");
renderer.text = player.stepper.text;
renderer.cursor = player.stepper.cursor;
if (keys) {
renderer.held = player.stepper.held;
}
};
const unsubscribePlayer = player.subscribe(apply);
textRenderer = renderer;
player.onTick = ontick;
player.onDone = ondone;
player.start();
apply();
setTimeout(() => {
renderer.animated = true;
});
return () => {
textRenderer = undefined;
replayPlayer = undefined;
unsubscribePlayer();
player.destroy();
renderer.destroy();
};
});
export function innerText(node: HTMLElement, text: Text) {
node.appendChild(text);
return {
destroy() {
text.remove();
},
};
}
</script>
{#key replay}
<svg bind:this={svg}></svg>
{#if browser}
<span use:innerText={text} style:opacity={paused ? 1 : 0}></span>
{:else if !(replay instanceof ReplayPlayer)}
{finalText}
{/if}
{/key}
{#if children}
{@render children()}
{/if}
<style>
:global(*):has(svg) {
position: relative;
}
span {
white-space: pre-wrap;
overflow-wrap: break-word;
}
svg {
position: absolute;
top: 0;
left: 0;
color: inherit;
font-size: inherit;
font-family: inherit;
user-select: none;
}
svg > :global(text) {
font-size: inherit;
font-family: inherit;
fill: currentColor;
dominant-baseline: middle;
}
svg > :global(text[incorrect]) {
fill: red;
}
svg > :global(rect) {
fill: currentcolor;
}
svg > :global(.animated) {
transition: transform 100ms ease;
}
</style>

View File

@@ -1,130 +0,0 @@
<script lang="ts">
import { fly, scale } from "svelte/transition";
import { KBD_ICONS } from "./renderer/kbd-icon.js";
import { expoOut } from "svelte/easing";
import type { InferredChord } from "./core/types.js";
let { chords }: { chords: InferredChord[] } = $props();
function getPercent(
deviation: number,
inputCount: number,
perfect: number,
fail: number,
) {
const failAdjusted = fail * inputCount;
const perfectAdjusted = perfect * inputCount;
return Math.min(
1,
Math.max(
0,
Math.max(0, deviation - perfectAdjusted) /
(failAdjusted - perfectAdjusted),
),
);
}
function getColor(percent: number, alpha = 1) {
return `hsl(${(1 - percent) * 120}deg 50% 50% / ${alpha})`;
}
</script>
<section>
{#each chords as { input, id, deviation }, i (id)}
{@const a = getPercent(deviation[0], input.length, 10, 25)}
{@const b = getPercent(deviation[1], input.length, 10, 18)}
{@const max = Math.max(a, b)}
<div
class="chord"
out:fly={{ x: -100 }}
style:translate="calc(-{(chords.length - i - 1) * 5}em - 50%) 0"
style:scale={1 - (chords.length - i) / 6}
style:opacity={1 - (chords.length - i - 1) / 6}
title="Press: {deviation[0]}ms, Release: {deviation[1]}ms"
>
<div
class="rating"
style:color={getColor(max)}
style:text-shadow="0 0 {Math.round((1 - max) * 10)}px {getColor(
max,
0.6,
)}"
in:scale={{
start: 1.5 + 1.2 * (1 - max),
easing: expoOut,
duration: 1000,
}}
>
{#if max === 1}
Close
{:else if max > 0.5}
Okay
{:else if max > 0}
Good
{:else}
Perfect
{/if}
</div>
<div
in:fly={{ y: 20, easing: expoOut, duration: 1000 }}
class="tile"
style:background="linear-gradient(to right, {getColor(a)}, {getColor(
b,
)})"
></div>
<div in:fly={{ y: 60, easing: expoOut, duration: 1000 }}>
{#each input as token}
<kbd>{KBD_ICONS.get(token.code)}</kbd>
{/each}
</div>
</div>
{/each}
</section>
<style>
section {
display: grid;
position: relative;
margin: 1em;
margin-bottom: 0;
height: 3em;
font-size: 2em;
}
.rating {
font-style: italic;
font-weight: bold;
text-transform: uppercase;
}
.tile {
border-radius: 0.1em;
width: 100%;
height: 0.2em;
}
kbd {
font-size: 0.6em;
}
kbd + kbd {
margin-inline-start: 0.3em;
}
.chord {
display: flex;
position: absolute;
top: 0;
left: 50%;
flex-direction: column;
justify-content: center;
align-items: center;
transition:
opacity 0.3s ease,
translate 0.3s ease,
scale 0.3s ease;
will-change: transform, opacity, scale;
margin-inline-end: 1em;
padding-inline: 0.1em;
}
</style>

View File

@@ -1,30 +0,0 @@
<script lang="ts">
import { getContext } from "svelte";
import { browser } from "$app/environment";
import type { InferredChord } from "./core/types.js";
import { ChordsReplayPlugin } from "./core/plugins/chords.js";
import type { ReplayPlayer } from "./core/player.js";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let {
chords = $bindable([]),
count = 1,
}: {
chords: InferredChord[];
count?: number;
} = $props();
if (browser) {
$effect(() => {
if (!player.player) return;
const tracker = new ChordsReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
chords = value.slice(-count);
});
return unsubscribe;
});
}
</script>

View File

@@ -1,20 +0,0 @@
<script lang="ts">
import { getContext } from "svelte";
import { RollingWpmReplayPlugin } from "./core/plugins/rolling-wpm";
import type { ReplayPlayer } from "./core/player";
const player: { player: ReplayPlayer | undefined } = getContext("replay");
let { wpm = $bindable(0) } = $props();
$effect(() => {
if (!player.player) return;
const tracker = new RollingWpmReplayPlugin();
tracker.register(player.player);
const unsubscribe = tracker.subscribe((value) => {
wpm = value;
});
return unsubscribe;
});
</script>

View File

@@ -1,20 +0,0 @@
<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

@@ -1,20 +0,0 @@
<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

@@ -1,155 +0,0 @@
import { ReplayStepper } from "./step";
import type { ReplayPlugin, Replay, TextToken } from "./types";
export const ROBOT_THRESHOLD = 20;
export class ReplayPlayer {
stepper = new ReplayStepper();
private replayCursor = 0;
private releaseAt = new Map<string, number>();
startTime = performance.now();
private animationFrameId: ReturnType<typeof requestAnimationFrame> | null =
null;
private timeoutId: ReturnType<typeof setTimeout> | null = null;
timescale = 1;
private subscribers = new Set<(value: TextToken | undefined) => void>();
onDone?: () => void;
onTick?: (time: number) => void;
constructor(
readonly replay: Replay,
plugins: ReplayPlugin[] = [],
) {
for (const plugin of plugins) {
plugin.register(this);
}
}
/** @type {import('./types').StoreContract<import('./types').TextToken | undefined>['subscribe']} */
subscribe(subscription: (value: TextToken | undefined) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
private updateLoop() {
if (
this.replayCursor >= this.replay.keys.length &&
this.releaseAt.size === 0
) {
if (this.onDone) {
this.onDone();
}
return;
}
const now = performance.now() - this.startTime;
this.onTick?.(now);
while (
this.replayCursor < this.replay.keys.length &&
this.replay.keys[this.replayCursor]![2] * this.timescale -
this.replay.start <=
now
) {
const [key, code, at, duration] = this.replay.keys[this.replayCursor++]!;
this.stepper.held.set(code, duration > ROBOT_THRESHOLD);
this.releaseAt.set(code, now + duration * this.timescale);
const token = this.stepper.step(key, code, at, duration);
for (const subscription of this.subscribers) {
subscription(token);
}
}
for (const [key, releaseAt] of this.releaseAt) {
if (releaseAt > now) continue;
this.stepper.held.delete(key);
this.releaseAt.delete(key);
for (const subscription of this.subscribers) {
subscription(undefined);
}
}
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}
playLiveEvent(key: string, code: string): (duration: number) => void {
this.replay.start = this.startTime;
const at = performance.now();
this.stepper.held.set(code, false);
const token = this.stepper.step(key, code, at) ?? {
text: key,
code,
stamp: at,
correct: true,
source: "robot",
};
for (const subscription of this.subscribers) {
subscription(token);
}
const timeout = setTimeout(() => {
token.source = "human";
this.stepper.held.set(code, true);
for (const subscription of this.subscribers) {
subscription(undefined);
}
}, ROBOT_THRESHOLD);
return (duration) => {
clearTimeout(timeout);
if (token) {
// TODO: will this cause performance issues with long text?
const index = this.stepper.text.indexOf(token);
if (index >= 0) {
this.stepper.text[index]!.duration = duration;
this.stepper.text[index]!.source =
duration < ROBOT_THRESHOLD ? "robot" : "human";
}
}
this.stepper.held.delete(code);
for (const subscription of this.subscribers) {
subscription(undefined);
}
};
}
start(delay = 200): this {
this.replayCursor = 0;
this.stepper = new ReplayStepper([], this.replay.challenge);
if (this.replay.keys.length === 0) {
if (this.onDone) {
this.onDone();
}
return this;
}
this.timeoutId = setTimeout(() => {
this.startTime = performance.now();
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
}, delay);
return this;
}
destroy() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
}
}

View File

@@ -1,111 +0,0 @@
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
import type {
StoreContract,
ReplayPlugin,
InferredChord,
TextToken,
} from "../types";
function isValid(human: TextToken[], robot: TextToken[]) {
return human.length > 1 && human.length <= 10 && robot.length > 0;
}
export class ChordsReplayPlugin
implements StoreContract<InferredChord[]>, ReplayPlugin
{
private readonly subscribers = new Set<(value: InferredChord[]) => void>();
private readonly chords: InferredChord[] = [];
private tokens: TextToken[] = [];
private timeout: Parameters<typeof clearTimeout>[0] = NaN;
private infer(human: TextToken[], robo: TextToken[]) {
const output = robo
.filter((token) => token.text.length === 1)
.map((token) => token.text)
.join("");
this.chords.push({
id: human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0),
input: human,
output,
deviation: [
human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0) -
human.reduce((acc, curr) => Math.min(acc, curr.stamp), Infinity),
human.reduce(
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
0,
) -
human.reduce(
(acc, curr) => Math.min(acc, curr.stamp + (curr.duration ?? 0)),
Infinity,
),
],
});
for (const subscription of this.subscribers) {
subscription(this.chords);
}
}
register(replay: ReplayPlayer) {
replay.subscribe((token) => {
if (token) {
this.tokens.push(token);
}
let last = NaN;
let roboStart = NaN;
let roboEnd = NaN;
for (let i = 0; i < this.tokens.length; i++) {
const token = this.tokens[i]!;
if (!token.duration || !token.source) break;
if (
Number.isNaN(roboStart) &&
token.source === "human" &&
token.stamp > last
) {
this.tokens = [];
}
if (Number.isNaN(last) || token.stamp + token.duration > last) {
last = token.stamp + token.duration;
}
if (Number.isNaN(roboStart) && token.source === "robot") {
roboStart = i;
} else if (!Number.isNaN(roboStart) && token.source === "human") {
roboEnd = i;
const human = this.tokens.splice(0, roboStart);
const robot = this.tokens.splice(0, roboEnd - roboStart);
if (isValid(human, robot)) {
this.infer(human, robot);
}
}
}
clearTimeout(this.timeout);
if (replay.stepper.held.size === 0) {
this.timeout = setTimeout(() => {
if (this.tokens.length > 0) {
const human = this.tokens.splice(
0,
this.tokens.findIndex((it) => it.source === "robot"),
);
const robot = this.tokens.splice(0, this.tokens.length);
if (isValid(human, robot)) {
this.infer(human, robot);
}
}
}, ROBOT_THRESHOLD);
}
});
}
subscribe(subscription: (value: InferredChord[]) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,71 +0,0 @@
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
import type { GraphData, ReplayPlugin, StoreContract } from "../types";
export class MetaReplayPlugin
implements StoreContract<GraphData>, ReplayPlugin
{
private subscribers = new Set<(value: GraphData) => void>();
private graphData: GraphData = { min: [0, 0], max: [0, 0], tokens: [] };
private liveHeldRoboFilter = new Set<string>();
register(replay: ReplayPlayer) {
replay.subscribe((token) => {
if (!token) return;
const lastHeld = this.graphData.tokens
.at(-1)
?.reduce(
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
0,
);
if (
lastHeld &&
(lastHeld === -1 || lastHeld > token.stamp + (token.duration ?? 0))
) {
this.graphData.tokens.at(-1)!.push(token);
} else {
this.graphData.tokens.push([token]);
}
if (this.graphData.tokens.length === 1) {
this.graphData.min = [token.stamp, 0];
}
this.graphData.max = [
this.graphData.tokens
.at(-1)!
.reduce(
(acc, { stamp, duration }) =>
Math.max(acc, stamp + (duration ?? 0)),
0,
),
Math.max(this.graphData.max[1], this.graphData.tokens.at(-1)!.length),
];
this.liveHeldRoboFilter.add(token.code);
if (token.duration === undefined) {
setTimeout(() => {
if (this.liveHeldRoboFilter.has(token.code)) {
token.source = "human";
for (const subscription of this.subscribers) {
subscription(this.graphData);
}
}
}, ROBOT_THRESHOLD);
} else {
setTimeout(() => {
this.liveHeldRoboFilter.delete(token.code);
}, token.duration);
}
for (const subscription of this.subscribers) {
subscription(this.graphData);
}
});
}
subscribe(subscription: (value: GraphData) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,48 +0,0 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
import { avgWordLength } from "./wpm";
export class RollingWpmReplayPlugin
implements StoreContract<number>, ReplayPlugin
{
subscribers = new Set<(value: number) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
let i = 0;
const index = Math.max(
0,
replay.stepper.text.findLastIndex((char) => {
if (char.source === "ghost") return false;
if (char.text === " " && i < 10) {
i++;
} else if (char.text === " ") {
return true;
}
return false;
}),
);
const length =
replay.stepper.text.length - replay.stepper.ghostCount - index;
const msPerChar =
((replay.stepper.text[
replay.stepper.text.length - replay.stepper.ghostCount - 1
]?.stamp ?? 0) -
(replay.stepper.text[index]?.stamp ?? 0)) /
length;
const value = 60_000 / (msPerChar * avgWordLength);
if (Number.isFinite(value)) {
for (const subscription of this.subscribers) {
subscription(value);
}
}
});
}
subscribe(subscription: (value: number) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,23 +0,0 @@
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

@@ -1,26 +0,0 @@
import type { ReplayPlayer } from "../player";
import type { ReplayPlugin, StoreContract } from "../types";
export const avgWordLength = 5;
export class WpmReplayPlugin implements StoreContract<number>, ReplayPlugin {
private subscribers = new Set<(value: number) => void>();
register(replay: ReplayPlayer) {
replay.subscribe(() => {
if (this.subscribers.size === 0) return;
const msPerChar =
((replay.stepper.text.at(-1)?.stamp ?? 0) - replay.startTime) /
replay.stepper.text.length;
const value = 60_000 / (msPerChar * avgWordLength);
for (const subscription of this.subscribers) {
subscription(value);
}
});
}
subscribe(subscription: (value: number) => void) {
this.subscribers.add(subscription);
return () => this.subscribers.delete(subscription);
}
}

View File

@@ -1,79 +0,0 @@
import { ReplayPlayer } from "./player.js";
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
function maybeRound<T>(value: T, round: boolean): T {
return typeof value === "number" && round ? (Math.round(value) as T) : value;
}
export class ReplayRecorder {
private held = new Map<string, [string, number]>();
private heldHandles = new Map<
string,
ReturnType<ReplayPlayer["playLiveEvent"]>
>();
replay: ReplayEvent[] = [];
private start = performance.now();
private isFirstPress = true;
player: ReplayPlayer;
constructor(challenge?: Replay["challenge"]) {
this.player = new ReplayPlayer({
start: this.start,
finish: this.start,
keys: [],
challenge,
});
}
next(event: TransmittableKeyEvent) {
if (this.isFirstPress) {
this.player.startTime = event.timeStamp;
this.isFirstPress = false;
}
this.player.replay.finish = event.timeStamp;
if (event.type === "keydown") {
this.held.set(event.code, [event.key, event.timeStamp]);
this.heldHandles.set(
event.code,
this.player.playLiveEvent(event.key, event.code),
);
} else {
const [key, start] = this.held.get(event.code) ?? ["", 0];
const delta = event.timeStamp - start;
this.held.delete(event.code);
const element = Object.freeze([key, event.code, start, delta] as const);
this.replay.push(element);
this.heldHandles.get(event.code)?.(delta);
this.heldHandles.delete(event.code);
}
}
finish(trim = true, round = true) {
return {
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round) ?? 0,
finish: maybeRound(
trim
? Math.max(...this.replay.map((it) => it[2] + it[3]))
: performance.now(),
round,
),
keys: this.replay
.map(
([key, code, at, duration]) =>
[
key,
code,
maybeRound(at, round),
maybeRound(duration, round),
] as const,
)
.sort((a, b) => a[2] - b[2]),
} satisfies Replay;
}
}

View File

@@ -1,132 +0,0 @@
import { ROBOT_THRESHOLD } from "./player";
import type { LiveReplayEvent, ReplayEvent, TextToken } from "./types";
/**
* This is the "heart" of the player logic
*/
export class ReplayStepper {
held = new Map<string, boolean>();
text: TextToken[];
cursor = 0;
challenge: TextToken[];
ghostCount: number;
mistakeCount = 0;
constructor(initialReplay: ReplayEvent[] = [], challenge = "") {
this.challenge = challenge.split("").map((text) => ({
stamp: 0,
duration: 0,
code: "",
text,
source: "ghost",
correct: true,
}));
this.text = [...this.challenge];
this.ghostCount = this.challenge.length;
for (const key of initialReplay) {
this.step(...key);
}
}
step(
...[output, code, at, duration]: ReplayEvent | LiveReplayEvent
): TextToken | undefined {
let token: TextToken | undefined = undefined;
if (output === "Backspace") {
if (this.held.has("ControlLeft") || this.held.has("ControlRight")) {
let wordIndex = 0;
for (let i = this.cursor - 1; i >= 0; i--) {
if (/\w+/.test(/** @type {TextToken} */ this.text[i]!.text)) {
wordIndex = i;
} else if (wordIndex !== 0) {
break;
}
}
this.text.splice(wordIndex, this.cursor - wordIndex);
} else if (this.cursor !== 0) {
this.text.splice(this.cursor - 1, 1);
}
this.cursor = Math.min(
this.cursor,
this.text.length - this.ghostCount + 1,
);
}
if (output.length === 1) {
token = {
stamp: at,
duration,
code,
text: output,
source:
duration === undefined
? undefined
: duration < ROBOT_THRESHOLD
? "robot"
: "human",
correct: true,
};
this.text.splice(this.cursor, 0, token);
}
if (code === "ArrowLeft" || code === "Backspace") {
this.cursor = Math.max(this.cursor - 1, 0);
}
if (code === "ArrowRight" || output.length === 1) {
this.cursor = Math.min(
this.cursor + 1,
this.text.length - this.ghostCount,
);
}
if (code === "Enter") {
token = {
stamp: at,
code,
duration,
text: "\n",
source:
duration === undefined
? undefined
: duration < ROBOT_THRESHOLD
? "robot"
: "human",
correct: true,
};
this.text.splice(this.cursor, 0, token);
this.cursor++;
}
if (this.challenge.length > 0) {
let challengeIndex = 0;
this.mistakeCount = 0;
for (let i = 0; i < this.text.length - this.ghostCount; i++) {
if (this.text[i]!.text === this.challenge[challengeIndex]?.text) {
this.text[i]!.correct = true;
} else {
this.mistakeCount++;
this.text[i]!.correct = false;
}
challengeIndex++;
}
const currentGhostCount = this.ghostCount;
this.ghostCount = Math.max(0, this.challenge.length - challengeIndex);
this.text.splice(
this.text.length - currentGhostCount,
Math.max(0, currentGhostCount - this.ghostCount),
...this.challenge.slice(
challengeIndex,
challengeIndex + Math.max(0, this.ghostCount - currentGhostCount),
),
);
}
return token;
}
}

View File

@@ -1,58 +0,0 @@
import { ReplayPlayer } from "./player.js";
export interface Replay {
start: number;
finish: number;
keys: ReplayEvent[];
challenge?: string;
}
export type LiveReplayEvent = readonly [
output: string,
code: string,
at: number,
];
export type ReplayEvent = readonly [...LiveReplayEvent, duration: number];
export interface TextToken {
stamp: number;
duration?: number;
text: string;
code: string;
source?: "human" | "robot" | "ghost";
correct: boolean;
}
export interface GraphData {
min: [number, number];
max: [number, number];
tokens: TextToken[][];
}
export interface ReplayStepResult {
text: TextToken[];
cursor: number;
challengeCursor: number;
token: TextToken | undefined;
}
export type TransmittableKeyEvent = Pick<
KeyboardEvent,
"timeStamp" | "type" | "code" | "key"
>;
export interface InferredChord {
id: number;
input: TextToken[];
output: string;
deviation: [number, number];
}
export interface ReplayPlugin {
register(replay: ReplayPlayer): void;
}
export interface StoreContract<T> {
subscribe(subscription: (value: T) => void): () => void;
set?: (value: T) => void;
}

View File

@@ -1,96 +0,0 @@
export const KBD_ICONS = new Map([
["KeyA", "a"],
["KeyB", "b"],
["KeyC", "c"],
["KeyD", "d"],
["KeyE", "e"],
["KeyF", "f"],
["KeyG", "g"],
["KeyH", "h"],
["KeyI", "i"],
["KeyJ", "j"],
["KeyK", "k"],
["KeyL", "l"],
["KeyM", "m"],
["KeyN", "n"],
["KeyO", "o"],
["KeyP", "p"],
["KeyQ", "q"],
["KeyR", "r"],
["KeyS", "s"],
["KeyT", "t"],
["KeyU", "u"],
["KeyV", "v"],
["KeyW", "w"],
["KeyX", "x"],
["KeyY", "y"],
["KeyZ", "z"],
["Digit0", "0"],
["Digit1", "1"],
["Digit2", "2"],
["Digit3", "3"],
["Digit4", "4"],
["Digit5", "5"],
["Digit6", "6"],
["Digit7", "7"],
["Digit8", "8"],
["Digit9", "9"],
["Period", "."],
["Comma", ","],
["Semicolon", ";"],
["Quote", "'"],
["BracketLeft", "["],
["BracketRight", "]"],
["Backslash", "\\"],
["Slash", "/"],
["Minus", "-"],
["Equal", "="],
["Backquote", "`"],
["IntlBackslash", "¦"],
["IntlRo", "ろ"],
["IntlYen", "¥"],
["IntlHash", "#"],
["BracketLeft", "["],
["BracketRight", "]"],
["NumLock", "⇭"],
["ScrollLock", "⇳"],
["Backspace", "⌫"],
["Delete", "⌦"],
["Enter", "↵"],
["Space", "␣"],
["Tab", "⇥"],
["ArrowLeft", "←"],
["ArrowRight", "→"],
["ArrowUp", "↑"],
["ArrowDown", "↓"],
["ShiftLeft", "⇧"],
["ShiftRight", "⇧"],
["ControlLeft", "Ctrl"],
["ControlRight", "Ctrl"],
["AltLeft", "Alt"],
["AltRight", "Alt"],
["MetaLeft", "⌘"],
["MetaRight", "⌘"],
["CapsLock", "⇪"],
["Escape", "Esc"],
["F1", "F1"],
["F2", "F2"],
["F3", "F3"],
["F4", "F4"],
["F5", "F5"],
["F6", "F6"],
["F7", "F7"],
["F8", "F8"],
["F9", "F9"],
["F10", "F10"],
["F11", "F11"],
["F12", "F12"],
["PrintScreen", "PrtSc"],
["Pause", "Pause"],
["Insert", "Ins"],
["Home", "Home"],
["End", "End"],
["PageUp", "PgUp"],
["PageDown", "PgDn"],
["ContextMenu", "Menu"],
]);

View File

@@ -1,300 +0,0 @@
import type { TextToken } from "../core/types";
import { KBD_ICONS } from "./kbd-icon";
export class TextRenderer {
shinyChords = true;
shiny: number[] | undefined;
readonly cursorNode: SVGRectElement;
private readonly nodes = new Map<TextToken, SVGTextElement>();
private readonly heldNodes = new Map<string, SVGTextElement>();
private readonly occupiedHeld: Array<boolean | undefined> = [];
private readonly occupied: number[] = [];
animationOptions: KeyframeAnimationOptions = {
duration: 100,
easing: "ease",
};
heldKeySize = 0.8;
ghostText = "";
constructor(
readonly node: HTMLElement,
readonly svg: SVGSVGElement,
readonly textNode: Text,
) {
this.cursorNode = document.createElementNS(
"http://www.w3.org/2000/svg",
"rect",
);
this.cursorNode.setAttribute("x", "0");
this.cursorNode.setAttribute("y", "0");
this.cursorNode.setAttribute("class", "cursor");
this.svg.appendChild(this.cursorNode);
}
set showCursor(value: boolean) {
this.cursorNode.style.visibility = value ? "visible" : "hidden";
}
getAtRange(i: number): [number, number] {
const range = document.createRange();
const rangeIndex = Math.max(0, Math.min(i, this.textNode.length - 1));
range.setStart(this.textNode, rangeIndex);
range.setEnd(
this.textNode,
this.textNode.length === 0 ? 0 : rangeIndex + 1,
);
const charBounds = range.getBoundingClientRect();
return [
i > this.textNode.length - 1
? charBounds.x + charBounds.width
: charBounds.x,
charBounds.y + charBounds.height / 2 + 1,
];
}
set held(keys: Map<string, boolean>) {
const prev = new Set(this.heldNodes.keys());
const fontSize = getComputedStyle(this.node).fontSize;
for (const [code, isHuman] of keys) {
if (!isHuman) continue;
prev.delete(code);
let node = this.heldNodes.get(code);
if (!node) {
let i = this.occupiedHeld.findIndex((it) => it === undefined);
if (i === -1) {
i = this.occupiedHeld.length;
this.occupiedHeld.push(true);
} else {
this.occupiedHeld[i] = true;
}
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
node.textContent = KBD_ICONS.get(code) ?? null;
node.setAttribute("i", i.toString());
this.heldNodes.set(code, node);
node.style.transform = `${this.cursorNode.style.transform} translateY(calc(${fontSize} * ${
i + 1.5
}))`;
node.style.fontSize = `calc(${fontSize} * ${this.heldKeySize})`;
this.svg.appendChild(node);
node
.animate(
[
{
transform: `translateY(calc(-${fontSize} * ${this.heldNodes.size})) scale(0)`,
},
{ transform: "translateY(0px) scale(1)" },
],
{ duration: 200, composite: "add", easing: "ease-out" },
)
.play();
}
}
for (const code of prev) {
const node = this.heldNodes.get(code);
if (!node) continue;
this.heldNodes.delete(code);
this.occupiedHeld[Number(node.getAttribute("i"))] = undefined;
node
.animate(
[
{ transform: "translateX(0px)" },
{ transform: "translateX(-10px)" },
],
{
duration: 500,
composite: "accumulate",
easing: "ease-in",
},
)
.play();
const animation = node.animate([{ opacity: 1 }, { opacity: 0 }], {
duration: 500,
easing: "ease-in",
});
animation.onfinish = () => {
node.remove();
};
animation.play();
}
}
get animated(): boolean {
return this.cursorNode.classList.contains("animated");
}
set animated(value: boolean) {
if (value) {
this.cursorNode.classList.add("animated");
} else {
this.cursorNode.classList.remove("animated");
}
}
set cursor(cursor: number) {
const bounds = this.node.getBoundingClientRect();
const style = getComputedStyle(this.node);
const pos = this.getAtRange(cursor);
const x = pos[0] - bounds.x;
const y = pos[1] - bounds.y;
this.cursorNode.setAttribute("height", style.fontSize);
this.cursorNode.setAttribute("width", "1");
this.cursorNode.style.transform = `translate(${x}px, calc(${y}px - ${style.fontSize} / 2))`;
}
set text(text: TextToken[]) {
const prev = new Set(this.nodes.keys());
const bounds = this.node.getBoundingClientRect();
this.svg.setAttribute("width", bounds.width.toFixed(2));
this.svg.setAttribute("height", bounds.height.toFixed(2));
this.svg.setAttribute(
"viewBox",
`0 0 ${bounds.width.toFixed(2)} ${bounds.height.toFixed(2)}`,
);
text.forEach((token, i) => {
prev.delete(token);
let node = this.nodes.get(token);
const pos = this.getAtRange(i);
const x = pos[0] - bounds.x;
const y = pos[1] - bounds.y;
const xStr = x.toFixed(2);
const yStr = y.toFixed(2);
if (!node) {
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
this.nodes.set(token, node);
this.svg.appendChild(node);
node.setAttribute("x", xStr);
node.setAttribute("y", yStr);
node.setAttribute("i", i.toString());
if (token.source === "ghost") {
node.setAttribute("opacity", "0.5");
}
this.occupied[i] ??= 0;
if (this.animated) {
if (this.occupied[i] > 0) {
node
.animate([{ opacity: 0 }, { opacity: 1 }], {
...this.animationOptions,
easing: "ease-out",
})
.play();
} else {
node
.animate(
[
{ opacity: 0, transform: "translateY(10px)" },
{ opacity: 1, transform: "translateY(0px)" },
],
{ ...this.animationOptions, easing: "ease-out" },
)
.play();
}
}
this.occupied[i]++;
}
if (!token.correct) {
node.setAttribute("incorrect", "");
} else {
node.removeAttribute("incorrect");
}
const prevX = node.getAttribute("x");
if (prevX && prevX !== xStr) {
const prev = parseFloat(prevX);
node.setAttribute("x", xStr);
/*if (this.animated) {
node.animate(
[{ transform: `translateX(${prev - x}px)` }, { transform: `translateX(0px)` }],
this.animationOptions
);
}*/
}
const prevY = node.getAttribute("y");
if (prevY && prevY !== yStr) {
const prev = parseFloat(prevY);
node.setAttribute("y", yStr);
/*if (this.animated) {
node.animate(
[{ transform: `translateY(${prev - y}px)` }, { transform: `translateY(0px)` }],
this.animationOptions
);
}*/
}
if (node.textContent !== token.text) {
node.textContent = token.text;
}
});
for (const token of prev) {
const node = this.nodes.get(token)!;
const i = parseInt(node.getAttribute("i")!);
this.nodes.delete(token);
if (this.animated) {
const animation = node.animate(
[{ opacity: 1 }, { opacity: 0 }],
this.animationOptions,
);
setTimeout(() => {
if (this.occupied[i] === 1) {
node
.animate(
[
{ transform: "translateY(0px)" },
{ transform: "translateY(10px)" },
],
this.animationOptions,
)
.play();
}
}, 10);
animation.onfinish = () => {
node.remove();
this.occupied[i]!--;
};
animation.play();
} else {
node.remove();
this.occupied[i]!--;
}
}
}
destroy() {
this.cursorNode.remove();
for (const node of this.nodes.values()) {
node.remove();
}
for (const node of this.heldNodes.values()) {
node.remove();
}
this.nodes.clear();
this.heldNodes.clear();
}
private isShiny(char: TextToken, index: number) {
return (
this.shiny?.includes(index) ||
(this.shinyChords && char.source === "robot")
);
}
}

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 {
flex-shrink: 0;
border-radius: 50%;
width: 32px;
height: 32px;
}
.avatar-placeholder {
display: flex;
justify-content: center;
align-items: 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;
height: 100%;
overflow-y: auto;
}
span {
word-break: break-all;
}
</style>

View File

@@ -1,73 +0,0 @@
<script lang="ts">
import type { Room } from "matrix-js-sdk";
import { matrixClient, currentRoomId } from "./chat";
let { rooms }: { rooms: Room[] } = $props();
</script>
<div class="rooms">
{#each $matrixClient.getRooms() as room}
{@const avatar = room.getMxcAvatarUrl()}
<button
class:active={$currentRoomId === room.roomId}
class="room"
onclick={() => ($currentRoomId = room.roomId)}
>
{#if avatar}
<img
alt={room.name}
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
width="16"
height="16"
/>
{:else}
<div>#</div>
{/if}
<div>{room.name}</div>
</button>
{/each}
{#await $matrixClient.publicRooms()}
<div>Loading...</div>
{:then rooms}
{#each rooms.chunk as room}
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
<div>#</div>
<div>{room.name}</div>
</button>
{/each}
{:catch error}
<div>{error.message}</div>
{/await}
</div>
<style lang="scss">
.rooms {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
padding-left: 0;
width: 100%;
}
.room {
display: flex;
justify-content: flex-start;
align-items: center;
gap: 0.5rem;
cursor: pointer;
border-radius: 8px;
padding-inline: 16px;
padding-block: 2px;
padding-block: 4px;
width: 100%;
height: unset;
min-height: 0;
&.active {
background: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}
}
</style>

View File

@@ -1,231 +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;
.input {
flex-grow: 1;
cursor: text;
border: 1px solid var(--md-sys-color-outline);
border-radius: $border-radius;
padding: 0.5em;
font-size: 1rem;
text-wrap: wrap;
white-space: pre-wrap;
word-break: break-word;
overflow-wrap: break-word;
&:focus-visible {
outline: none;
}
}
.input-box {
display: flex;
flex-shrink: 0;
gap: 4px;
padding-block: 8px;
width: 100%;
}
.static-elements {
position: relative;
width: 100%;
}
.timeline {
display: flex;
flex-grow: 1;
flex-direction: column-reverse;
contain: content;
width: 100%;
height: auto;
overflow-x: hidden;
overflow-y: scroll;
}
section {
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 100%;
height: 100%;
overflow: hidden;
}
</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,35 +0,0 @@
import { writable, type Writable } from "svelte/store";
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
import { persistentWritable } from "$lib/storage";
import {
themeFromSourceColor,
argbFromHex,
type CustomColorGroup,
} from "@material/material-color-utilities";
import type { UserTheme } from "$lib/preferences";
export const matrixClient: Writable<MatrixClient> = writable();
export const currentRoomId = persistentWritable<string | null>(
"currentRoomId",
null,
);
export function memberColor(
member: RoomMember,
theme: UserTheme,
): CustomColorGroup {
let hash = 0;
member.userId.split("").forEach((char) => {
hash = char.charCodeAt(0) + ((hash << 5) - hash);
});
let color = "#";
for (let i = 0; i < 3; i++) {
const value = (hash >> (i * 8)) & 0xff;
color += value.toString(16).padStart(2, "0");
}
return themeFromSourceColor(argbFromHex(theme.color), [
{ value: argbFromHex(color), name: "member", blend: true },
]).customColors.find((c) => c.color.name === "member")!;
}

View File

@@ -1,381 +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)}
>
{#if event.getType() === "m.room.message"}
{@const message = event.event.content?.["body"]}
<a
class="icon rocket"
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
>rocket_launch</a
>
{/if}
<button class="icon">add_reaction</button>
<button class="icon">reply</button>
{#if event.event.content?.["m.replay"]}
{#if replay}
<button class="icon" onclick={() => (replay = undefined)}>stop</button
>
{:else}
<button
class="icon"
onclick={() => (replay = event.event.content?.["m.replay"])}
>replay</button
>
{/if}
{/if}
<button class="icon">more_horiz</button>
</div>
{/if}
{#if $annotations && $annotations.length > 0}
<div class="reactions">
{#each $annotations as [reaction, events]}
<button class="reaction"
>{reaction} <span class="count">{events.size}</span></button
>
{/each}
</div>
{/if}
</div>
<style lang="scss">
details {
opacity: 0.5;
word-wrap: break-word;
}
pre {
text-wrap: wrap;
word-wrap: break-word;
}
@keyframes rocket {
0% {
transform: translate(0, 0);
}
90% {
transform: translate(4px, -4px);
}
100% {
transform: translate(0, 0);
}
}
.icon.rocket {
animation: rocket 2s;
}
.toolbar {
display: flex;
position: absolute;
top: -26px;
right: 0;
z-index: 100;
border-radius: 4px;
background: var(--md-sys-color-secondary-container);
padding: 4px;
color: var(--md-sys-color-on-secondary-container);
a,
button {
width: 24px;
height: 24px;
font-size: 16px;
}
}
.dots {
display: flex;
gap: 2px;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-4px);
}
}
.dot {
animation: bounce 1s infinite;
border-radius: 50%;
width: 6px;
height: 6px;
}
.sender,
.avatar {
margin-block: 2px 4px;
}
.avatar {
grid-area: avatar;
translate: 0 2px;
border-radius: 50%;
width: 32px;
height: 32px;
}
div.avatar {
display: flex;
justify-content: center;
align-items: center;
}
.sender {
display: flex;
grid-area: sender;
align-items: center;
gap: 8px;
}
.reactions {
display: flex;
grid-area: reactions;
gap: 4px;
margin-top: 2px;
}
.reaction {
display: flex;
border: 1px solid var(--md-sys-color-outline);
border-radius: 6px;
padding: 6px;
height: 24px;
font-size: 12px;
> .count {
font-size: 10px;
}
}
.event {
display: grid;
position: relative;
grid-template-columns: 32px 1fr auto;
grid-template-areas:
"avatar sender date"
"avatar content content"
"none reactions reactions";
margin-inline: 0.5em;
border-radius: 4px;
padding-inline: 0.5em;
padding-block: 0.25em;
}
.content {
grid-area: content;
text-wrap: wrap;
word-wrap: break-word;
}
.reactions,
.content,
.sender {
margin-inline: 8px;
}
.backdrop {
position: absolute;
opacity: 0.25;
z-index: -1;
inset: 0;
border-radius: 8px;
background: var(--md-sys-color-surface-variant);
}
</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 {
border-radius: 8px;
max-width: 100%;
max-height: 16em;
}
.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

@@ -1,54 +0,0 @@
<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

@@ -1,107 +0,0 @@
import {
Decoration,
EditorView,
ViewPlugin,
ViewUpdate,
WidgetType,
} from "@codemirror/view";
import { mount, unmount } from "svelte";
import Action from "$lib/components/Action.svelte";
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
export class ActionWidget extends WidgetType {
component?: {};
element?: HTMLElement;
constructor(readonly id: string | number) {
super();
this.id = id;
}
override eq(other: ActionWidget) {
return this.id == other.id;
}
toDOM() {
if (!this.element) {
this.element = document.createElement("span");
this.element.style.paddingInline = "2px";
this.component = mount(Action, {
target: this.element,
props: { action: this.id, display: "keys", inText: true },
});
}
return this.element;
}
override ignoreEvent() {
return true;
}
override destroy() {
if (this.component) {
unmount(this.component);
}
}
}
function actionWidgets(view: EditorView) {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== "ExplicitAction") return;
const value =
node.node.getChild("ActionId") ??
node.node.getChild("HexNumber") ??
node.node.getChild("DecimalNumber");
if (!value) return;
if (!node.node.getChild("ExplicitDelimEnd")) {
return;
}
const id = view.state.doc.sliceString(value.from, value.to);
let deco = Decoration.replace({
widget: new ActionWidget(
value.name === "ActionId" ? id : parseInt(id),
),
});
widgets.push(deco.range(node.from, node.to));
},
});
}
return Decoration.set(widgets);
}
export const actionPlugin = ViewPlugin.fromClass(
class {
decorations = Decoration.none;
constructor(view: EditorView) {
this.decorations = actionWidgets(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) != syntaxTree(update.state)
)
this.decorations = actionWidgets(update.view);
}
},
{
decorations(instance) {
return instance.decorations;
},
provide(plugin) {
return EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
);
},
},
);

View File

@@ -1,16 +0,0 @@
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
import { get } from "svelte/store";
export function canUseIdAsString(info: KeyInfo): boolean {
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
}
export function actionToValue(action: number | KeyInfo) {
const info =
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
if (info && info.id?.length === 1)
return /^[<>\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
if (!info || !canUseIdAsString(info))
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
return `<${info.id}>`;
}

View File

@@ -1,72 +0,0 @@
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type {
Completion,
CompletionSection,
CompletionSource,
} from "@codemirror/autocomplete";
import { derived, get } from "svelte/store";
import { actionToValue, canUseIdAsString } from "./action-serializer";
const completionSections = derived(
KEYMAP_CATEGORIES,
(categories) =>
new Map(
categories.map(
(category) =>
[
category,
{
name: category.name,
} satisfies CompletionSection,
] as const,
),
),
);
export const actionAutocompleteItems = derived(
[KEYMAP_CODES, completionSections],
([codes, sections]) =>
codes
.values()
.map((info) => {
const canUseId = canUseIdAsString(info);
const completionValue =
(canUseId && info.id) ||
`0x${info.code.toString(16).padStart(2, "0")}`;
return {
label:
[
canUseId || !info.id ? undefined : `"${info.id}"`,
info.title,
info.variant?.replace(/^[a-z]/g, (c) => c.toUpperCase()),
]
.filter(Boolean)
.join(" ") || completionValue,
detail: actionToValue(info),
section: info.category ? sections.get(info.category) : undefined,
info: info.description,
type: "keyword",
apply: completionValue + ">",
} satisfies Completion;
})
.filter(
(item) => typeof item.label === "string" && item.apply !== undefined,
)
.toArray(),
);
export const actionAutocomplete = ((context) => {
let word = context.tokenBefore([
"ExplicitDelimStart",
"ActionId",
"HexNumber",
"DecimalNumber",
]);
if (!word) return null;
console.log(get(actionAutocompleteItems));
return {
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
validFor: /^<?[a-zA-Z0-9_]*$/,
options: get(actionAutocompleteItems),
};
}) satisfies CompletionSource;

View File

@@ -1,17 +0,0 @@
import {
EditorView,
ViewPlugin,
ViewUpdate,
type PluginValue,
} from "@codemirror/view";
export const changesPlugin = ViewPlugin.fromClass(
class implements PluginValue {
constructor(readonly view: EditorView) {}
update(update: ViewUpdate) {}
},
{
eventHandlers: {},
},
);

View File

@@ -1,157 +0,0 @@
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);
}
}
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

@@ -1,57 +0,0 @@
import { parser } from "./chords.grammar";
import {
LRLanguage,
LanguageSupport,
HighlightStyle,
} from "@codemirror/language";
import { styleTags, tags } from "@lezer/highlight";
import { actionAutocomplete } from "./autocomplete";
export const chordHighlightStyle = HighlightStyle.define([
{
tag: tags.keyword,
paddingInline: "2px",
opacity: "0.5",
},
{
tag: tags.className,
backgroundColor:
"color-mix(in srgb, var(--md-sys-color-surface-variant) 50%, transparent)",
borderRadius: "4px",
paddingInline: "4px",
marginInline: "-4px",
},
{
tag: tags.integer,
color: "var(--md-sys-color-tertiary)",
},
{
tag: tags.angleBracket,
opacity: "0.5",
},
{ tag: tags.modifier, opacity: "0.25" },
{ tag: tags.escape, color: "var(--md-sys-color-primary)" },
{ tag: tags.strong, fontWeight: "bold" },
]);
export const chordLanguage = LRLanguage.define({
name: "chords",
parser: parser.configure({
props: [
styleTags({
"PhraseDelim CompoundDelim": [tags.keyword, tags.strong],
"HexNumber DecimalNumber": [tags.className, tags.integer],
"ExplicitDelimStart ExplicitDelimEnd": tags.angleBracket,
ActionId: tags.className,
EscapedLetter: tags.escape,
Escape: [tags.escape, tags.modifier],
}),
],
}),
});
export function chordLanguageSupport() {
return new LanguageSupport(chordLanguage, [
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
]);
}

View File

@@ -1,27 +0,0 @@
@top Program { Chord* }
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
EscapedSingleAction { Escape EscapedLetter }
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
ActionString { Action* }
ChordInput { (ActionString CompoundDelim)* ActionString }
ChordPhrase { ActionString }
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
@tokens {
@precedence {HexNumber, DecimalNumber}
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
@precedence {EscapedLetter}
ExplicitDelimStart {"<"}
ExplicitDelimEnd {">"}
CompoundDelim {"+>"}
PhraseDelim {"=>"}
Escape { "\\" }
HexNumber { "0x" $[a-fA-F0-9]+ }
DecimalNumber { $[0-9]+ }
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
SingleLetter { ![\\] }
EscapedLetter { ![] }
ChordDelim { ($[\n] | @eof) }
}

View File

@@ -1,13 +0,0 @@
.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;
}
}

View File

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

View File

@@ -1,16 +0,0 @@
.=<LEFT_SHIFT> => =>
;ims => <0x219><IMPULSE>
-;<KSC_2C><LEFT_SHIFT> => <0x23e>_<0x23e>
.;g => <0x23e>...<0x23e><LH_THUMB_3_3D>
'dg => <0x23e>'<0x23e>
'gl => <0x23e>'ll<0x23e>
'ar => <0x23e>'re<0x23e>
'gs => <0x23e>'s<0x23e>
'ev => <0x23e>'ve<0x23e>
<SPACE>-; => <0x23e><0x223>-<0x223><KSC_00>
<SPACE>;<LEFT_SHIFT> => <0x23e><0x223><0x23d><0x223><KSC_00>
<SPACE>;g => <0x23e><0x223><SPACE><0x223><KSC_00>
deg => <0x23e>ed<0x23e>
;gr => <0x23e>er<0x23e>
;es => <0x23e>es<0x23e>
;est => <0x23e>est<0x23e>

View File

@@ -1,213 +1,98 @@
<script lang="ts"> <script lang="ts">
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes"; import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { KeyInfo } from "$lib/serial/keymap-codes";
import { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout"; import { osLayout } from "$lib/os-layout";
import { isVerbose } from "./verbose-action"; import LL from "../../i18n/i18n-svelte";
import { actionTooltip } from "$lib/title";
let { export let action: number | KeyInfo;
action, export let display: "inline-keys" | "keys" = "inline-keys";
display,
inText = false,
}: {
action: string | number | KeyInfo;
display: "inline-keys" | "keys" | "verbose";
inText?: boolean;
} = $props();
let retrievedInfo = $derived( $: info =
typeof action === "number" typeof action === "number"
? $KEYMAP_CODES.get(action) ? KEYMAP_CODES.get(action) ?? { code: action }
: typeof action === "string" : action;
? $KEYMAP_IDS.get(action) $: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
: action,
); $: tooltip =
let info = $derived( `&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
retrievedInfo ?? (info.title ?? "") +
(typeof action === "number" (info.variant === "left"
? ({ code: action } satisfies KeyInfo) ? " (left)"
: typeof action === "string" : info.variant === "right"
? ({ code: 1024, id: action } satisfies KeyInfo) ? " (right)"
: action), : "");
);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let hasPopover = $derived(
!retrievedInfo || !info.id || info.title || info.description,
);
</script> </script>
{#snippet popover()} {#if dynamicMapping}
{#if retrievedInfo} <span
{#if info.icon || info.display || !info.id} use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
&lt;<b>{info.id ?? `0x${info.code.toString(16)}`}</b>&gt; class="dynamic"
{/if} class:left={info.variant === "left"}
{#if info.title} class:right={info.variant === "right"}
{info.title} class:inline={display === "inline-keys"}>{dynamicMapping}</span
{/if} >
{#if info.variant === "left"} {:else if display === "keys"}
(Left)
{:else if info.variant === "right"}
(Right)
{/if}
{#if info.description}
<br />
<small>{info.description}</small>
{/if}
{:else}
<b>Unknown Action</b><br />
{#if info.code > 1023}
This action cannot be translated and will be ingored.
{/if}
{/if}
{/snippet}
{#snippet kbdText()}
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{/snippet}
{#snippet kbdSnippet(withPopover = true)}
<kbd <kbd
class:in-text={inText}
class:icon={!!info.icon} class:icon={!!info.icon}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"} class:right={info.variant === "right"}
class:error={info.code > 1023} use:title={{ title: tooltip }}
class:warn={!retrievedInfo}
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
> >
{@render kbdText()} {info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
</kbd> </kbd>
{/snippet} {:else if display === "inline-keys"}
{#snippet inlineKbdSnippet()} {#if !info.icon && info.id?.length === 1}
{#if !info.icon && dynamicMapping?.length === 1}
<span <span
{@attach hasPopover ? actionTooltip(popover) : null}
class:in-text={inText}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
class:left={info.variant === "left"}
class:right={info.variant === "right"}>{dynamicMapping}</span
>
{:else if !info.icon && info.id?.length === 1}
<span
{@attach hasPopover ? actionTooltip(popover) : null}
class:in-text={inText}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
class:left={info.variant === "left"} class: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={!!info.icon}
class:warn={!retrievedInfo} use:title={{ title: tooltip }}
class:error={info.code > 1023} >
{@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) {
transition: color 250ms ease;
padding-block: auto;
height: 24px; height: 24px;
padding-block: auto;
&.in-text { transition: color 250ms ease;
display: inline-flex;
vertical-align: middle;
margin-block: auto;
padding-block: revert;
} }
}
.warn:not(.error) {
border-color: var(--md-sys-color-error);
color: var(--md-sys-color-error);
}
.error {
opacity: 0.6;
text-decoration: line-through;
}
$variant-offset: 12px;
$variant-padding: calc(2px + $variant-offset);
$variant-color: color-mix(
in srgb,
var(--md-sys-color-on-surface) 50%,
transparent
);
.left { .left {
padding-inline-end: $variant-padding; border-left-width: 3px;
text-shadow: $variant-offset 0 2px $variant-color;
} }
.right { .right {
padding-inline-start: $variant-padding; border-right-width: 3px;
text-shadow: -$variant-offset 0 2px $variant-color; }
.dynamic {
padding: 4px;
border-radius: 1px;
min-width: 8px;
background: var(--md-sys-color-surface-variant);
&.inline {
padding: 0px;
}
} }
.inline-kbd { .inline-kbd {
margin-inline-end: 2px; margin-inline-end: 2px;
&.in-text.icon {
translate: 0 -4em;
}
} }
:global(span) + .inline-kbd { :global(span) + .inline-kbd {
margin-inline-start: 2px; margin-inline-start: 2px;
} }
.verbose {
display: flex;
align-items: center;
gap: 8px;
margin-inline: 2px;
min-width: 160px;
height: 32px;
kbd {
justify-content: flex-start;
}
.verbose-title {
display: -webkit-box;
opacity: 0.9;
max-width: 15ch;
overflow: hidden;
font-style: italic;
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style> </style>

View File

@@ -1,24 +1,17 @@
<script lang="ts"> <script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes"; import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { KeyInfo } from "$lib/serial/keymap-codes";
import LL from "$i18n/i18n-svelte"; import LL from "../../i18n/i18n-svelte";
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
import type { MouseEventHandler } from "svelte/elements";
let { export let id: number | KeyInfo;
id,
onclick,
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
$props();
let key = $derived( $: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
(typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
| number | number
| KeyInfo, | KeyInfo;
);
</script> </script>
<button {onclick}> <button on:click>
{#if typeof key === "object"} {#if typeof key === "object"}
<div class="title"> <div class="title">
<b> <b>
@@ -48,33 +41,33 @@
<style lang="scss"> <style lang="scss">
button { button {
display: flex; display: flex;
align-items: center;
gap: 4px; gap: 4px;
margin: 0; align-items: center;
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) {
border: none;
background: transparent;
color: inherit; color: inherit;
background: transparent;
border: none;
&:focus-visible { &:focus-visible {
outline: none;
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant); color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
} }
} }
@media (forced-colors: active) { @media (forced-colors: active) {
margin-block: 4px;
border: 1px solid ButtonBorder; border: 1px solid ButtonBorder;
margin-block: 4px;
&:hover { &:hover {
color: ActiveText; color: ActiveText;
@@ -86,8 +79,8 @@
display: flex; display: flex;
flex: 1; flex: 1;
flex-direction: column; flex-direction: column;
justify-content: flex-start;
align-items: flex-start; align-items: flex-start;
justify-content: flex-start;
text-align: start; text-align: start;
} }

View File

@@ -2,11 +2,8 @@
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { KeyInfo } from "$lib/serial/keymap-codes";
let { export let actions: Array<number | KeyInfo>;
actions, export let display: "keys" | "inline-keys" = "inline-keys";
display = "inline-keys",
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
$props();
</script> </script>
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)} {#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}

View File

@@ -1,51 +0,0 @@
<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

@@ -6,7 +6,7 @@
</script> </script>
{#if $needRefresh} {#if $needRefresh}
<button title="Update ready" onclick={() => updateServiceWorker(true)} <button title="Update ready" on:click={() => updateServiceWorker(true)}
>Update <span class="icon">update</span></button >Update <span class="icon">update</span></button
> >
{:else if $offlineReady} {:else if $offlineReady}
@@ -16,8 +16,8 @@
<style lang="scss"> <style lang="scss">
button { button {
cursor: pointer; cursor: pointer;
border: none;
background: transparent;
color: var(--md-sys-color-on-background); color: var(--md-sys-color-on-background);
background: transparent;
border: none;
} }
</style> </style>

View File

@@ -1,24 +1,19 @@
<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 });
} }
let value: string = $state(""); let value: string;
let io: HTMLDivElement; let io: HTMLDivElement;
</script> </script>
<form onsubmit={submit}> <form on:submit={submit}>
<div bind:this={io} class="io"> <div bind:this={io} class="io">
{#each $serialLog as { type, value }} {#each $serialLog as { type, value }}
{#if type === "input"} {#if type === "input"}
@@ -29,70 +24,72 @@
<p transition:slide>{value}</p> <p transition:slide>{value}</p>
{/if} {/if}
{/each} {/each}
<div class="anchor"></div> <div class="anchor" />
</div> </div>
<fieldset> <fieldset>
<input onsubmit={submit} bind:value /> <input on:submit={submit} bind:value />
</fieldset> </fieldset>
</form> </form>
<style lang="scss"> <style lang="scss">
form { form {
display: flex;
position: relative; position: relative;
flex-direction: column;
contain: strict; contain: strict;
overflow: hidden;
border-radius: 16px; display: flex;
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 {
appearance: none;
margin-block-start: -16px;
border: none;
background: var(--md-sys-color-secondary);
padding: 8px;
padding-inline-start: calc(8px + 1.5ch);
padding-block-start: 24px;
width: 100%; width: 100%;
color: var(--md-sys-color-on-secondary); margin-block-start: -16px;
font-weight: 600; padding: 8px;
padding-block-start: 24px;
padding-inline-start: calc(8px + 1.5ch);
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;
background: var(--md-sys-color-secondary-container); overflow-y: auto;
flex: 1;
padding: 12px; padding: 12px;
overflow-y: auto;
color: var(--md-sys-color-on-secondary-container); color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border-radius: 0 0 16px 16px;
} }
:focus-visible { :focus-visible {
@@ -102,10 +99,10 @@
fieldset { fieldset {
all: unset; all: unset;
display: block;
position: relative; position: relative;
display: block;
opacity: 0.8; opacity: 0.8;
transition: opacity 250ms ease; transition: opacity 250ms ease;
@@ -116,16 +113,16 @@
} }
.anchor { .anchor {
height: 1px;
overflow-anchor: auto; overflow-anchor: auto;
height: 1px;
} }
code, code,
samp, samp,
p { p {
display: block; display: block;
margin-block: 0.15rem;
overflow-anchor: none; overflow-anchor: none;
margin-block: 0.15rem;
} }
p { p {
@@ -133,24 +130,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 {
margin-block-end: 0.25rem;
content: "> "; content: "> ";
color: var(--md-sys-color-primary); margin-block-end: 0.25rem;
font-weight: 900; font-weight: 900;
color: var(--md-sys-color-primary);
} }
::selection { ::selection {
background: var(--md-sys-color-on-background);
color: var(--md-sys-color-background); color: var(--md-sys-color-background);
background: var(--md-sys-color-on-background);
} }
@keyframes blink { @keyframes blink {

View File

@@ -1,14 +1,10 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from "svelte"; export let title: string | undefined;
export let shortcut: string | undefined;
let { title, shortcut }: { title?: string | Snippet; shortcut?: string } =
$props();
</script> </script>
{#if typeof title === "string"} {#if title}
<p>{@html title}</p> <p>{@html title}</p>
{:else}
{@render title?.()}
{/if} {/if}
{#if shortcut} {#if shortcut}
@@ -22,11 +18,5 @@
<style lang="scss"> <style lang="scss">
p { p {
margin-block: 0; margin-block: 0;
:global(kbd.icon) {
display: inline-flex;
translate: 0 0.2em;
font-size: inherit;
}
} }
</style> </style>

View File

@@ -1,357 +0,0 @@
<script lang="ts">
import {
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
type KeyInfo,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "$i18n/i18n-svelte";
import { actionTooltip } from "$lib/title";
import { get } from "svelte/store";
import type { KeymapCategory } from "$lib/meta/types/actions";
import Action from "../Action.svelte";
import { isVerbose } from "../verbose-action";
import { actionToValue } from "$lib/chord-editor/action-serializer";
let {
currentAction = undefined,
nextAction = undefined,
autofocus = false,
onselect,
onclose,
}: {
currentAction?: number;
nextAction?: number;
autofocus?: boolean;
onselect?: (id: number) => void;
onclose?: () => void;
} = $props();
onMount(() => {
search();
if (autofocus) {
searchBox.focus();
}
});
const index = new FlexSearch.Index({ tokenize: "full" });
$effect(() => {
createIndex($KEYMAP_CODES);
});
async function createIndex(codes: Map<number, KeyInfo>) {
for (const [, action] of codes) {
await index?.addAsync(
action.code,
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
action.description || ""
}`,
);
}
}
async function search() {
const groups = new Map(
$KEYMAP_CATEGORIES.map(
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
),
);
const result =
searchBox.value === ""
? Array.from($KEYMAP_CODES.keys())
: await index!.searchAsync(searchBox.value);
for (const id of result) {
const action = $KEYMAP_CODES.get(id as number);
if (action?.category) {
groups.get(action.category)?.push(action);
}
}
function sortValue(action: KeyInfo): number {
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
}
for (const actions of groups.values()) {
actions.sort((a, b) => sortValue(b) - sortValue(a));
}
results = groups;
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value);
}
function select(id?: number) {
if (id !== undefined) {
onselect?.(id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
onselect?.(exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else {
searchBox.focus();
return;
}
event.preventDefault();
}
let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN);
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
</script>
<div class="content">
<div class="search-row">
<!-- svelte-ignore a11y_autofocus -->
<input
type="search"
bind:this={searchBox}
oninput={search}
onkeypress={keyboardNavigation}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
{#if onclose}
<button onclick={() => select(0)} {@attach actionTooltip("", "shift+esc")}
>{$LL.actionSearch.DELETE()}</button
>
<button
{@attach actionTooltip($LL.modal.CLOSE(), "esc")}
class="icon"
onclick={onclose}>close</button
>
{/if}
</div>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if}
<ul bind:this={resultList}>
{#if exact !== undefined}
<li class="exact">
<i>Exact match</i>
<ActionListItem id={exact} onclick={() => select(exact)} />
</li>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<li><button onclick={() => select(code)}>USE CODE</button></li>
{:else}
<li>Action code is out of range</li>
{/if}
{/if}
{#each results as [category, actions] (category)}
{#if actions.length > 0}
<div class="category">
<h3>{category.name}</h3>
<div class="description">{category.description}</div>
<ul>
{#each actions as action (action.code)}
<button
class="action-item"
draggable={!onclose}
onclick={() => select(action.code)}
ondragstart={onclose === undefined
? (event) => {
if (!event.dataTransfer) return;
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.clearData();
event.dataTransfer.setData(
"text/plain",
actionToValue(action.code),
);
}
: undefined}
>
<Action {action} display="verbose"></Action>
</button>
{/each}
</ul>
</div>
{/if}
{/each}
</ul>
</div>
<style lang="scss">
.action-item {
margin: 0;
padding: 0;
height: auto;
font: inherit;
&[draggable="true"] {
cursor: grab;
}
}
aside {
opacity: 0.4;
margin: 8px;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
pointer-events: none;
> h3 {
margin-inline-start: 16px;
margin-block-start: -13px;
margin-block-end: 0;
background: var(--md-sys-color-background);
padding-inline: 8px;
width: fit-content;
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
display: flex;
align-items: center;
gap: 4px;
margin-inline: 16px;
}
.content {
display: flex;
position: relative;
flex-direction: column;
transform-origin: top left;
border-radius: 16px;
background: var(--md-sys-color-background);
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
overflow: hidden;
color: var(--md-sys-color-on-background);
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
transition: all 250ms ease;
margin-block-end: 8px;
border: none;
border-bottom: 1px solid var(--md-sys-color-surface-variant);
background: none;
padding-inline: 16px;
width: 100%;
height: 64px;
color: currentcolor;
font-size: 16px;
font-family: inherit;
&:focus {
outline: none;
border-bottom: 1px solid var(--md-sys-color-primary);
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
box-sizing: border-box;
margin: 0;
padding: 0;
padding-inline: 4px;
height: 100%;
overflow-y: auto;
scrollbar-gutter: both-edges stable;
}
.category {
.description {
opacity: 0.8;
margin-block-start: -16px;
font-style: italic;
font-size: 14px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-block: 24px;
overflow: hidden;
}
}
li {
display: contents;
}
.exact {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
width: 100%;
> i {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 0 0 8px 8px;
background: var(--md-sys-color-primary);
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
}
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -1,45 +1,333 @@
<script lang="ts"> <script lang="ts">
import ActionList from "./ActionList.svelte"; import {
KEYMAP_CATEGORIES,
KEYMAP_CODES,
KEYMAP_IDS,
} from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import { createEventDispatcher, onMount } from "svelte";
import ActionListItem from "$lib/components/ActionListItem.svelte";
import LL from "../../../i18n/i18n-svelte";
import { action } from "$lib/title";
let { export let currentAction: number | undefined = undefined;
currentAction = undefined, export let nextAction: number | undefined = undefined;
nextAction = undefined,
onselect, onMount(() => {
onclose, searchBox.focus();
}: { });
currentAction?: number;
nextAction?: number; const index = new FlexSearch.Index({ tokenize: "full" });
onselect: (id: number) => void; createIndex();
onclose: () => void;
} = $props(); 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) {
dispatch("select", id);
}
}
function keyboardNavigation(event: KeyboardEvent) {
if (event.shiftKey && event.key === "Enter") {
dispatch("select", exact);
} else if (event.key === "ArrowDown") {
const element =
resultList.querySelector("li:focus-within")?.nextSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else if (event.key === "ArrowUp") {
const element =
resultList.querySelector("li:focus-within")?.previousSibling ??
resultList.querySelector("li:not(.exact)");
if (element instanceof HTMLLIElement) {
element.querySelector("button")?.focus();
}
} else {
searchBox.focus();
return;
}
event.preventDefault();
}
let results: number[] = [];
let exact: number | undefined = undefined;
let code: number = Number.NaN;
const dispatch = createEventDispatcher();
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter: Set<number>;
</script> </script>
<dialog <svelte:window on:keydown={keyboardNavigation} />
open
onclick={(event) => { <!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
if (event.target === event.currentTarget) onclose(); <dialog open on:click|self={() => dispatch("close")}>
<div class="content">
<div class="search-row">
<input
type="search"
bind:this={searchBox}
on:input={search}
on:keypress={(event) => {
if (event.key === "Enter") {
select(exact);
}
}} }}
> placeholder={$LL.actionSearch.PLACEHOLDER()}
<ActionList
autofocus={true}
{currentAction}
{nextAction}
{onselect}
{onclose}
/> />
<button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
>{$LL.actionSearch.DELETE()}</button
>
<button
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
class="icon"
on:click={() => dispatch("close")}>close</button
>
</div>
<fieldset class="filters">
<label
>{$LL.actionSearch.filter.ALL()}<input
checked
name="category"
type="radio"
value={undefined}
bind:group={filter}
/></label
>
{#each KEYMAP_CATEGORIES as category}
<label
>{category.name}<input
name="category"
type="radio"
value={new Set(Object.keys(category.actions).map(Number))}
bind:group={filter}
/></label
>
{/each}
</fieldset>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
<ActionListItem id={currentAction} />
</aside>
{#if nextAction}
<aside>
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
<ActionListItem id={nextAction} />
</aside>
{/if}
{/if}
<ul bind:this={resultList}>
{#if exact !== undefined}
<li class="exact">
<i>Exact match</i>
<ActionListItem id={exact} on:click={() => select(exact)} />
</li>
{/if}
{#if !exact && code}
{#if code >= 2 ** 5 && code < 2 ** 13}
<li><button on:click={() => select(code)}>USE CODE</button></li>
{:else}
<li>Action code is out of range</li>
{/if}
{/if}
{#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} on:click={() => select(id)} /></li>
{/each}
{/if}
</ul>
</div>
</dialog> </dialog>
<style lang="scss"> <style lang="scss">
dialog { .filters {
display: flex; display: flex;
justify-content: center; gap: 4px;
align-items: center;
border: none; border: none;
background: rgba(0 0 0 / 60%); 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 {
display: flex;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
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%;
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%;
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,22 +1,24 @@
<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 } 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
@@ -28,7 +30,8 @@
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer"); console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
} }
let { layoutInfo }: { layoutInfo: CompiledLayout } = $props(); export let visualLayout: VisualLayout;
$: layoutInfo = compileLayout(visualLayout);
function getCenter(key: CompiledLayoutKey): [x: number, y: number] { function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2]; return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
@@ -122,33 +125,13 @@
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 = const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id]; const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = const component = new ActionSelector({
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, {
target: document.body, target: document.body,
props: { props: {
currentAction, currentAction,
nextAction: nextAction?.isApplied ? undefined : nextAction?.action, nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
onclose() {
closed();
},
onselect(action) {
changes.update((changes) => {
changes.push([
{
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
profile: get(activeProfile),
action,
},
]);
return changes;
});
closed();
},
}, },
}); });
const dialog = document.querySelector("dialog > div") as HTMLDivElement; const dialog = document.querySelector("dialog > div") as HTMLDivElement;
@@ -184,8 +167,22 @@
await dialogAnimation.finished; await dialogAnimation.finished;
unmount(component); component.$destroy();
} }
component.$on("close", closed);
component.$on("select", ({ detail }) => {
changes.update((changes) => {
changes.push({
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
action: detail,
});
return changes;
});
closed();
});
} }
let focusKey: CompiledLayoutKey; let focusKey: CompiledLayoutKey;
@@ -204,9 +201,9 @@
<KeyboardKey <KeyboardKey
{i} {i}
{key} {key}
onfocusin={() => (focusKey = key)} on:focusin={() => (focusKey = key)}
onclick={() => edit(i)} on:click={() => edit(i)}
onkeypress={({ key }) => { on:keypress={({ key }) => {
if (key === "Enter") { if (key === "Enter") {
edit(i); edit(i);
} }
@@ -217,9 +214,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,51 +1,34 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout"; import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts"; import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import { layout } from "$lib/undo-redo.js"; import { layout } from "$lib/undo-redo.js";
import { osLayout } from "$lib/os-layout.js"; import { osLayout } from "$lib/os-layout.js";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes"; import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { actionTooltip } from "$lib/title"; import { action } from "$lib/title";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import { getContext } from "svelte";
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } = const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config"); getContext<VisualLayoutConfig>("visual-layout-config");
const currentAction = getContext<Writable<Set<number>> | undefined>( const activeLayer = getContext<Writable<number>>("active-layer");
"highlight-action",
);
let { export let key: CompiledLayoutKey;
key, export let fontSizeMultiplier = 1;
fontSizeMultiplier = 1,
middle, export let middle: [number, number];
pos, export let pos: [number, number];
rotate, export let rotate: number;
positions,
}: { export let positions: [[number, number], [number, number], [number, number]];
key: CompiledLayoutKey;
fontSizeMultiplier?: number;
middle: [number, number];
pos: [number, number];
rotate: number;
positions: [
[number, number],
[number, number],
[number, number],
[number, number],
];
} = $props();
</script> </script>
{#each positions as position, layer} {#each positions as position, layer}
{@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[ {@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
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)}`) +
@@ -57,7 +40,6 @@
]} ]}
{@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"
@@ -72,9 +54,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"
{@attach actionTooltip(tooltip)} use:action={{ title: tooltip }}
> >
{#if code !== 0 && code != 1023} {#if code !== 0}
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`} {dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
{/if} {/if}
{#if !isApplied} {#if !isApplied}
@@ -88,15 +70,15 @@
$transition: 200ms; $transition: 200ms;
text { text {
transform-box: fill-box; will-change: translate, scale;
user-select: none;
transform-origin: center; transform-origin: center;
transform-box: fill-box;
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;
@@ -107,8 +89,4 @@
text:focus-within { text:focus-within {
outline: none; outline: none;
} }
text.hidden {
opacity: 0.2;
}
</style> </style>

View File

@@ -1,48 +1,26 @@
<script lang="ts"> <script lang="ts">
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts"; import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js"; import type { VisualLayoutConfig } from "./visual-layout.js";
import KeyText from "$lib/components/layout/KeyText.svelte"; import KeyText from "$lib/components/layout/KeyText.svelte";
import type {
FocusEventHandler,
KeyboardEventHandler,
MouseEventHandler,
} from "svelte/elements";
import { type Writable } from "svelte/store";
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>( const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
"visual-layout-config", "visual-layout-config",
); );
export let i: number;
export let key: CompiledLayoutKey;
const highlight = getContext<Writable<Set<number>> | undefined>("highlight"); $: posX = key.pos[0] * scale;
$: posY = key.pos[1] * scale;
let { $: sizeX = key.size[0] * scale;
i, $: sizeY = key.size[1] * scale;
key,
onclick,
onkeypress,
onfocusin,
}: {
i: number;
key: CompiledLayoutKey;
onclick: MouseEventHandler<SVGGElement>;
onkeypress: KeyboardEventHandler<SVGGElement>;
onfocusin: FocusEventHandler<SVGGElement>;
} = $props();
let posX = $derived(key.pos[0] * scale);
let posY = $derived(key.pos[1] * scale);
let sizeX = $derived(key.size[0] * scale);
let sizeY = $derived(key.size[1] * scale);
</script> </script>
<g <g
class="key-group" class="key-group"
class:highlight={$highlight?.has(key.id) === true} on:click
class:faded={$highlight?.has(key.id) === false} on:keypress
{onclick} on:focusin
{onkeypress}
{onfocusin}
role="button" role="button"
tabindex={i + 1} tabindex={i + 1}
> >
@@ -64,7 +42,6 @@
[-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"}
@@ -104,7 +81,6 @@
[-rotY, -rotX], [-rotY, -rotX],
[-rotX, -rotY], [-rotX, -rotY],
[rotX, rotY], [rotX, rotY],
[rotY, rotX],
]} ]}
/> />
{/if} {/if}
@@ -115,14 +91,14 @@
$transition: 200ms; $transition: 200ms;
rect { rect {
transform-box: fill-box;
transform-origin: center; transform-origin: center;
transform-box: fill-box;
} }
path, path,
g { g {
transform-box: fill-box;
transform-origin: top left; transform-origin: top left;
transform-box: fill-box;
} }
path, path,
@@ -138,17 +114,15 @@
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 {
outline: none;
color: var(--md-sys-color-primary); color: var(--md-sys-color-primary);
outline: none;
> path, > path,
> rect { > rect {

View File

@@ -1,83 +1,54 @@
<script lang="ts"> <script lang="ts">
import { deviceMeta, serialPort } from "$lib/serial/connection"; import { serialPort } from "$lib/serial/connection";
import { actionTooltip } from "$lib/title"; import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte"; import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { activeProfile, activeLayer } from "$lib/serial/connection"; import { getContext } from "svelte";
import { fade, fly } from "svelte/transition"; import type { Writable } from "svelte/store";
import { restoreFromFile } from "$lib/backup/backup"; import type { VisualLayout } from "$lib/serialization/visual-layout";
import type { CompiledLayout } from "$lib/assets/layouts/layout.d.ts"; import { fade } from "svelte/transition";
const layouts: Record<string, (() => Promise<CompiledLayout>) | undefined> = { $: device = $serialPort?.device;
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const;
const layouts = {
ONE: () => ONE: () =>
import("$lib/assets/layouts/one.layout.yml").then( import("$lib/assets/layouts/one.yml").then(
(it) => it.default as CompiledLayout, (it) => it.default as VisualLayout,
),
TWO: () =>
import("$lib/assets/layouts/one.layout.yml").then(
(it) => it.default as CompiledLayout,
), ),
LITE: () => LITE: () =>
import("$lib/assets/layouts/lite.layout.yml").then( import("$lib/assets/layouts/lite.yml").then(
(it) => it.default as CompiledLayout, (it) => it.default as VisualLayout,
), ),
X: () => X: () =>
import("$lib/assets/layouts/103-key.layout.yml").then( import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as CompiledLayout, (it) => it.default as VisualLayout,
),
ZERO: () =>
import("$lib/assets/layouts/103-key.layout.yml").then(
(it) => it.default as CompiledLayout,
),
M4G: () =>
import("$lib/assets/layouts/m4g.layout.yml").then(
(it) => it.default as CompiledLayout,
),
M4GR: () =>
import("$lib/assets/layouts/m4gr.layout.yml").then(
(it) => it.default as CompiledLayout,
),
T4G: () =>
import("$lib/assets/layouts/t4g.layout.yml").then(
(it) => it.default as CompiledLayout,
), ),
}; };
</script> </script>
<div class="container"> <div class="container">
{#if $serialPort} {#if device}
{#await layouts[$serialPort.device]?.() then layoutInfo} {#await layouts[device]() then visualLayout}
<fieldset transition:fade> <fieldset transition:fade>
<div class="layers"> {#each layers as [title, icon, value]}
{#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
{@attach actionTooltip("Reset Layout")} class="icon"
transition:fly={{ x: -8 }} use:action={{ title, shortcut: `alt+${value + 1}` }}
class="icon reset-layout" on:click={() => ($activeLayer = value)}
onclick={() => class:active={$activeLayer === value}
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
>reset_wrench</button
> >
{/if} {icon}
</button>
{/each}
</fieldset> </fieldset>
{#if layoutInfo} <GenericLayout {visualLayout} />
<GenericLayout {layoutInfo} />
{/if}
{/await} {/await}
{/if} {/if}
</div> </div>
@@ -86,32 +57,71 @@
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
justify-content: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
max-height: 20cm; margin-bottom: 96px;
} }
fieldset { fieldset {
display: flex;
position: relative; position: relative;
justify-content: center;
align-items: center;
border: none; display: flex;
align-items: center;
justify-content: center;
padding: 8px; padding: 8px;
border: none;
} }
.layers { button.icon {
display: flex; cursor: pointer;
justify-content: center;
align-items: center;
gap: 2px; z-index: 1;
margin-inline: auto; font-size: 24px;
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

@@ -1,9 +0,0 @@
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,25 +1,16 @@
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import Dialog from "$lib/dialogs/Dialog.svelte"; import Dialog from "$lib/dialogs/Dialog.svelte";
import type { Chord } from "$lib/serial/chord"; import ActionString from "$lib/components/ActionString.svelte";
import ChordActionEdit from "../../routes/(app)/config/chords/ChordActionEdit.svelte";
let { export let title: string;
title, export let message: string | undefined;
message, export let abortTitle: string;
abortTitle, export let confirmTitle: string;
confirmTitle,
chord, export let actions: number[] = [];
onabort,
onconfirm, const dispatch = createEventDispatcher();
}: {
title: string;
message?: string;
abortTitle: string;
confirmTitle: string;
chord: Chord & { deleted: boolean };
onabort: () => void;
onconfirm: () => void;
} = $props();
</script> </script>
<Dialog> <Dialog>
@@ -27,23 +18,12 @@
{#if message} {#if message}
<p>{@html message}</p> <p>{@html message}</p>
{/if} {/if}
<p> <p><ActionString {actions} /></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 on:click={() => dispatch("abort")}>{abortTitle}</button>
<button class="primary" onclick={onconfirm}>{confirmTitle}</button> <button class="primary" on:click={() => dispatch("confirm")}
>{confirmTitle}</button
>
</div> </div>
</Dialog> </Dialog>

View File

@@ -1,106 +0,0 @@
<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

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount, type Snippet } from "svelte"; import { onMount } from "svelte";
let { children }: { children: Snippet } = $props();
onMount(() => { onMount(() => {
modal.showModal(); modal.showModal();
@@ -11,20 +9,20 @@
</script> </script>
<dialog bind:this={modal}> <dialog bind:this={modal}>
{@render children()} <slot />
</dialog> </dialog>
<style lang="scss"> <style lang="scss">
dialog { dialog {
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
border: none;
border-radius: 38px;
background: var(--md-sys-color-background);
min-width: 300px; min-width: 300px;
max-width: 512px; max-width: 512px;
color: var(--md-sys-color-on-background); color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
border: none;
border-radius: 38px;
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
} }
dialog::backdrop { dialog::backdrop {

View File

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

View File

@@ -1,24 +0,0 @@
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-style: normal;
font-weight: 100 700;
src: url("$lib/assets/icons.min.woff2") format("woff2");
font-family: "Material Symbols Rounded"; font-family: "Material Symbols Rounded";
font-weight: 100 700;
font-style: normal;
src: url("$lib/assets/icons.min.woff2") format("woff2");
} }
.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-feature-settings: "liga"; font-weight: normal;
letter-spacing: normal; font-style: normal;
line-height: 1;
direction: ltr;
user-select: none;
text-transform: none; text-transform: none;
letter-spacing: normal;
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,30 +66,28 @@
/* noto-sans-mono-latin-ext-wght-normal */ /* noto-sans-mono-latin-ext-wght-normal */
@font-face { @font-face {
font-style: normal; font-family: "Noto Sans Mono Variable";
font-weight: 100 900; font-weight: 100 900;
font-style: normal;
font-display: swap;
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");
font-family: "Noto Sans Mono Variable"; unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
font-display: swap; U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
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-style: normal; font-family: "Noto Sans Mono";
font-weight: 100 900; font-weight: 100 900;
font-style: normal;
font-display: swap;
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");
font-family: "Noto Sans Mono"; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
font-display: swap; U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
unicode-range: U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }

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