mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-22 18:02:42 +00:00
Compare commits
53 Commits
d2accfb838
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
bd1c6147fd
|
|||
|
891abda0fb
|
|||
|
3611f65e24
|
|||
|
f76882a09c
|
|||
|
ff7e4f7b2e
|
|||
|
1c1c86241f
|
|||
|
dc8b3c3d66
|
|||
|
|
65911419b0 | ||
|
|
ccfb09e261 | ||
|
b841469505
|
|||
|
bc06e8ee80
|
|||
|
24fc861ef4
|
|||
|
5801e5fbbe
|
|||
|
92b52e08f7
|
|||
|
4192210d27
|
|||
|
|
0e5640a1ee | ||
|
7f27499003
|
|||
|
|
b6ded5f94c | ||
|
|
63d0ad7ae8 | ||
|
|
1c8f53caf6 | ||
|
1d60b12d43
|
|||
|
e85a731410
|
|||
|
050af564ab
|
|||
|
6545124aa2
|
|||
| b93724add3 | |||
|
|
e1092113f6 | ||
|
|
0bb4bbe838 | ||
|
089812c555
|
|||
|
45c5f21cc4
|
|||
|
fb5959998a
|
|||
|
f319714489
|
|||
|
fb1f5b7ec7
|
|||
|
ac16cfd3bf
|
|||
|
9d5b0e01d2
|
|||
|
e7517f821d
|
|||
|
762f73063a
|
|||
|
7ca9e04dd3
|
|||
|
4d73dad780
|
|||
|
5419824c06
|
|||
|
075d05dd0b
|
|||
|
9266702cbb
|
|||
|
77e2d2b20e
|
|||
|
7819f546a6
|
|||
|
e37b38085d
|
|||
|
a3bf9ac32b
|
|||
|
|
5bd3245084 | ||
|
1cd2ec318a
|
|||
|
6c8bfa0272
|
|||
|
f69be14b5e
|
|||
|
dce554fc66
|
|||
|
f152dbdcf5
|
|||
|
6a29e6a2fc
|
|||
|
9bf3801fef
|
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
@@ -1,6 +1,12 @@
|
||||
name: Build
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -8,9 +14,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 🚚 Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: 🐍 Use Python 3.x
|
||||
uses: actions/setup-python@v3.1.4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
cache: pip
|
||||
@@ -20,11 +26,11 @@ jobs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8
|
||||
- name: 🐉 Use Node.js 22.4.x
|
||||
uses: actions/setup-node@v3
|
||||
version: 10
|
||||
- name: 🐉 Use Node.js 22.14.x
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.4.x
|
||||
node-version: 22.14.x
|
||||
cache: "pnpm"
|
||||
- name: ⏬ Install Node dependencies
|
||||
run: pnpm install
|
||||
@@ -41,10 +47,11 @@ jobs:
|
||||
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
|
||||
|
||||
- name: Publish Stable
|
||||
if: ${{ github.ref == 'refs/tags/v*' }}
|
||||
if: ${{ github.ref == 'refs/tags/v*' && !github.event.pull_request.head.repo.fork }}
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/www/
|
||||
|
||||
- name: Publish Branch
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
|
||||
- name: Publish Commit
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}
|
||||
|
||||
@@ -7,6 +7,8 @@ node_modules
|
||||
.env.*
|
||||
!.env.example
|
||||
/src-tauri/target
|
||||
/openssl*
|
||||
/src/i18n/i18n*
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -35,3 +35,9 @@ way to subset variable woff2 fonts with ligatures.
|
||||
|
||||
In other words, either have python as a development dependency or
|
||||
serve a 3.5MB icons font of which 99.5% is completely unused.
|
||||
|
||||
To generate the icons use the following command:
|
||||
|
||||
```shell
|
||||
npm run minify-icons
|
||||
```
|
||||
|
||||
24
flake.lock
generated
24
flake.lock
generated
@@ -5,11 +5,11 @@
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1710146030,
|
||||
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -20,11 +20,11 @@
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1722415718,
|
||||
"narHash": "sha256-5US0/pgxbMksF92k1+eOa8arJTJiPvsdZj9Dl+vJkM4=",
|
||||
"lastModified": 1743259260,
|
||||
"narHash": "sha256-ArWLUgRm1tKHiqlhnymyVqi5kLNCK5ghvm06mfCl4QY=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "c3392ad349a5227f4a3464dce87bcc5046692fce",
|
||||
"rev": "eb0e0f21f15c559d2ac7633dc81d079d1caf5f5f",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -36,11 +36,11 @@
|
||||
},
|
||||
"nixpkgs_2": {
|
||||
"locked": {
|
||||
"lastModified": 1718428119,
|
||||
"narHash": "sha256-WdWDpNaq6u1IPtxtYHHWpl5BmabtpmLnMAx0RdJ/vo8=",
|
||||
"lastModified": 1736320768,
|
||||
"narHash": "sha256-nIYdTAiKIGnFNugbomgBJR+Xv5F1ZQU+HfaBqJKroC0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e6cea36f83499eb4e9cd184c8a8e823296b50ad5",
|
||||
"rev": "4bc9c909d9ac828a039f288cf872d16d38185db8",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
@@ -62,11 +62,11 @@
|
||||
"nixpkgs": "nixpkgs_2"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1722391647,
|
||||
"narHash": "sha256-JTi7l1oxnatF1uX/gnGMlRnyFMtylRw4MqhCUdoN2K4=",
|
||||
"lastModified": 1743388531,
|
||||
"narHash": "sha256-OBcNE+2/TD1AMgq8HKMotSQF8ZPJEFGZdRoBJ7t/HIc=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "0fd4a5d2098faa516a9b83022aec7db766cd1de8",
|
||||
"rev": "011de3c895927300651d9c2cb8e062adf17aa665",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
16
flake.nix
16
flake.nix
@@ -14,7 +14,13 @@
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
overlays = [
|
||||
(import rust-overlay)
|
||||
(final: prev: {
|
||||
nodejs = prev.nodejs_22;
|
||||
corepack = prev.corepack_22;
|
||||
})
|
||||
];
|
||||
pkgs = import nixpkgs { inherit system overlays; };
|
||||
rust-bin = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [
|
||||
@@ -46,8 +52,8 @@
|
||||
];
|
||||
packages =
|
||||
(with pkgs; [
|
||||
nodejs_22
|
||||
nodePackages.pnpm
|
||||
nodejs
|
||||
pnpm
|
||||
rust-bin
|
||||
fontMin
|
||||
])
|
||||
@@ -59,7 +65,7 @@
|
||||
openssl_3
|
||||
glib
|
||||
gtk3
|
||||
libsoup
|
||||
libsoup_2_4
|
||||
webkitgtk
|
||||
librsvg
|
||||
# serial plugin
|
||||
@@ -70,7 +76,7 @@
|
||||
devShell = pkgs.mkShell {
|
||||
buildInputs = packages;
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||
#export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const config = {
|
||||
"node_modules/@fontsource-variable/material-symbols-rounded/files/material-symbols-rounded-latin-full-normal.woff2",
|
||||
outputPath: "src/lib/assets/icons.min.woff2",
|
||||
icons: [
|
||||
"rocket_launch",
|
||||
"deployed_code_update",
|
||||
"adjust",
|
||||
"add",
|
||||
@@ -43,6 +44,19 @@ const config = {
|
||||
"arrow_back_ios_new",
|
||||
"save",
|
||||
"settings_backup_restore",
|
||||
"sound_detection_loud_sound",
|
||||
"ring_volume",
|
||||
"wifi",
|
||||
"power_settings_circle",
|
||||
"graphic_eq",
|
||||
"mail",
|
||||
"calculate",
|
||||
"open_in_browser",
|
||||
"chevron_backward",
|
||||
"chevron_forward",
|
||||
"bookmark",
|
||||
"drag_pan",
|
||||
"markdown_copy",
|
||||
"sort",
|
||||
"shopping_bag",
|
||||
"filter_list",
|
||||
@@ -66,16 +80,24 @@ const config = {
|
||||
"delete",
|
||||
"remove_selection",
|
||||
"bolt",
|
||||
"thunderstorm",
|
||||
"join_inner",
|
||||
"uppercase",
|
||||
"undo",
|
||||
"redo",
|
||||
"replay",
|
||||
"reply",
|
||||
"navigate_before",
|
||||
"navigate_next",
|
||||
"library_add",
|
||||
"reset_wrench",
|
||||
"reset_settings",
|
||||
"delete_sweep",
|
||||
"print",
|
||||
"restore_from_trash",
|
||||
"history",
|
||||
"history_toggle_off",
|
||||
"text_to_speech",
|
||||
"sentiment_satisfied",
|
||||
"sentiment_dissatisfied",
|
||||
"sentiment_very_satisfied",
|
||||
@@ -89,6 +111,7 @@ const config = {
|
||||
"sentiment_sad",
|
||||
"sentiment_content",
|
||||
"sentiment_worried",
|
||||
"construction",
|
||||
"timer",
|
||||
"target",
|
||||
"download",
|
||||
|
||||
81
package.json
81
package.json
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "charachorder-device-manager",
|
||||
"version": "2.1.0",
|
||||
"version": "2.3.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18.16",
|
||||
"pnpm": ">=8.6"
|
||||
"node": ">=22.14",
|
||||
"pnpm": ">=10.7"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -34,62 +34,61 @@
|
||||
"typesafe-i18n": "typesafe-i18n"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.2",
|
||||
"@codemirror/commands": "^6.7.1",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/language": "^6.10.3",
|
||||
"@codemirror/state": "^6.4.1",
|
||||
"@codemirror/view": "^6.34.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.1.3",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.1.0",
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.3",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.36.5",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.6",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.0",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.7.5",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||
"@sveltejs/adapter-static": "^3.0.8",
|
||||
"@sveltejs/kit": "^2.20.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||
"@tauri-apps/api": "^1.6.0",
|
||||
"@tauri-apps/cli": "^1.6.0",
|
||||
"@types/dom-view-transitions": "^1.0.5",
|
||||
"@types/flexsearch": "^0.7.6",
|
||||
"@types/w3c-web-serial": "^1.0.7",
|
||||
"@types/dom-view-transitions": "^1.0.6",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@types/w3c-web-usb": "^1.0.10",
|
||||
"@types/wicg-file-system-access": "^2023.10.5",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"codemirror": "^6.0.1",
|
||||
"cypress": "^13.13.2",
|
||||
"cypress": "^14.2.1",
|
||||
"d3": "^7.9.0",
|
||||
"esptool-js": "^0.4.7",
|
||||
"flexsearch": "^0.7.43",
|
||||
"esptool-js": "^0.5.4",
|
||||
"flexsearch": "^0.8.147",
|
||||
"fontkit": "^2.0.4",
|
||||
"glob": "^11.0.0",
|
||||
"jsdom": "^25.0.1",
|
||||
"matrix-js-sdk": "^34.9.0",
|
||||
"glob": "^11.0.1",
|
||||
"jsdom": "^26.0.0",
|
||||
"matrix-js-sdk": "^37.2.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.7",
|
||||
"rxjs": "^7.8.1",
|
||||
"sass": "^1.80.6",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.86.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stylelint": "^16.10.0",
|
||||
"stylelint-config-clean-order": "^6.1.0",
|
||||
"stylelint": "^16.17.0",
|
||||
"stylelint-config-clean-order": "^7.0.0",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-recommended-scss": "^14.1.0",
|
||||
"stylelint-config-standard-scss": "^13.1.0",
|
||||
"svelte": "5.1.9",
|
||||
"svelte-check": "^4.0.5",
|
||||
"stylelint-config-standard-scss": "^14.0.0",
|
||||
"svelte": "5.25.3",
|
||||
"svelte-check": "^4.1.5",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-mkcert": "^1.17.6",
|
||||
"vite-plugin-pwa": "^0.20.5",
|
||||
"vitest": "^2.1.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.2.4",
|
||||
"vite-plugin-mkcert": "^1.17.8",
|
||||
"vite-plugin-pwa": "^1.0.0",
|
||||
"vitest": "^3.1.1",
|
||||
"web-serial-polyfill": "^1.0.15",
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
|
||||
2329
pnpm-lock.yaml
generated
2329
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "2.1.0"
|
||||
version = "2.3.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||
license = "AGPL-3"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": { "productName": "amacc1ng", "version": "2.1.0" },
|
||||
"package": { "productName": "amacc1ng", "version": "2.3.0" },
|
||||
"tauri": {
|
||||
"allowlist": { "all": false },
|
||||
"bundle": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { expoIn, expoOut } from "svelte/easing";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
|
||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
|
||||
$props();
|
||||
|
||||
let inDirection = $state(0);
|
||||
|
||||
@@ -1,497 +0,0 @@
|
||||
s + Dup,say
|
||||
y + b,by
|
||||
y + Dup,why
|
||||
n + Dup,no
|
||||
n + f,find
|
||||
l + f,life
|
||||
l + p,people
|
||||
l + s + p,spell
|
||||
l + n,line
|
||||
l + n + p,plant
|
||||
t + h,that
|
||||
t + n + h,than
|
||||
t + n + p,plant
|
||||
t + l + f,left
|
||||
a + f,after
|
||||
a + d,add
|
||||
a + d + f,had
|
||||
a + h,has
|
||||
a + s,as
|
||||
a + s + h,has
|
||||
a + y + d,day
|
||||
a + y + s,say
|
||||
a + n,an
|
||||
a + n + d,and
|
||||
a + n + d + f,hand
|
||||
a + n + h,hand
|
||||
a + n + y,any
|
||||
a + l,all
|
||||
a + l + d,land
|
||||
a + l + p,plant
|
||||
a + l + s,last
|
||||
a + l + y + p,play
|
||||
a + l + n + d,land
|
||||
a + t,at
|
||||
a + t + h,that
|
||||
a + t + n + h,than
|
||||
a + t + l + s,last
|
||||
a + t + l + n + p,plant
|
||||
- + ?,question
|
||||
w + h,who
|
||||
w + s,saw
|
||||
w + y + h,why
|
||||
w + t,without
|
||||
w + t + h,watch
|
||||
w + t + n,went
|
||||
w + a,was
|
||||
w + a + h,what
|
||||
w + a + s,saw
|
||||
w + a + y,way
|
||||
w + a + y + Dup,away
|
||||
w + a + n,want
|
||||
w + a + l + f,walk
|
||||
w + a + l + y,always
|
||||
w + a + l + y + s,always
|
||||
w + a + t,watch
|
||||
w + a + t + h,watch
|
||||
w + a + t + n,want
|
||||
g + b,begin
|
||||
g + Dup,question
|
||||
g + h,here
|
||||
g + p,page
|
||||
g + l + n,long
|
||||
g + t,get
|
||||
k + q,quick
|
||||
k + f,and
|
||||
k + l + y + q,quickly
|
||||
k + t + l,talk
|
||||
k + a + b,back
|
||||
k + a + s,ask
|
||||
k + a + t + l,talk
|
||||
k + w + a + l,walk
|
||||
m + f,form
|
||||
m + y,my
|
||||
m + t + Dup,mountain
|
||||
m + a + f,family
|
||||
m + a + s,small
|
||||
m + a + y,may
|
||||
m + a + n,man
|
||||
m + a + n + y,many
|
||||
m + a + l,almost
|
||||
m + a + l + s,small
|
||||
m + a + l + s + Dup,small
|
||||
c + b,because
|
||||
c + Dup,sea
|
||||
c + h,head
|
||||
c + a + n,can
|
||||
c + a + n + h,change
|
||||
c + a + l,call
|
||||
c + a + l + p,place
|
||||
c + k + a + b,back
|
||||
u + p,us
|
||||
u + y,you
|
||||
u + j,just
|
||||
u + j + s,just
|
||||
u + t + b,but
|
||||
u + t + p,put
|
||||
u + t + s + d,study
|
||||
u + t + n,until
|
||||
u + t + j + s,just
|
||||
u + a + l,last
|
||||
u + k + q,quick
|
||||
u + k + l + y + q,quickly
|
||||
u + m + h,much
|
||||
u + m + n,number
|
||||
u + m + t + s,must
|
||||
u + m + c + h,much
|
||||
u + c + s + h,such
|
||||
u + c + t,cut
|
||||
u + c + t + n,country
|
||||
' + a + s,say
|
||||
' + a + n,any
|
||||
' + m + a + n,many
|
||||
o + Dup,off
|
||||
o + f,of
|
||||
o + f + f,food
|
||||
o + d,do
|
||||
o + s,so
|
||||
o + y + b,boy
|
||||
o + n,on
|
||||
o + n + s + Dup,soon
|
||||
o + l,line
|
||||
o + l + Dup,oil
|
||||
o + l + d,old
|
||||
o + l + y,only
|
||||
o + l + n + y,only
|
||||
o + t,to
|
||||
o + t + b,both
|
||||
o + t + Dup,too
|
||||
o + t + s,stop
|
||||
o + t + s + p,stop
|
||||
o + t + n,not
|
||||
o + t + n + f,often
|
||||
o + t + n + d,don't
|
||||
o + t + n + p,point
|
||||
o + a + n + h,another
|
||||
o + a + l,also
|
||||
o + a + l + s,also
|
||||
o + w,own
|
||||
o + w + h,how
|
||||
o + w + s + h,show
|
||||
o + w + n,now
|
||||
o + w + n + d,down
|
||||
o + w + l + f,follow
|
||||
o + w + t,two
|
||||
o + g,go
|
||||
o + g + Dup,good
|
||||
o + g + n + s,song
|
||||
o + g + l + n,long
|
||||
o + g + t,got
|
||||
o + g + a + l,along
|
||||
o + g + a + l + n,along
|
||||
o + k + b,book
|
||||
o + k + n,know
|
||||
o + k + l,look
|
||||
o + k + t,took
|
||||
o + k + t + Dup,took
|
||||
o + k + w + n,know
|
||||
o + v + a + b,above
|
||||
o + v + k,move
|
||||
o + m + s,some
|
||||
o + m + t + s,most
|
||||
o + m + a + l,almost
|
||||
o + m + a + l + s,almost
|
||||
o + m + a + t + l + s,almost
|
||||
o + c + f,food
|
||||
o + c + l + s + h,school
|
||||
o + u,our
|
||||
o + u + f,four
|
||||
o + u + s + h,should
|
||||
o + u + y,you
|
||||
o + u + n + f,found
|
||||
o + u + n + f + f,found
|
||||
o + u + n + s,sound
|
||||
o + u + n + s + d,sound
|
||||
o + u + l + s + d + f,should
|
||||
o + u + l + s + h,should
|
||||
o + u + t,out
|
||||
o + u + a + b,about
|
||||
o + u + a + t + b,about
|
||||
o + u + w + l + d,would
|
||||
o + u + g + n + y,young
|
||||
o + u + g + t + h,thought
|
||||
o + u + m + t + n,mountain
|
||||
o + u + m + t + n + Dup,mountain
|
||||
o + u + c + l + d,could
|
||||
o + ' + t + n + d,don't
|
||||
o + o + l,oil
|
||||
o + o + t + n,into
|
||||
o + o + t + n + p,point
|
||||
o + o + u + w + t + h,without
|
||||
o + o + u + m + a + t + n,mountain
|
||||
i + f,if
|
||||
i + f + f,different
|
||||
i + d,did
|
||||
i + RH_Thumb_1_Center,different
|
||||
i + s,is
|
||||
i + s + d,side
|
||||
i + s + h,his
|
||||
i + n,in
|
||||
i + n + f + f,find
|
||||
i + l,list
|
||||
i + l + n,line
|
||||
i + t,it
|
||||
i + t + s + Dup,still
|
||||
i + t + s + h,this
|
||||
i + t + n,into
|
||||
i + t + l + s,list
|
||||
i + t + l + s + Dup,still
|
||||
i + a + s,said
|
||||
i + a + s + d,said
|
||||
i + a + n,animal
|
||||
i + w + h,which
|
||||
i + w + h + Dup,which
|
||||
i + w + l,will
|
||||
i + w + l + Dup,will
|
||||
i + w + l + h,while
|
||||
i + w + t + h,with
|
||||
i + g,give
|
||||
i + g + b,big
|
||||
i + g + h,high
|
||||
i + g + n + h,night
|
||||
i + g + l + h,light
|
||||
i + g + t + n + h,thing
|
||||
i + g + t + l + h,light
|
||||
i + g + a + n,again
|
||||
i + g + a + n + Dup,again
|
||||
i + k + n + d,kind
|
||||
i + k + l,like
|
||||
i + k + t + n + h,think
|
||||
i + v + l,live
|
||||
i + m + h,him
|
||||
i + m + p,important
|
||||
i + m + s,miss
|
||||
i + m + s + Dup,miss
|
||||
i + m + l,mile
|
||||
i + m + t,time
|
||||
i + m + t + h,might
|
||||
i + m + a + n,animal
|
||||
i + m + a + n + Dup,animal
|
||||
i + m + a + l + n,another
|
||||
i + m + g + t + h,might
|
||||
i + c + p,picture
|
||||
i + c + t + y,city
|
||||
i + u + t + l + n,until
|
||||
i + u + w + t + h,without
|
||||
i + u + k + q,quick
|
||||
i + u + k + l + y + q,quickly
|
||||
i + u + c + k + q,quick
|
||||
i + u + c + k + l + y + q,quickly
|
||||
i + ' + t,it's
|
||||
i + ' + t + s,it's
|
||||
e + b,be
|
||||
e + Dup,earth
|
||||
e + x,example
|
||||
e + f + b,before
|
||||
e + h,he
|
||||
e + h + Dup,here
|
||||
e + s,state
|
||||
e + s + Dup,see
|
||||
e + s + h,she
|
||||
e + y + Dup,eye
|
||||
e + n,name
|
||||
e + n + b,been
|
||||
e + n + Dup,need
|
||||
e + n + d,end
|
||||
e + l + h,help
|
||||
e + l + h + f,help
|
||||
e + l + s + p,spell
|
||||
e + t,the
|
||||
e + t + Dup,eat
|
||||
e + t + f,feet
|
||||
e + t + h,there
|
||||
e + t + s,set
|
||||
e + t + s + h,these
|
||||
e + t + y + h,they
|
||||
e + t + n + x,next
|
||||
e + t + n + h,then
|
||||
e + t + l,let
|
||||
e + t + l + Dup,tell
|
||||
e + t + l + f,left
|
||||
e + a,at
|
||||
e + a + f,father
|
||||
e + a + d + f,head
|
||||
e + a + h,hear
|
||||
e + a + p,paper
|
||||
e + a + s,sea
|
||||
e + a + y,year
|
||||
e + a + n,name
|
||||
e + a + t,eat
|
||||
e + a + t + s + Dup,state
|
||||
e + w,we
|
||||
e + w + f,few
|
||||
e + w + n,new
|
||||
e + w + n + h,when
|
||||
e + w + l,well
|
||||
e + w + l + Dup,well
|
||||
e + w + t + b,between
|
||||
e + w + t + n,went
|
||||
e + w + t + n + b,between
|
||||
e + g + h,here
|
||||
e + g + t,get
|
||||
e + g + a + p,page
|
||||
e + g + a + n + b,began
|
||||
e + k,keep
|
||||
e + k + p,keep
|
||||
e + k + t,take
|
||||
e + k + a + t,take
|
||||
e + v + y,every
|
||||
e + v + n,even
|
||||
e + v + a + h,have
|
||||
e + v + a + l,leave
|
||||
e + v + a + l + Dup,leave
|
||||
e + m,me
|
||||
e + m + s,seem
|
||||
e + m + s + Dup,seem
|
||||
e + m + n,men
|
||||
e + m + t + h,them
|
||||
e + m + a,make
|
||||
e + m + a + d,made
|
||||
e + m + a + s,same
|
||||
e + m + a + n,mean
|
||||
e + m + a + l + p + x,example
|
||||
e + m + c + a,came
|
||||
e + c + s,second
|
||||
e + c + t + n + s,sentence
|
||||
e + c + a,came
|
||||
e + c + a + f,face
|
||||
e + c + a + h,each
|
||||
e + c + a + l + p,place
|
||||
e + LH_Thumb_1_Center + a,make
|
||||
e + u + s,use
|
||||
e + u + s + q,question
|
||||
e + u + n + d,under
|
||||
e + u + t + q,quite
|
||||
e + u + c + a + s + b,because
|
||||
e + o + p,open
|
||||
e + o + s + d,does
|
||||
e + o + n,one
|
||||
e + o + n + p,open
|
||||
e + o + l + b,below
|
||||
e + o + l + h,hello
|
||||
e + o + l + h + Dup,hello
|
||||
e + o + l + p,people
|
||||
e + o + t + f,often
|
||||
e + o + t + h,other
|
||||
e + o + t + s + h,those
|
||||
e + o + t + n + f,often
|
||||
e + o + w + l + b,below
|
||||
e + o + g + t + h,together
|
||||
e + o + v,over
|
||||
e + o + v + a + b,above
|
||||
e + o + v + k,move
|
||||
e + o + m,move
|
||||
e + o + m + h,home
|
||||
e + o + m + s,some
|
||||
e + o + m + t + s,sometime
|
||||
e + o + m + t + s + h,something
|
||||
e + o + m + g + t + n + s + h,something
|
||||
e + o + m + c,come
|
||||
e + o + c,come
|
||||
e + o + c + n,once
|
||||
e + o + c + n + s + d,second
|
||||
e + o + c + l + s,close
|
||||
e + o + u + s + h,house
|
||||
e + o + u + n,enough
|
||||
e + o + u + g + n + h,enough
|
||||
e + i + s + d,side
|
||||
e + i + l + f,life
|
||||
e + i + l + n,line
|
||||
e + i + t + q,quite
|
||||
e + i + t + l,little
|
||||
e + i + t + l + Dup,little
|
||||
e + i + a + d,idea
|
||||
e + i + w + l + h,while
|
||||
e + i + w + t + h,white
|
||||
e + i + g,give
|
||||
e + i + g + p,give
|
||||
e + i + g + n + b,begin
|
||||
e + i + k + l,like
|
||||
e + i + v + l,live
|
||||
e + i + m + l,mile
|
||||
e + i + m + t,time
|
||||
e + i + u + t + q,quite
|
||||
e + i + u + u + t + n + s + q,question
|
||||
r + e + h,her
|
||||
r + e + t + Dup,tree
|
||||
r + e + t + h,there
|
||||
r + e + t + h + Dup,three
|
||||
r + e + t + l,letter
|
||||
r + e + a,are
|
||||
r + e + a + d,read
|
||||
r + e + a + h,hear
|
||||
r + e + a + p,paper
|
||||
r + e + a + n,near
|
||||
r + e + a + l + y,really
|
||||
r + e + a + l + y + Dup,really
|
||||
r + e + a + t + f,after
|
||||
r + e + a + t + h,earth
|
||||
r + e + a + t + RH_Thumb_1_Center,father
|
||||
r + e + a + t + l,learn
|
||||
r + e + w,were
|
||||
r + e + w + Dup,were
|
||||
r + e + w + h,where
|
||||
r + e + w + a + n + s,answer
|
||||
r + e + w + a + t,water
|
||||
r + e + g + h,here
|
||||
r + e + g + a + l,large
|
||||
r + e + g + a + t,great
|
||||
r + e + v + y,very
|
||||
r + e + v + y + Dup,every
|
||||
r + e + v + n,never
|
||||
r + e + u + n + d,under
|
||||
r + e + u + m + n + b,number
|
||||
r + e + o + f + b,before
|
||||
r + e + o + t + h,other
|
||||
r + e + o + g + t + h,together
|
||||
r + e + o + v,over
|
||||
r + e + o + m,more
|
||||
r + e + o + m + t + h,mother
|
||||
r + e + i + t + h,their
|
||||
r + e + i + t + n + f + f,different
|
||||
r + e + i + v,river
|
||||
r + e + i + c + l + d + f,children
|
||||
r + e + i + u + c + t + p,picture
|
||||
r + f,for
|
||||
r + h,her
|
||||
r + y,your
|
||||
r + n,near
|
||||
r + l,learn
|
||||
r + t + Dup,tree
|
||||
r + t + f,father
|
||||
r + t + s + Dup,start
|
||||
r + t + y,try
|
||||
r + t + l,letter
|
||||
r + t + l + Dup,letter
|
||||
r + a,are
|
||||
r + a + f,far
|
||||
r + a + d,read
|
||||
r + a + d + f,hard
|
||||
r + a + h,hard
|
||||
r + a + p,part
|
||||
r + a + l + y,really
|
||||
r + a + l + y + Dup,really
|
||||
r + a + t + p,part
|
||||
r + a + t + s + Dup,start
|
||||
r + w,were
|
||||
r + w + h,where
|
||||
r + w + t,water
|
||||
r + w + a + n + s,answer
|
||||
r + g + t,great
|
||||
r + g + a + l,large
|
||||
r + v + y,very
|
||||
r + v + n,never
|
||||
r + v + l,later
|
||||
r + m + f,form
|
||||
r + c + a,car
|
||||
r + c + a + y,carry
|
||||
r + c + a + y + Dup,carry
|
||||
r + u + n,run
|
||||
r + u + t + h,through
|
||||
r + u + t + n,turn
|
||||
r + ' + t + s,story
|
||||
r + o,or
|
||||
r + o + f,for
|
||||
r + o + t + y + s,story
|
||||
r + o + a + n + h,another
|
||||
r + o + w,work
|
||||
r + o + w + d,word
|
||||
r + o + w + l + d,world
|
||||
r + o + g,grow
|
||||
r + o + LeftDoubleClick,grow
|
||||
r + o + k + w,work
|
||||
r + o + m,more
|
||||
r + o + m + f,from
|
||||
r + o + m + t + h,mother
|
||||
r + o + u,our
|
||||
r + o + u + f,four
|
||||
r + o + u + y,your
|
||||
r + o + u + a,around
|
||||
r + o + u + a + n,around
|
||||
r + o + u + a + n + d,around
|
||||
r + o + u + g,group
|
||||
r + o + u + g + p,group
|
||||
r + o + u + g + t + h,through
|
||||
r + o + u + c + t + n,country
|
||||
r + o + u + c + t + n + y,country
|
||||
r + i + t + h,their
|
||||
r + i + t + s + f,first
|
||||
r + i + a,air
|
||||
r + i + w + t,write
|
||||
r + i + g + h,right
|
||||
r + i + g + l,girl
|
||||
r + i + g + t + h,right
|
||||
r + i + v,river
|
||||
r + i + v + Dup,river
|
||||
r + i + m + t + n + p,important
|
||||
r + i + c + l + h,children
|
||||
|
@@ -13,7 +13,7 @@
|
||||
"GTM stands for Generative Text Menu and can be used to change your device's settings anywhere",
|
||||
"Ambidextrous Throwover (aka Mirror Mode) is a mode designed for one-handed use",
|
||||
"Chentry stands for character entry, or typing letter by letter on a chording enabled device",
|
||||
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjucations and more",
|
||||
"Chord modifiers are hard-coded (as of now) and can be used in the English language to add conjugations and more",
|
||||
"You can use 'cursor warping' by adding arrow key actions to a chord, for example to chord '()' with the cursor in the middle of the brackets",
|
||||
"An arpeggiate is a single key press that modifies the preceding chord. Modifiers can be arpeggiated",
|
||||
"Some actions are marked as a 'macro', which means the output is generated by a key sequence rather than a pure key press. Be cautious with those, as they can affect other keys when held together!",
|
||||
|
||||
@@ -1,118 +1,154 @@
|
||||
settings:
|
||||
0x1:
|
||||
title: Enable Serial Header
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x2:
|
||||
title: Enable Serial Logging
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x3:
|
||||
title: Enable Serial Debugging
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x4:
|
||||
title: Enable Serial Raw
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x5:
|
||||
title: Enable Serial Chord
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x6:
|
||||
title: Enable Serial Keyboard
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x7:
|
||||
title: Enable Serial Mouse
|
||||
description: boolean 0 or 1, default is 0
|
||||
0x11:
|
||||
title: Enable USB HID Keyboard
|
||||
description: boolean 0 or 1, default is 1
|
||||
0x12:
|
||||
title: Enable Character Entry
|
||||
description: boolean 0 or 1
|
||||
0x13:
|
||||
title: GUI-CTRL Swap Mode
|
||||
description: boolean 0 or 1; 1 swaps keymap 0 and 1. (CCL only)
|
||||
0x14:
|
||||
title: Key Scan Duration
|
||||
description: scan rate described in milliseconds; default is 2ms = 500Hz
|
||||
0x15:
|
||||
title: Key Debounce Press Duration
|
||||
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
|
||||
0x16:
|
||||
title: Key Debounce Release Duration
|
||||
description: debounce time in milliseconds; default is 7ms on the One and 20ms on the Lite
|
||||
0x17:
|
||||
title: Keyboard Output Character Microsecond Delays
|
||||
description: delay time in microseconds (one delay for press and again for release); default is 480us; max is 10240us; increments of 40us
|
||||
0x21:
|
||||
title: Enable USB HID Mouse
|
||||
description: boolean 0 or 1; default is 1
|
||||
0x22:
|
||||
title: Slow Mouse Speed
|
||||
description: pixels to move at the mouse poll rate; default for CC1 is 5 = 250px/s
|
||||
0x23:
|
||||
title: Fast Mouse Speed
|
||||
description: pixels to move at the mouse poll rate; default for CC1 is 25 = 1250px/s
|
||||
0x24:
|
||||
title: Enable Active Mouse
|
||||
description: boolean 0 or 1; moves mouse back and forth every 60s
|
||||
0x25:
|
||||
title: Mouse Scroll Speed
|
||||
description: default is 1; polls at 1/4th the rate of the mouse move updates
|
||||
0x26:
|
||||
title: Mouse Poll Duration
|
||||
description: poll rate described in milliseconds; default is 20ms = 50Hz
|
||||
0x31:
|
||||
title: Enable Chording
|
||||
description: boolean 0 or 1
|
||||
0x32:
|
||||
title: Enable Chording Character Counter Timeout
|
||||
description: boolean 0 or 1; default is 1
|
||||
0x33:
|
||||
title: Chording Character Counter Timeout Timer
|
||||
description: 0-255 deciseconds; default is 40 or 4.0 seconds
|
||||
0x34:
|
||||
title: Chord Detection Press Tolerance(ms)
|
||||
description: 1-50 milliseconds
|
||||
0x35:
|
||||
title: Chord Detection Release Tolerance(ms)
|
||||
description: 1-50 milliseconds
|
||||
0x41:
|
||||
title: Enable Spurring
|
||||
description: boolean 0 or 1; default is 1
|
||||
0x42:
|
||||
title: Enable Spurring Character Counter Timeout
|
||||
description: boolean 0 or 1; default is 1
|
||||
0x43:
|
||||
title: Spurring Character Counter Timeout Timer
|
||||
description: 0-255 seconds; default is 240
|
||||
0x51:
|
||||
title: Enable Arpeggiates
|
||||
description: boolean 0 or 1; default is 1
|
||||
0x54:
|
||||
title: Arpeggiate Tolerance
|
||||
description: in milliseconds; default 800ms
|
||||
0x61:
|
||||
title: Enable Compound Chording (coming soon)
|
||||
description: boolean 0 or 1; default is 0
|
||||
0x64:
|
||||
title: Compound Tolerance
|
||||
description: in milliseconds; default 1500ms
|
||||
0x81:
|
||||
title: LED Brightness
|
||||
description: 0-50 (CCL only); default is 5, which draws around 100 mA of current
|
||||
0x82:
|
||||
title: LED Color Code
|
||||
description: Color Codes to be listed (CCL only)
|
||||
0x83:
|
||||
title: Enable LED Key Highlight (coming soon)
|
||||
description: boolean 0 or 1 (CCL only)
|
||||
0x84:
|
||||
title: Enable LEDs
|
||||
description: boolean 0 or 1; default is 1 (CCL only)
|
||||
0x91:
|
||||
title: Operating System
|
||||
description: Operating system codes listed below
|
||||
0x92:
|
||||
title: Enable Realtime Feedback
|
||||
description: boolean 0 or 1; default is 1
|
||||
0x93:
|
||||
title: Enable CharaChorder Ready on startup
|
||||
description: boolean 0 or 1; default is 1
|
||||
- name: spurring
|
||||
description: |
|
||||
"Chording only" mode which tells your device to output chords on a press
|
||||
rather than a press & release. It also enables you to jump from one
|
||||
chord to another without releasing everything and can be activated in
|
||||
GTM or by chording both mirror keys. It can provide significant speed
|
||||
gains with chording, but also takes away the flexibility of character
|
||||
entry.
|
||||
items:
|
||||
- id: 0x41
|
||||
name: enable
|
||||
range: [0, 1]
|
||||
- id: 0x43
|
||||
name: character counter timeout
|
||||
range: [0, 240000]
|
||||
step: 1000
|
||||
scale: 0.001
|
||||
unit: s
|
||||
- name: arpeggiates
|
||||
description: |
|
||||
Allows chord modifiers to be hit after instead of with a chord,
|
||||
and enables select keys to be placed before auto-spaces.
|
||||
items:
|
||||
- id: 0x51
|
||||
name: enable
|
||||
range: [0, 1]
|
||||
- id: 0x54
|
||||
name: timeout
|
||||
range: [0, 2550]
|
||||
step: 10
|
||||
unit: ms
|
||||
- name: keyboard
|
||||
items:
|
||||
- id: 0x11
|
||||
name: enable
|
||||
range: [0, 1]
|
||||
- id: 0x12
|
||||
name: character entry
|
||||
range: [0, 1]
|
||||
- id: 0x13
|
||||
name: command option swap
|
||||
range: [0, 1]
|
||||
description: |
|
||||
Swaps ⌥ and ⌘ to make transitioning between Mac and other systems easier.
|
||||
- id: 0x14
|
||||
name: poll rate
|
||||
range: [0, 255]
|
||||
unit: Hz
|
||||
inverse: 1000
|
||||
- id: 0x15
|
||||
name: debounce press
|
||||
range: [0, 255]
|
||||
unit: ms
|
||||
- id: 0x16
|
||||
name: debounce release
|
||||
range: [0, 255]
|
||||
unit: ms
|
||||
- id: 0x17
|
||||
name: output delay
|
||||
range: [0, 10200]
|
||||
step: 40
|
||||
unit: µs
|
||||
- name: mouse
|
||||
items:
|
||||
- id: 0x21
|
||||
name: enable
|
||||
range: [0, 1]
|
||||
- id: 0x22
|
||||
name: slow speed
|
||||
range: [0, 255]
|
||||
unit: px
|
||||
- id: 0x23
|
||||
name: fast speed
|
||||
range: [0, 255]
|
||||
unit: px
|
||||
- id: 0x24
|
||||
name: caffeine
|
||||
range: [0, 1]
|
||||
description: |
|
||||
Keeps computer alive by moving the mouse back and forth one pixel every 60s
|
||||
- id: 0x25
|
||||
name: scroll speed
|
||||
range: [0, 255]
|
||||
unit: pg
|
||||
- id: 0x26
|
||||
name: poll rate
|
||||
range: [0, 255]
|
||||
unit: Hz
|
||||
inverse: 1000
|
||||
- name: chording
|
||||
items:
|
||||
- id: 0x31
|
||||
name: enable
|
||||
range: [0, 1]
|
||||
- id: 0x33
|
||||
name: auto delete timeout
|
||||
range: [0, 25500]
|
||||
step: 100
|
||||
- id: 0x34
|
||||
name: press tolerance
|
||||
description: |
|
||||
Scales with the number of chord inputs.
|
||||
range: [0, 255]
|
||||
unit: ms
|
||||
- id: 0x35
|
||||
name: release tolerance
|
||||
description: |
|
||||
Scales with the number of chord inputs.
|
||||
range: [0, 255]
|
||||
unit: ms
|
||||
- name: leds
|
||||
items:
|
||||
- id: 0x84
|
||||
name: enable
|
||||
range: [0, 1]
|
||||
- id: 0x81
|
||||
name: brightness
|
||||
range: [0, 50]
|
||||
- id: 0x82
|
||||
name: base color code
|
||||
enum:
|
||||
white: 0
|
||||
red: 1
|
||||
orange: 2
|
||||
yellow: 3
|
||||
charteuse: 4
|
||||
green: 5
|
||||
spring green: 6
|
||||
cyan: 7
|
||||
azure: 8
|
||||
blue: 9
|
||||
violet: 10
|
||||
magenta: 11
|
||||
rose: 12
|
||||
rainbow: 13
|
||||
- id: 0x83
|
||||
name: highlight
|
||||
range: [0, 1]
|
||||
- name: misc
|
||||
items:
|
||||
- id: 0x91
|
||||
name: operating system
|
||||
enum:
|
||||
windows: 0
|
||||
mac: 1
|
||||
linux: 2
|
||||
ios: 3
|
||||
android: 4
|
||||
- id: 0x92
|
||||
name: GTM realtime feedback
|
||||
range: [0, 1]
|
||||
- id: 0x93
|
||||
name: startup message
|
||||
range: [0, 1]
|
||||
|
||||
@@ -107,32 +107,32 @@ export function restoreFromFile(
|
||||
}
|
||||
|
||||
changes.update((changes) => {
|
||||
changes.push(
|
||||
changes.push([
|
||||
...getChangesFromChordFile(recent[0]),
|
||||
...getChangesFromLayoutFile(recent[1]),
|
||||
...getChangesFromSettingsFile(recent[2]),
|
||||
);
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "chords": {
|
||||
changes.update((changes) => {
|
||||
changes.push(...getChangesFromChordFile(file));
|
||||
changes.push(getChangesFromChordFile(file));
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "layout": {
|
||||
changes.update((changes) => {
|
||||
changes.push(...getChangesFromLayoutFile(file));
|
||||
changes.push(getChangesFromLayoutFile(file));
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "settings": {
|
||||
changes.update((changes) => {
|
||||
changes.push(...getChangesFromSettingsFile(file));
|
||||
changes.push(getChangesFromSettingsFile(file));
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
|
||||
20
src/lib/charrecorder/TrackText.svelte
Normal file
20
src/lib/charrecorder/TrackText.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import type { ReplayPlayer } from "./core/player";
|
||||
import { TextPlugin } from "./core/plugins/text";
|
||||
|
||||
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||
|
||||
let { text = $bindable("") } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!player.player) return;
|
||||
const tracker = new TextPlugin();
|
||||
tracker.register(player.player);
|
||||
const unsubscribe = tracker.subscribe((value) => {
|
||||
text = value;
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
20
src/lib/charrecorder/TrackWpm.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { WpmReplayPlugin } from "./core/plugins/wpm";
|
||||
import type { ReplayPlayer } from "./core/player";
|
||||
|
||||
const player: { player: ReplayPlayer | undefined } = getContext("replay");
|
||||
|
||||
let { wpm = $bindable(0) } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!player.player) return;
|
||||
const tracker = new WpmReplayPlugin();
|
||||
tracker.register(player.player);
|
||||
const unsubscribe = tracker.subscribe((value) => {
|
||||
wpm = value;
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
@@ -85,7 +85,6 @@ export class ChordsReplayPlugin
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(this.tokens);
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
if (replay.stepper.held.size === 0) {
|
||||
|
||||
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
23
src/lib/charrecorder/core/plugins/text.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReplayPlayer } from "../player";
|
||||
import type { ReplayPlugin, StoreContract } from "../types";
|
||||
|
||||
export class TextPlugin implements StoreContract<string>, ReplayPlugin {
|
||||
private subscribers = new Set<(value: string) => void>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe(() => {
|
||||
if (this.subscribers.size === 0) return;
|
||||
const text = replay.stepper.text
|
||||
.filter((it) => it.source !== "ghost")
|
||||
.map((it) => it.text)
|
||||
.join("");
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
subscribe(subscription: (value: string) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export class TextRenderer {
|
||||
);
|
||||
this.cursorNode.setAttribute("x", "0");
|
||||
this.cursorNode.setAttribute("y", "0");
|
||||
this.cursorNode.setAttribute("class", "cursor");
|
||||
this.svg.appendChild(this.cursorNode);
|
||||
}
|
||||
|
||||
|
||||
73
src/lib/chat/MatrixRooms.svelte
Normal file
73
src/lib/chat/MatrixRooms.svelte
Normal file
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import type { Room } from "matrix-js-sdk";
|
||||
import { matrixClient, currentRoomId } from "./chat";
|
||||
|
||||
let { rooms }: { rooms: Room[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="rooms">
|
||||
{#each $matrixClient.getRooms() as room}
|
||||
{@const avatar = room.getMxcAvatarUrl()}
|
||||
<button
|
||||
class:active={$currentRoomId === room.roomId}
|
||||
class="room"
|
||||
onclick={() => ($currentRoomId = room.roomId)}
|
||||
>
|
||||
{#if avatar}
|
||||
<img
|
||||
alt={room.name}
|
||||
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{:else}
|
||||
<div>#</div>
|
||||
{/if}
|
||||
<div>{room.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#await $matrixClient.publicRooms()}
|
||||
<div>Loading...</div>
|
||||
{:then rooms}
|
||||
{#each rooms.chunk as room}
|
||||
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
|
||||
<div>#</div>
|
||||
<div>{room.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:catch error}
|
||||
<div>{error.message}</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rooms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
padding-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
padding-block: 2px;
|
||||
min-height: 0;
|
||||
height: unset;
|
||||
padding-inline: 16px;
|
||||
padding-block: 4px;
|
||||
border-radius: 8px;
|
||||
width: 100%;
|
||||
|
||||
&.active {
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -177,10 +177,6 @@
|
||||
<style lang="scss">
|
||||
$border-radius: 16px;
|
||||
|
||||
h2 {
|
||||
height: min-content;
|
||||
}
|
||||
|
||||
.input {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
flex-grow: 1;
|
||||
@@ -223,22 +219,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.back-to-present {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.scroll-controls {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
min-height: 16px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
var(--md-sys-color-background)
|
||||
);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
109
src/lib/chat/chat-rx.ts
Normal file
109
src/lib/chat/chat-rx.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { derived, writable, type Writable } from "svelte/store";
|
||||
import type {
|
||||
ClientEvent,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
argbFromHex,
|
||||
type CustomColorGroup,
|
||||
} from "@material/material-color-utilities";
|
||||
import type { UserTheme } from "$lib/preferences";
|
||||
import { MatrixRx } from "./matrix-rx/client";
|
||||
|
||||
export const matrixClient: Writable<MatrixClient> = writable();
|
||||
|
||||
export const isLoggedIn: Writable<boolean> = writable(false);
|
||||
|
||||
export const matrix = derived(
|
||||
[matrixClient, isLoggedIn],
|
||||
([matrixClient, isLoggedIn]) =>
|
||||
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
|
||||
);
|
||||
|
||||
export const currentRoomId = persistentWritable<string | null>(
|
||||
"currentRoomId",
|
||||
null,
|
||||
);
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
export async function initMatrixClient() {
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
const cryptoStore = new IndexedDBCryptoStore(
|
||||
window.indexedDB,
|
||||
"matrix-crypto",
|
||||
);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
console.log("store");
|
||||
await store.startup();
|
||||
console.log("cryptoStore");
|
||||
await cryptoStore.startup();
|
||||
console.log("client");
|
||||
await client.startClient();
|
||||
client.once("sync" as ClientEvent.Sync, () => {
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
storeLogin(await client.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
}
|
||||
|
||||
matrixClient.set(client);
|
||||
console.log("done");
|
||||
}
|
||||
|
||||
export function memberColor(
|
||||
member: RoomMember,
|
||||
theme: UserTheme,
|
||||
): CustomColorGroup {
|
||||
let hash = 0;
|
||||
member.userId.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
||||
{ value: argbFromHex(color), name: "member", blend: true },
|
||||
]).customColors.find((c) => c.color.name === "member")!;
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
import { derived, writable, type Writable } from "svelte/store";
|
||||
import type {
|
||||
ClientEvent,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
@@ -12,83 +7,14 @@ import {
|
||||
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,
|
||||
|
||||
@@ -191,6 +191,14 @@
|
||||
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"]}
|
||||
@@ -231,6 +239,21 @@
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@keyframes rocket {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate(4px, -4px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
.icon.rocket {
|
||||
animation: rocket 2s;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
@@ -242,6 +265,7 @@
|
||||
display: flex;
|
||||
z-index: 100;
|
||||
|
||||
a,
|
||||
button {
|
||||
font-size: 16px;
|
||||
width: 24px;
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
let info = $derived(
|
||||
typeof action === "number"
|
||||
? (KEYMAP_CODES.get(action) ?? { code: action })
|
||||
? ($KEYMAP_CODES.get(action) ?? { code: action })
|
||||
: action,
|
||||
);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
@@ -84,17 +84,6 @@
|
||||
border-right-width: 3px;
|
||||
}
|
||||
|
||||
.dynamic {
|
||||
padding: 4px;
|
||||
border-radius: 1px;
|
||||
min-width: 8px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
|
||||
&.inline {
|
||||
padding: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-kbd {
|
||||
margin-inline-end: 2px;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
$props();
|
||||
|
||||
let key = $derived(
|
||||
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
||||
(typeof id === "number" ? ($KEYMAP_CODES.get(id) ?? id) : id) as
|
||||
| number
|
||||
| KeyInfo,
|
||||
);
|
||||
|
||||
51
src/lib/components/AnimatedNumber.svelte
Normal file
51
src/lib/components/AnimatedNumber.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { fade, slide } from "svelte/transition";
|
||||
|
||||
let { value }: { value: number } = $props();
|
||||
|
||||
let digits: number[] = $derived(value.toString().split("").map(Number));
|
||||
const nums = Array.from({ length: 10 }, (_, i) => i);
|
||||
</script>
|
||||
|
||||
<div class="digits" style:width="{digits.length}ch">
|
||||
{#each digits as digit, i (digits.length - i)}
|
||||
<div
|
||||
class="digit-wrapper"
|
||||
style:right="{digits.length - 1 - i}ch"
|
||||
transition:fade
|
||||
>
|
||||
{#each nums as num (num)}
|
||||
<div
|
||||
class="digit"
|
||||
style:transform="translateY({(digit - num) / 4}em)"
|
||||
style:opacity={digit === num ? 1 : 0}
|
||||
>
|
||||
{num}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.digits {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
transition: width 500ms ease;
|
||||
}
|
||||
|
||||
.digit-wrapper {
|
||||
display: inline-grid;
|
||||
height: 1em;
|
||||
width: 1ch;
|
||||
}
|
||||
|
||||
.digit {
|
||||
display: inline-block;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
transition:
|
||||
transform 500ms ease,
|
||||
opacity 500ms ease;
|
||||
}
|
||||
</style>
|
||||
@@ -3,12 +3,14 @@
|
||||
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 { action } from "$lib/title";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
let {
|
||||
currentAction = undefined,
|
||||
@@ -27,10 +29,13 @@
|
||||
});
|
||||
|
||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||
createIndex();
|
||||
|
||||
async function createIndex() {
|
||||
for (const [, action] of KEYMAP_CODES) {
|
||||
$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 || ""} ${
|
||||
@@ -42,7 +47,7 @@
|
||||
|
||||
async function search() {
|
||||
results = (await index!.searchAsync(searchBox.value)) as number[];
|
||||
exact = KEYMAP_IDS.get(searchBox.value)?.code;
|
||||
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||
code = Number(searchBox.value);
|
||||
}
|
||||
|
||||
@@ -82,7 +87,7 @@
|
||||
|
||||
let searchBox: HTMLInputElement;
|
||||
let resultList: HTMLUListElement;
|
||||
let filter = $state(new Set<number>());
|
||||
let filter: Set<number> | undefined = $state(undefined);
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={keyboardNavigation} />
|
||||
@@ -127,7 +132,7 @@
|
||||
bind:group={filter}
|
||||
/></label
|
||||
>
|
||||
{#each KEYMAP_CATEGORIES as category}
|
||||
{#each $KEYMAP_CATEGORIES as category}
|
||||
<label
|
||||
>{category.name}<input
|
||||
name="category"
|
||||
@@ -167,7 +172,7 @@
|
||||
{#if filter !== undefined || results.length > 0}
|
||||
{@const resultValue =
|
||||
results.length === 0
|
||||
? Array.from(KEYMAP_CODES, ([it]) => it)
|
||||
? Array.from($KEYMAP_CODES, ([it]) => it)
|
||||
: results}
|
||||
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
||||
<li><ActionListItem {id} onclick={() => select(id)} /></li>
|
||||
|
||||
@@ -137,12 +137,14 @@
|
||||
},
|
||||
onselect(action) {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Layout,
|
||||
id: keyInfo.id,
|
||||
layer: get(activeLayer),
|
||||
action,
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Layout,
|
||||
id: keyInfo.id,
|
||||
layer: get(activeLayer),
|
||||
action,
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
closed();
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
||||
"highlight-action",
|
||||
);
|
||||
|
||||
let {
|
||||
key,
|
||||
@@ -35,7 +38,7 @@
|
||||
isApplied: true,
|
||||
}}
|
||||
{@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 tooltip =
|
||||
(title ?? id ?? `0x${code.toString(16)}`) +
|
||||
@@ -47,6 +50,7 @@
|
||||
]}
|
||||
{@const hasIcon = !dynamicMapping && !!icon}
|
||||
<text
|
||||
class:hidden={$currentAction?.has(actionId) === false}
|
||||
fill={isApplied ? "currentcolor" : "var(--md-sys-color-primary)"}
|
||||
font-weight={isApplied ? "" : "bold"}
|
||||
text-anchor="middle"
|
||||
@@ -96,4 +100,8 @@
|
||||
text:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
text.hidden {
|
||||
opacity: 0.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,11 +8,14 @@
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
} from "svelte/elements";
|
||||
import { type Writable } from "svelte/store";
|
||||
|
||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||
"visual-layout-config",
|
||||
);
|
||||
|
||||
const highlight = getContext<Writable<Set<number>> | undefined>("highlight");
|
||||
|
||||
let {
|
||||
i,
|
||||
key,
|
||||
@@ -35,6 +38,8 @@
|
||||
|
||||
<g
|
||||
class="key-group"
|
||||
class:highlight={$highlight?.has(key.id) === true}
|
||||
class:faded={$highlight?.has(key.id) === false}
|
||||
{onclick}
|
||||
{onkeypress}
|
||||
{onfocusin}
|
||||
@@ -131,12 +136,14 @@
|
||||
stroke-opacity: 0.3;
|
||||
}
|
||||
|
||||
g.faded,
|
||||
g:hover {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
transition: opacity #{$transition} ease;
|
||||
}
|
||||
|
||||
g.highlight,
|
||||
g:focus-within {
|
||||
color: var(--md-sys-color-primary);
|
||||
outline: none;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||
import { action } from "$lib/title";
|
||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
||||
import { fade } from "svelte/transition";
|
||||
import { fade, fly } from "svelte/transition";
|
||||
import { restoreFromFile } from "$lib/backup/backup";
|
||||
|
||||
let device = $derived($serialPort?.device);
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
@@ -58,6 +59,16 @@
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
{#if $deviceMeta?.factoryDefaults?.layout}
|
||||
<button
|
||||
use:action={{ title: "Reset Layout" }}
|
||||
transition:fly={{ x: -8 }}
|
||||
class="icon reset-layout"
|
||||
onclick={() =>
|
||||
restoreFromFile($deviceMeta!.factoryDefaults!.layout)}
|
||||
>reset_wrench</button
|
||||
>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<GenericLayout {visualLayout} />
|
||||
@@ -113,7 +124,7 @@
|
||||
}
|
||||
|
||||
&:first-child,
|
||||
&:last-child {
|
||||
&:nth-child(3) {
|
||||
aspect-ratio: unset;
|
||||
height: unset;
|
||||
}
|
||||
@@ -124,12 +135,21 @@
|
||||
border-radius: 16px 0 0 16px;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&:nth-child(3) {
|
||||
margin-inline-start: -8px;
|
||||
padding-inline: 24px 4px;
|
||||
border-radius: 0 16px 16px 0;
|
||||
}
|
||||
|
||||
&.reset-layout {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 0;
|
||||
transform: translate(100%, -50%);
|
||||
background: none;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: 900;
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
import ChordActionEdit from "../../routes/(app)/config/chords/ChordActionEdit.svelte";
|
||||
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
abortTitle,
|
||||
confirmTitle,
|
||||
actions = [],
|
||||
chord,
|
||||
onabort,
|
||||
onconfirm,
|
||||
}: {
|
||||
@@ -15,7 +16,7 @@
|
||||
message?: string;
|
||||
abortTitle: string;
|
||||
confirmTitle: string;
|
||||
actions: number[];
|
||||
chord: Chord & { deleted: boolean };
|
||||
onabort: () => void;
|
||||
onconfirm: () => void;
|
||||
} = $props();
|
||||
@@ -26,7 +27,20 @@
|
||||
{#if message}
|
||||
<p>{@html message}</p>
|
||||
{/if}
|
||||
<p><ActionString {actions} /></p>
|
||||
<p>
|
||||
<ChordActionEdit
|
||||
chord={{
|
||||
...chord,
|
||||
isApplied: false,
|
||||
phraseChanged: false,
|
||||
actionsChanged: false,
|
||||
sortBy: "",
|
||||
id: chord.actions,
|
||||
}}
|
||||
interactive={false}
|
||||
onsubmit={() => {}}
|
||||
/>
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<button onclick={onabort}>{abortTitle}</button>
|
||||
<button class="primary" onclick={onconfirm}>{confirmTitle}</button>
|
||||
|
||||
106
src/lib/dialogs/ConnectionFailed.svelte
Normal file
106
src/lib/dialogs/ConnectionFailed.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
|
||||
let {
|
||||
message,
|
||||
onclose,
|
||||
}: {
|
||||
message: string;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
{#if !navigator.serial}
|
||||
<h1>Incompatible Browser</h1>
|
||||
<p>Your browser does not support the Web Serial API.</p>
|
||||
<p>Supported browsers are any Chromium based Browsers, such as</p>
|
||||
<ul>
|
||||
<li>Google Chrome</li>
|
||||
<li>Microsoft Edge</li>
|
||||
<li>Opera</li>
|
||||
<li>Brave</li>
|
||||
</ul>
|
||||
{:else}
|
||||
<h1>Connection Failed</h1>
|
||||
<pre>{message}</pre>
|
||||
<h2>Troubleshooting Steps</h2>
|
||||
<ul>
|
||||
{#if navigator.userAgent.includes("Linux")}
|
||||
<li>
|
||||
<p>{@html $LL.deviceManager.LINUX_PERMISSIONS()}</p>
|
||||
<p>
|
||||
In most cases you can simply follow the <a
|
||||
target="_blank"
|
||||
href="https://docs.arduino.cc/software/ide-v1/tutorials/Linux#please-read"
|
||||
>Arduino Guide</a
|
||||
> on serial port permissions.
|
||||
</p>
|
||||
<p>Special systems:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://wiki.archlinux.org/title/Arduino#Accessing_serial"
|
||||
>Arch and Arch-based like Manjaro or EndeavourOS</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://gist.github.com/CMCDragonkai/d00201ec143c9f749fc49533034e5009?permalink_comment_id=4670311#gistcomment-4670311"
|
||||
>NixOS</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://wiki.gentoo.org/wiki/Arduino#Grant_access_to_non-root_users"
|
||||
>Gentoo</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
<li>
|
||||
You device may be pre-CCOS. refer to <a
|
||||
target="_blank"
|
||||
href="https://docs.charachorder.com/CCOS.html#upgrade-to-ccos"
|
||||
>Upgrade to CCOS</a
|
||||
> on how to upgrade your device.
|
||||
</li>
|
||||
<li>
|
||||
Some USB cables or hubs can cause issues, try directly connecting to a
|
||||
port on your computer with the included cable.
|
||||
</li>
|
||||
</ul>
|
||||
{/if}
|
||||
<div class="buttons">
|
||||
<button class="primary" onclick={onclose}>Close</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<style lang="scss">
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
pre {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline;
|
||||
color: var(--md-sys-color-primary);
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +1,34 @@
|
||||
import ConfirmDialog from "$lib/dialogs/ConfirmDialog.svelte";
|
||||
import { mount, unmount } from "svelte";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
|
||||
export async function askForConfirmation(
|
||||
title: string,
|
||||
message: string,
|
||||
confirmTitle: string,
|
||||
abortTitle: string,
|
||||
actions: number[],
|
||||
chord: Chord,
|
||||
): Promise<boolean> {
|
||||
const dialog = new ConfirmDialog({
|
||||
let resolvePromise: (value: boolean) => void;
|
||||
const resultPromise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
const dialog = mount(ConfirmDialog, {
|
||||
target: document.body,
|
||||
props: {
|
||||
title,
|
||||
message,
|
||||
confirmTitle,
|
||||
abortTitle,
|
||||
actions,
|
||||
chord,
|
||||
onabort: () => resolvePromise(false),
|
||||
onconfirm: () => resolvePromise(true),
|
||||
},
|
||||
});
|
||||
|
||||
let resolvePromise: (value: boolean) => void;
|
||||
const resultPromise = new Promise<boolean>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
dialog.$on("abort", () => resolvePromise(false));
|
||||
dialog.$on("confirm", () => resolvePromise(true));
|
||||
|
||||
const result = await resultPromise;
|
||||
dialog.$destroy();
|
||||
unmount(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
24
src/lib/dialogs/connection-failed-dialog.ts
Normal file
24
src/lib/dialogs/connection-failed-dialog.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import ConnectionFailed from "$lib/dialogs/ConnectionFailed.svelte";
|
||||
import { mount, unmount } from "svelte";
|
||||
|
||||
export async function showConnectionFailedDialog(
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
let resolvePromise: (value: void) => void;
|
||||
const resultPromise = new Promise<void>((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
|
||||
const dialog = mount(ConnectionFailed, {
|
||||
target: document.body,
|
||||
props: {
|
||||
message,
|
||||
onclose: () => resolvePromise(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resultPromise;
|
||||
unmount(dialog);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -73,8 +73,9 @@
|
||||
font-stretch: 62.5% 100%;
|
||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
||||
format("woff2-variations");
|
||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
|
||||
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,7 +88,8 @@
|
||||
font-stretch: 62.5% 100%;
|
||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
||||
format("woff2-variations");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
||||
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
|
||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
|
||||
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
150
src/lib/meta/meta-storage.ts
Normal file
150
src/lib/meta/meta-storage.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { RawVersionMeta, SettingsMeta, VersionMeta } from "./types/meta";
|
||||
import type { Listing } from "./types/listing";
|
||||
import type { KeymapCategory } from "./types/actions";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
let lock: Promise<void> | undefined = undefined;
|
||||
|
||||
export async function getMeta(
|
||||
device: string,
|
||||
version: string,
|
||||
fetch: typeof window.fetch = window.fetch,
|
||||
): Promise<VersionMeta> {
|
||||
while (lock) await lock;
|
||||
let resolveLock!: () => void;
|
||||
lock = new Promise((resolve) => (resolveLock = resolve));
|
||||
|
||||
try {
|
||||
if (!browser) return fetchMeta(device, version, fetch);
|
||||
|
||||
const dbRequest = indexedDB.open("version-meta", 4);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||
dbRequest.onerror = () => reject(dbRequest.error);
|
||||
dbRequest.onupgradeneeded = () => {
|
||||
const db = dbRequest.result;
|
||||
if (db.objectStoreNames.contains("meta")) {
|
||||
db.deleteObjectStore("meta");
|
||||
}
|
||||
db.createObjectStore("meta", { keyPath: ["device", "version"] });
|
||||
};
|
||||
});
|
||||
console.log("upgrading version meta db");
|
||||
|
||||
try {
|
||||
const readTransaction = db.transaction(["meta"], "readonly");
|
||||
const store = readTransaction.objectStore("meta");
|
||||
const itemRequest = store.get([device, version]);
|
||||
const item = await new Promise<VersionMeta | undefined>((resolve) => {
|
||||
itemRequest.onsuccess = () => resolve(itemRequest.result);
|
||||
itemRequest.onerror = () => resolve(undefined);
|
||||
});
|
||||
|
||||
if (item) return item;
|
||||
|
||||
const meta = await fetchMeta(device, version);
|
||||
|
||||
const putTransaction = db.transaction(["meta"], "readwrite");
|
||||
const putStore = putTransaction.objectStore("meta");
|
||||
const putRequest = putStore.put(meta);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
putRequest.onsuccess = () => resolve();
|
||||
putRequest.onerror = () => reject(putRequest.error);
|
||||
});
|
||||
putTransaction.commit();
|
||||
|
||||
return meta;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
resolveLock();
|
||||
lock = undefined;
|
||||
}
|
||||
return fetchMeta(device, version, fetch);
|
||||
}
|
||||
|
||||
async function fetchMeta(
|
||||
device: string,
|
||||
version: string,
|
||||
fetch: typeof window.fetch = window.fetch,
|
||||
): Promise<VersionMeta> {
|
||||
const path = `${import.meta.env.VITE_FIRMWARE_URL}/${device}/${version}`;
|
||||
const files: Listing[] = await fetch(`${path}/`)
|
||||
.then((res) => res.json())
|
||||
.catch(() => []);
|
||||
const meta: Partial<RawVersionMeta> | undefined = files.some(
|
||||
(entry) => entry.type === "file" && entry.name === "meta.json",
|
||||
)
|
||||
? await fetch(`${path}/meta.json`).then((res) => res.json())
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
version: meta?.version ?? version,
|
||||
device: meta?.target ?? device,
|
||||
date: new Date(meta?.git_date ?? files[0]?.mtime ?? ""),
|
||||
path,
|
||||
commit: meta?.git_commit ?? undefined,
|
||||
dirty: meta?.git_is_dirty ?? false,
|
||||
public: meta?.public_build ?? !version.includes("+"),
|
||||
developmentBuild: (meta?.development_mode ?? 0) === 1,
|
||||
factoryDefaults: meta?.factory_defaults
|
||||
? {
|
||||
layout: await fetch(`${path}/${meta.factory_defaults.layout}`).then(
|
||||
(it) => it.json(),
|
||||
),
|
||||
settings: await fetch(
|
||||
`${path}/${meta.factory_defaults.settings}`,
|
||||
).then((it) => it.json()),
|
||||
chords: Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(meta.factory_defaults.chords).map(
|
||||
async ([name, file]) => [
|
||||
name,
|
||||
await fetch(`${path}/${file}`).then((it) => it.json()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
settings: await (meta?.settings
|
||||
? fetch(`${path}/${meta.settings}`).then((it) => it.json())
|
||||
: import("$lib/assets/settings.yml")
|
||||
.then((it) => (it as any).default)
|
||||
.then((settings: SettingsMeta[]) => {
|
||||
if (!device.startsWith("lite_")) {
|
||||
settings = settings.filter((it) => it.name !== "leds");
|
||||
}
|
||||
return settings;
|
||||
})),
|
||||
changelog: await (meta?.changelog
|
||||
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
|
||||
: {}),
|
||||
actions: await (meta?.actions
|
||||
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
||||
: Promise.all<KeymapCategory[]>(
|
||||
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
|
||||
async (load) => load().then((it) => (it as any).default),
|
||||
),
|
||||
)),
|
||||
update: {
|
||||
uf2:
|
||||
meta?.update?.uf2 ??
|
||||
files.find(
|
||||
(entry) => entry.type === "file" && entry.name === "CURRENT.UF2",
|
||||
)?.name ??
|
||||
undefined,
|
||||
ota:
|
||||
meta?.update?.ota ??
|
||||
files.find(
|
||||
(entry) => entry.type === "file" && entry.name === "firmware.bin",
|
||||
)?.name ??
|
||||
undefined,
|
||||
esptool: meta?.update?.esptool ?? undefined,
|
||||
},
|
||||
spiFlash: meta?.spi_flash ?? undefined,
|
||||
};
|
||||
}
|
||||
19
src/lib/meta/types/actions.ts
Normal file
19
src/lib/meta/types/actions.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface KeymapCategory {
|
||||
name: string;
|
||||
description: string;
|
||||
icon?: string;
|
||||
display?: string;
|
||||
type?: "unassigned";
|
||||
actions: Record<number, Partial<ActionInfo>>;
|
||||
}
|
||||
|
||||
export interface ActionInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
display: string;
|
||||
description: string;
|
||||
variant: "left" | "right";
|
||||
variantOf: number;
|
||||
keyCode: string;
|
||||
}
|
||||
108
src/lib/meta/types/meta.ts
Normal file
108
src/lib/meta/types/meta.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
CharaChordFile,
|
||||
CharaLayoutFile,
|
||||
CharaSettingsFile,
|
||||
} from "$lib/share/chara-file";
|
||||
import type { KeymapCategory } from "./actions";
|
||||
|
||||
export interface SettingsMeta {
|
||||
name: string;
|
||||
description?: string;
|
||||
items: SettingsItemMeta[];
|
||||
}
|
||||
|
||||
export interface SettingsItemMeta {
|
||||
id: number;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
range: [number, number];
|
||||
step?: number;
|
||||
unit?: string;
|
||||
inverse?: number;
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
export interface ChangelogEntry {
|
||||
summary: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Changelog {
|
||||
features: ChangelogEntry[];
|
||||
fixes: ChangelogEntry[];
|
||||
}
|
||||
|
||||
export interface RawVersionMeta {
|
||||
version: string;
|
||||
target: string;
|
||||
git_commit: string;
|
||||
git_is_dirty: boolean;
|
||||
git_date: string;
|
||||
public_build: boolean;
|
||||
development_mode: number;
|
||||
actions: string;
|
||||
settings: string;
|
||||
changelog: string;
|
||||
factory_defaults: {
|
||||
layout: string;
|
||||
settings: string;
|
||||
chords: Record<string, string>;
|
||||
};
|
||||
update: {
|
||||
ota: string | null;
|
||||
uf2: string | null;
|
||||
esptool: EspToolData | null;
|
||||
};
|
||||
files: string[];
|
||||
spi_flash: SPIFlashInfo | null;
|
||||
}
|
||||
|
||||
export interface VersionMeta {
|
||||
version: string;
|
||||
device: string;
|
||||
path: string;
|
||||
date: Date;
|
||||
public: boolean;
|
||||
commit?: string;
|
||||
dirty: boolean;
|
||||
developmentBuild: boolean;
|
||||
actions: KeymapCategory[];
|
||||
settings: SettingsMeta[];
|
||||
changelog: Changelog;
|
||||
factoryDefaults?: {
|
||||
layout: CharaLayoutFile;
|
||||
settings: CharaSettingsFile;
|
||||
chords: Record<string, CharaChordFile>;
|
||||
};
|
||||
update: {
|
||||
ota?: string;
|
||||
uf2?: string;
|
||||
esptool?: EspToolData;
|
||||
};
|
||||
spiFlash?: SPIFlashInfo;
|
||||
}
|
||||
|
||||
export interface SPIFlashInfo {
|
||||
type: string;
|
||||
size: string;
|
||||
connection: SPIConnection;
|
||||
}
|
||||
|
||||
export interface SPIConnection {
|
||||
clk: number;
|
||||
q: number;
|
||||
d: number;
|
||||
hd: number;
|
||||
cs: number;
|
||||
}
|
||||
|
||||
export interface EspToolData {
|
||||
chip: string;
|
||||
baud: string;
|
||||
before: string;
|
||||
after: string;
|
||||
flash_mode: string;
|
||||
flash_freq: string;
|
||||
flash_size: string;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
@@ -5,7 +5,8 @@ import type { Writable } from "svelte/store";
|
||||
import type { CharaLayout } from "$lib/serialization/layout";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { userPreferences } from "$lib/preferences";
|
||||
import settingInfo from "$lib/assets/settings.yml";
|
||||
import { getMeta } from "$lib/meta/meta-storage";
|
||||
import type { VersionMeta } from "$lib/meta/types/meta";
|
||||
|
||||
export const serialPort = writable<CharaDevice | undefined>();
|
||||
|
||||
@@ -47,6 +48,8 @@ export const syncStatus: Writable<
|
||||
"done" | "error" | "downloading" | "uploading"
|
||||
> = writable("done");
|
||||
|
||||
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
|
||||
|
||||
export interface ProgressInfo {
|
||||
max: number;
|
||||
current: number;
|
||||
@@ -65,13 +68,19 @@ export async function initSerial(manual = false, withSync = true) {
|
||||
export async function sync() {
|
||||
const device = get(serialPort);
|
||||
if (!device) return;
|
||||
const chordCount = await device.getChordCount();
|
||||
syncStatus.set("downloading");
|
||||
const meta = await getMeta(
|
||||
`${device.device}_${device.chipset}`.toLowerCase(),
|
||||
device.version.toString(),
|
||||
);
|
||||
deviceMeta.set(meta);
|
||||
const chordCount = await device.getChordCount();
|
||||
|
||||
const max =
|
||||
Object.keys(settingInfo["settings"]).length +
|
||||
device.keyCount * 3 +
|
||||
chordCount;
|
||||
const maxSettings = meta.settings
|
||||
.map((it) => it.items.length)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const max = maxSettings + device.keyCount * 3 + chordCount;
|
||||
let current = 0;
|
||||
syncProgress.set({ max, current });
|
||||
function progressTick() {
|
||||
@@ -80,12 +89,12 @@ export async function sync() {
|
||||
}
|
||||
|
||||
const parsedSettings: number[] = [];
|
||||
for (const key in settingInfo["settings"]) {
|
||||
try {
|
||||
parsedSettings[Number.parseInt(key)] = await device.getSetting(
|
||||
Number.parseInt(key),
|
||||
);
|
||||
} catch {}
|
||||
for (const category of meta.settings) {
|
||||
for (const setting of category.items) {
|
||||
try {
|
||||
parsedSettings[setting.id] = await device.getSetting(setting.id);
|
||||
} catch {}
|
||||
}
|
||||
progressTick();
|
||||
}
|
||||
deviceSettings.set(parsedSettings);
|
||||
|
||||
@@ -9,15 +9,17 @@ import {
|
||||
stringifyPhrase,
|
||||
} from "$lib/serial/chord";
|
||||
import { browser } from "$app/environment";
|
||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||
|
||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }], // TODO: remove this after everyone has migrated
|
||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
|
||||
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
||||
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
|
||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
||||
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
||||
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
|
||||
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
|
||||
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
|
||||
]);
|
||||
|
||||
const KEY_COUNTS = {
|
||||
@@ -134,17 +136,16 @@ export class CharaDevice {
|
||||
await this.port.close();
|
||||
|
||||
this.version = new SemVer(
|
||||
await this.send(1, "VERSION").then(([version]) => version),
|
||||
await this.send(1, ["VERSION"]).then(([version]) => version),
|
||||
);
|
||||
const [company, device, chipset] = await this.send(3, "ID");
|
||||
const [company, device, chipset] = await this.send(3, ["ID"]);
|
||||
this.company = company as typeof this.company;
|
||||
this.device = device as typeof this.device;
|
||||
this.chipset = chipset as typeof this.chipset;
|
||||
this.keyCount = KEY_COUNTS[this.device];
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
console.error(e);
|
||||
throw e;
|
||||
await showConnectionFailedDialog(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,9 +186,12 @@ export class CharaDevice {
|
||||
});
|
||||
}
|
||||
|
||||
private async internalRead() {
|
||||
private async internalRead(timeoutMs: number | undefined) {
|
||||
try {
|
||||
const { value } = await timeout(this.reader.read(), 5000);
|
||||
const { value } =
|
||||
timeoutMs !== undefined
|
||||
? await timeout(this.reader.read(), timeoutMs)
|
||||
: await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
@@ -278,14 +282,15 @@ export class CharaDevice {
|
||||
*/
|
||||
async send<T extends number>(
|
||||
expectedLength: T,
|
||||
...command: string[]
|
||||
command: string[],
|
||||
timeout: number | undefined = 5000,
|
||||
): Promise<LengthArray<string, T>> {
|
||||
return this.runWith(async (send, read) => {
|
||||
await send(...command);
|
||||
const commandString = command
|
||||
.join(" ")
|
||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||
const readResult = await read();
|
||||
const readResult = await read(timeout);
|
||||
if (readResult === undefined) {
|
||||
console.error("No response");
|
||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||
@@ -307,7 +312,7 @@ export class CharaDevice {
|
||||
}
|
||||
|
||||
async getChordCount(): Promise<number> {
|
||||
const [count] = await this.send(1, "CML C0");
|
||||
const [count] = await this.send(1, ["CML", "C0"]);
|
||||
return Number.parseInt(count);
|
||||
}
|
||||
|
||||
@@ -315,7 +320,11 @@ export class CharaDevice {
|
||||
* Retrieves a chord by index
|
||||
*/
|
||||
async getChord(index: number | number[]): Promise<Chord> {
|
||||
const [actions, phrase] = await this.send(2, `CML C1 ${index}`);
|
||||
const [actions, phrase] = await this.send(2, [
|
||||
"CML",
|
||||
"C1",
|
||||
index.toString(),
|
||||
]);
|
||||
return {
|
||||
actions: parseChordActions(actions),
|
||||
phrase: parsePhrase(phrase),
|
||||
@@ -326,29 +335,30 @@ export class CharaDevice {
|
||||
* Retrieves the phrase for a set of actions
|
||||
*/
|
||||
async getChordPhrase(actions: number[]): Promise<number[] | undefined> {
|
||||
const [phrase] = await this.send(
|
||||
1,
|
||||
`CML C2 ${stringifyChordActions(actions)}`,
|
||||
);
|
||||
const [phrase] = await this.send(1, [
|
||||
"CML",
|
||||
"C2",
|
||||
stringifyChordActions(actions),
|
||||
]);
|
||||
return phrase === "2" ? undefined : parsePhrase(phrase);
|
||||
}
|
||||
|
||||
async setChord(chord: Chord) {
|
||||
const [status] = await this.send(
|
||||
1,
|
||||
const [status] = await this.send(1, [
|
||||
"CML",
|
||||
"C3",
|
||||
stringifyChordActions(chord.actions),
|
||||
stringifyPhrase(chord.phrase),
|
||||
);
|
||||
]);
|
||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||
const status = await this.send(
|
||||
1,
|
||||
`CML C4 ${stringifyChordActions(chord.actions)}`,
|
||||
);
|
||||
const status = await this.send(1, [
|
||||
"CML",
|
||||
"C4",
|
||||
stringifyChordActions(chord.actions),
|
||||
]);
|
||||
if (status?.at(-1) !== "2" && status?.at(-1) !== "0")
|
||||
throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
@@ -360,7 +370,13 @@ export class CharaDevice {
|
||||
* @param action the assigned action id
|
||||
*/
|
||||
async setLayoutKey(layer: number, id: number, action: number) {
|
||||
const [status] = await this.send(1, `VAR B4 A${layer} ${id} ${action}`);
|
||||
const [status] = await this.send(1, [
|
||||
"VAR",
|
||||
"B4",
|
||||
`A${layer}`,
|
||||
id.toString(),
|
||||
action.toString(),
|
||||
]);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
@@ -371,7 +387,12 @@ export class CharaDevice {
|
||||
* @returns the assigned action id
|
||||
*/
|
||||
async getLayoutKey(layer: number, id: number) {
|
||||
const [position, status] = await this.send(2, `VAR B3 A${layer} ${id}`);
|
||||
const [position, status] = await this.send(2, [
|
||||
"VAR",
|
||||
"B3",
|
||||
`A${layer}`,
|
||||
id.toString(),
|
||||
]);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
return Number(position);
|
||||
}
|
||||
@@ -384,7 +405,7 @@ export class CharaDevice {
|
||||
* **This does not need to be called for chords**
|
||||
*/
|
||||
async commit() {
|
||||
const [status] = await this.send(1, "VAR B0");
|
||||
const [status] = await this.send(1, ["VAR", "B0"]);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
@@ -395,10 +416,12 @@ export class CharaDevice {
|
||||
* To permanently store the settings, you *must* call commit.
|
||||
*/
|
||||
async setSetting(id: number, value: number) {
|
||||
const [status] = await this.send(
|
||||
1,
|
||||
`VAR B2 ${id.toString(16).toUpperCase()} ${value}`,
|
||||
);
|
||||
const [status] = await this.send(1, [
|
||||
"VAR",
|
||||
"B2",
|
||||
id.toString(16).toUpperCase(),
|
||||
value.toString(),
|
||||
]);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
@@ -406,10 +429,11 @@ export class CharaDevice {
|
||||
* Retrieves a setting from the device
|
||||
*/
|
||||
async getSetting(id: number): Promise<number> {
|
||||
const [value, status] = await this.send(
|
||||
2,
|
||||
`VAR B1 ${id.toString(16).toUpperCase()}`,
|
||||
);
|
||||
const [value, status] = await this.send(2, [
|
||||
"VAR",
|
||||
"B1",
|
||||
id.toString(16).toUpperCase(),
|
||||
]);
|
||||
if (status !== "0")
|
||||
throw new Error(
|
||||
`Setting "0x${id.toString(16)}" doesn't exist (Status code ${status})`,
|
||||
@@ -421,14 +445,14 @@ export class CharaDevice {
|
||||
* Reboots the device
|
||||
*/
|
||||
async reboot() {
|
||||
await this.send(0, "RST");
|
||||
await this.send(0, ["RST"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reboots the device to the bootloader
|
||||
*/
|
||||
async bootloader() {
|
||||
await this.send(0, "RST BOOTLOADER");
|
||||
await this.send(0, ["RST", "BOOTLOADER"]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -437,7 +461,12 @@ export class CharaDevice {
|
||||
async reset(
|
||||
type: "FACTORY" | "PARAMS" | "KEYMAPS" | "STARTER" | "CLEARCML" | "FUNC",
|
||||
) {
|
||||
await this.send(0, `RST ${type}`);
|
||||
await this.send(0, ["RST", type]);
|
||||
}
|
||||
|
||||
async queryKey(): Promise<number> {
|
||||
const [value] = await this.send(1, ["QRY", "KEY"], undefined);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -446,13 +475,17 @@ export class CharaDevice {
|
||||
* This is useful for debugging when there is a suspected heap or stack issue.
|
||||
*/
|
||||
async getRamBytesAvailable(): Promise<number> {
|
||||
return Number(await this.send(1, "RAM").then(([bytes]) => bytes));
|
||||
return Number(await this.send(1, ["RAM"]).then(([bytes]) => bytes));
|
||||
}
|
||||
|
||||
async updateFirmware(file: File | Blob): Promise<void> {
|
||||
async updateFirmware(
|
||||
file: ArrayBuffer,
|
||||
progress: (transferred: number, total: number) => void,
|
||||
): Promise<void> {
|
||||
while (this.lock) {
|
||||
await this.lock;
|
||||
}
|
||||
|
||||
let resolveLock: (result: true) => void;
|
||||
this.lock = new Promise<true>((resolve) => {
|
||||
resolveLock = resolve;
|
||||
@@ -482,46 +515,46 @@ export class CharaDevice {
|
||||
});
|
||||
return it;
|
||||
});
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
// Wait for the device to be ready
|
||||
const signal = await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
// Wait for the device to be ready
|
||||
const signal = await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
await file.stream().pipeTo(this.port.writable!);
|
||||
const chunkSize = 128;
|
||||
for (let i = 0; i < file.byteLength; i += chunkSize) {
|
||||
const chunk = file.slice(i, i + chunkSize);
|
||||
await writer.write(new Uint8Array(chunk));
|
||||
progress(i + chunk.byteLength, file.byteLength);
|
||||
}
|
||||
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: `...${file.size} bytes`,
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: `...${file.byteLength} bytes`,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
const result = (await this.reader.read()).value!.trim();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: result!,
|
||||
const result = (await this.reader.read()).value!.trim();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: result!,
|
||||
});
|
||||
return it;
|
||||
});
|
||||
return it;
|
||||
});
|
||||
|
||||
if (result !== "OTA OK") {
|
||||
throw new Error(result);
|
||||
}
|
||||
if (result !== "OTA OK") {
|
||||
throw new Error(result);
|
||||
}
|
||||
|
||||
const writer2 = this.port.writable!.getWriter();
|
||||
try {
|
||||
await writer2.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
||||
await writer.write(new TextEncoder().encode(`RST RESTART\r\n`));
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
@@ -530,7 +563,7 @@ export class CharaDevice {
|
||||
return it;
|
||||
});
|
||||
} finally {
|
||||
writer2.releaseLock();
|
||||
writer.releaseLock();
|
||||
}
|
||||
|
||||
await this.suspend();
|
||||
|
||||
@@ -1,38 +1,64 @@
|
||||
import type { ActionInfo, KeymapCategory } from "$lib/assets/keymaps/keymap";
|
||||
import { derived, type Readable } from "svelte/store";
|
||||
import { deviceMeta } from "./connection";
|
||||
|
||||
export interface KeyInfo extends Partial<ActionInfo> {
|
||||
code: number;
|
||||
category?: KeymapCategory;
|
||||
}
|
||||
|
||||
export const KEYMAP_CATEGORIES = (await Promise.all(
|
||||
const fallbackActions = await Promise.all<KeymapCategory>(
|
||||
Object.values(import.meta.glob("$lib/assets/keymaps/*.yml")).map(
|
||||
async (load) => load().then((it) => (it as any).default),
|
||||
),
|
||||
)) as KeymapCategory[];
|
||||
|
||||
export const KEYMAP_CODES = new Map<number, KeyInfo>(
|
||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
||||
Object.entries(category.actions).map(([code, action]) => [
|
||||
Number(code),
|
||||
{ ...action, code: Number(code), category },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
export const KEYMAP_KEYCODES = new Map<string, number>(
|
||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) => [action.keyCode!, Number(code)] as const,
|
||||
export let KEYMAP_CATEGORIES: Readable<KeymapCategory[]> = derived(
|
||||
deviceMeta,
|
||||
(deviceMeta) => deviceMeta?.actions ?? fallbackActions,
|
||||
);
|
||||
|
||||
export const KEYMAP_CODES: Readable<Map<number, KeyInfo>> = derived(
|
||||
KEYMAP_CATEGORIES,
|
||||
(categories) =>
|
||||
new Map<number, KeyInfo>(
|
||||
categories.flatMap((category) =>
|
||||
Object.entries(category.actions).map(([code, action]) => [
|
||||
Number(code),
|
||||
{ ...action, code: Number(code), category },
|
||||
]),
|
||||
),
|
||||
),
|
||||
).filter(([keyCode]) => keyCode !== undefined),
|
||||
);
|
||||
|
||||
export const KEYMAP_IDS = new Map<string, KeyInfo>(
|
||||
KEYMAP_CATEGORIES.flatMap((category) =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) =>
|
||||
[action.id!, { ...action, code: Number(code), category }] as const,
|
||||
export const KEYMAP_KEYCODES: Readable<Map<string, number>> = derived(
|
||||
KEYMAP_CATEGORIES,
|
||||
(categories) =>
|
||||
new Map<string, number>(
|
||||
categories
|
||||
.flatMap((category) =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) => [action.keyCode!, Number(code)] as const,
|
||||
),
|
||||
)
|
||||
.filter(([keyCode]) => keyCode !== undefined),
|
||||
),
|
||||
);
|
||||
|
||||
export const KEYMAP_IDS: Readable<Map<string, KeyInfo>> = derived(
|
||||
KEYMAP_CATEGORIES,
|
||||
(categories) =>
|
||||
new Map<string, KeyInfo>(
|
||||
categories
|
||||
.flatMap((category) =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) =>
|
||||
[
|
||||
action.id!,
|
||||
{ ...action, code: Number(code), category },
|
||||
] as const,
|
||||
),
|
||||
)
|
||||
.filter(([id]) => id !== undefined),
|
||||
),
|
||||
).filter(([id]) => id !== undefined),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,85 @@
|
||||
import type { Action } from "svelte/action";
|
||||
import { changes, ChangeType, settings } from "$lib/undo-redo";
|
||||
|
||||
/**
|
||||
* https://gist.github.com/mjackson/5311256
|
||||
*/
|
||||
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
let h = 0;
|
||||
const v = max;
|
||||
|
||||
const d = max - min;
|
||||
const s = max == 0 ? 0 : d / max;
|
||||
|
||||
if (max == min) {
|
||||
h = 0; // achromatic
|
||||
} else {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return [Math.floor(h * 0xffff), Math.floor(s * 0xff), Math.floor(v * 0xff)];
|
||||
}
|
||||
|
||||
/**
|
||||
* https://gist.github.com/mjackson/5311256
|
||||
*/
|
||||
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
|
||||
h /= 0xffff;
|
||||
s /= 0xff;
|
||||
v /= 0xff;
|
||||
|
||||
let r = 0;
|
||||
let g = 0;
|
||||
let b = 0;
|
||||
|
||||
const i = Math.floor(h * 6);
|
||||
const f = h * 6 - i;
|
||||
const p = v * (1 - s);
|
||||
const q = v * (1 - f * s);
|
||||
const t = v * (1 - (1 - f) * s);
|
||||
|
||||
switch (i % 6) {
|
||||
case 0:
|
||||
(r = v), (g = t), (b = p);
|
||||
break;
|
||||
case 1:
|
||||
(r = q), (g = v), (b = p);
|
||||
break;
|
||||
case 2:
|
||||
(r = p), (g = v), (b = t);
|
||||
break;
|
||||
case 3:
|
||||
(r = p), (g = q), (b = v);
|
||||
break;
|
||||
case 4:
|
||||
(r = t), (g = p), (b = v);
|
||||
break;
|
||||
case 5:
|
||||
(r = v), (g = p), (b = q);
|
||||
break;
|
||||
}
|
||||
|
||||
return [Math.floor(r * 0xff), Math.floor(g * 0xff), Math.floor(b * 0xff)];
|
||||
}
|
||||
|
||||
export const setting: Action<
|
||||
HTMLInputElement | HTMLSelectElement,
|
||||
{ id: number; inverse?: number; scale?: number }
|
||||
@@ -9,7 +88,12 @@ export const setting: Action<
|
||||
{ id, inverse, scale },
|
||||
) {
|
||||
node.setAttribute("disabled", "");
|
||||
const type = node.getAttribute("type") as "number" | "checkbox" | "range";
|
||||
const type = node.getAttribute("type") as
|
||||
| "number"
|
||||
| "checkbox"
|
||||
| "range"
|
||||
| "color";
|
||||
const isColor = type === "color";
|
||||
const isNumeric =
|
||||
type === "number" || type === "range" || node instanceof HTMLSelectElement;
|
||||
const min = node.hasAttribute("min")
|
||||
@@ -30,6 +114,13 @@ export const setting: Action<
|
||||
? scale * value
|
||||
: value
|
||||
).toString();
|
||||
} else if (isColor) {
|
||||
const rgb = hsvToRgb(
|
||||
settings[id]!.value,
|
||||
settings[id + 1]!.value,
|
||||
settings[id + 2]!.value,
|
||||
);
|
||||
node.value = `#${rgb.map((c) => c.toString(16).padStart(2, "0")).join("")}`;
|
||||
} else {
|
||||
node.checked = value !== 0;
|
||||
}
|
||||
@@ -49,6 +140,8 @@ export const setting: Action<
|
||||
if (isNumeric) {
|
||||
value = Number(node.value);
|
||||
if (Number.isNaN(value)) return;
|
||||
if (min !== undefined) value = Math.max(min, value);
|
||||
if (max !== undefined) value = Math.min(max, value);
|
||||
value = Math.floor(
|
||||
inverse !== undefined
|
||||
? inverse / value
|
||||
@@ -56,18 +149,34 @@ export const setting: Action<
|
||||
? value / scale
|
||||
: value,
|
||||
);
|
||||
if (min !== undefined) value = Math.max(min, value);
|
||||
if (max !== undefined) value = Math.min(max, value);
|
||||
} else if (isColor) {
|
||||
const r = parseInt(node.value.slice(1, 3), 16);
|
||||
const g = parseInt(node.value.slice(3, 5), 16);
|
||||
const b = parseInt(node.value.slice(5, 7), 16);
|
||||
const hsv = rgbToHsv(r, g, b);
|
||||
changes.update((changes) => {
|
||||
changes.push(
|
||||
hsv.map((value, i) => ({
|
||||
type: ChangeType.Setting,
|
||||
id: id + i,
|
||||
setting: value,
|
||||
})),
|
||||
);
|
||||
return changes;
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
value = node.checked ? 1 : 0;
|
||||
}
|
||||
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Setting,
|
||||
id: id,
|
||||
setting: value,
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Setting,
|
||||
id: id,
|
||||
setting: value,
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ export function persistentWritable<T>(
|
||||
: writable(value);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
store = writable(value);
|
||||
}
|
||||
store.subscribe((value) => {
|
||||
|
||||
@@ -19,6 +19,8 @@ button {
|
||||
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: 32px;
|
||||
transition: all 250ms ease;
|
||||
|
||||
@media not (forced-colors: active) {
|
||||
color: currentcolor;
|
||||
@@ -36,10 +38,6 @@ button {
|
||||
color: ButtonText;
|
||||
}
|
||||
|
||||
border-radius: 32px;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&.icon {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -48,7 +46,6 @@ button {
|
||||
padding-inline: 0;
|
||||
|
||||
font-size: 24px;
|
||||
|
||||
border-radius: 50%;
|
||||
|
||||
@media (forced-colors: active) {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
@import "./reset";
|
||||
@use "reset";
|
||||
|
||||
@import "./form/button";
|
||||
@import "./form/toggle";
|
||||
@import "./form/checkbox";
|
||||
@use "form/button";
|
||||
@use "form/toggle";
|
||||
@use "form/checkbox";
|
||||
|
||||
@import "./kbd";
|
||||
@import "./print";
|
||||
@use "kbd";
|
||||
@use "print";
|
||||
|
||||
@import "./elements/h1";
|
||||
@use "elements/h1";
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface ChangeInfo {
|
||||
|
||||
export type Change = LayoutChange | ChordChange | SettingChange;
|
||||
|
||||
export const changes = persistentWritable<Change[]>("changes", []);
|
||||
export const changes = persistentWritable<Change[][]>("changes", []);
|
||||
|
||||
export interface Overlay {
|
||||
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
|
||||
@@ -57,21 +57,23 @@ export const overlay = derived(changes, (changes) => {
|
||||
settings: new Map(),
|
||||
};
|
||||
|
||||
for (const change of changes) {
|
||||
switch (change.type) {
|
||||
case ChangeType.Layout:
|
||||
overlay.layout[change.layer]?.set(change.id, change.action);
|
||||
break;
|
||||
case ChangeType.Chord:
|
||||
overlay.chords.set(JSON.stringify(change.id), {
|
||||
actions: change.actions,
|
||||
phrase: change.phrase,
|
||||
deleted: change.deleted ?? false,
|
||||
});
|
||||
break;
|
||||
case ChangeType.Setting:
|
||||
overlay.settings.set(change.id, change.setting);
|
||||
break;
|
||||
for (const changeset of changes) {
|
||||
for (const change of changeset) {
|
||||
switch (change.type) {
|
||||
case ChangeType.Layout:
|
||||
overlay.layout[change.layer]?.set(change.id, change.action);
|
||||
break;
|
||||
case ChangeType.Chord:
|
||||
overlay.chords.set(JSON.stringify(change.id), {
|
||||
actions: change.actions,
|
||||
phrase: change.phrase,
|
||||
deleted: change.deleted ?? false,
|
||||
});
|
||||
break;
|
||||
case ChangeType.Setting:
|
||||
overlay.settings.set(change.id, change.setting);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,57 +109,61 @@ export type ChordInfo = Chord &
|
||||
id: number[];
|
||||
deleted: boolean;
|
||||
};
|
||||
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
|
||||
const newChords = new Set(overlay.chords.keys());
|
||||
export const chords = derived(
|
||||
[overlay, deviceChords, KEYMAP_CODES],
|
||||
([overlay, chords, codes]) => {
|
||||
const newChords = new Set(overlay.chords.keys());
|
||||
|
||||
const changedChords = chords.map<ChordInfo>((chord) => {
|
||||
const id = JSON.stringify(chord.actions);
|
||||
if (overlay.chords.has(id)) {
|
||||
newChords.delete(id);
|
||||
const changedChord = overlay.chords.get(id)!;
|
||||
return {
|
||||
id: chord.actions,
|
||||
// use the old phrase for stable editing
|
||||
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
|
||||
actions: changedChord.actions,
|
||||
phrase: changedChord.phrase,
|
||||
actionsChanged: id !== JSON.stringify(changedChord.actions),
|
||||
phraseChanged:
|
||||
JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase),
|
||||
isApplied: false,
|
||||
deleted: changedChord.deleted,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: chord.actions,
|
||||
sortBy: chord.phrase.map((it) => KEYMAP_CODES.get(it)?.id ?? it).join(),
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase,
|
||||
phraseChanged: false,
|
||||
actionsChanged: false,
|
||||
isApplied: true,
|
||||
deleted: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
for (const id of newChords) {
|
||||
const chord = overlay.chords.get(id)!;
|
||||
changedChords.push({
|
||||
sortBy: "",
|
||||
isApplied: false,
|
||||
actionsChanged: true,
|
||||
phraseChanged: false,
|
||||
deleted: chord.deleted,
|
||||
id: JSON.parse(id),
|
||||
phrase: chord.phrase,
|
||||
actions: chord.actions,
|
||||
const changedChords = chords.map<ChordInfo>((chord) => {
|
||||
const id = JSON.stringify(chord.actions);
|
||||
if (overlay.chords.has(id)) {
|
||||
newChords.delete(id);
|
||||
const changedChord = overlay.chords.get(id)!;
|
||||
return {
|
||||
id: chord.actions,
|
||||
// use the old phrase for stable editing
|
||||
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
|
||||
actions: changedChord.actions,
|
||||
phrase: changedChord.phrase,
|
||||
actionsChanged: id !== JSON.stringify(changedChord.actions),
|
||||
phraseChanged:
|
||||
JSON.stringify(chord.phrase) !==
|
||||
JSON.stringify(changedChord.phrase),
|
||||
isApplied: false,
|
||||
deleted: changedChord.deleted,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: chord.actions,
|
||||
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase,
|
||||
phraseChanged: false,
|
||||
actionsChanged: false,
|
||||
isApplied: true,
|
||||
deleted: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const id of newChords) {
|
||||
const chord = overlay.chords.get(id)!;
|
||||
changedChords.push({
|
||||
sortBy: "",
|
||||
isApplied: false,
|
||||
actionsChanged: true,
|
||||
phraseChanged: false,
|
||||
deleted: chord.deleted,
|
||||
id: JSON.parse(id),
|
||||
phrase: chord.phrase,
|
||||
actions: chord.actions,
|
||||
});
|
||||
}
|
||||
|
||||
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
});
|
||||
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const chordHashes = derived(
|
||||
chords,
|
||||
|
||||
47
src/lib/util/debounce.ts
Normal file
47
src/lib/util/debounce.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Creates a debounced function that delays invoking the provided function
|
||||
* until after 'wait' milliseconds have elapsed since the last time it was
|
||||
* invoked.
|
||||
*
|
||||
* I could use _.debounce(), but bringing dependency on lodash didn't feel
|
||||
* justified yet.
|
||||
*
|
||||
* @param func The function to debounce
|
||||
* @param wait The number of milliseconds to delay execution
|
||||
* @returns A debounced version of the provided function
|
||||
*/
|
||||
function debounce<T extends (...args: any[]) => void>(
|
||||
func: T,
|
||||
wait: number,
|
||||
): T & { cancel: () => void } {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const debounced = function (
|
||||
this: ThisParameterType<T>,
|
||||
...args: Parameters<T>
|
||||
): void {
|
||||
const context = this;
|
||||
|
||||
const later = function () {
|
||||
timeout = null;
|
||||
func.apply(context, args);
|
||||
};
|
||||
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
|
||||
debounced.cancel = function () {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
timeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
return debounced as T & { cancel: () => void };
|
||||
}
|
||||
|
||||
export default debounce;
|
||||
15
src/lib/util/shuffle.ts
Normal file
15
src/lib/util/shuffle.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm
|
||||
*/
|
||||
export function shuffleInPlace<T>(array: T[]) {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j]!, array[i]!];
|
||||
}
|
||||
}
|
||||
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
const result = [...array];
|
||||
shuffleInPlace(result);
|
||||
return result;
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
syncStatus,
|
||||
} from "$lib/serial/connection";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||
|
||||
let locale = $state(
|
||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
||||
@@ -52,9 +53,7 @@
|
||||
await initSerial(true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(
|
||||
"Connection failed. Is your device maybe pre-CCOS? Refer to the doc link in the bottom left for more information on your device.",
|
||||
);
|
||||
await showConnectionFailedDialog(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,11 +173,6 @@
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
select {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sync-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
|
||||
],
|
||||
[
|
||||
// { href: "/learn", icon: "school", title: "Learn", wip: true },
|
||||
{
|
||||
href: import.meta.env.VITE_LEARN_URL,
|
||||
icon: "school",
|
||||
@@ -26,8 +25,17 @@
|
||||
title: "Docs",
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: "https://voicebox.charachorder.io/",
|
||||
icon: "text_to_speech",
|
||||
title: "Voicebox",
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
||||
{ href: "https://chat.dev.charachorder.io", icon: "chat", title: "Chat", wip: true },
|
||||
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
|
||||
{ href: "/learn", icon: "school", title: "Learn", wip: true },
|
||||
],
|
||||
/*[
|
||||
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
||||
|
||||
@@ -54,5 +54,6 @@
|
||||
|
||||
progress::-webkit-progress-value {
|
||||
background: var(--md-sys-color-primary);
|
||||
transition: width 2s ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"CHARACHORDER ONE M0": {
|
||||
"latest": "1.1.3",
|
||||
"next": null
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from "$app/navigation";
|
||||
import { page } from "$app/stores";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const animationDuration = 400;
|
||||
const stagger = 80;
|
||||
|
||||
let targetDevice = $derived($page.params["device"]);
|
||||
let version = $derived($page.params["version"]);
|
||||
|
||||
let currentDevice = $derived(
|
||||
$serialPort
|
||||
? `${$serialPort.device.toLowerCase()}_${$serialPort.chipset.toLowerCase()}`
|
||||
: undefined,
|
||||
);
|
||||
let isCorrectDevice = $derived(
|
||||
currentDevice ? currentDevice === targetDevice : undefined,
|
||||
);
|
||||
|
||||
let fullBack = $state(false);
|
||||
|
||||
beforeNavigate(({ from, to, cancel }) => {
|
||||
fullBack = version !== undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1><a href="/ccos">Firmware Updates</a></h1>
|
||||
<h1>
|
||||
<a class="inline-link" href="/ccos">CCOS</a>
|
||||
{#if targetDevice !== undefined}
|
||||
<div
|
||||
class="uri-fragment"
|
||||
transition:slide={{
|
||||
axis: "x",
|
||||
duration: animationDuration,
|
||||
delay: fullBack ? stagger : 0,
|
||||
easing: expoOut,
|
||||
}}
|
||||
>
|
||||
<span class="separator">/</span>
|
||||
<a
|
||||
href="/ccos/{targetDevice}"
|
||||
class="device inline-link"
|
||||
class:correct-device={isCorrectDevice === true}
|
||||
class:incorrect-device={isCorrectDevice === false}>{targetDevice}</a
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if version !== undefined}
|
||||
<div
|
||||
class="uri-fragment"
|
||||
transition:slide={{
|
||||
axis: "x",
|
||||
duration: animationDuration,
|
||||
easing: expoOut,
|
||||
}}
|
||||
>
|
||||
<span class="separator">/</span>
|
||||
<em class="version">{version}</em>
|
||||
</div>
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<style lang="scss">
|
||||
h1 {
|
||||
display: flex;
|
||||
margin-block: 1em;
|
||||
padding: 0;
|
||||
font-size: 3em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.uri-fragment {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin-inline: 0.5em;
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.device {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.inline-link {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.correct-device {
|
||||
color: var(--md-sys-color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.incorrect-device {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import type { DirectoryListing } from "./listing";
|
||||
import type { DirectoryListing } from "$lib/meta/types/listing";
|
||||
|
||||
export const load = (async ({ fetch }) => {
|
||||
const result = await fetch(import.meta.env.VITE_FIRMWARE_URL);
|
||||
const result = await fetch(`${import.meta.env.VITE_FIRMWARE_URL}/`);
|
||||
const data = await result.json();
|
||||
|
||||
return { devices: data as DirectoryListing[] };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import type { DirectoryListing } from "../listing";
|
||||
import type { DirectoryListing } from "$lib/meta/types/listing";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const result = await fetch(
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
let success = $state(false);
|
||||
let error = $state<Error | undefined>(undefined);
|
||||
|
||||
let unsafeUpdate = $state(false);
|
||||
|
||||
let terminalOutput = $state("");
|
||||
let progress = $state(0);
|
||||
|
||||
let step = $state(0);
|
||||
let eraseAll = $state(false);
|
||||
@@ -25,10 +28,12 @@
|
||||
$serialPort = undefined;
|
||||
try {
|
||||
const file = await fetch(
|
||||
`${data.meta.path}/${data.meta.update.ota!}`,
|
||||
).then((it) => it.blob());
|
||||
`${data.meta.path}/${data.meta.update.ota}`,
|
||||
).then((it) => it.arrayBuffer());
|
||||
|
||||
await port.updateFirmware(file);
|
||||
await port.updateFirmware(file, (transferred, total) => {
|
||||
progress = transferred / total;
|
||||
});
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
@@ -44,7 +49,7 @@
|
||||
: undefined,
|
||||
);
|
||||
let isCorrectDevice = $derived(
|
||||
currentDevice ? currentDevice === data.meta.target : undefined,
|
||||
currentDevice ? currentDevice === data.meta.device : undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -82,11 +87,11 @@
|
||||
|
||||
async function getFileSystem() {
|
||||
if (!data.meta.update.uf2) return;
|
||||
const uf2Promise = fetch(
|
||||
`${data.meta.path}/${data.meta.update.uf2.name}`,
|
||||
).then((it) => it.blob());
|
||||
const uf2Promise = fetch(`${data.meta.path}/${data.meta.update.uf2}`).then(
|
||||
(it) => it.blob(),
|
||||
);
|
||||
const handle = await window.showSaveFilePicker({
|
||||
id: `${data.meta.target}-update`,
|
||||
id: `${data.meta.device}-update`,
|
||||
suggestedName: "CURRENT.UF2",
|
||||
excludeAcceptAllOption: true,
|
||||
types: [
|
||||
@@ -170,7 +175,7 @@
|
||||
const port = await navigator.serial.requestPort();
|
||||
try {
|
||||
console.log(data.meta);
|
||||
const spiFlash = data.meta.spi_flash!;
|
||||
const spiFlash = data.meta.spiFlash!;
|
||||
espLoader = await connectEsp(port);
|
||||
|
||||
/*espLoader.flashSpiAttach(
|
||||
@@ -187,23 +192,14 @@
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<h2>
|
||||
<a class="inline-link" href="/ccos">CCOS</a> /
|
||||
<a
|
||||
href="/ccos/{data.meta.target}"
|
||||
class="device inline-link"
|
||||
class:correct-device={isCorrectDevice === true}
|
||||
class:incorrect-device={isCorrectDevice === false}>{data.meta.target}</a
|
||||
>
|
||||
/ <em class="version">{data.meta.version}</em>
|
||||
</h2>
|
||||
|
||||
{#if data.meta.update.ota && !data.meta.target.endsWith("m0")}
|
||||
{#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
|
||||
{@const buttonError = error || (!success && isCorrectDevice === false)}
|
||||
<section>
|
||||
<button
|
||||
class="update-button"
|
||||
class:working
|
||||
class:working={working && (progress <= 0 || progress >= 1)}
|
||||
class:progress={working && progress > 0 && progress < 1}
|
||||
style:--progress="{progress * 100}%"
|
||||
class:primary={!buttonError}
|
||||
class:error={buttonError}
|
||||
disabled={working || $serialPort === undefined || !isCorrectDevice}
|
||||
@@ -237,103 +233,162 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<h3>Manual Update</h3>
|
||||
<label class="unsafe-opt-in"
|
||||
><input type="checkbox" /> Unsafe recovery options</label
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if isCorrectDevice === false}
|
||||
<div transition:slide class="incorrect-device">
|
||||
These files are incompatible with your device
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<ol>
|
||||
<li>
|
||||
<button class="inline-button" onclick={connect}
|
||||
><span class="icon">usb</span>Connect</button
|
||||
>
|
||||
your device
|
||||
{#if step >= 1}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 1}>
|
||||
Make a <button class="inline-button" onclick={backup}
|
||||
><span class="icon">download</span>Backup</button
|
||||
>
|
||||
{#if step >= 2}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 2}>
|
||||
Reboot to <button class="inline-button" onclick={bootloader}
|
||||
><span class="icon">restart_alt</span>Bootloader</button
|
||||
>
|
||||
{#if step >= 3}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 3}>
|
||||
Replace <button class="inline-button" onclick={getFileSystem}
|
||||
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
|
||||
>
|
||||
on the new drive
|
||||
{#if step >= 4}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{#if data.meta.update.esptool}
|
||||
<section>
|
||||
<h3>Factory Flash</h3>
|
||||
<p>
|
||||
If everything else fails, you can go through the same process that is
|
||||
being used in the factory.
|
||||
</p>
|
||||
<p>
|
||||
This will temporarily brick your device if the process is not done
|
||||
completely or incorrectly.
|
||||
</p>
|
||||
|
||||
<div class="esp-buttons">
|
||||
<button onclick={espBootloader}
|
||||
><span class="icon">memory</span>ESP Bootloader</button
|
||||
>
|
||||
<button onclick={flashImages}
|
||||
><span class="icon">developer_board</span>Flash Images</button
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase All</label
|
||||
>
|
||||
<button onclick={eraseSPI}
|
||||
><span class="icon">developer_board</span>Erase SPI Flash</button
|
||||
>
|
||||
<div class="unsafe-updates">
|
||||
{#if isCorrectDevice === false}
|
||||
<div transition:slide class="incorrect-device">
|
||||
These files are incompatible with your device
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<pre>{terminalOutput}</pre>
|
||||
<section>
|
||||
<ol>
|
||||
<li>
|
||||
<button class="inline-button" onclick={connect}
|
||||
><span class="icon">usb</span>Connect</button
|
||||
>
|
||||
your device
|
||||
{#if step >= 1}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 1}>
|
||||
Make a <button class="inline-button" onclick={backup}
|
||||
><span class="icon">download</span>Backup</button
|
||||
>
|
||||
{#if step >= 2}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 2}>
|
||||
Reboot to <button class="inline-button" onclick={bootloader}
|
||||
><span class="icon">restart_alt</span>Bootloader</button
|
||||
>
|
||||
{#if step >= 3}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 3}>
|
||||
Replace <button class="inline-button" onclick={getFileSystem}
|
||||
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
|
||||
>
|
||||
on the new drive
|
||||
{#if step >= 4}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if false && data.meta.update.esptool}
|
||||
<section>
|
||||
<h3>Factory Flash (WIP)</h3>
|
||||
<p>
|
||||
If everything else fails, you can go through the same process that is
|
||||
being used in the factory.
|
||||
</p>
|
||||
<p>
|
||||
This will temporarily brick your device if the process is not done
|
||||
completely or incorrectly.
|
||||
</p>
|
||||
|
||||
<div class="esp-buttons">
|
||||
<button onclick={espBootloader}
|
||||
><span class="icon">memory</span>ESP Bootloader</button
|
||||
>
|
||||
<button onclick={flashImages}
|
||||
><span class="icon">developer_board</span>Flash Images</button
|
||||
>
|
||||
<label
|
||||
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase
|
||||
All</label
|
||||
>
|
||||
<button onclick={eraseSPI}
|
||||
><span class="icon">developer_board</span>Erase SPI Flash</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<pre>{terminalOutput}</pre>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<section class="changelog">
|
||||
<h2>Changelog</h2>
|
||||
{#if data.meta.changelog.features}
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
{#each data.meta.changelog.features as feature}
|
||||
<li>
|
||||
<b>{@html feature.summary}</b>
|
||||
{@html feature.description}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if data.meta.changelog.fixes}
|
||||
<h3>Fixes</h3>
|
||||
<ul>
|
||||
{#each data.meta.changelog.fixes as fix}
|
||||
<li>
|
||||
<b>{@html fix.summary}</b>
|
||||
{@html fix.description}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
h2 > em {
|
||||
font-style: normal;
|
||||
transition: color 200ms ease;
|
||||
.changelog:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-block-start: 4em;
|
||||
.changelog ul {
|
||||
list-style: none;
|
||||
padding-inline-start: 0em;
|
||||
}
|
||||
|
||||
.changelog li {
|
||||
margin-block: 0.2em;
|
||||
padding: 0.5em 1em;
|
||||
}
|
||||
|
||||
.changelog b {
|
||||
display: inline-block;
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
background: var(--md-sys-color-tertiary-container);
|
||||
padding: 0.2em 0.5em;
|
||||
border-radius: 8px;
|
||||
translate: -0.5em -0.2em;
|
||||
}
|
||||
|
||||
pre {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.unsafe-opt-in {
|
||||
margin-block: 1em;
|
||||
opacity: 0.6;
|
||||
font-size: 0.7em;
|
||||
|
||||
& + .unsafe-updates {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:has(input:checked) + .unsafe-updates {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.primary {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
@@ -417,6 +472,7 @@
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.progress,
|
||||
&.working {
|
||||
border-color: transparent;
|
||||
}
|
||||
@@ -440,40 +496,16 @@
|
||||
height: 30%;
|
||||
width: 120%;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
color: var(--md-sys-color-outline);
|
||||
margin-block: 3em;
|
||||
margin-inline: 5em;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.files {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
padding: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
a[download] {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
height: auto;
|
||||
|
||||
.size {
|
||||
font-size: 0.8em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-inline-start: 0.4em;
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 2;
|
||||
&.progress::after {
|
||||
z-index: -2;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
content: "";
|
||||
background: var(--md-sys-color-primary);
|
||||
opacity: 0.2;
|
||||
height: 100%;
|
||||
width: var(--progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -481,20 +513,6 @@
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.device {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.inline-link {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.correct-device {
|
||||
color: var(--md-sys-color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.incorrect-device {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
@@ -1,50 +1,14 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import type { FileListing, Listing } from "../../listing";
|
||||
import type { VersionMeta } from "./meta";
|
||||
import { getMeta } from "$lib/meta/meta-storage";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
export const load = (async ({ fetch, params }) => {
|
||||
const result = await fetch(
|
||||
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/`,
|
||||
);
|
||||
const data: Listing[] = await result.json();
|
||||
const meta: VersionMeta | undefined = data.some(
|
||||
(entry) => entry.type === "file" && entry.name === "meta.json",
|
||||
)
|
||||
? await fetch(
|
||||
`${import.meta.env.VITE_FIRMWARE_URL}/${params.device}/${params.version}/meta.json`,
|
||||
).then((res) => res.json())
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
meta: {
|
||||
version: meta?.version ?? params.version,
|
||||
target: meta?.target ?? params.device,
|
||||
path: `${import.meta.env.VITE_FIRMWARE_URL}${params.device}/${params.version}`,
|
||||
git_commit: meta?.git_commit ?? "",
|
||||
git_is_dirty: meta?.git_is_dirty ?? false,
|
||||
git_date: meta?.git_date ?? data[0]?.mtime ?? "",
|
||||
public_build: meta?.public_build ?? !params.version.startsWith("."),
|
||||
development_mode: meta?.development_mode ?? 0,
|
||||
update: {
|
||||
uf2:
|
||||
(data.find(
|
||||
(entry) =>
|
||||
entry.type === "file" &&
|
||||
entry.name === (meta?.update?.uf2 ?? "CURRENT.UF2"),
|
||||
) as FileListing) ?? undefined,
|
||||
ota:
|
||||
data.find(
|
||||
(entry) =>
|
||||
entry.type === "file" &&
|
||||
entry.name === (meta?.update?.ota ?? "firmware.bin"),
|
||||
) ?? undefined,
|
||||
esptool: meta?.update?.esptool ?? undefined,
|
||||
},
|
||||
files: data.filter(
|
||||
(entry) =>
|
||||
entry.type === "file" && (!meta?.files || entry.name in meta.files),
|
||||
) as FileListing[],
|
||||
spi_flash: meta?.spi_flash ?? undefined,
|
||||
},
|
||||
};
|
||||
const meta = await getMeta(params.device, params.version, fetch);
|
||||
if (meta === undefined) {
|
||||
error(
|
||||
404,
|
||||
`The version ${params.version} for device ${params.device} does not exist.`,
|
||||
);
|
||||
}
|
||||
return { meta };
|
||||
}) satisfies PageLoad;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
export interface VersionMeta {
|
||||
version: string;
|
||||
target: string;
|
||||
git_commit: string;
|
||||
git_is_dirty: boolean;
|
||||
git_date: string;
|
||||
public_build: boolean;
|
||||
development_mode: number;
|
||||
update: {
|
||||
ota: string | null;
|
||||
uf2: string | null;
|
||||
esptool: EspToolData | null;
|
||||
};
|
||||
files: string[];
|
||||
spi_flash: SPIFlashInfo | null;
|
||||
}
|
||||
|
||||
export interface SPIFlashInfo {
|
||||
type: string;
|
||||
size: string;
|
||||
connection: SPIConnection;
|
||||
}
|
||||
|
||||
export interface SPIConnection {
|
||||
clk: number;
|
||||
q: number;
|
||||
d: number;
|
||||
hd: number;
|
||||
cs: number;
|
||||
}
|
||||
|
||||
export interface EspToolData {
|
||||
chip: string;
|
||||
baud: string;
|
||||
before: string;
|
||||
after: string;
|
||||
flash_mode: string;
|
||||
flash_freq: string;
|
||||
flash_size: string;
|
||||
files: Record<string, string>;
|
||||
}
|
||||
92
src/routes/(app)/chat-rx/+page.svelte
Normal file
92
src/routes/(app)/chat-rx/+page.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat-rx";
|
||||
import { flip } from "svelte/animate";
|
||||
import { slide } from "svelte/transition";
|
||||
import Login from "./Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
await initMatrixClient();
|
||||
}
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let spaces = $derived($matrix?.topLevelSpaces$);
|
||||
|
||||
function spaceShort(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((it) => it[0])
|
||||
.join("");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLoggedIn}
|
||||
<div class="layout">
|
||||
<nav class="spaces">
|
||||
<a href="/chat/chats" class="icon chats">chat</a>
|
||||
<hr />
|
||||
{#if $spaces}
|
||||
<ul>
|
||||
{#each $spaces as space (space.roomId)}
|
||||
<li animate:flip transition:slide>
|
||||
<a class="space" href="/chat/space/{space.roomId}">
|
||||
{spaceShort(space.name)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="icon">add</button>
|
||||
</nav>
|
||||
</div>
|
||||
{:else}
|
||||
<Login />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
|
||||
.chats {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.space {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,92 +1,180 @@
|
||||
<script lang="ts">
|
||||
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat";
|
||||
import { flip } from "svelte/animate";
|
||||
import { slide } from "svelte/transition";
|
||||
import Login from "./Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { onDestroy, onMount, setContext } from "svelte";
|
||||
import type {
|
||||
IndexedDBStore,
|
||||
IndexedDBCryptoStore,
|
||||
LoginResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
|
||||
import { matrixClient, currentRoomId } from "$lib/chat/chat";
|
||||
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
|
||||
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
|
||||
|
||||
let loggedIn = $state(false);
|
||||
let ready = $state(false);
|
||||
|
||||
let store: IndexedDBStore;
|
||||
let cryptoStore: IndexedDBCryptoStore;
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
await initMatrixClient();
|
||||
if (!browser) return;
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
|
||||
|
||||
$matrixClient = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
await handleLogin(await $matrixClient.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
await postLogin();
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
async function passwordLogin(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const username = (form.elements.namedItem("username") as HTMLInputElement)
|
||||
.value;
|
||||
const password = (form.elements.namedItem("password") as HTMLInputElement)
|
||||
.value;
|
||||
|
||||
let spaces = $derived($matrix?.topLevelSpaces$);
|
||||
|
||||
function spaceShort(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((it) => it[0])
|
||||
.join("");
|
||||
await handleLogin(
|
||||
await $matrixClient.loginWithPassword(username, password),
|
||||
);
|
||||
await postLogin();
|
||||
}
|
||||
|
||||
async function handleLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
async function postLogin() {
|
||||
loggedIn = $matrixClient.isLoggedIn();
|
||||
|
||||
if (loggedIn) {
|
||||
await store.startup();
|
||||
await cryptoStore.startup();
|
||||
await $matrixClient.startClient();
|
||||
$matrixClient.once("sync", function (state, prevState, res) {
|
||||
ready = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($matrixClient) {
|
||||
$matrixClient.stopClient();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $isLoggedIn}
|
||||
<div class="layout">
|
||||
<nav class="spaces">
|
||||
<a href="/chat/chats" class="icon chats">chat</a>
|
||||
<hr />
|
||||
{#if $spaces}
|
||||
<ul>
|
||||
{#each $spaces as space (space.roomId)}
|
||||
<li animate:flip transition:slide>
|
||||
<a class="space" href="/chat/space/{space.roomId}">
|
||||
{spaceShort(space.name)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if $matrixClient && loggedIn}
|
||||
{#if ready}
|
||||
<div class="chat">
|
||||
<div class="rooms">
|
||||
<button
|
||||
onclick={() => {
|
||||
$matrixClient.logout(true);
|
||||
$matrixClient.clearStores();
|
||||
localStorage.removeItem("matrix-login");
|
||||
window.location.reload();
|
||||
}}>logout</button
|
||||
>
|
||||
<MatrixRooms rooms={$matrixClient.getRooms()} />
|
||||
</div>
|
||||
{#if $currentRoomId}
|
||||
{@const room = $matrixClient.getRoom($currentRoomId)}
|
||||
{#key room}
|
||||
{#if room}
|
||||
<div class="timeline">
|
||||
<MatrixTimeline timeline={room.getLiveTimeline()} />
|
||||
</div>
|
||||
<div class="members">
|
||||
<MatrixRoomMembers members={room.getJoinedMembers()} />
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
<button class="icon">add</button>
|
||||
</nav>
|
||||
</div>
|
||||
{:else}
|
||||
<Login />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if $matrixClient}
|
||||
{#await $matrixClient.loginFlows() then flows}
|
||||
{#each flows.flows as flow}
|
||||
{#if flow.type === "m.login.sso"}
|
||||
<a
|
||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
||||
>
|
||||
{#each flow.identity_providers as idp}
|
||||
{#if idp.icon}
|
||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
||||
{:else}
|
||||
{idp.name}
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else if flow.type === "m.login.password"}
|
||||
<!-- TODO: unambigous sso
|
||||
<form onsubmit={passwordLogin}>
|
||||
<input name="username" type="text" placeholder="Username" />
|
||||
<input name="password" type="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
-->
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> *:not(:last-child) {
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
.timeline {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
.rooms {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
|
||||
.chats {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.space {
|
||||
font-size: 20px;
|
||||
margin-bottom: 8px;
|
||||
.members {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
let redoQueue: Change[] = $state([]);
|
||||
let redoQueue: Change[][] = $state([]);
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
@@ -47,10 +47,26 @@
|
||||
if (!port) return;
|
||||
$syncStatus = "uploading";
|
||||
|
||||
for (const [id, { actions, phrase, deleted }] of $overlay.chords) {
|
||||
if (!deleted) {
|
||||
if (id !== JSON.stringify(actions)) {
|
||||
const existingChord = await port.getChordPhrase(actions);
|
||||
const layoutChanges = $overlay.layout.reduce(
|
||||
(acc, layer) => acc + layer.size,
|
||||
0,
|
||||
);
|
||||
const settingChanges = $overlay.settings.size;
|
||||
const chordChanges = $overlay.chords.size;
|
||||
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||
|
||||
let progressCurrent = 0;
|
||||
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent,
|
||||
});
|
||||
|
||||
for (const [id, chord] of $overlay.chords) {
|
||||
if (!chord.deleted) {
|
||||
if (id !== JSON.stringify(chord.actions)) {
|
||||
const existingChord = await port.getChordPhrase(chord.actions);
|
||||
if (
|
||||
existingChord !== undefined &&
|
||||
!(await askForConfirmation(
|
||||
@@ -58,37 +74,53 @@
|
||||
$LL.configure.chords.conflict.DESCRIPTION(),
|
||||
$LL.configure.chords.conflict.CONFIRM(),
|
||||
$LL.configure.chords.conflict.ABORT(),
|
||||
actions.slice(0, actions.lastIndexOf(0)),
|
||||
chord,
|
||||
))
|
||||
) {
|
||||
changes.update((changes) =>
|
||||
changes.filter(
|
||||
(it) =>
|
||||
!(
|
||||
it.type === ChangeType.Chord &&
|
||||
JSON.stringify(it.id) === id
|
||||
changes
|
||||
.map((it) =>
|
||||
it.filter(
|
||||
(it) =>
|
||||
!(
|
||||
it.type === ChangeType.Chord &&
|
||||
JSON.stringify(it.id) === id
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter((it) => it.length > 0),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await port.deleteChord({ actions: JSON.parse(id) });
|
||||
}
|
||||
await port.setChord({ actions, phrase });
|
||||
await port.setChord({ actions: chord.actions, phrase: chord.phrase });
|
||||
} else {
|
||||
await port.deleteChord({ actions });
|
||||
await port.deleteChord({ actions: chord.actions });
|
||||
}
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [layer, actions] of $overlay.layout.entries()) {
|
||||
for (const [id, action] of actions) {
|
||||
await port.setLayoutKey(layer + 1, id, action);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, setting] of $overlay.settings) {
|
||||
await port.setSetting(id, setting);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
|
||||
// Yes, this is a completely arbitrary and unnecessary delay.
|
||||
@@ -98,24 +130,9 @@
|
||||
// would be if they click it every time they change a setting.
|
||||
// Because of that, we don't need to show a fearmongering message such as
|
||||
// "Your device will break after you click this 10,000 times!"
|
||||
const virtualWriteTime = 1000;
|
||||
const startStamp = performance.now();
|
||||
await new Promise<void>((resolve) => {
|
||||
function animate() {
|
||||
const delta = performance.now() - startStamp;
|
||||
syncProgress.set({
|
||||
max: virtualWriteTime,
|
||||
current: delta,
|
||||
});
|
||||
if (delta >= virtualWriteTime) {
|
||||
resolve();
|
||||
} else {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
});
|
||||
await port.commit();
|
||||
if (needsCommit) {
|
||||
await port.commit();
|
||||
}
|
||||
|
||||
$deviceLayout = $layout.map((layer) =>
|
||||
layer.map<number>(({ action }) => action),
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { action } from "$lib/title";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import EditActions from "./EditActions.svelte";
|
||||
import { sync, syncStatus } from "$lib/serial/connection";
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
@@ -46,32 +45,6 @@
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
@keyframes syncing {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.syncing {
|
||||
transform-origin: 50% 49%;
|
||||
animation: syncing 1s linear infinite;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
margin-block: 0;
|
||||
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
cursor: pointer;
|
||||
|
||||
@@ -92,11 +65,6 @@
|
||||
border-radius: 50%;
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&.error {
|
||||
color: var(--md-sys-color-on-error);
|
||||
background: var(--md-sys-color-error);
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { action } from "$lib/title";
|
||||
import { onDestroy, onMount, setContext, tick } from "svelte";
|
||||
import { changes, ChangeType, chords } from "$lib/undo-redo";
|
||||
import type { ChordInfo } from "$lib/undo-redo";
|
||||
import type { ChordChange, ChordInfo } from "$lib/undo-redo";
|
||||
import { derived, writable } from "svelte/store";
|
||||
import ChordEdit from "./ChordEdit.svelte";
|
||||
import { crossfade, fly } from "svelte/transition";
|
||||
@@ -14,6 +14,8 @@
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import randomTips from "$lib/assets/random-tips/en.json";
|
||||
import { deviceMeta } from "$lib/serial/connection";
|
||||
import { restoreFromFile } from "$lib/backup/backup";
|
||||
|
||||
const resultSize = 38;
|
||||
let results: HTMLElement;
|
||||
@@ -40,63 +42,70 @@
|
||||
$effect(() => {
|
||||
abortIndexing?.();
|
||||
progress = 0;
|
||||
buildIndex($chords, $osLayout).then(searchIndex.set);
|
||||
buildIndex($chords, $osLayout, $KEYMAP_CODES).then(searchIndex.set);
|
||||
});
|
||||
|
||||
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>, onlyPhrase: boolean = false) {
|
||||
function encodeChord(
|
||||
chord: ChordInfo,
|
||||
osLayout: Map<string, string>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
onlyPhrase: boolean = false,
|
||||
) {
|
||||
const plainPhrase: string[] = [""];
|
||||
const extraActions: string[] = [];
|
||||
const extraCodes: string[] = [];
|
||||
const tags = new Set<string>();
|
||||
const extraActions = new Set<string>();
|
||||
const extraCodes = new Set<string>();
|
||||
|
||||
for (const actionCode of chord.phrase ?? []) {
|
||||
const action = KEYMAP_CODES.get(actionCode);
|
||||
const action = codes.get(actionCode);
|
||||
if (!action) {
|
||||
extraCodes.push(`0x${actionCode.toString(16)}`);
|
||||
extraCodes.add(`0x${actionCode.toString(16)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const osCode = action.keyCode && osLayout.get(action.keyCode);
|
||||
const token = osCode?.length === 1 ? osCode : action.display || action.id;
|
||||
if (!token) {
|
||||
extraCodes.push(`0x${action.code.toString(16)}`);
|
||||
extraCodes.add(`0x${action.code.toString(16)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s$/.test(token) && plainPhrase.at(-1) !== "") {
|
||||
if (
|
||||
(token === "SPACE" || /^\s$/.test(token)) &&
|
||||
plainPhrase.at(-1) !== ""
|
||||
) {
|
||||
plainPhrase.push("");
|
||||
} else if (token.length === 1) {
|
||||
plainPhrase[plainPhrase.length - 1] =
|
||||
plainPhrase[plainPhrase.length - 1] + token;
|
||||
} else {
|
||||
extraActions.push(token);
|
||||
extraActions.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (chord.phrase?.[0] === 298) {
|
||||
plainPhrase.push("suffix");
|
||||
tags.add("suffix");
|
||||
}
|
||||
if (
|
||||
["ARROW_LT", "ARROW_RT", "ARROW_UP", "ARROW_DN"].some((it) =>
|
||||
extraActions.includes(it),
|
||||
extraActions.has(it),
|
||||
)
|
||||
) {
|
||||
plainPhrase.push("cursor warp");
|
||||
tags.add("cursor warp");
|
||||
}
|
||||
if (
|
||||
["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) =>
|
||||
extraActions.includes(it),
|
||||
)
|
||||
["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) => extraActions.has(it))
|
||||
) {
|
||||
plainPhrase.push("macro");
|
||||
tags.add("macro");
|
||||
}
|
||||
if (chord.actions[0] !== 0) {
|
||||
plainPhrase.push("compound");
|
||||
tags.add("compound");
|
||||
}
|
||||
|
||||
const input = chord.actions
|
||||
.slice(chord.actions.lastIndexOf(0) + 1)
|
||||
.map((it) => {
|
||||
const info = KEYMAP_CODES.get(it);
|
||||
const info = codes.get(it);
|
||||
if (!info) return `0x${it.toString(16)}`;
|
||||
const osCode = info.keyCode && osLayout.get(info.keyCode);
|
||||
const result = osCode?.length === 1 ? osCode : info.id;
|
||||
@@ -104,22 +113,25 @@
|
||||
});
|
||||
|
||||
if (onlyPhrase) {
|
||||
return plainPhrase.join();
|
||||
return plainPhrase.join(" ");
|
||||
}
|
||||
|
||||
return [
|
||||
...plainPhrase,
|
||||
`+${input.join("+")}`,
|
||||
...new Set(extraActions),
|
||||
...new Set(extraCodes),
|
||||
...tags,
|
||||
...extraActions,
|
||||
...extraCodes,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function buildIndex(
|
||||
chords: ChordInfo[],
|
||||
osLayout: Map<string, string>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
): Promise<FlexSearch.Index> {
|
||||
if (chords.length === 0 || !browser) return index;
|
||||
|
||||
index = new FlexSearch.Index({
|
||||
tokenize: "full",
|
||||
encode(phrase: string) {
|
||||
@@ -137,20 +149,36 @@
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
let abort = false;
|
||||
abortIndexing = () => {
|
||||
abort = true;
|
||||
};
|
||||
for (let i = 0; i < chords.length; i++) {
|
||||
|
||||
const batchSize = 200;
|
||||
const batches = Math.ceil(chords.length / batchSize);
|
||||
|
||||
for (let b = 0; b < batches; b++) {
|
||||
if (abort) return index;
|
||||
|
||||
const chord = chords[i]!;
|
||||
progress = i;
|
||||
const start = b * batchSize;
|
||||
const end = Math.min((b + 1) * batchSize, chords.length);
|
||||
const batch = chords.slice(start, end);
|
||||
|
||||
if ("phrase" in chord) {
|
||||
await index.addAsync(i, encodeChord(chord, osLayout));
|
||||
}
|
||||
const promises = batch.map((chord, i) => {
|
||||
const chordIndex = start + i;
|
||||
progress = chordIndex + 1;
|
||||
|
||||
if ("phrase" in chord) {
|
||||
const encodedChord = encodeChord(chord, osLayout, codes);
|
||||
return index.addAsync(chordIndex, encodedChord);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
@@ -173,12 +201,14 @@
|
||||
return;
|
||||
}
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id: actions,
|
||||
actions,
|
||||
phrase: [],
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: actions,
|
||||
actions,
|
||||
phrase: [],
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
@@ -186,7 +216,9 @@
|
||||
function downloadVocabulary() {
|
||||
const vocabulary = new Set(
|
||||
$chords.map((it) =>
|
||||
"phrase" in it ? encodeChord(it, $osLayout, true).trim() : "",
|
||||
"phrase" in it
|
||||
? encodeChord(it, $osLayout, $KEYMAP_CODES, true).trim()
|
||||
: "",
|
||||
),
|
||||
);
|
||||
vocabulary.delete("");
|
||||
@@ -201,6 +233,21 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function clearChords() {
|
||||
changes.update((changes) => {
|
||||
changes.push(
|
||||
$chords.map<ChordChange>((it) => ({
|
||||
type: ChangeType.Chord,
|
||||
id: it.id,
|
||||
actions: it.actions,
|
||||
phrase: it.phrase,
|
||||
deleted: true,
|
||||
})),
|
||||
);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
const items = derived(
|
||||
[searchFilter, chords],
|
||||
([filter, chords]) =>
|
||||
@@ -225,9 +272,9 @@
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress)}
|
||||
oninput={(event) => $searchIndex && search($searchIndex, event)}
|
||||
class:loading={progress !== $chords.length - 1}
|
||||
class:loading={progress !== $chords.length}
|
||||
/>
|
||||
<div class="paginator">
|
||||
{#if $lastPage !== -1}
|
||||
@@ -281,6 +328,17 @@
|
||||
"\n\nDid you know? " +
|
||||
randomTips[Math.floor(randomTips.length * Math.random())]}
|
||||
></textarea>
|
||||
<button onclick={clearChords}
|
||||
><span class="icon">delete_sweep</span>
|
||||
Clear Chords</button
|
||||
>
|
||||
<div>
|
||||
{#each Object.entries($deviceMeta?.factoryDefaults?.chords ?? {}) as [title, library]}
|
||||
<button onclick={() => restoreFromFile(library)}
|
||||
><span class="icon">library_add</span>{title}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<button onclick={downloadVocabulary}
|
||||
><span class="icon">download</span>
|
||||
{$LL.configure.chords.VOCABULARY()}</button
|
||||
|
||||
@@ -13,7 +13,12 @@
|
||||
let {
|
||||
chord = undefined,
|
||||
onsubmit,
|
||||
}: { chord?: ChordInfo; onsubmit: (actions: number[]) => void } = $props();
|
||||
interactive = true,
|
||||
}: {
|
||||
chord?: ChordInfo;
|
||||
interactive?: boolean;
|
||||
onsubmit: (actions: number[]) => void;
|
||||
} = $props();
|
||||
|
||||
let pressedKeys = new SvelteSet<number>();
|
||||
let editing = $state(false);
|
||||
@@ -61,12 +66,14 @@
|
||||
if (pressedKeys.size < 1) return;
|
||||
if (!chord) return onsubmit(makeChordInput(...pressedKeys));
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id: chord!.id,
|
||||
actions: makeChordInput(...pressedKeys),
|
||||
phrase: chord!.phrase,
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: chord!.id,
|
||||
actions: makeChordInput(...pressedKeys),
|
||||
phrase: chord!.phrase,
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
return undefined;
|
||||
@@ -76,12 +83,14 @@
|
||||
event.stopPropagation();
|
||||
selectAction(event, (action) => {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id: chord!.id,
|
||||
actions: makeChordInput(...chordActions!, action),
|
||||
phrase: chord!.phrase,
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: chord!.id,
|
||||
actions: makeChordInput(...chordActions!, action),
|
||||
phrase: chord!.phrase,
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
});
|
||||
@@ -125,6 +134,7 @@
|
||||
onkeydown={keydown}
|
||||
onkeyup={keyup}
|
||||
onblur={keyup}
|
||||
disabled={!interactive}
|
||||
>
|
||||
{#if editing && pressedKeys.size === 0}
|
||||
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
|
||||
|
||||
@@ -15,13 +15,15 @@
|
||||
|
||||
function remove() {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id: chord.id,
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase,
|
||||
deleted: true,
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: chord.id,
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase,
|
||||
deleted: true,
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
@@ -35,9 +37,13 @@
|
||||
|
||||
function restore() {
|
||||
changes.update((changes) =>
|
||||
changes.filter(
|
||||
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)),
|
||||
),
|
||||
changes
|
||||
.map((it) =>
|
||||
it.filter(
|
||||
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)),
|
||||
),
|
||||
)
|
||||
.filter((it) => it.length > 0),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,12 +56,14 @@
|
||||
}
|
||||
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id,
|
||||
actions: [...chord.actions],
|
||||
phrase: [...chord.phrase],
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id,
|
||||
actions: [...chord.actions],
|
||||
phrase: [...chord.phrase],
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
});
|
||||
|
||||
function keypress(event: KeyboardEvent) {
|
||||
if (event.key === "ArrowUp") {
|
||||
if (!event.shiftKey && event.key === "ArrowUp") {
|
||||
addSpecial(event);
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
} else if (!event.shiftKey && event.key === "ArrowLeft") {
|
||||
moveCursor(cursorPosition - 1);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
} else if (!event.shiftKey && event.key === "ArrowRight") {
|
||||
moveCursor(cursorPosition + 1);
|
||||
} else if (event.key === "Backspace") {
|
||||
deleteAction(cursorPosition - 1);
|
||||
@@ -49,24 +49,28 @@
|
||||
function deleteAction(at: number, count = 1) {
|
||||
if (!(at in chord.phrase)) return;
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id: chord.id,
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase.toSpliced(at, count),
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: chord.id,
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase.toSpliced(at, count),
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
function insertAction(at: number, action: number) {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
id: chord.id,
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase.toSpliced(at, 0, action),
|
||||
});
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: chord.id,
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase.toSpliced(at, 0, action),
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { KEYMAP_IDS, KEYMAP_KEYCODES } from "$lib/serial/keymap-codes";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export function inputToAction(
|
||||
event: KeyboardEvent,
|
||||
useKeycodes?: boolean,
|
||||
): number | undefined {
|
||||
if (useKeycodes) {
|
||||
return KEYMAP_KEYCODES.get(event.code);
|
||||
return get(KEYMAP_KEYCODES).get(event.code);
|
||||
} else {
|
||||
return KEYMAP_IDS.get(event.key)?.code ?? KEYMAP_KEYCODES.get(event.code);
|
||||
return (
|
||||
get(KEYMAP_IDS).get(event.key)?.code ??
|
||||
get(KEYMAP_KEYCODES).get(event.code)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import { popup } from "$lib/popup";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||
import { setting } from "$lib/setting";
|
||||
import ResetPopup from "./ResetPopup.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
@@ -12,8 +12,26 @@
|
||||
downloadBackup,
|
||||
downloadFile,
|
||||
restoreBackup,
|
||||
restoreFromFile,
|
||||
} from "$lib/backup/backup";
|
||||
import { preference } from "$lib/preferences";
|
||||
import { action } from "$lib/title";
|
||||
import { fly } from "svelte/transition";
|
||||
import type { SettingsItemMeta } from "$lib/meta/types/meta";
|
||||
|
||||
function titlecase(str: string) {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
function settingValue(value: number, setting: SettingsItemMeta) {
|
||||
if (setting.inverse !== undefined) {
|
||||
return setting.inverse / value;
|
||||
}
|
||||
if (setting.scale !== undefined) {
|
||||
return value * setting.scale;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -22,353 +40,155 @@
|
||||
</svelte:head>
|
||||
|
||||
<section>
|
||||
<fieldset>
|
||||
<legend>{$LL.backup.TITLE()}</legend>
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
use:preference={"backup"}
|
||||
/>{$LL.backup.AUTO_BACKUP()}</label
|
||||
>
|
||||
<p class="disclaimer">
|
||||
{$LL.backup.DISCLAIMER()}
|
||||
</p>
|
||||
<div class="row" style="margin-top: auto">
|
||||
<button onclick={() => downloadFile(createChordBackup())}>
|
||||
<span class="icon">piano</span>
|
||||
{$LL.configure.chords.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
||||
<span class="icon">keyboard</span>
|
||||
{$LL.configure.layout.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
||||
<span class="icon">settings</span>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={downloadBackup}
|
||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||
>
|
||||
<label class="button"
|
||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
||||
>settings_backup_restore</span
|
||||
>{$LL.backup.RESTORE()}</label
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Device</legend>
|
||||
<nav>
|
||||
{#if $deviceMeta}
|
||||
{#each $deviceMeta?.settings as category}
|
||||
<a href={`#${category.name}`}>{titlecase(category.name)}</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<a href="#backup">Backup</a>
|
||||
</nav>
|
||||
<div class="content">
|
||||
<label
|
||||
>{$LL.deviceManager.AUTO_CONNECT()}<input
|
||||
type="checkbox"
|
||||
use:preference={"autoConnect"}
|
||||
/></label
|
||||
>
|
||||
{#if $serialPort}
|
||||
{#if $deviceMeta}
|
||||
{#each $deviceMeta.settings as category}
|
||||
<fieldset id={category.name}>
|
||||
<legend>
|
||||
{titlecase(category.name)}
|
||||
</legend>
|
||||
{#if category.description}
|
||||
<p>{category.description}</p>
|
||||
{/if}
|
||||
{#each category.items as item}
|
||||
{#if item.unit === "H"}
|
||||
<label
|
||||
><input type="color" use:setting={{ id: item.id }} /> Color</label
|
||||
>
|
||||
{:else if item.unit !== "S" && item.unit !== "B"}
|
||||
<label class:enable-item={item.name === "enable"}
|
||||
>{#if item.enum}
|
||||
<select class="value" use:setting={{ id: item.id }}>
|
||||
{#each item.enum as name, value}
|
||||
<option {value}>{titlecase(name)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else if item.range[0] === 0 && item.range[1] === 1}
|
||||
<input
|
||||
class="value"
|
||||
type="checkbox"
|
||||
use:setting={{ id: item.id }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="value unit">
|
||||
<input
|
||||
type="number"
|
||||
min={settingValue(item.range[0], item)}
|
||||
max={settingValue(item.range[1], item)}
|
||||
step={item.inverse !== undefined ||
|
||||
item.scale !== undefined ||
|
||||
item.step === undefined
|
||||
? undefined
|
||||
: settingValue(item.step, item)}
|
||||
use:setting={{
|
||||
id: item.id,
|
||||
inverse: item.inverse,
|
||||
scale: item.scale,
|
||||
}}
|
||||
/>{item.unit}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="title">{titlecase(item.name)}</div>
|
||||
{#if item.description}
|
||||
<div class="description">{item.description}</div>
|
||||
{/if}
|
||||
</label>
|
||||
{/if}
|
||||
{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<fieldset id="backup">
|
||||
<legend>{$LL.backup.TITLE()}</legend>
|
||||
<label
|
||||
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
|
||||
>
|
||||
<label
|
||||
>GTM Realtime Feedback<input
|
||||
><input
|
||||
type="checkbox"
|
||||
use:setting={{ id: 0x92 }}
|
||||
/></label
|
||||
use:preference={"backup"}
|
||||
/>{$LL.backup.AUTO_BACKUP()}</label
|
||||
>
|
||||
<button class="outline" use:popup={ResetPopup}>Reset...</button>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
{#if $serialPort}
|
||||
<fieldset>
|
||||
<legend
|
||||
><label
|
||||
><input type="checkbox" use:setting={{ id: 0x41 }} />Spurring</label
|
||||
></legend
|
||||
>
|
||||
<p>
|
||||
"Chording only" mode which tells your device to output chords on a press
|
||||
rather than a press & release. It also enables you to jump from one
|
||||
chord to another without releasing everything and can be activated in
|
||||
GTM or by chording both mirror keys. It can provide significant speed
|
||||
gains with chording, but also takes away the flexibility of character
|
||||
entry.
|
||||
<p class="disclaimer">
|
||||
{$LL.backup.DISCLAIMER()}
|
||||
</p>
|
||||
<p>
|
||||
Spurring also helps new users learn how to chord by eliminating the need
|
||||
to focus on timing.
|
||||
</p>
|
||||
<p>
|
||||
Spurring is toggled by chording <Action display="keys" action={540} /> and
|
||||
<Action display="keys" action={542} /> together.
|
||||
</p>
|
||||
<label
|
||||
>Character Counter Timeout<span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
step="0.001"
|
||||
min="0"
|
||||
max="240"
|
||||
use:setting={{ id: 0x43, scale: 0.001 }}
|
||||
/>s</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend
|
||||
><label
|
||||
><input
|
||||
type="checkbox"
|
||||
use:setting={{ id: 0x51 }}
|
||||
/>Arpeggiates</label
|
||||
></legend
|
||||
>
|
||||
<p>
|
||||
A quick, single key press and release used to indicate a suffix, prefix,
|
||||
or modifier to be associated with a chord.
|
||||
</p>
|
||||
<p>
|
||||
The following keys have special behavior when arpeggiates are enabled:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Action display="keys" action={44} />, <Action
|
||||
display="keys"
|
||||
action={59}
|
||||
/> and <Action display="keys" action={58} /> will be placed before the
|
||||
auto-inserted space
|
||||
</li>
|
||||
<li>
|
||||
<Action display="keys" action={46} />, <Action
|
||||
display="keys"
|
||||
action={63}
|
||||
/> and <Action display="keys" action={33} /> will be placed before the
|
||||
auto-inserted space and capitalize the next word
|
||||
</li>
|
||||
<li>
|
||||
<Action display="keys" action={45} /> and <Action
|
||||
display="keys"
|
||||
action={47}
|
||||
/> will replace the auto-inserted space
|
||||
</li>
|
||||
</ul>
|
||||
<label
|
||||
>Timeout After Chord<span class="unit"
|
||||
><input type="number" step="1" use:setting={{ id: 0x54 }} />ms</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Chord Modifiers</legend>
|
||||
<p>
|
||||
Chord modifiers change a chord when held with the chord or when pressed
|
||||
after (arpeggiated), <b>provided that arpeggiates are enabled.</b>
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<Action display="keys" action={513} /> Capitalizes the first letter of
|
||||
a chord
|
||||
</li>
|
||||
<li>
|
||||
<Action display="keys" action={540} /> Present Tense (supported words only)
|
||||
</li>
|
||||
<li>
|
||||
<Action display="keys" action={542} /> Plural (supported words only)
|
||||
</li>
|
||||
<li>
|
||||
<Action display="keys" action={550} /> Past Tense (supported words only)
|
||||
</li>
|
||||
<li>
|
||||
<Action display="keys" action={551} /> Comparative (supported words only)
|
||||
</li>
|
||||
</ul>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>Character Entry</legend>
|
||||
{#if $serialPort.device === "LITE"}
|
||||
<label
|
||||
>Swap Keymap 0 and 1<input
|
||||
type="checkbox"
|
||||
use:setting={{ id: 0x13 }}
|
||||
/></label
|
||||
<div class="row" style="margin-top: auto">
|
||||
<button onclick={() => downloadFile(createChordBackup())}>
|
||||
<span class="icon">piano</span>
|
||||
{$LL.configure.chords.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
||||
<span class="icon">keyboard</span>
|
||||
{$LL.configure.layout.TITLE()}
|
||||
</button>
|
||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
||||
<span class="icon">settings</span>
|
||||
Settings
|
||||
</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="primary" onclick={downloadBackup}
|
||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||
>
|
||||
<label class="button"
|
||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
||||
>settings_backup_restore</span
|
||||
>{$LL.backup.RESTORE()}</label
|
||||
>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="footer">
|
||||
{#if $serialPort}
|
||||
{#if $deviceMeta?.factoryDefaults?.settings}
|
||||
<button
|
||||
use:action={{ title: "Reset Settings" }}
|
||||
transition:fly={{ x: -8 }}
|
||||
onclick={() =>
|
||||
restoreFromFile($deviceMeta.factoryDefaults!.settings)}
|
||||
><span class="icon">reset_settings</span>Reset Settings</button
|
||||
>
|
||||
{/if}
|
||||
<button use:popup={ResetPopup}>Recovery...</button>
|
||||
{/if}
|
||||
<label>
|
||||
Character Entry (chentry)
|
||||
<input type="checkbox" use:setting={{ id: 0x12 }} />
|
||||
</label>
|
||||
<label>
|
||||
Key Scan Rate
|
||||
<span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
use:setting={{ id: 0x14, inverse: 1000 }}
|
||||
/>Hz</span
|
||||
></label
|
||||
>
|
||||
<label>
|
||||
Key Debounce Press<span class="unit"
|
||||
><input type="number" use:setting={{ id: 0x15 }} />ms</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Key Debounce Release<span class="unit"
|
||||
><input type="number" use:setting={{ id: 0x16 }} />ms</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Output Character Delay<span class="unit"
|
||||
><input type="number" use:setting={{ id: 0x17 }} />µs</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend
|
||||
><label><input type="checkbox" use:setting={{ id: 0x21 }} />Mouse</label
|
||||
></legend
|
||||
>
|
||||
<label
|
||||
>Mouse Speed<input type="number" use:setting={{ id: 0x22 }} /><input
|
||||
type="number"
|
||||
use:setting={{ id: 0x23 }}
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Scroll Speed<input type="number" use:setting={{ id: 0x25 }} /></label
|
||||
>
|
||||
<label>
|
||||
<span>
|
||||
Active Mouse
|
||||
<p>Bounces mouse by 1px every 60s if enabled</p></span
|
||||
>
|
||||
<input type="checkbox" use:setting={{ id: 0x24 }} /></label
|
||||
>
|
||||
<label
|
||||
>Poll Rate<span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
use:setting={{ id: 0x26, inverse: 1000 }}
|
||||
/>Hz</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend
|
||||
><label
|
||||
><input type="checkbox" use:setting={{ id: 0x31 }} />Chording</label
|
||||
></legend
|
||||
>
|
||||
<label
|
||||
>Auto-delete Timeout <span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
min="0"
|
||||
max="25500"
|
||||
step="10"
|
||||
use:setting={{ id: 0x33 }}
|
||||
/>ms</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Press Tolerance<span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
min="1"
|
||||
max="150"
|
||||
step="1"
|
||||
use:setting={{ id: 0x34 }}
|
||||
/>ms</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Release Tolerance<span class="unit"
|
||||
><input
|
||||
type="number"
|
||||
min="1"
|
||||
max="150"
|
||||
step="1"
|
||||
use:setting={{ id: 0x35 }}
|
||||
/>ms</span
|
||||
></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
{#if $serialPort.device === "LITE"}
|
||||
<fieldset>
|
||||
<legend
|
||||
><label><input type="checkbox" use:setting={{ id: 0x84 }} />RGB</label
|
||||
></legend
|
||||
>
|
||||
<label
|
||||
>Brightness<input
|
||||
use:setting={{ id: 0x81 }}
|
||||
type="number"
|
||||
min="0"
|
||||
max="50"
|
||||
step="1"
|
||||
/></label
|
||||
>
|
||||
<select use:setting={{ id: 0x82 }}>
|
||||
<option value="0">White</option>
|
||||
<option value="1">Red</option>
|
||||
<option value="2">Orange</option>
|
||||
<option value="3">Yellow</option>
|
||||
<option value="4">Lime</option>
|
||||
<option value="5">Green</option>
|
||||
<option value="7">Cyan</option>
|
||||
<option value="9">Blue</option>
|
||||
<option value="10">Violet</option>
|
||||
<option value="11">Pink</option>
|
||||
<option value="13">Multicolor</option>
|
||||
</select>
|
||||
</fieldset>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 16px;
|
||||
justify-content: center;
|
||||
|
||||
margin-block: auto;
|
||||
padding-block-end: 48px;
|
||||
scroll-behavior: smooth;
|
||||
max-width: 20cm;
|
||||
}
|
||||
|
||||
button.outline {
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 8px;
|
||||
height: 2em;
|
||||
margin-block: 2em;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
legend,
|
||||
legend > label {
|
||||
font-size: 24px;
|
||||
legend {
|
||||
color: var(--md-sys-color-primary);
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
position: relative;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
legend:has(label) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
legend:not(:has(label)) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
font-size: 12px !important;
|
||||
}
|
||||
@@ -376,41 +196,33 @@
|
||||
fieldset {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
max-width: 400px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 24px;
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
border: none;
|
||||
margin-block-end: 32px;
|
||||
|
||||
/*&:has(> legend input:not(:checked)) > :not(legend) {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}*/
|
||||
> p {
|
||||
padding-inline-start: 16px;
|
||||
}
|
||||
|
||||
> label {
|
||||
appearance: none;
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
height: auto;
|
||||
font-weight: normal;
|
||||
padding: 8px;
|
||||
width: fit-content;
|
||||
|
||||
margin-block: 4px;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
> input[type="number"] {
|
||||
border-radius: 16px 4px 4px 16px;
|
||||
height: 24px;
|
||||
text-align: center;
|
||||
|
||||
&:last-child:not(:only-child) {
|
||||
border-radius: 4px 16px 16px 4px;
|
||||
}
|
||||
|
||||
&:only-child {
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&:has(input[type="number"]) {
|
||||
cursor: text;
|
||||
|
||||
@@ -418,6 +230,26 @@
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.enable-item {
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
margin-inline-start: 8px;
|
||||
padding-inline-end: 16px;
|
||||
padding-inline-start: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-inline-start: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.description {
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
white-space: normal;
|
||||
text-wrap: wrap;
|
||||
}
|
||||
|
||||
.unit {
|
||||
@@ -463,16 +295,16 @@
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul,
|
||||
p {
|
||||
font-size: 10px;
|
||||
|
||||
:global(kbd) {
|
||||
font-size: 12px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
select {
|
||||
appearance: none;
|
||||
background: var(--md-sys-color-secondary);
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
// stylelint-disable-next-line
|
||||
@@ -492,9 +324,15 @@
|
||||
display: flex;
|
||||
justify-content: space-evenly;
|
||||
margin-block: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,231 +1,24 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import {
|
||||
words,
|
||||
nextWord,
|
||||
scores,
|
||||
learnConfigDefault,
|
||||
learnConfig,
|
||||
learnConfigStored,
|
||||
} from "$lib/learn/chords";
|
||||
import { blur, fade } from "svelte/transition";
|
||||
import ChordActionEdit from "../config/chords/ChordActionEdit.svelte";
|
||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||
|
||||
let recorder = $derived(new ReplayRecorder($nextWord));
|
||||
let start = performance.now();
|
||||
$effect(() => {
|
||||
start = recorder && performance.now();
|
||||
});
|
||||
|
||||
let chords: InferredChord[] = $state([]);
|
||||
|
||||
function onkeyboard(event: KeyboardEvent) {
|
||||
recorder.next(event);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
$inspect(chords);
|
||||
|
||||
$effect(() => {
|
||||
const [chord] = chords;
|
||||
if (!chord) return;
|
||||
|
||||
console.log(chord);
|
||||
|
||||
if (chord.output.trim() === $nextWord) {
|
||||
scores.update((scores) => {
|
||||
const score = Math.max(
|
||||
$learnConfig.minScore,
|
||||
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
||||
);
|
||||
|
||||
if (!scores[$nextWord]) {
|
||||
scores[$nextWord] = {
|
||||
score,
|
||||
lastTyped: performance.now(),
|
||||
total: 1,
|
||||
};
|
||||
return scores;
|
||||
}
|
||||
|
||||
const oldScore = scores[$nextWord].score;
|
||||
scores[$nextWord].score = lerp(
|
||||
score,
|
||||
oldScore,
|
||||
$learnConfig.scoreBlend,
|
||||
);
|
||||
scores[$nextWord].lastTyped = performance.now();
|
||||
scores[$nextWord].total += 1;
|
||||
|
||||
return scores;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function skip() {
|
||||
button?.blur();
|
||||
scores.update((scores) => {
|
||||
return scores;
|
||||
});
|
||||
}
|
||||
|
||||
let button = $state<HTMLButtonElement>();
|
||||
</script>
|
||||
|
||||
<h2>WIP</h2>
|
||||
|
||||
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
||||
|
||||
{#key $nextWord}
|
||||
<h3>
|
||||
{$nextWord}
|
||||
{#if $scores[$nextWord!] === undefined}
|
||||
<sup class="new-word">new</sup>
|
||||
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
||||
<sup class="weak">weak</sup>
|
||||
{/if}
|
||||
</h3>
|
||||
|
||||
<div class="chord" in:fade>
|
||||
<CharRecorder replay={recorder.player} cursor={true}>
|
||||
<TrackChords bind:chords />
|
||||
</CharRecorder>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
{#key $nextWord}
|
||||
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
||||
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
||||
</div>
|
||||
{/key}
|
||||
<button onclick={skip} bind:this={button}>skip</button>
|
||||
|
||||
<section class="stats">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Weak</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => a.score - b.score)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
<td><i>{score.score.toFixed(2)}</i></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Strong</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => b.score - a.score)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
<td><i>{score.score.toFixed(2)}</i></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Rehearse</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
<button onclick={() => ($scores = {})}>Reset</button>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each Object.entries(learnConfigDefault) as [key, value]}
|
||||
<tr>
|
||||
<th>{key}</th>
|
||||
<td
|
||||
><input
|
||||
type="number"
|
||||
value={$learnConfig[key] ?? value}
|
||||
step="0.1"
|
||||
oninput={(event) =>
|
||||
($learnConfigStored[key] = event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
disabled={!$learnConfigStored[key]}
|
||||
onclick={() => ($learnConfigStored[key] = undefined)}>⟲</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
<ul>
|
||||
<li><a href="/learn/layout/">Layout</a></li>
|
||||
<li><a href="/learn/chords/">Chords</a></li>
|
||||
<li><a href="/learn/sentence/">Sentences</a></li>
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
@use "sass:math";
|
||||
|
||||
input {
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
width: 5ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div {
|
||||
min-width: 20ch;
|
||||
padding: 1ch;
|
||||
|
||||
ul {
|
||||
margin: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 3em;
|
||||
}
|
||||
|
||||
sup {
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
|
||||
&.new-word {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
&.weak {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 10 {
|
||||
tr.decay:nth-child(#{$i}) {
|
||||
opacity: 1 - math.div($i, 10);
|
||||
}
|
||||
a {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
</style>
|
||||
|
||||
229
src/routes/(app)/learn/chords/+page.svelte
Normal file
229
src/routes/(app)/learn/chords/+page.svelte
Normal file
@@ -0,0 +1,229 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import {
|
||||
words,
|
||||
nextWord,
|
||||
scores,
|
||||
learnConfigDefault,
|
||||
learnConfig,
|
||||
learnConfigStored,
|
||||
} from "$lib/learn/chords";
|
||||
import { blur, fade } from "svelte/transition";
|
||||
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
|
||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||
|
||||
let recorder = $derived(new ReplayRecorder($nextWord));
|
||||
let start = performance.now();
|
||||
$effect(() => {
|
||||
start = recorder && performance.now();
|
||||
});
|
||||
|
||||
let chords: InferredChord[] = $state([]);
|
||||
|
||||
function onkeyboard(event: KeyboardEvent) {
|
||||
recorder.next(event);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const [chord] = chords;
|
||||
if (!chord) return;
|
||||
|
||||
console.log(chord);
|
||||
|
||||
if (chord.output.trim() === $nextWord) {
|
||||
scores.update((scores) => {
|
||||
const score = Math.max(
|
||||
$learnConfig.minScore,
|
||||
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
||||
);
|
||||
|
||||
if (!scores[$nextWord]) {
|
||||
scores[$nextWord] = {
|
||||
score,
|
||||
lastTyped: performance.now(),
|
||||
total: 1,
|
||||
};
|
||||
return scores;
|
||||
}
|
||||
|
||||
const oldScore = scores[$nextWord].score;
|
||||
scores[$nextWord].score = lerp(
|
||||
score,
|
||||
oldScore,
|
||||
$learnConfig.scoreBlend,
|
||||
);
|
||||
scores[$nextWord].lastTyped = performance.now();
|
||||
scores[$nextWord].total += 1;
|
||||
|
||||
return scores;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function skip() {
|
||||
button?.blur();
|
||||
scores.update((scores) => {
|
||||
return scores;
|
||||
});
|
||||
}
|
||||
|
||||
let button = $state<HTMLButtonElement>();
|
||||
</script>
|
||||
|
||||
<h2>WIP</h2>
|
||||
|
||||
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
||||
|
||||
{#key $nextWord}
|
||||
<h3>
|
||||
{$nextWord}
|
||||
{#if $scores[$nextWord!] === undefined}
|
||||
<sup class="new-word">new</sup>
|
||||
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
||||
<sup class="weak">weak</sup>
|
||||
{/if}
|
||||
</h3>
|
||||
|
||||
<div class="chord" in:fade>
|
||||
<CharRecorder replay={recorder.player} cursor={true}>
|
||||
<TrackChords bind:chords />
|
||||
</CharRecorder>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
{#key $nextWord}
|
||||
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
||||
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
||||
</div>
|
||||
{/key}
|
||||
<button onclick={skip} bind:this={button}>skip</button>
|
||||
|
||||
<section class="stats">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Weak</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => a.score - b.score)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
<td><i>{score.score.toFixed(2)}</i></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Strong</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => b.score - a.score)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
<td><i>{score.score.toFixed(2)}</i></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Rehearse</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
<button onclick={() => ($scores = {})}>Reset</button>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each Object.entries(learnConfigDefault) as [key, value]}
|
||||
<tr>
|
||||
<th>{key}</th>
|
||||
<td
|
||||
><input
|
||||
type="number"
|
||||
value={$learnConfig[key] ?? value}
|
||||
step="0.1"
|
||||
oninput={(event) =>
|
||||
($learnConfigStored[key] = event.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
disabled={!$learnConfigStored[key]}
|
||||
onclick={() => ($learnConfigStored[key] = undefined)}>⟲</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
<style lang="scss">
|
||||
@use "sass:math";
|
||||
|
||||
input {
|
||||
background: none;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
width: 5ch;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div {
|
||||
min-width: 20ch;
|
||||
padding: 1ch;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 3em;
|
||||
}
|
||||
|
||||
sup {
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
|
||||
&.new-word {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
&.weak {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 10 {
|
||||
tr.decay:nth-child(#{$i}) {
|
||||
opacity: 1 - math.div($i, 10);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
126
src/routes/(app)/learn/layout/+page.svelte
Normal file
126
src/routes/(app)/learn/layout/+page.svelte
Normal file
@@ -0,0 +1,126 @@
|
||||
<script lang="ts">
|
||||
import { share } from "$lib/share";
|
||||
import tippy from "tippy.js";
|
||||
import { mount, setContext, unmount } from "svelte";
|
||||
import Layout from "$lib/components/layout/Layout.svelte";
|
||||
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { layout } from "$lib/undo-redo";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
|
||||
let hasStarted = $state(false);
|
||||
|
||||
setContext<VisualLayoutConfig>("visual-layout-config", {
|
||||
scale: 50,
|
||||
inactiveScale: 0.5,
|
||||
inactiveOpacity: 0.4,
|
||||
strokeWidth: 1,
|
||||
margin: 5,
|
||||
fontSize: 9,
|
||||
iconFontSize: 14,
|
||||
});
|
||||
|
||||
const actions = derived(layout, (layout) => {
|
||||
const result = new Set<number>();
|
||||
for (const layer of layout) {
|
||||
for (const key of layer) {
|
||||
result.add(key.action);
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
});
|
||||
|
||||
const currentAction = writable(0);
|
||||
|
||||
const expected = derived(
|
||||
[layout, currentAction],
|
||||
([layout, currentAction]) => {
|
||||
const result: Array<{ layer: number; key: number }> = [];
|
||||
for (let layer = 0; layer <= layout.length; layer++) {
|
||||
if (layout[layer] === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (let key = 0; key <= layout[layer].length; key++) {
|
||||
if (layout[layer][key]?.action === currentAction) {
|
||||
result.push({ layer, key });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const highlight = derived(
|
||||
expected,
|
||||
(expected) => new Set(expected.map(({ key }) => key)),
|
||||
);
|
||||
|
||||
const highlightAction = derived(
|
||||
currentAction,
|
||||
(currentAction) => new Set([currentAction]),
|
||||
);
|
||||
|
||||
const currentLayer = writable(0);
|
||||
|
||||
setContext("highlight", highlight);
|
||||
|
||||
setContext("highlight-action", highlightAction);
|
||||
|
||||
setContext("active-layer", currentLayer);
|
||||
|
||||
async function next() {
|
||||
console.log("Next");
|
||||
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
|
||||
if (nextAction !== undefined) {
|
||||
currentAction.set(nextAction);
|
||||
currentLayer.set($expected[0]?.layer ?? 0);
|
||||
const key = await $serialPort?.queryKey();
|
||||
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
|
||||
console.log("Correct", key);
|
||||
} else {
|
||||
console.log("Incorrect", key);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
|
||||
hasStarted = true;
|
||||
next();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="challenge">
|
||||
<Action display="inline-keys" action={$currentAction}></Action>
|
||||
</div>
|
||||
|
||||
<Layout />
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.challenge {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
652
src/routes/(app)/learn/sentence/+page.svelte
Normal file
652
src/routes/(app)/learn/sentence/+page.svelte
Normal file
@@ -0,0 +1,652 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import debounce from "$lib/util/debounce";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import { shuffleInPlace } from "$lib/util/shuffle";
|
||||
import { fade, fly, slide } from "svelte/transition";
|
||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
|
||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||
import TrackText from "$lib/charrecorder/TrackText.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { goto } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
type PageParam,
|
||||
SENTENCE_TRAINER_PAGE_PARAMS,
|
||||
} from "./configuration";
|
||||
import {
|
||||
AVG_WORD_LENGTH,
|
||||
MILLIS_IN_SECOND,
|
||||
SECONDS_IN_MINUTE,
|
||||
} from "./constants";
|
||||
import { pickNextWord } from "./word-selector";
|
||||
|
||||
/**
|
||||
* Resolves parameter from search URL or returns default
|
||||
* @param param {@link PageParam} generic parameter that can be provided
|
||||
* in search url
|
||||
* @return Value of the parameter converted to its type or default value
|
||||
* if parameter is not present in the URL.
|
||||
*/
|
||||
function getParamOrDefault<T>(param: PageParam<T>): T {
|
||||
if (browser) {
|
||||
const value = $page.url.searchParams.get(param.key);
|
||||
if (null !== value) {
|
||||
return param.parse ? param.parse(value) : (value as unknown as T);
|
||||
}
|
||||
}
|
||||
return param.default;
|
||||
}
|
||||
|
||||
function viaLocalStorage<T>(key: string, initial: T) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) ?? "");
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to ensure cursor is visible after focus is set.
|
||||
// it is a workaround for conflict between goto call on sentence update
|
||||
// and cursor focus when next word is selected.
|
||||
const CURSOR_FOCUS_DELAY_MS = 10;
|
||||
|
||||
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
|
||||
viaLocalStorage("mastery-thresholds", [
|
||||
[1500, 1050, "Words"],
|
||||
[3000, 2500, "Pairs"],
|
||||
[5000, 3500, "Trios"],
|
||||
]),
|
||||
);
|
||||
|
||||
function reset() {
|
||||
localStorage.removeItem("mastery-thresholds");
|
||||
localStorage.removeItem("idle-timeout");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const inputSentence = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
|
||||
);
|
||||
|
||||
const wpmTarget = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
|
||||
);
|
||||
|
||||
const devTools = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
|
||||
);
|
||||
|
||||
let chordInputContainer: HTMLDivElement | null = null;
|
||||
|
||||
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
|
||||
|
||||
let inputSentenceLength = $derived(inputSentence.length);
|
||||
let msPerChar = $derived(
|
||||
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
|
||||
MILLIS_IN_SECOND,
|
||||
);
|
||||
let totalMs = $derived(inputSentenceLength * msPerChar);
|
||||
let msPerWord = $derived(
|
||||
(inputSentenceLength * msPerChar) / sentenceWords.length,
|
||||
);
|
||||
let currentWord = $state("");
|
||||
let wordStats = new SvelteMap<string, number[]>();
|
||||
let wordMastery = new SvelteMap<string, number>();
|
||||
let text = $state("");
|
||||
let level = $state(0);
|
||||
let bestWPM = $state(0);
|
||||
let wpm = $state(0);
|
||||
let chords: InferredChord[] = $state([]);
|
||||
let recorder = $state(new ReplayRecorder());
|
||||
let idle = $state(true);
|
||||
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
|
||||
|
||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
if (wpm > bestWPM) {
|
||||
bestWPM = wpm;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (browser && $page.url.searchParams) {
|
||||
selectNextWord();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
localStorage.setItem("idle-timeout", idleTime.toString());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
localStorage.setItem(
|
||||
"mastery-thresholds",
|
||||
JSON.stringify(masteryThresholds),
|
||||
);
|
||||
});
|
||||
|
||||
let words = $derived.by(() => {
|
||||
const words = sentenceWords;
|
||||
switch (level) {
|
||||
case 0: {
|
||||
shuffleInPlace(words);
|
||||
return words;
|
||||
}
|
||||
case 1: {
|
||||
const pairs = [];
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
pairs.push(`${words[i]} ${words[i + 1]}`);
|
||||
}
|
||||
shuffleInPlace(pairs);
|
||||
return pairs;
|
||||
}
|
||||
case 2: {
|
||||
const trios = [];
|
||||
for (let i = 0; i < words.length - 2; i++) {
|
||||
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
||||
}
|
||||
shuffleInPlace(trios);
|
||||
return trios;
|
||||
}
|
||||
default: {
|
||||
return [inputSentence];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
for (const [word, speeds] of wordStats.entries()) {
|
||||
const level = word.split(" ").length - 1;
|
||||
const masteryThreshold = masteryThresholds[level];
|
||||
if (masteryThreshold === undefined) continue;
|
||||
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
|
||||
wordMastery.set(
|
||||
word,
|
||||
1 -
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(averageSpeed - masteryThreshold[1]) /
|
||||
(masteryThreshold[0] - masteryThreshold[1]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let progress = $derived(
|
||||
level === masteryThresholds.length
|
||||
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
|
||||
: words.length > 0
|
||||
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
|
||||
words.length
|
||||
: 0,
|
||||
);
|
||||
let mastered = $derived(
|
||||
words.length > 0
|
||||
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
|
||||
: 0,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (progress === 1 && level < masteryThresholds.length) {
|
||||
level++;
|
||||
}
|
||||
});
|
||||
|
||||
function selectNextWord() {
|
||||
const nextWord = pickNextWord(
|
||||
words,
|
||||
wordMastery,
|
||||
untrack(() => currentWord),
|
||||
);
|
||||
currentWord = nextWord;
|
||||
recorder = new ReplayRecorder(nextWord);
|
||||
setTimeout(() => {
|
||||
chordInputContainer?.focus();
|
||||
}, CURSOR_FOCUS_DELAY_MS);
|
||||
}
|
||||
|
||||
function checkInput() {
|
||||
if (recorder.player.stepper.challenge.length === 0) return;
|
||||
const replay = recorder.finish(false);
|
||||
const elapsed = replay.finish - replay.start! - idleTime;
|
||||
if (elapsed < masteryThresholds[level]![0]) {
|
||||
const prevStats = wordStats.get(currentWord) ?? [];
|
||||
prevStats.push(elapsed);
|
||||
wordStats.set(currentWord, prevStats.slice(-10));
|
||||
}
|
||||
|
||||
text = "";
|
||||
setTimeout(() => {
|
||||
selectNextWord();
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!idle || !text) return;
|
||||
if (text.trim() !== currentWord.trim()) return;
|
||||
if (level === masteryThresholds.length) {
|
||||
const replay = recorder.finish();
|
||||
const elapsed = replay.finish - replay.start!;
|
||||
text = "";
|
||||
recorder = new ReplayRecorder(currentWord);
|
||||
console.log(elapsed, totalMs);
|
||||
wpm = (totalMs / elapsed) * wpmTarget;
|
||||
} else {
|
||||
checkInput();
|
||||
}
|
||||
});
|
||||
|
||||
function onkey(event: KeyboardEvent) {
|
||||
if (idleTimeout) {
|
||||
clearTimeout(idleTimeout);
|
||||
}
|
||||
idle = false;
|
||||
recorder.next(event);
|
||||
idleTimeout = setTimeout(() => {
|
||||
idle = true;
|
||||
}, idleTime);
|
||||
}
|
||||
|
||||
function updateSentence(event: Event) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(
|
||||
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
|
||||
(event.target as HTMLInputElement).value,
|
||||
);
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const debouncedUpdateSentence = debounce(
|
||||
updateSentence,
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
|
||||
);
|
||||
|
||||
function handleInputAreaKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault(); // Prevent new line.
|
||||
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
|
||||
updateSentence(event); // Update immediately
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Sentence Trainer</h1>
|
||||
<textarea
|
||||
rows="7"
|
||||
cols="80"
|
||||
oninput={debouncedUpdateSentence}
|
||||
onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
|
||||
>
|
||||
|
||||
<div class="levels">
|
||||
{#each masteryThresholds as [, , title], i}
|
||||
<button
|
||||
class:active={level === i}
|
||||
class:mastered={i < level || progress === 1}
|
||||
class="threshold"
|
||||
onclick={() => {
|
||||
level = i;
|
||||
selectNextWord();
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
class:active={level === masteryThresholds.length}
|
||||
class:mastered={masteryThresholds.length < level || progress === 1}
|
||||
class="threshold"
|
||||
onclick={() => {
|
||||
level = masteryThresholds.length;
|
||||
selectNextWord();
|
||||
}}
|
||||
>
|
||||
{wpmTarget} WPM
|
||||
</button>
|
||||
{#each masteryThresholds as _, i}
|
||||
<div
|
||||
class="progress"
|
||||
style:--progress="{-100 *
|
||||
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
|
||||
style:--mastered="{-100 *
|
||||
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
|
||||
class:active={level === i}
|
||||
></div>
|
||||
{/each}
|
||||
<div
|
||||
class="progress"
|
||||
style:--progress="-100%"
|
||||
style:--mastered="{-100 *
|
||||
(1 -
|
||||
(level === masteryThresholds.length
|
||||
? progress
|
||||
: masteryThresholds.length < level
|
||||
? 1
|
||||
: 0))}%"
|
||||
class:active={level === masteryThresholds.length}
|
||||
></div>
|
||||
</div>
|
||||
<div class="sentence">
|
||||
{#each sentenceWords as _, i}
|
||||
{#if i !== sentenceWords.length - 1}
|
||||
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
|
||||
{@const mastery = wordMastery.get(word) ?? 0}
|
||||
<div
|
||||
class="arch"
|
||||
class:mastered={mastery === 1}
|
||||
style:opacity={mastery}
|
||||
style:grid-row={(i % 2) + 1}
|
||||
style:grid-column="{i + 1} / span 2"
|
||||
style:border-bottom="none"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each sentenceWords as word, i}
|
||||
{@const mastery = wordMastery.get(word)}
|
||||
<div
|
||||
class="word"
|
||||
class:mastered={mastery === 1}
|
||||
style:opacity={mastery ?? 0}
|
||||
style:grid-row={3}
|
||||
style:grid-column={i + 1}
|
||||
>
|
||||
{word}
|
||||
</div>
|
||||
{/each}
|
||||
{#each sentenceWords as _, i}
|
||||
{#if i < sentenceWords.length - 2}
|
||||
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
|
||||
{@const mastery = wordMastery.get(word) ?? 0}
|
||||
<div
|
||||
class="arch"
|
||||
class:mastered={mastery === 1}
|
||||
style:opacity={mastery}
|
||||
style:grid-row={(i % 3) + 4}
|
||||
style:grid-column="{i + 1} / span 3"
|
||||
style:border-top="none"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if level === masteryThresholds.length}
|
||||
{@const maxDigits = 4}
|
||||
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
|
||||
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
|
||||
<div class="finish" transition:slide>
|
||||
<div
|
||||
class="wpm"
|
||||
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
|
||||
style:opacity={progress}
|
||||
style:font-size="3rem"
|
||||
style:color="var(--md-sys-color-{progress === 1
|
||||
? 'primary'
|
||||
: 'on-background'})"
|
||||
style:scale={(progress + 0.5) / 2}
|
||||
>
|
||||
{#each indices as i}
|
||||
{@const char = wpmString[i]}
|
||||
{#key char}
|
||||
<div
|
||||
style:grid-column={i + 1}
|
||||
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
|
||||
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
|
||||
>
|
||||
{char}
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
<div style:grid-column={maxDigits + 3} style:justify-self="start">
|
||||
WPM
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="wpm"
|
||||
style:grid-template-columns="4ch 1ch auto"
|
||||
style:font-size="1.5rem"
|
||||
>
|
||||
{#key wpm}
|
||||
<div
|
||||
style:grid-column={1}
|
||||
style:justify-self="end"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
{Math.floor(wpm)}
|
||||
</div>
|
||||
{/key}
|
||||
<div style:grid-column={3} style:justify-self="start">WPM</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ChordHud {chords} />
|
||||
<div class="container">
|
||||
<div
|
||||
bind:this={chordInputContainer}
|
||||
class="input-section"
|
||||
onkeydown={onkey}
|
||||
onkeyup={onkey}
|
||||
tabindex="0"
|
||||
role="textbox"
|
||||
>
|
||||
{#key recorder}
|
||||
<div class="input" transition:fade={{ duration: 200 }}>
|
||||
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
||||
<TrackText bind:text />
|
||||
<TrackChords bind:chords />
|
||||
</CharRecorder>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{#if devTools}
|
||||
<div>Dev Tools</div>
|
||||
<button onclick={reset}>Reset</button>
|
||||
<label>Idle Time <input bind:value={idleTime} /></label>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(totalMs)}</span
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Char</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(msPerChar)}</span
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Word</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(msPerWord)}</span
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each masteryThresholds as _, i}
|
||||
<tr>
|
||||
<th>L{i + 1}</th>
|
||||
<td><input bind:value={masteryThresholds[i]![0]} /></td>
|
||||
<td><input bind:value={masteryThresholds[i]![1]} /></td>
|
||||
<td><input bind:value={masteryThresholds[i]![2]} /></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each wordStats.entries() as [word, stats]}
|
||||
{@const mastery = wordMastery.get(word) ?? 0}
|
||||
<tr>
|
||||
<th>{word}</th>
|
||||
<td
|
||||
style:color="var(--md-sys-color-{mastery === 1
|
||||
? 'primary'
|
||||
: 'tertiary'})"
|
||||
>{Math.round(mastery * 100)}%
|
||||
</td>
|
||||
{#each stats as stat}
|
||||
<td>{stat}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.levels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wpm {
|
||||
width: min-content;
|
||||
display: grid;
|
||||
transition: scale 0.2s ease;
|
||||
|
||||
* {
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.finish {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
font-weight: bold;
|
||||
justify-items: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sentence {
|
||||
display: grid;
|
||||
width: min-content;
|
||||
gap: 4px 1ch;
|
||||
grid-template-rows: repeat(4, auto);
|
||||
margin-block: 1rem;
|
||||
|
||||
.word,
|
||||
.arch {
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&.mastered {
|
||||
color: var(--md-sys-color-primary);
|
||||
border-color: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.arch {
|
||||
border: 2px solid var(--md-sys-color-outline);
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
height: 1rem;
|
||||
width: auto;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
border: none;
|
||||
overflow: hidden;
|
||||
grid-row: 2;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&::before {
|
||||
background: var(--md-sys-color-outline);
|
||||
transform: translateX(var(--progress));
|
||||
}
|
||||
|
||||
&::after {
|
||||
background: var(--md-sys-color-primary);
|
||||
transform: translateX(var(--mastered));
|
||||
}
|
||||
}
|
||||
|
||||
.threshold {
|
||||
width: auto;
|
||||
justify-self: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
grid-row: 1;
|
||||
|
||||
&.mastered,
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.mastered {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: grid;
|
||||
cursor: text;
|
||||
|
||||
:global(.cursor) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
font-size: 1.5rem;
|
||||
padding: 1rem;
|
||||
max-width: 16cm;
|
||||
outline: 2px dashed transparent;
|
||||
border-radius: 0.25rem;
|
||||
margin-block: 1rem;
|
||||
transition:
|
||||
outline 0.2s ease,
|
||||
border-radius 0.2s ease;
|
||||
}
|
||||
|
||||
.input-section:focus-within {
|
||||
outline: none;
|
||||
|
||||
.input {
|
||||
outline-color: var(--md-sys-color-primary);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
:global(.cursor) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export interface PageParam<T> {
|
||||
key: string;
|
||||
default: T;
|
||||
parse?: (value: string) => T;
|
||||
}
|
||||
|
||||
export const SENTENCE_TRAINER_PAGE_PARAMS: {
|
||||
sentence: PageParam<string>;
|
||||
wpm: PageParam<number>;
|
||||
showDevTools: PageParam<boolean>;
|
||||
textAreaDebounceInMillis: PageParam<number>;
|
||||
} = {
|
||||
sentence: {
|
||||
key: "sentence",
|
||||
default: "This text has been typed at the speed of thought",
|
||||
},
|
||||
wpm: {
|
||||
key: "wpm",
|
||||
default: 250,
|
||||
parse: (value) => Number(value),
|
||||
},
|
||||
showDevTools: {
|
||||
key: "dev",
|
||||
default: false,
|
||||
parse: (value) => value === "true",
|
||||
},
|
||||
textAreaDebounceInMillis: {
|
||||
key: "debounceMillis",
|
||||
default: 5000,
|
||||
parse: (value) => Number(value),
|
||||
},
|
||||
};
|
||||
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Domain constants
|
||||
export const AVG_WORD_LENGTH = 5;
|
||||
export const SECONDS_IN_MINUTE = 60;
|
||||
export const MILLIS_IN_SECOND = 1000;
|
||||
|
||||
// Error messages.
|
||||
export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE =
|
||||
"The sentence is too short to make N-Grams, please enter longer sentence";
|
||||
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, it, beforeEach, expect, vi } from "vitest";
|
||||
import { pickNextWord } from "./word-selector";
|
||||
import { untrack } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
||||
|
||||
// Mock untrack so it simply executes the callback, allowing us to spy on its usage.
|
||||
vi.mock("svelte", () => ({
|
||||
untrack: vi.fn((fn: any) => fn()),
|
||||
}));
|
||||
|
||||
describe("pickNextWord", () => {
|
||||
let words: string[];
|
||||
let wordMastery: SvelteMap<string, number>;
|
||||
let currentWord: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up sample words and mastery values.
|
||||
words = ["alpha", "beta", "gamma"];
|
||||
wordMastery = new SvelteMap<string, number>();
|
||||
// For this test, assume none of the words are mastered.
|
||||
words.forEach((word) => wordMastery.set(word, 0));
|
||||
currentWord = "alpha";
|
||||
});
|
||||
|
||||
it("should return a word different from current", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.3);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since currentWord ("alpha") should be skipped, we expect next word.
|
||||
expect(nextWord).toBe("beta");
|
||||
});
|
||||
|
||||
it("should randomly skip words", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since currentWord ("alpha") should be skipped as current
|
||||
// and "beta" should be randomly skipped we expect "gamma".
|
||||
expect(nextWord).toBe("gamma");
|
||||
});
|
||||
|
||||
it("should return current word if all other words were randomly skipped", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since all other words have been randomly skipped, we expect
|
||||
// current word to be returned.
|
||||
expect(nextWord).toBe("alpha");
|
||||
});
|
||||
|
||||
it("current word should be passed untracked", () => {
|
||||
pickNextWord(words, wordMastery, currentWord);
|
||||
expect(untrack).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => {
|
||||
const result = pickNextWord([], wordMastery, currentWord);
|
||||
expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE);
|
||||
});
|
||||
});
|
||||
25
src/routes/(app)/learn/sentence/word-selector.ts
Normal file
25
src/routes/(app)/learn/sentence/word-selector.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
export function pickNextWord(
|
||||
words: string[],
|
||||
wordMastery: SvelteMap<string, number>,
|
||||
untrackedCurrentWord: string,
|
||||
) {
|
||||
const unmasteredWords = words
|
||||
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
|
||||
.filter(([, it]) => it !== 1);
|
||||
unmasteredWords.sort(([, a], [, b]) => a - b);
|
||||
let nextWord =
|
||||
unmasteredWords[0]?.[0] ??
|
||||
words[0] ??
|
||||
TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE;
|
||||
// This is important to break infinite loop created by
|
||||
// reading and writing `currentWord` inside $effect rune
|
||||
for (const [word] of unmasteredWords) {
|
||||
if (word === untrackedCurrentWord || Math.random() > 0.5) continue;
|
||||
nextWord = word;
|
||||
break;
|
||||
}
|
||||
return nextWord;
|
||||
}
|
||||
20
src/routes/(app)/test/+page.svelte
Normal file
20
src/routes/(app)/test/+page.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import AnimatedNumber from "$lib/components/AnimatedNumber.svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
let value = $state(Math.round(Math.random() * 100));
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => {
|
||||
value = Math.round(Math.random() * 100);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<p>The number is <AnimatedNumber {value} /></p>
|
||||
@@ -23,7 +23,7 @@ process.env["VITE_LEARN_URL"] = "https://www.iq-eq.io/";
|
||||
process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
|
||||
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
|
||||
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
|
||||
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware/";
|
||||
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -37,6 +37,13 @@ export default defineConfig({
|
||||
define: {
|
||||
global: "window",
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
api: "modern-compiler",
|
||||
},
|
||||
},
|
||||
},
|
||||
envPrefix: ["TAURI_", "VITE_"],
|
||||
plugins: [
|
||||
ViteYaml(),
|
||||
|
||||
Reference in New Issue
Block a user