mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-05 08:42:41 +00:00
Compare commits
48 Commits
24fc861ef4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
b9c6c05819
|
|||
|
16bf766de9
|
|||
|
ee8d400ad7
|
|||
|
9a1c2b5bf6
|
|||
|
1d1fcb72e3
|
|||
|
ee3f84645d
|
|||
|
82dd08f2a2
|
|||
|
9f65b4bb6c
|
|||
|
e08dda40d9
|
|||
|
a403bf1ac0
|
|||
|
1aff1703ac
|
|||
|
fe42dcd2ab
|
|||
|
b13c34ca15
|
|||
|
4023ab9bd5
|
|||
|
2893afa2ba
|
|||
|
7beab5ac07
|
|||
|
6895fa4a82
|
|||
|
245dd97532
|
|||
|
d84495894a
|
|||
|
1de52f7f81
|
|||
|
45682f0d1a
|
|||
|
5f0bc45851
|
|||
|
c6f1f3f6fc
|
|||
|
32c2ce2f45
|
|||
|
c6e2f59b05
|
|||
|
2a872bafac
|
|||
|
a940d1b480
|
|||
|
f3b1d76666
|
|||
|
0b2695a380
|
|||
|
048dee0a6d
|
|||
|
977bdf3043
|
|||
|
9ca30f412e
|
|||
|
f2a18cafe8
|
|||
|
b27182dc35
|
|||
|
74ce6af318
|
|||
|
782f1fc38b
|
|||
|
|
087ff36d5d | ||
|
bd1c6147fd
|
|||
|
891abda0fb
|
|||
|
3611f65e24
|
|||
|
f76882a09c
|
|||
|
ff7e4f7b2e
|
|||
|
1c1c86241f
|
|||
|
dc8b3c3d66
|
|||
|
|
65911419b0 | ||
|
|
ccfb09e261 | ||
|
b841469505
|
|||
|
bc06e8ee80
|
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -4,6 +4,8 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- master
|
- master
|
||||||
|
tags:
|
||||||
|
- v*
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ node_modules
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
|
/openssl*
|
||||||
|
/src/i18n/i18n*
|
||||||
|
|
||||||
# Ignore files for PNPM, NPM and YARN
|
# Ignore files for PNPM, NPM and YARN
|
||||||
pnpm-lock.yaml
|
pnpm-lock.yaml
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte", "prettier-plugin-css-order"],
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"stylelint-config-standard-scss",
|
"stylelint-config-standard-scss",
|
||||||
"stylelint-config-recommended-scss",
|
"stylelint-config-recommended-scss",
|
||||||
"stylelint-config-html/svelte",
|
"stylelint-config-html/svelte",
|
||||||
"stylelint-config-clean-order",
|
|
||||||
"stylelint-config-prettier-scss"
|
"stylelint-config-prettier-scss"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
|
|||||||
@@ -40,4 +40,4 @@ To generate the icons use the following command:
|
|||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm run minify-icons
|
npm run minify-icons
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const config = {
|
|||||||
"abc",
|
"abc",
|
||||||
"function",
|
"function",
|
||||||
"cloud_done",
|
"cloud_done",
|
||||||
|
"counter_4",
|
||||||
"backup",
|
"backup",
|
||||||
"cloud_download",
|
"cloud_download",
|
||||||
"cloud_off",
|
"cloud_off",
|
||||||
@@ -43,14 +44,20 @@ const config = {
|
|||||||
"arrow_back",
|
"arrow_back",
|
||||||
"arrow_back_ios_new",
|
"arrow_back_ios_new",
|
||||||
"save",
|
"save",
|
||||||
|
"step_over",
|
||||||
|
"step_into",
|
||||||
|
"step_out",
|
||||||
|
"timer_play",
|
||||||
"settings_backup_restore",
|
"settings_backup_restore",
|
||||||
"sound_detection_loud_sound",
|
"sound_detection_loud_sound",
|
||||||
"ring_volume",
|
"ring_volume",
|
||||||
|
"skillet",
|
||||||
"wifi",
|
"wifi",
|
||||||
"power_settings_circle",
|
"power_settings_circle",
|
||||||
"graphic_eq",
|
"graphic_eq",
|
||||||
"mail",
|
"mail",
|
||||||
"calculate",
|
"calculate",
|
||||||
|
"playground_2",
|
||||||
"open_in_browser",
|
"open_in_browser",
|
||||||
"chevron_backward",
|
"chevron_backward",
|
||||||
"chevron_forward",
|
"chevron_forward",
|
||||||
@@ -72,17 +79,25 @@ const config = {
|
|||||||
"light_mode",
|
"light_mode",
|
||||||
"palette",
|
"palette",
|
||||||
"translate",
|
"translate",
|
||||||
|
"smart_toy",
|
||||||
|
"visibility_off",
|
||||||
"play_arrow",
|
"play_arrow",
|
||||||
"extension",
|
"extension",
|
||||||
"upload_file",
|
"upload_file",
|
||||||
|
"file_export",
|
||||||
|
"file_save",
|
||||||
"commit",
|
"commit",
|
||||||
"bug_report",
|
"bug_report",
|
||||||
"delete",
|
"delete",
|
||||||
"remove_selection",
|
"remove_selection",
|
||||||
"bolt",
|
"bolt",
|
||||||
|
"thunderstorm",
|
||||||
|
"join_inner",
|
||||||
|
"uppercase",
|
||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"redo",
|
||||||
"replay",
|
"replay",
|
||||||
|
"clock_loader_80",
|
||||||
"reply",
|
"reply",
|
||||||
"navigate_before",
|
"navigate_before",
|
||||||
"navigate_next",
|
"navigate_next",
|
||||||
@@ -133,15 +148,29 @@ const config = {
|
|||||||
"developer_board",
|
"developer_board",
|
||||||
"developer_board_off",
|
"developer_board_off",
|
||||||
"memory",
|
"memory",
|
||||||
|
"gamepad_circle_up",
|
||||||
|
"gamepad_circle_left",
|
||||||
|
"gamepad_circle_down",
|
||||||
|
"gamepad_circle_right",
|
||||||
|
"trail_length_medium",
|
||||||
|
"blur_short",
|
||||||
|
"combine_columns",
|
||||||
|
"animation",
|
||||||
|
"text_select_move_back_word",
|
||||||
],
|
],
|
||||||
codePoints: {
|
codePoints: {
|
||||||
speed: "e9e4",
|
speed: "e9e4",
|
||||||
arrow_split: "e985",
|
arrow_split: "e985",
|
||||||
arrow_circle_down: "f181",
|
arrow_circle_down: "f181",
|
||||||
arrow_circle_up: "f182",
|
arrow_circle_up: "f182",
|
||||||
|
gamepad_circle_up: "eecd",
|
||||||
|
gamepad_circle_right: "eece",
|
||||||
|
gamepad_circle_left: "eecf",
|
||||||
|
gamepad_circle_down: "eed0",
|
||||||
counter_1: "f784",
|
counter_1: "f784",
|
||||||
counter_2: "f783",
|
counter_2: "f783",
|
||||||
counter_3: "f782",
|
counter_3: "f782",
|
||||||
|
counter_4: "f781",
|
||||||
ios_share: "e6b8",
|
ios_share: "e6b8",
|
||||||
light_mode: "e518",
|
light_mode: "e518",
|
||||||
upload_file: "e9fc",
|
upload_file: "e9fc",
|
||||||
@@ -154,6 +183,8 @@ const config = {
|
|||||||
routine: "e20c",
|
routine: "e20c",
|
||||||
experiment: "e686",
|
experiment: "e686",
|
||||||
dictionary: "f539",
|
dictionary: "f539",
|
||||||
|
visibility_off: "e8f5",
|
||||||
|
file_save: "f17f",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
81
package.json
81
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "2.2.3",
|
"version": "2.7.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -34,61 +34,68 @@
|
|||||||
"typesafe-i18n": "typesafe-i18n"
|
"typesafe-i18n": "typesafe-i18n"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.18.6",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.8.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.3",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/language": "^6.11.0",
|
"@codemirror/language": "^6.11.3",
|
||||||
|
"@codemirror/merge": "^6.11.2",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.36.5",
|
"@codemirror/view": "^6.39.4",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
|
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.2.6",
|
"@fontsource-variable/noto-sans-mono": "^5.2.10",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/common": "^1.4.0",
|
||||||
|
"@lezer/generator": "^1.8.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
|
"@lezer/lr": "^1.4.5",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@melt-ui/pp": "^0.3.2",
|
"@melt-ui/pp": "^0.3.2",
|
||||||
"@melt-ui/svelte": "^0.86.6",
|
"@melt-ui/svelte": "^0.86.6",
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.8",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.20.2",
|
"@sveltejs/kit": "^2.49.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@tauri-apps/api": "^1.6.0",
|
"@tauri-apps/api": "^1.6.0",
|
||||||
"@tauri-apps/cli": "^1.6.0",
|
"@tauri-apps/cli": "^1.6.0",
|
||||||
"@types/dom-view-transitions": "^1.0.6",
|
"@types/dom-view-transitions": "^1.0.6",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/semver": "^7.7.1",
|
||||||
"@types/w3c-web-serial": "^1.0.8",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
"@types/w3c-web-usb": "^1.0.10",
|
"@types/w3c-web-usb": "^1.0.13",
|
||||||
"@types/wicg-file-system-access": "^2023.10.5",
|
"@types/wicg-file-system-access": "^2023.10.7",
|
||||||
"@vite-pwa/sveltekit": "^1.0.0",
|
"@vite-pwa/sveltekit": "^1.1.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.23",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.2",
|
||||||
"cypress": "^14.2.1",
|
"cypress": "^14.5.3",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"esptool-js": "^0.5.4",
|
"esptool-js": "^0.5.7",
|
||||||
"flexsearch": "^0.8.147",
|
"flexsearch": "^0.8.212",
|
||||||
"fontkit": "^2.0.4",
|
"fontkit": "^2.0.4",
|
||||||
"glob": "^11.0.1",
|
"glob": "^11.0.3",
|
||||||
"jsdom": "^26.0.0",
|
"js-yaml": "^4.1.1",
|
||||||
"matrix-js-sdk": "^37.2.0",
|
"jsdom": "^26.1.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-svelte": "^3.3.3",
|
"prettier-plugin-css-order": "^2.1.2",
|
||||||
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.86.0",
|
"sass": "^1.97.0",
|
||||||
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"stylelint": "^16.17.0",
|
"stylelint": "^16.26.1",
|
||||||
"stylelint-config-clean-order": "^7.0.0",
|
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-recommended-scss": "^14.1.0",
|
"stylelint-config-recommended-scss": "^16.0.2",
|
||||||
"stylelint-config-standard-scss": "^14.0.0",
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
"svelte": "5.25.3",
|
"svelte": "5.37.1",
|
||||||
"svelte-check": "^4.1.5",
|
"svelte-check": "^4.3.4",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^6.2.4",
|
"vite": "^7.0.6",
|
||||||
"vite-plugin-mkcert": "^1.17.8",
|
"vite-plugin-mkcert": "^1.17.9",
|
||||||
"vite-plugin-pwa": "^1.0.0",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vitest": "^3.1.1",
|
"vitest": "^4.0.16",
|
||||||
"web-serial-polyfill": "^1.0.15",
|
"web-serial-polyfill": "^1.0.15",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.3.0"
|
||||||
},
|
},
|
||||||
|
|||||||
4645
pnpm-lock.yaml
generated
4645
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
onlyBuiltDependencies:
|
||||||
|
- svelte-preprocess
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "2.2.3"
|
version = "2.7.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||||
license = "AGPL-3"
|
license = "AGPL-3"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devPath": "http://localhost:5173",
|
"devPath": "http://localhost:5173",
|
||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": { "productName": "amacc1ng", "version": "2.2.3" },
|
"package": { "productName": "amacc1ng", "version": "2.7.0" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const de = {
|
|||||||
saveActions: {
|
saveActions: {
|
||||||
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
||||||
REDO: "Wiederholen",
|
REDO: "Wiederholen",
|
||||||
SAVE: "Speichern",
|
SAVE: "Anwended",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
TITLE: "Gerät aktualisieren",
|
TITLE: "Gerät aktualisieren",
|
||||||
@@ -18,10 +18,10 @@ const de = {
|
|||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Backup",
|
TITLE: "Backup",
|
||||||
AUTO_BACKUP: "Auto-backup",
|
AUTO_BACKUP: "Beschleunigtes Verbinden",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
|
"<b>Nicht auf öffentlichen oder geteilten Computern einschalten.</b> Gerätedaten werden für schnelleren Zugriff lokal zwischengespeichert.",
|
||||||
DOWNLOAD: "Alles",
|
DOWNLOAD: "Komplettes Profil",
|
||||||
RESTORE: "Wiederherstellen",
|
RESTORE: "Wiederherstellen",
|
||||||
},
|
},
|
||||||
modal: {
|
modal: {
|
||||||
|
|||||||
@@ -7,17 +7,17 @@ const en = {
|
|||||||
saveActions: {
|
saveActions: {
|
||||||
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
||||||
REDO: "Redo",
|
REDO: "Redo",
|
||||||
SAVE: "Save",
|
SAVE: "Apply",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
TITLE: "Update your device",
|
TITLE: "Update your device",
|
||||||
},
|
},
|
||||||
backup: {
|
backup: {
|
||||||
TITLE: "Backup",
|
TITLE: "Backup",
|
||||||
AUTO_BACKUP: "Auto-backup",
|
AUTO_BACKUP: "Fast Connect",
|
||||||
DISCLAIMER:
|
DISCLAIMER:
|
||||||
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
|
"<b>Turn off if using a shared or public computer.</b> Caches your device's data locally for quick access next time you connect.",
|
||||||
DOWNLOAD: "Everything",
|
DOWNLOAD: "Full profile",
|
||||||
RESTORE: "Restore",
|
RESTORE: "Restore",
|
||||||
},
|
},
|
||||||
sync: {
|
sync: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { expoIn, expoOut } from "svelte/easing";
|
import { expoIn, expoOut } from "svelte/easing";
|
||||||
import type { Snippet } from "svelte";
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
let { children, routeOrder }: { children: Snippet; routeOrder: string[]; direction: } =
|
let { children, routeOrder }: { children: Snippet; routeOrder: string[] } =
|
||||||
$props();
|
$props();
|
||||||
|
|
||||||
let inDirection = $state(0);
|
let inDirection = $state(0);
|
||||||
|
|||||||
121
src/lib/ProgressButton.svelte
Normal file
121
src/lib/ProgressButton.svelte
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Snippet } from "svelte";
|
||||||
|
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
onclick,
|
||||||
|
children,
|
||||||
|
working,
|
||||||
|
progress,
|
||||||
|
error,
|
||||||
|
disabled = false,
|
||||||
|
element = $bindable(),
|
||||||
|
...restProps
|
||||||
|
}: {
|
||||||
|
onclick: () => void;
|
||||||
|
children: Snippet;
|
||||||
|
working: boolean;
|
||||||
|
progress: number;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
element?: HTMLButtonElement;
|
||||||
|
} & HTMLButtonAttributes = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class:working={working && (progress <= 0 || progress >= 1)}
|
||||||
|
class:progress={working && progress > 0 && progress < 1}
|
||||||
|
style:--progress="{progress * 100}%"
|
||||||
|
class:primary={!error}
|
||||||
|
class:error={!!error}
|
||||||
|
disabled={disabled || working}
|
||||||
|
bind:this={element}
|
||||||
|
{...restProps}
|
||||||
|
{onclick}>{@render children()}</button
|
||||||
|
>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
transform: rotate(120deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
20% {
|
||||||
|
transform: rotate(120deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: rotate(270deg);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
--height: 42px;
|
||||||
|
--border-radius: calc(var(--height) / 2);
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
transition:
|
||||||
|
border 200ms ease,
|
||||||
|
color 200ms ease;
|
||||||
|
|
||||||
|
margin: 6px;
|
||||||
|
|
||||||
|
outline: 2px dashed currentcolor;
|
||||||
|
outline-offset: 4px;
|
||||||
|
|
||||||
|
border: 2px solid currentcolor;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
height: var(--height);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
background: none;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.progress,
|
||||||
|
&.working {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.working::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
border-radius: calc(var(--border-radius) - 2px);
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
height: calc(100% - 4px);
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.working::after {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -2;
|
||||||
|
animation: rotate 1s ease-out forwards infinite;
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
width: 120%;
|
||||||
|
height: 30%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
&.progress::after {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
opacity: 0.2;
|
||||||
|
z-index: -2;
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
width: var(--progress);
|
||||||
|
height: 100%;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
src/lib/assets/keymaps/keymap.d.ts
vendored
3
src/lib/assets/keymaps/keymap.d.ts
vendored
@@ -16,4 +16,7 @@ export interface ActionInfo {
|
|||||||
variant: "left" | "right";
|
variant: "left" | "right";
|
||||||
variantOf: number;
|
variantOf: number;
|
||||||
keyCode: string;
|
keyCode: string;
|
||||||
|
printable?: boolean;
|
||||||
|
separator?: boolean;
|
||||||
|
breaking?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/lib/assets/layouts/layout.d.ts
vendored
Normal file
19
src/lib/assets/layouts/layout.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export interface CompiledLayout {
|
||||||
|
name: string;
|
||||||
|
size: [number, number];
|
||||||
|
keys: CompiledLayoutKey[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompiledLayoutKey {
|
||||||
|
id: number;
|
||||||
|
shape: "quarter-circle" | "square";
|
||||||
|
cornerRadius: number;
|
||||||
|
size: [number, number];
|
||||||
|
pos: [number, number];
|
||||||
|
rotate: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "*.layout.yml" {
|
||||||
|
const layout: CompiledLayout;
|
||||||
|
export default layout;
|
||||||
|
}
|
||||||
10
src/lib/assets/layouts/t4g.layout.yml
Normal file
10
src/lib/assets/layouts/t4g.layout.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
name: T4G
|
||||||
|
col:
|
||||||
|
- row:
|
||||||
|
- switch: { e: 3, n: 5, w: 4, s: 6 }
|
||||||
|
- offset: [0.5, 0]
|
||||||
|
row:
|
||||||
|
- key: 2
|
||||||
|
- row:
|
||||||
|
- key: 0
|
||||||
|
- key: 1
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
settings,
|
settings,
|
||||||
} from "$lib/undo-redo.js";
|
} from "$lib/undo-redo.js";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import { serialPort } from "../serial/connection";
|
import { activeProfile, serialPort } from "../serial/connection";
|
||||||
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
|
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
|
||||||
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
|
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
|
||||||
|
|
||||||
@@ -50,11 +50,9 @@ export function createLayoutBackup(): CharaLayoutFile {
|
|||||||
charaVersion: 1,
|
charaVersion: 1,
|
||||||
type: "layout",
|
type: "layout",
|
||||||
device: get(serialPort)?.device,
|
device: get(serialPort)?.device,
|
||||||
layout: get(layout).map((it) => it.map((it) => it.action)) as [
|
layout: (get(layout)[get(activeProfile)]?.map((it) =>
|
||||||
number[],
|
it.map((it) => it.action),
|
||||||
number[],
|
) ?? []) as [number[], number[], number[]],
|
||||||
number[],
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,26 +68,30 @@ export function createSettingsBackup(): CharaSettingsFile {
|
|||||||
return {
|
return {
|
||||||
charaVersion: 1,
|
charaVersion: 1,
|
||||||
type: "settings",
|
type: "settings",
|
||||||
settings: get(settings).map((it) => it.value),
|
settings: get(settings)[get(activeProfile)]?.map((it) => it.value) ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function restoreBackup(event: Event) {
|
export async function restoreBackup(
|
||||||
|
event: Event,
|
||||||
|
only?: "chords" | "layout" | "settings",
|
||||||
|
) {
|
||||||
const input = (event.target as HTMLInputElement).files![0];
|
const input = (event.target as HTMLInputElement).files![0];
|
||||||
if (!input) return;
|
if (!input) return;
|
||||||
const text = await input.text();
|
const text = await input.text();
|
||||||
if (input.name.endsWith(".json")) {
|
if (input.name.endsWith(".json")) {
|
||||||
restoreFromFile(JSON.parse(text));
|
restoreFromFile(JSON.parse(text), only);
|
||||||
} else if (isCsvLayout(text)) {
|
} else if (isCsvLayout(text)) {
|
||||||
restoreFromFile(csvLayoutToJson(text));
|
restoreFromFile(csvLayoutToJson(text), only);
|
||||||
} else if (isCsvChords(text)) {
|
} else if (isCsvChords(text)) {
|
||||||
restoreFromFile(csvChordsToJson(text));
|
restoreFromFile(csvChordsToJson(text), only);
|
||||||
} else {
|
} else {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreFromFile(
|
export function restoreFromFile(
|
||||||
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
||||||
|
only?: "chords" | "layout" | "settings",
|
||||||
) {
|
) {
|
||||||
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
||||||
switch (file.type) {
|
switch (file.type) {
|
||||||
@@ -97,9 +99,15 @@ export function restoreFromFile(
|
|||||||
const recent = file.history[0];
|
const recent = file.history[0];
|
||||||
if (!recent) return;
|
if (!recent) return;
|
||||||
let backupDevice = recent[1].device;
|
let backupDevice = recent[1].device;
|
||||||
if (backupDevice === "TWO") backupDevice = "ONE";
|
if (backupDevice === "TWO" || backupDevice === "M4G")
|
||||||
|
backupDevice = "ONE";
|
||||||
|
else if (backupDevice === "ZERO" || backupDevice === "ENGINE")
|
||||||
|
backupDevice = "X";
|
||||||
let currentDevice = get(serialPort)?.device;
|
let currentDevice = get(serialPort)?.device;
|
||||||
if (currentDevice === "TWO") currentDevice = "ONE";
|
if (currentDevice === "TWO" || currentDevice === "M4G")
|
||||||
|
currentDevice = "ONE";
|
||||||
|
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
|
||||||
|
currentDevice = "X";
|
||||||
|
|
||||||
if (backupDevice !== currentDevice) {
|
if (backupDevice !== currentDevice) {
|
||||||
alert("Backup is incompatible with this device");
|
alert("Backup is incompatible with this device");
|
||||||
@@ -108,33 +116,45 @@ export function restoreFromFile(
|
|||||||
|
|
||||||
changes.update((changes) => {
|
changes.update((changes) => {
|
||||||
changes.push([
|
changes.push([
|
||||||
...getChangesFromChordFile(recent[0]),
|
...(!only || only === "chords"
|
||||||
...getChangesFromLayoutFile(recent[1]),
|
? getChangesFromChordFile(recent[0])
|
||||||
...getChangesFromSettingsFile(recent[2]),
|
: []),
|
||||||
|
...(!only || only === "layout"
|
||||||
|
? getChangesFromLayoutFile(recent[1])
|
||||||
|
: []),
|
||||||
|
...(!only || only === "settings"
|
||||||
|
? getChangesFromSettingsFile(recent[2])
|
||||||
|
: []),
|
||||||
]);
|
]);
|
||||||
return changes;
|
return changes;
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "chords": {
|
case "chords": {
|
||||||
changes.update((changes) => {
|
if (!only || only === "chords") {
|
||||||
changes.push(getChangesFromChordFile(file));
|
changes.update((changes) => {
|
||||||
return changes;
|
changes.push(getChangesFromChordFile(file));
|
||||||
});
|
return changes;
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "layout": {
|
case "layout": {
|
||||||
changes.update((changes) => {
|
if (!only || only === "layout") {
|
||||||
changes.push(getChangesFromLayoutFile(file));
|
changes.update((changes) => {
|
||||||
return changes;
|
changes.push(getChangesFromLayoutFile(file));
|
||||||
});
|
return changes;
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "settings": {
|
case "settings": {
|
||||||
changes.update((changes) => {
|
if (!only || only === "settings") {
|
||||||
changes.push(getChangesFromSettingsFile(file));
|
changes.update((changes) => {
|
||||||
return changes;
|
changes.push(getChangesFromSettingsFile(file));
|
||||||
});
|
return changes;
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
@@ -167,12 +187,13 @@ export function getChangesFromChordFile(file: CharaChordFile) {
|
|||||||
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
|
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
|
||||||
const changes: Change[] = [];
|
const changes: Change[] = [];
|
||||||
for (const [id, value] of file.settings.entries()) {
|
for (const [id, value] of file.settings.entries()) {
|
||||||
const setting = get(settings)[id];
|
const setting = get(settings)[get(activeProfile)]?.[id];
|
||||||
if (setting !== undefined && setting.value !== value) {
|
if (setting !== undefined && setting.value !== value) {
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Setting,
|
type: ChangeType.Setting,
|
||||||
id,
|
id,
|
||||||
setting: value,
|
setting: value,
|
||||||
|
profile: get(activeProfile),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,12 +204,13 @@ export function getChangesFromLayoutFile(file: CharaLayoutFile) {
|
|||||||
const changes: Change[] = [];
|
const changes: Change[] = [];
|
||||||
for (const [layer, keys] of file.layout.entries()) {
|
for (const [layer, keys] of file.layout.entries()) {
|
||||||
for (const [id, action] of keys.entries()) {
|
for (const [id, action] of keys.entries()) {
|
||||||
if (get(layout)[layer]?.[id]?.action !== action) {
|
if (get(layout)[get(activeProfile)]?.[layer]?.[id]?.action !== action) {
|
||||||
changes.push({
|
changes.push({
|
||||||
type: ChangeType.Layout,
|
type: ChangeType.Layout,
|
||||||
layer,
|
layer,
|
||||||
id,
|
id,
|
||||||
action,
|
action,
|
||||||
|
profile: get(activeProfile),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/lib/ccos/attachment.ts
Normal file
35
src/lib/ccos/attachment.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
import type { CharaDevice } from "$lib/serial/device";
|
||||||
|
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
|
||||||
|
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
|
||||||
|
export function ccosKeyInterceptor(
|
||||||
|
port: CharaDevice | undefined,
|
||||||
|
recorder: ReplayRecorder,
|
||||||
|
) {
|
||||||
|
return ((element: HTMLElement) => {
|
||||||
|
const ccos =
|
||||||
|
port?.port && "handleKeyEvent" in port?.port
|
||||||
|
? (port.port as CCOS)
|
||||||
|
: undefined;
|
||||||
|
console.log("Attaching CCOS key interceptor", ccos);
|
||||||
|
|
||||||
|
function onEvent(event: KeyboardEvent) {
|
||||||
|
ccos?.handleKeyEvent(event);
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
recorder.next(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ccos) {
|
||||||
|
element.addEventListener("keydown", onEvent, true);
|
||||||
|
element.addEventListener("keyup", onEvent, true);
|
||||||
|
element.add;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
element.removeEventListener("keydown", onEvent, true);
|
||||||
|
element.removeEventListener("keyup", onEvent, true);
|
||||||
|
};
|
||||||
|
}) satisfies Attachment<HTMLElement>;
|
||||||
|
}
|
||||||
37
src/lib/ccos/ccos-events.ts
Normal file
37
src/lib/ccos/ccos-events.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export interface CCOSInitEvent {
|
||||||
|
type: "init";
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSKeyPressEvent {
|
||||||
|
type: "press";
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSKeyReleaseEvent {
|
||||||
|
type: "release";
|
||||||
|
code: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSSerialEvent {
|
||||||
|
type: "serial";
|
||||||
|
data: Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CCOSInEvent =
|
||||||
|
| CCOSInitEvent
|
||||||
|
| CCOSKeyPressEvent
|
||||||
|
| CCOSKeyReleaseEvent
|
||||||
|
| CCOSSerialEvent;
|
||||||
|
|
||||||
|
export interface CCOSReportEvent {
|
||||||
|
type: "report";
|
||||||
|
modifiers: number;
|
||||||
|
keys: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CCOSReadyEvent {
|
||||||
|
type: "ready";
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CCOSOutEvent = CCOSReportEvent | CCOSReadyEvent | CCOSSerialEvent;
|
||||||
111
src/lib/ccos/ccos-interop.ts
Normal file
111
src/lib/ccos/ccos-interop.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
export const KEYCODE_TO_SCANCODE = new Map<string, number | undefined>(
|
||||||
|
Object.entries({
|
||||||
|
KeyA: 0x04,
|
||||||
|
KeyB: 0x05,
|
||||||
|
KeyC: 0x06,
|
||||||
|
KeyD: 0x07,
|
||||||
|
KeyE: 0x08,
|
||||||
|
KeyF: 0x09,
|
||||||
|
KeyG: 0x0a,
|
||||||
|
KeyH: 0x0b,
|
||||||
|
KeyI: 0x0c,
|
||||||
|
KeyJ: 0x0d,
|
||||||
|
KeyK: 0x0e,
|
||||||
|
KeyL: 0x0f,
|
||||||
|
KeyM: 0x10,
|
||||||
|
KeyN: 0x11,
|
||||||
|
KeyO: 0x12,
|
||||||
|
KeyP: 0x13,
|
||||||
|
KeyQ: 0x14,
|
||||||
|
KeyR: 0x15,
|
||||||
|
KeyS: 0x16,
|
||||||
|
KeyT: 0x17,
|
||||||
|
KeyU: 0x18,
|
||||||
|
KeyV: 0x19,
|
||||||
|
KeyW: 0x1a,
|
||||||
|
KeyX: 0x1b,
|
||||||
|
KeyY: 0x1c,
|
||||||
|
KeyZ: 0x1d,
|
||||||
|
Digit1: 0x1e,
|
||||||
|
Digit2: 0x1f,
|
||||||
|
Digit3: 0x20,
|
||||||
|
Digit4: 0x21,
|
||||||
|
Digit5: 0x22,
|
||||||
|
Digit6: 0x23,
|
||||||
|
Digit7: 0x24,
|
||||||
|
Digit8: 0x25,
|
||||||
|
Digit9: 0x26,
|
||||||
|
Digit0: 0x27,
|
||||||
|
Enter: 0x28,
|
||||||
|
Escape: 0x29,
|
||||||
|
Backspace: 0x2a,
|
||||||
|
Tab: 0x2b,
|
||||||
|
Space: 0x2c,
|
||||||
|
Minus: 0x2d,
|
||||||
|
Equal: 0x2e,
|
||||||
|
BracketLeft: 0x2f,
|
||||||
|
BracketRight: 0x30,
|
||||||
|
Backslash: 0x31,
|
||||||
|
Semicolon: 0x33,
|
||||||
|
Quote: 0x34,
|
||||||
|
Backquote: 0x35,
|
||||||
|
Comma: 0x36,
|
||||||
|
Period: 0x37,
|
||||||
|
Slash: 0x38,
|
||||||
|
CapsLock: 0x39,
|
||||||
|
F1: 0x3a,
|
||||||
|
F2: 0x3b,
|
||||||
|
F3: 0x3c,
|
||||||
|
F4: 0x3d,
|
||||||
|
F5: 0x3e,
|
||||||
|
F6: 0x3f,
|
||||||
|
F7: 0x40,
|
||||||
|
F8: 0x41,
|
||||||
|
F9: 0x42,
|
||||||
|
F10: 0x43,
|
||||||
|
F11: 0x44,
|
||||||
|
F12: 0x45,
|
||||||
|
PrintScreen: 0x46,
|
||||||
|
ScrollLock: 0x47,
|
||||||
|
Pause: 0x48,
|
||||||
|
Insert: 0x49,
|
||||||
|
Home: 0x4a,
|
||||||
|
PageUp: 0x4b,
|
||||||
|
Delete: 0x4c,
|
||||||
|
End: 0x4d,
|
||||||
|
PageDown: 0x4e,
|
||||||
|
ArrowRight: 0x4f,
|
||||||
|
ArrowLeft: 0x50,
|
||||||
|
ArrowDown: 0x51,
|
||||||
|
ArrowUp: 0x52,
|
||||||
|
NumLock: 0x53,
|
||||||
|
NumpadDivide: 0x54,
|
||||||
|
NumpadMultiply: 0x55,
|
||||||
|
NumpadSubtract: 0x56,
|
||||||
|
NumpadAdd: 0x57,
|
||||||
|
NumpadEnter: 0x58,
|
||||||
|
Numpad1: 0x59,
|
||||||
|
Numpad2: 0x5a,
|
||||||
|
Numpad3: 0x5b,
|
||||||
|
Numpad4: 0x5c,
|
||||||
|
Numpad5: 0x5d,
|
||||||
|
Numpad6: 0x5e,
|
||||||
|
Numpad7: 0x5f,
|
||||||
|
Numpad8: 0x60,
|
||||||
|
Numpad9: 0x61,
|
||||||
|
Numpad0: 0x62,
|
||||||
|
NumpadDecimal: 0x63,
|
||||||
|
ControlLeft: 0xe0,
|
||||||
|
ShiftLeft: 0xe1,
|
||||||
|
AltLeft: 0xe2,
|
||||||
|
MetaLeft: 0xe3,
|
||||||
|
ControlRight: 0xe4,
|
||||||
|
ShiftRight: 0xe5,
|
||||||
|
AltRight: 0xe6,
|
||||||
|
MetaRight: 0xe7,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const SCANCODE_TO_KEYCODE = new Map<number, string>(
|
||||||
|
KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]),
|
||||||
|
);
|
||||||
269
src/lib/ccos/ccos.ts
Normal file
269
src/lib/ccos/ccos.ts
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { getMeta } from "$lib/meta/meta-storage";
|
||||||
|
import type { SerialPortLike } from "$lib/serial/device";
|
||||||
|
import type {
|
||||||
|
CCOSInitEvent,
|
||||||
|
CCOSKeyPressEvent,
|
||||||
|
CCOSKeyReleaseEvent,
|
||||||
|
CCOSOutEvent,
|
||||||
|
} from "./ccos-events";
|
||||||
|
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
||||||
|
|
||||||
|
const device = "zero_wasm";
|
||||||
|
|
||||||
|
export class CCOSKeyboardEvent extends KeyboardEvent {
|
||||||
|
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||||
|
super(...params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MASK_CTRL = 0b0001_0001;
|
||||||
|
const MASK_SHIFT = 0b0010_0010;
|
||||||
|
const MASK_ALT = 0b0100_0100;
|
||||||
|
const MASK_ALT_GRAPH = 0b0000_0100;
|
||||||
|
const MASK_GUI = 0b1000_1000;
|
||||||
|
|
||||||
|
export class CCOS implements SerialPortLike {
|
||||||
|
private readonly currKeys = new Set<number>();
|
||||||
|
|
||||||
|
private readonly layout = new Map<string, string>([
|
||||||
|
...Array.from(
|
||||||
|
{ length: 26 },
|
||||||
|
(_, i) =>
|
||||||
|
[
|
||||||
|
JSON.stringify([`Key${String.fromCharCode(65 + i)}`, "Shift"]),
|
||||||
|
String.fromCharCode(65 + i),
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
...Array.from(
|
||||||
|
{ length: 10 },
|
||||||
|
(_, i) => [JSON.stringify([`Key${i}`]), i.toString()] as const,
|
||||||
|
),
|
||||||
|
|
||||||
|
[JSON.stringify(["Space"]), " "],
|
||||||
|
[JSON.stringify(["Backquote"]), "`"],
|
||||||
|
[JSON.stringify(["Minus"]), "-"],
|
||||||
|
[JSON.stringify(["Comma"]), ","],
|
||||||
|
[JSON.stringify(["Period"]), "."],
|
||||||
|
[JSON.stringify(["Semicolon"]), ";"],
|
||||||
|
[JSON.stringify(["Equal"]), "="],
|
||||||
|
|
||||||
|
[JSON.stringify(["Backquote", "Shift"]), "~"],
|
||||||
|
[JSON.stringify(["Minus", "Shift"]), "_"],
|
||||||
|
[JSON.stringify(["Comma", "Shift"]), "<"],
|
||||||
|
[JSON.stringify(["Period", "Shift"]), ">"],
|
||||||
|
[JSON.stringify(["Semicolon", "Shift"]), ":"],
|
||||||
|
[JSON.stringify(["Equal", "Shift"]), "+"],
|
||||||
|
|
||||||
|
[JSON.stringify(["Digit0", "Shift"]), ")"],
|
||||||
|
[JSON.stringify(["Digit1", "Shift"]), "!"],
|
||||||
|
[JSON.stringify(["Digit2", "Shift"]), "@"],
|
||||||
|
[JSON.stringify(["Digit3", "Shift"]), "#"],
|
||||||
|
[JSON.stringify(["Digit4", "Shift"]), "$"],
|
||||||
|
[JSON.stringify(["Digit5", "Shift"]), "%"],
|
||||||
|
[JSON.stringify(["Digit6", "Shift"]), "^"],
|
||||||
|
[JSON.stringify(["Digit7", "Shift"]), "&"],
|
||||||
|
[JSON.stringify(["Digit8", "Shift"]), "*"],
|
||||||
|
[JSON.stringify(["Digit9", "Shift"]), "("],
|
||||||
|
]);
|
||||||
|
|
||||||
|
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||||
|
|
||||||
|
private resolveReady!: () => void;
|
||||||
|
private ready = new Promise<void>((resolve) => {
|
||||||
|
this.resolveReady = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
private lastEvent?: KeyboardEvent;
|
||||||
|
|
||||||
|
private onKey(
|
||||||
|
type: ConstructorParameters<typeof KeyboardEvent>[0],
|
||||||
|
modifiers: number,
|
||||||
|
scanCode: number,
|
||||||
|
) {
|
||||||
|
if (!this.lastEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = SCANCODE_TO_KEYCODE.get(scanCode);
|
||||||
|
if (code === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const layoutKey = [code];
|
||||||
|
if (modifiers & MASK_SHIFT) {
|
||||||
|
layoutKey.push("Shift");
|
||||||
|
}
|
||||||
|
if (modifiers & MASK_ALT_GRAPH) {
|
||||||
|
layoutKey.push("AltGraph");
|
||||||
|
}
|
||||||
|
const key = this.layout.get(JSON.stringify(layoutKey)) ?? code;
|
||||||
|
|
||||||
|
const params: Required<KeyboardEventInit> = {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
location: this.lastEvent.location,
|
||||||
|
repeat: this.lastEvent.repeat,
|
||||||
|
detail: this.lastEvent.detail,
|
||||||
|
view: this.lastEvent.view,
|
||||||
|
isComposing: this.lastEvent.isComposing,
|
||||||
|
which: this.lastEvent.which,
|
||||||
|
composed: this.lastEvent.composed,
|
||||||
|
key,
|
||||||
|
code,
|
||||||
|
charCode: key.charCodeAt(0),
|
||||||
|
keyCode: this.lastEvent.keyCode,
|
||||||
|
shiftKey: (modifiers & MASK_SHIFT) !== 0,
|
||||||
|
ctrlKey: (modifiers & MASK_CTRL) !== 0,
|
||||||
|
metaKey: (modifiers & MASK_GUI) !== 0,
|
||||||
|
altKey: (modifiers & MASK_ALT) !== 0,
|
||||||
|
modifierAltGraph: (modifiers & MASK_ALT_GRAPH) !== 0,
|
||||||
|
modifierCapsLock: this.lastEvent.getModifierState("CapsLock"),
|
||||||
|
modifierFn: this.lastEvent.getModifierState("Fn"),
|
||||||
|
modifierFnLock: this.lastEvent.getModifierState("FnLock"),
|
||||||
|
modifierHyper: this.lastEvent.getModifierState("Hyper"),
|
||||||
|
modifierNumLock: this.lastEvent.getModifierState("NumLock"),
|
||||||
|
modifierSuper: (modifiers & MASK_GUI) !== 0,
|
||||||
|
modifierSymbol: this.lastEvent.getModifierState("Symbol"),
|
||||||
|
modifierSymbolLock: this.lastEvent.getModifierState("SymbolLock"),
|
||||||
|
modifierScrollLock: this.lastEvent.getModifierState("ScrollLock"),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.lastEvent.target?.dispatchEvent(new CCOSKeyboardEvent(type, params));
|
||||||
|
}
|
||||||
|
|
||||||
|
private onReport(modifiers: number, keys: number[]) {
|
||||||
|
const nextKeys = new Set<number>(keys);
|
||||||
|
nextKeys.delete(0);
|
||||||
|
for (const key of this.currKeys) {
|
||||||
|
if (!nextKeys.has(key)) {
|
||||||
|
this.onKey("keyup", modifiers, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of nextKeys) {
|
||||||
|
if (!this.currKeys.has(key)) {
|
||||||
|
this.onKey("keydown", modifiers, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.currKeys.clear();
|
||||||
|
for (const key of keys) {
|
||||||
|
this.currKeys.add(key);
|
||||||
|
}
|
||||||
|
this.currKeys.delete(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private controller?: ReadableStreamDefaultController<Uint8Array>;
|
||||||
|
|
||||||
|
readable!: ReadableStream<Uint8Array>;
|
||||||
|
writable!: WritableStream<Uint8Array>;
|
||||||
|
|
||||||
|
constructor(url: string) {
|
||||||
|
this.worker.addEventListener(
|
||||||
|
"message",
|
||||||
|
(event: MessageEvent<CCOSOutEvent>) => {
|
||||||
|
if (event.data instanceof Uint8Array) {
|
||||||
|
this.controller?.enqueue(event.data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
switch (event.data.type) {
|
||||||
|
case "ready": {
|
||||||
|
this.resolveReady();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "report": {
|
||||||
|
this.onReport(event.data.modifiers, event.data.keys);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(navigator as any).keyboard
|
||||||
|
?.getLayoutMap()
|
||||||
|
?.then((it: Map<string, string>) =>
|
||||||
|
it.entries().forEach(([key, value]) => {
|
||||||
|
this.layout.set(JSON.stringify([key]), value);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "init",
|
||||||
|
url,
|
||||||
|
} satisfies CCOSInitEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
getInfo(): SerialPortInfo {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(_options: SerialOptions) {
|
||||||
|
this.readable = new ReadableStream<Uint8Array>({
|
||||||
|
start: (controller) => {
|
||||||
|
this.controller = controller;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.writable = new WritableStream<Uint8Array>({
|
||||||
|
write: (chunk) => {
|
||||||
|
this.worker.postMessage(chunk, [chunk.buffer]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.ready;
|
||||||
|
}
|
||||||
|
async close() {
|
||||||
|
await this.ready;
|
||||||
|
}
|
||||||
|
async forget() {
|
||||||
|
await this.ready;
|
||||||
|
this.close();
|
||||||
|
this.worker.terminate();
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleKeyEvent(event: KeyboardEvent) {
|
||||||
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement
|
||||||
|
) {
|
||||||
|
console.error("CCOS does not support input elements");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.ready || event instanceof CCOSKeyboardEvent) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
this.lastEvent = event;
|
||||||
|
|
||||||
|
const layoutKey = [event.code];
|
||||||
|
if (event.getModifierState("Shift")) {
|
||||||
|
layoutKey.push("Shift");
|
||||||
|
}
|
||||||
|
if (event.getModifierState("AltGraph")) {
|
||||||
|
layoutKey.push("AltGraph");
|
||||||
|
}
|
||||||
|
this.layout.set(JSON.stringify(layoutKey), event.key);
|
||||||
|
|
||||||
|
const scanCode = KEYCODE_TO_SCANCODE.get(event.code);
|
||||||
|
if (scanCode === undefined) return;
|
||||||
|
if (event.type === "keydown") {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "press",
|
||||||
|
code: scanCode,
|
||||||
|
} satisfies CCOSKeyPressEvent);
|
||||||
|
} else {
|
||||||
|
this.worker.postMessage({
|
||||||
|
type: "release",
|
||||||
|
code: scanCode,
|
||||||
|
} satisfies CCOSKeyReleaseEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCCOS(
|
||||||
|
version = "3.0.0-rc.0",
|
||||||
|
fetch: typeof window.fetch = window.fetch,
|
||||||
|
): Promise<CCOS | undefined> {
|
||||||
|
const meta = await getMeta(device, version, fetch);
|
||||||
|
if (!meta?.update.js || !meta?.update.wasm) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CCOS(`${meta.path}/${meta.update.js}`);
|
||||||
|
}
|
||||||
@@ -10,14 +10,18 @@
|
|||||||
replay,
|
replay,
|
||||||
cursor = false,
|
cursor = false,
|
||||||
keys = false,
|
keys = false,
|
||||||
|
paused = false,
|
||||||
children,
|
children,
|
||||||
ondone,
|
ondone,
|
||||||
|
ontick,
|
||||||
}: {
|
}: {
|
||||||
replay: ReplayPlayer | Replay;
|
replay: ReplayPlayer | Replay;
|
||||||
cursor?: boolean;
|
cursor?: boolean;
|
||||||
keys?: boolean;
|
keys?: boolean;
|
||||||
|
paused?: boolean;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
ondone?: () => void;
|
ondone?: () => void;
|
||||||
|
ontick?: (time: number) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let replayPlayer: ReplayPlayer | undefined = $state();
|
let replayPlayer: ReplayPlayer | undefined = $state();
|
||||||
@@ -45,6 +49,10 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!svg || !text) return;
|
if (!svg || !text) return;
|
||||||
|
if (paused) {
|
||||||
|
text.textContent = finalText ?? "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
const player =
|
const player =
|
||||||
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
|
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
|
||||||
replayPlayer = player;
|
replayPlayer = player;
|
||||||
@@ -63,6 +71,7 @@
|
|||||||
const unsubscribePlayer = player.subscribe(apply);
|
const unsubscribePlayer = player.subscribe(apply);
|
||||||
textRenderer = renderer;
|
textRenderer = renderer;
|
||||||
|
|
||||||
|
player.onTick = ontick;
|
||||||
player.onDone = ondone;
|
player.onDone = ondone;
|
||||||
player.start();
|
player.start();
|
||||||
apply();
|
apply();
|
||||||
@@ -70,8 +79,11 @@
|
|||||||
renderer.animated = true;
|
renderer.animated = true;
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
|
textRenderer = undefined;
|
||||||
|
replayPlayer = undefined;
|
||||||
unsubscribePlayer();
|
unsubscribePlayer();
|
||||||
player?.destroy();
|
player.destroy();
|
||||||
|
renderer.destroy();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,7 +100,7 @@
|
|||||||
{#key replay}
|
{#key replay}
|
||||||
<svg bind:this={svg}></svg>
|
<svg bind:this={svg}></svg>
|
||||||
{#if browser}
|
{#if browser}
|
||||||
<span use:innerText={text}></span>
|
<span use:innerText={text} style:opacity={paused ? 1 : 0}></span>
|
||||||
{:else if !(replay instanceof ReplayPlayer)}
|
{:else if !(replay instanceof ReplayPlayer)}
|
||||||
{finalText}
|
{finalText}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -104,7 +116,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
span {
|
span {
|
||||||
opacity: 0;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
@@ -113,15 +124,15 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg > :global(text) {
|
svg > :global(text) {
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
font-family: inherit;
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
dominant-baseline: middle;
|
dominant-baseline: middle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,24 +83,24 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
section {
|
section {
|
||||||
|
display: grid;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 1em;
|
margin: 1em;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
display: grid;
|
|
||||||
height: 3em;
|
height: 3em;
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rating {
|
.rating {
|
||||||
font-weight: bold;
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
font-weight: bold;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tile {
|
.tile {
|
||||||
|
border-radius: 0.1em;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 0.2em;
|
height: 0.2em;
|
||||||
border-radius: 0.1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kbd {
|
kbd {
|
||||||
@@ -112,19 +112,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chord {
|
.chord {
|
||||||
will-change: transform, opacity, scale;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
margin-inline-end: 1em;
|
|
||||||
padding-inline: 0.1em;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition:
|
transition:
|
||||||
opacity 0.3s ease,
|
opacity 0.3s ease,
|
||||||
translate 0.3s ease,
|
translate 0.3s ease,
|
||||||
scale 0.3s ease;
|
scale 0.3s ease;
|
||||||
|
will-change: transform, opacity, scale;
|
||||||
|
margin-inline-end: 1em;
|
||||||
|
padding-inline: 0.1em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ export class ReplayPlayer {
|
|||||||
|
|
||||||
startTime = performance.now();
|
startTime = performance.now();
|
||||||
|
|
||||||
private animationFrameId: number | null = null;
|
private animationFrameId: ReturnType<typeof requestAnimationFrame> | null =
|
||||||
|
null;
|
||||||
|
|
||||||
|
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
timescale = 1;
|
timescale = 1;
|
||||||
|
|
||||||
@@ -20,6 +23,8 @@ export class ReplayPlayer {
|
|||||||
|
|
||||||
onDone?: () => void;
|
onDone?: () => void;
|
||||||
|
|
||||||
|
onTick?: (time: number) => void;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly replay: Replay,
|
readonly replay: Replay,
|
||||||
plugins: ReplayPlugin[] = [],
|
plugins: ReplayPlugin[] = [],
|
||||||
@@ -47,6 +52,7 @@ export class ReplayPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = performance.now() - this.startTime;
|
const now = performance.now() - this.startTime;
|
||||||
|
this.onTick?.(now);
|
||||||
|
|
||||||
while (
|
while (
|
||||||
this.replayCursor < this.replay.keys.length &&
|
this.replayCursor < this.replay.keys.length &&
|
||||||
@@ -131,7 +137,7 @@ export class ReplayPlayer {
|
|||||||
}
|
}
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
this.timeoutId = setTimeout(() => {
|
||||||
this.startTime = performance.now();
|
this.startTime = performance.now();
|
||||||
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -139,6 +145,9 @@ export class ReplayPlayer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
}
|
||||||
if (this.animationFrameId) {
|
if (this.animationFrameId) {
|
||||||
cancelAnimationFrame(this.animationFrameId);
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class ReplayRecorder {
|
|||||||
|
|
||||||
finish(trim = true, round = true) {
|
finish(trim = true, round = true) {
|
||||||
return {
|
return {
|
||||||
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
|
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round) ?? 0,
|
||||||
finish: maybeRound(
|
finish: maybeRound(
|
||||||
trim
|
trim
|
||||||
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
||||||
@@ -74,6 +74,6 @@ export class ReplayRecorder {
|
|||||||
] as const,
|
] as const,
|
||||||
)
|
)
|
||||||
.sort((a, b) => a[2] - b[2]),
|
.sort((a, b) => a[2] - b[2]),
|
||||||
};
|
} satisfies Replay;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -279,6 +279,18 @@ export class TextRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.cursorNode.remove();
|
||||||
|
for (const node of this.nodes.values()) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
for (const node of this.heldNodes.values()) {
|
||||||
|
node.remove();
|
||||||
|
}
|
||||||
|
this.nodes.clear();
|
||||||
|
this.heldNodes.clear();
|
||||||
|
}
|
||||||
|
|
||||||
private isShiny(char: TextToken, index: number) {
|
private isShiny(char: TextToken, index: number) {
|
||||||
return (
|
return (
|
||||||
this.shiny?.includes(index) ||
|
this.shiny?.includes(index) ||
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { RoomMember } from "matrix-js-sdk";
|
|
||||||
import { matrixClient, memberColor } from "./chat";
|
|
||||||
import { theme } from "$lib/preferences";
|
|
||||||
import { hexFromArgb } from "@material/material-color-utilities";
|
|
||||||
|
|
||||||
let { members }: { members: RoomMember[] } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="member-list">
|
|
||||||
{#each members as member (member.userId)}
|
|
||||||
{@const avatar = member.getMxcAvatarUrl()}
|
|
||||||
<div class="member">
|
|
||||||
{#if avatar}
|
|
||||||
<img
|
|
||||||
class="avatar"
|
|
||||||
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
|
|
||||||
alt={member.name}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{@const color = memberColor(member, $theme)}
|
|
||||||
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
|
|
||||||
<div
|
|
||||||
style:background={hexFromArgb(modeColor.color)}
|
|
||||||
style:color={hexFromArgb(modeColor.onColor)}
|
|
||||||
class="avatar avatar-placeholder icon"
|
|
||||||
>
|
|
||||||
person
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span>{member.name}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.avatar {
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-placeholder {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 8px;
|
|
||||||
overflow-y: auto;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Room } from "matrix-js-sdk";
|
|
||||||
import { matrixClient, currentRoomId } from "./chat";
|
|
||||||
|
|
||||||
let { rooms }: { rooms: Room[] } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="rooms">
|
|
||||||
{#each $matrixClient.getRooms() as room}
|
|
||||||
{@const avatar = room.getMxcAvatarUrl()}
|
|
||||||
<button
|
|
||||||
class:active={$currentRoomId === room.roomId}
|
|
||||||
class="room"
|
|
||||||
onclick={() => ($currentRoomId = room.roomId)}
|
|
||||||
>
|
|
||||||
{#if avatar}
|
|
||||||
<img
|
|
||||||
alt={room.name}
|
|
||||||
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div>#</div>
|
|
||||||
{/if}
|
|
||||||
<div>{room.name}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#await $matrixClient.publicRooms()}
|
|
||||||
<div>Loading...</div>
|
|
||||||
{:then rooms}
|
|
||||||
{#each rooms.chunk as room}
|
|
||||||
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
|
|
||||||
<div>#</div>
|
|
||||||
<div>{room.name}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:catch error}
|
|
||||||
<div>{error.message}</div>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.rooms {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
padding-left: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room {
|
|
||||||
display: flex;
|
|
||||||
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>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {
|
|
||||||
EventTimeline,
|
|
||||||
MatrixEvent,
|
|
||||||
MsgType,
|
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RoomMember,
|
|
||||||
RoomMemberEvent,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { onDestroy, onMount, tick } from "svelte";
|
|
||||||
import { matrixClient } from "./chat";
|
|
||||||
import MatrixEventComponent from "./events/MatrixEvent.svelte";
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
|
||||||
import { type Socket, io } from "socket.io-client";
|
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
|
||||||
|
|
||||||
let { timeline }: { timeline: EventTimeline } = $props();
|
|
||||||
|
|
||||||
const excludeEvents = ["m.reaction", "m.room.redaction"];
|
|
||||||
|
|
||||||
let events = $state(
|
|
||||||
timeline
|
|
||||||
.getEvents()
|
|
||||||
.filter((it) => !excludeEvents.includes(it.getType()))
|
|
||||||
.reverse(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let recorder = $state(new ReplayRecorder());
|
|
||||||
let showCursor = $state(false);
|
|
||||||
|
|
||||||
let timelineElement: HTMLElement = $state()!;
|
|
||||||
|
|
||||||
async function onTimeline(
|
|
||||||
event: MatrixEvent,
|
|
||||||
room?: Room,
|
|
||||||
toStartOfTimeline?: boolean,
|
|
||||||
) {
|
|
||||||
if (room?.roomId !== timeline.getRoomId()) return;
|
|
||||||
const sender = event.getSender();
|
|
||||||
if (sender) {
|
|
||||||
live.delete(sender);
|
|
||||||
}
|
|
||||||
if (excludeEvents.includes(event.getType())) return;
|
|
||||||
if (toStartOfTimeline) {
|
|
||||||
events.push(event);
|
|
||||||
} else {
|
|
||||||
const needScroll = timelineElement.scrollTop < 20;
|
|
||||||
events.unshift(event);
|
|
||||||
if (needScroll) {
|
|
||||||
await tick();
|
|
||||||
timelineElement.scroll({
|
|
||||||
top: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let typing = $state<string[]>([]);
|
|
||||||
|
|
||||||
function onTyping(event: MatrixEvent, member: RoomMember) {
|
|
||||||
typing = event.event.content?.["user_ids"] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function send() {
|
|
||||||
const roomId = timeline.getRoomId();
|
|
||||||
if (!roomId) return;
|
|
||||||
const finalText = recorder.player.stepper.text
|
|
||||||
.map((token) => token.text)
|
|
||||||
.join("");
|
|
||||||
const finalRecording = recorder.finish();
|
|
||||||
if (!finalText) return;
|
|
||||||
recorder = new ReplayRecorder();
|
|
||||||
await $matrixClient.sendMessage(roomId, {
|
|
||||||
msgtype: "m.text" as MsgType.Text,
|
|
||||||
body: finalText,
|
|
||||||
// @ts-expect-error
|
|
||||||
"m.replay": finalRecording,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
|
|
||||||
send();
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
recorder.next(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
|
|
||||||
recorder = new ReplayRecorder();
|
|
||||||
} else {
|
|
||||||
socket.emit("message", {
|
|
||||||
timeStamp: event.timeStamp,
|
|
||||||
type: event.type,
|
|
||||||
key: event.key,
|
|
||||||
code: event.code,
|
|
||||||
username: $matrixClient.getUserId(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let socket: Socket = $state()!;
|
|
||||||
let live = new SvelteMap<string, ReplayRecorder>();
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
socket = io("https://srv.charachorder.io");
|
|
||||||
socket.emit("join", timeline.getRoomId());
|
|
||||||
|
|
||||||
socket.on("message", async ({ message }) => {
|
|
||||||
let userRecorder = live.get(message.username);
|
|
||||||
if (!userRecorder) {
|
|
||||||
userRecorder = new ReplayRecorder();
|
|
||||||
live.set(message.username, userRecorder);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
userRecorder.next(message);
|
|
||||||
|
|
||||||
if (userRecorder.player.stepper.text.length === 0) {
|
|
||||||
live.delete(message.username);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
|
||||||
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
socket?.disconnect();
|
|
||||||
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
|
||||||
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div bind:this={timelineElement} class="timeline">
|
|
||||||
{#each live.entries() as [userId, recorder] (userId)}
|
|
||||||
{@const roomId = timeline.getRoomId()}
|
|
||||||
{#if roomId}
|
|
||||||
{@const room = $matrixClient.getRoom(roomId)}
|
|
||||||
{@const member = room?.getMember(userId)}
|
|
||||||
{#if member}
|
|
||||||
<MatrixEventComponent sender={member} replay={recorder.player} />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{#each events as event, i (event.event["event_id"])}
|
|
||||||
{@const prev = events[i + 1]}
|
|
||||||
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="static-elements">
|
|
||||||
<div class="indicators"></div>
|
|
||||||
<div class="input-box">
|
|
||||||
<button class="icon">add</button>
|
|
||||||
<div
|
|
||||||
role="textbox"
|
|
||||||
tabindex="0"
|
|
||||||
class="input"
|
|
||||||
onkeydown={onKey}
|
|
||||||
onkeyup={onKey}
|
|
||||||
onfocusin={() => (showCursor = true)}
|
|
||||||
onfocusout={() => (showCursor = false)}
|
|
||||||
>
|
|
||||||
<CharRecorder replay={recorder.player} cursor={showCursor} />
|
|
||||||
</div>
|
|
||||||
<button class="icon" onclick={send}>send</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
$border-radius: 16px;
|
|
||||||
|
|
||||||
|
|
||||||
.input {
|
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
|
||||||
flex-grow: 1;
|
|
||||||
cursor: text;
|
|
||||||
padding: 0.5em;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
|
|
||||||
text-wrap: wrap;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
word-break: break-word;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding-block: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.static-elements {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline {
|
|
||||||
contain: content;
|
|
||||||
height: auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
overflow-y: scroll;
|
|
||||||
overflow-x: hidden;
|
|
||||||
flex-grow: 1;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { derived, writable, type Writable } from "svelte/store";
|
|
||||||
import type {
|
|
||||||
ClientEvent,
|
|
||||||
LoginResponse,
|
|
||||||
MatrixClient,
|
|
||||||
RoomMember,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import {
|
|
||||||
themeFromSourceColor,
|
|
||||||
argbFromHex,
|
|
||||||
type CustomColorGroup,
|
|
||||||
} from "@material/material-color-utilities";
|
|
||||||
import type { UserTheme } from "$lib/preferences";
|
|
||||||
import { MatrixRx } from "./matrix-rx/client";
|
|
||||||
|
|
||||||
export const matrixClient: Writable<MatrixClient> = writable();
|
|
||||||
|
|
||||||
export const isLoggedIn: Writable<boolean> = writable(false);
|
|
||||||
|
|
||||||
export const matrix = derived(
|
|
||||||
[matrixClient, isLoggedIn],
|
|
||||||
([matrixClient, isLoggedIn]) =>
|
|
||||||
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const currentRoomId = persistentWritable<string | null>(
|
|
||||||
"currentRoomId",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
function getStoredLogin(): LoginResponse | undefined {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeLogin(response: LoginResponse) {
|
|
||||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initMatrixClient() {
|
|
||||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
|
||||||
"matrix-js-sdk"
|
|
||||||
);
|
|
||||||
|
|
||||||
const storedLogin = getStoredLogin();
|
|
||||||
|
|
||||||
const store = new IndexedDBStore({
|
|
||||||
dbName: "matrix",
|
|
||||||
indexedDB: window.indexedDB,
|
|
||||||
});
|
|
||||||
const cryptoStore = new IndexedDBCryptoStore(
|
|
||||||
window.indexedDB,
|
|
||||||
"matrix-crypto",
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = createClient({
|
|
||||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
|
||||||
userId: storedLogin?.user_id,
|
|
||||||
accessToken: storedLogin?.access_token,
|
|
||||||
timelineSupport: true,
|
|
||||||
store,
|
|
||||||
cryptoStore,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("store");
|
|
||||||
await store.startup();
|
|
||||||
console.log("cryptoStore");
|
|
||||||
await cryptoStore.startup();
|
|
||||||
console.log("client");
|
|
||||||
await client.startClient();
|
|
||||||
client.once("sync" as ClientEvent.Sync, () => {
|
|
||||||
isLoggedIn.set(client.isLoggedIn());
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginToken = new URLSearchParams(window.location.search).get(
|
|
||||||
"loginToken",
|
|
||||||
);
|
|
||||||
if (loginToken) {
|
|
||||||
storeLogin(await client.loginWithToken(loginToken));
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
isLoggedIn.set(client.isLoggedIn());
|
|
||||||
}
|
|
||||||
|
|
||||||
matrixClient.set(client);
|
|
||||||
console.log("done");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function memberColor(
|
|
||||||
member: RoomMember,
|
|
||||||
theme: UserTheme,
|
|
||||||
): CustomColorGroup {
|
|
||||||
let hash = 0;
|
|
||||||
member.userId.split("").forEach((char) => {
|
|
||||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
|
||||||
});
|
|
||||||
let color = "#";
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const value = (hash >> (i * 8)) & 0xff;
|
|
||||||
color += value.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
|
||||||
{ value: argbFromHex(color), name: "member", blend: true },
|
|
||||||
]).customColors.find((c) => c.color.name === "member")!;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { writable, type Writable } from "svelte/store";
|
|
||||||
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import {
|
|
||||||
themeFromSourceColor,
|
|
||||||
argbFromHex,
|
|
||||||
type CustomColorGroup,
|
|
||||||
} from "@material/material-color-utilities";
|
|
||||||
import type { UserTheme } from "$lib/preferences";
|
|
||||||
|
|
||||||
export const matrixClient: Writable<MatrixClient> = writable();
|
|
||||||
|
|
||||||
export const currentRoomId = persistentWritable<string | null>(
|
|
||||||
"currentRoomId",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function memberColor(
|
|
||||||
member: RoomMember,
|
|
||||||
theme: UserTheme,
|
|
||||||
): CustomColorGroup {
|
|
||||||
let hash = 0;
|
|
||||||
member.userId.split("").forEach((char) => {
|
|
||||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
|
||||||
});
|
|
||||||
let color = "#";
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const value = (hash >> (i * 8)) & 0xff;
|
|
||||||
color += value.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
|
||||||
{ value: argbFromHex(color), name: "member", blend: true },
|
|
||||||
]).customColors.find((c) => c.color.name === "member")!;
|
|
||||||
}
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {
|
|
||||||
EventTimeline,
|
|
||||||
MatrixEvent,
|
|
||||||
MatrixEventEvent,
|
|
||||||
Relations,
|
|
||||||
RelationsEvent,
|
|
||||||
RoomMember,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
|
|
||||||
import { matrixClient, memberColor } from "../chat";
|
|
||||||
import { theme } from "$lib/preferences";
|
|
||||||
import { hexFromArgb } from "@material/material-color-utilities";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import type { Replay } from "$lib/charrecorder/core/types";
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
import { writable } from "svelte/store";
|
|
||||||
|
|
||||||
let {
|
|
||||||
event,
|
|
||||||
prev,
|
|
||||||
sender,
|
|
||||||
replay: replayPlayer,
|
|
||||||
timeline,
|
|
||||||
}: {
|
|
||||||
event?: MatrixEvent;
|
|
||||||
prev?: MatrixEvent;
|
|
||||||
sender?: RoomMember | null;
|
|
||||||
replay?: Replay | ReplayPlayer;
|
|
||||||
timeline?: EventTimeline;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let toolbarHover = $state(false);
|
|
||||||
let mainHover = $state(false);
|
|
||||||
|
|
||||||
let hover = $derived(toolbarHover || mainHover);
|
|
||||||
|
|
||||||
let replay: Replay | undefined = $state();
|
|
||||||
|
|
||||||
let reactions: Relations | undefined = $state(
|
|
||||||
timeline && event?.event.event_id
|
|
||||||
? timeline
|
|
||||||
.getTimelineSet()
|
|
||||||
.relations.getChildEventsForEvent(
|
|
||||||
event.event.event_id,
|
|
||||||
"m.annotation",
|
|
||||||
"m.reaction",
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
|
|
||||||
|
|
||||||
function createRelations() {
|
|
||||||
if (!timeline || !event?.event.event_id) return;
|
|
||||||
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
|
|
||||||
reactions?.off(
|
|
||||||
"Relations.remove" as RelationsEvent.Remove,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
reactions = timeline
|
|
||||||
.getTimelineSet()
|
|
||||||
.relations.getChildEventsForEvent(
|
|
||||||
event.event.event_id,
|
|
||||||
"m.annotation",
|
|
||||||
"m.reaction",
|
|
||||||
);
|
|
||||||
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
|
|
||||||
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
|
|
||||||
reactions?.on(
|
|
||||||
"Relations.redaction" as RelationsEvent.Redaction,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
annotations.set(
|
|
||||||
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
|
|
||||||
);
|
|
||||||
console.log("create");
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
createRelations();
|
|
||||||
event?.on(
|
|
||||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
event?.off(
|
|
||||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
|
|
||||||
reactions?.off(
|
|
||||||
"Relations.remove" as RelationsEvent.Remove,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
reactions?.off(
|
|
||||||
"Relations.redaction" as RelationsEvent.Redaction,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="event"
|
|
||||||
role="log"
|
|
||||||
onmouseover={() => (mainHover = true)}
|
|
||||||
onfocus={() => (mainHover = true)}
|
|
||||||
onmouseout={() => (mainHover = false)}
|
|
||||||
onblur={() => (mainHover = false)}
|
|
||||||
>
|
|
||||||
{#if event && hover}
|
|
||||||
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
|
|
||||||
{@const color = memberColor(sender, $theme)}
|
|
||||||
{@const avatarMxc = sender.getMxcAvatarUrl()}
|
|
||||||
{#if avatarMxc}
|
|
||||||
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
|
|
||||||
<img
|
|
||||||
class="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={sender.name}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="avatar avatar-placeholder icon"
|
|
||||||
style:background={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
|
||||||
)}
|
|
||||||
style:color={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
person
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="sender"
|
|
||||||
style:color={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<strong>{sender.name}</strong>
|
|
||||||
{#if replay || replayPlayer}
|
|
||||||
<div class="dots">
|
|
||||||
{#each new Array(3) as _, i}
|
|
||||||
<div
|
|
||||||
style:animation-delay={i * 0.2 + "s"}
|
|
||||||
style:background={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
|
||||||
)}
|
|
||||||
class="dot"
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{#if event}
|
|
||||||
{#if event.getType() === "m.room.message"}
|
|
||||||
<MatrixMessageEvent {event} bind:replay />
|
|
||||||
{:else}
|
|
||||||
<details>
|
|
||||||
<summary>{event.getType()}</summary>
|
|
||||||
<pre>{JSON.stringify(event.event, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if replayPlayer}
|
|
||||||
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if event && hover}
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
tabindex="0"
|
|
||||||
class="toolbar"
|
|
||||||
transition:fade={{ duration: 100 }}
|
|
||||||
onmouseover={() => (toolbarHover = true)}
|
|
||||||
onfocus={() => (toolbarHover = true)}
|
|
||||||
onmouseout={() => (toolbarHover = false)}
|
|
||||||
onblur={() => (toolbarHover = false)}
|
|
||||||
>
|
|
||||||
{#if event.getType() === "m.room.message"}
|
|
||||||
{@const message = event.event.content?.["body"]}
|
|
||||||
<a
|
|
||||||
class="icon rocket"
|
|
||||||
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
|
|
||||||
>rocket_launch</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<button class="icon">add_reaction</button>
|
|
||||||
<button class="icon">reply</button>
|
|
||||||
{#if event.event.content?.["m.replay"]}
|
|
||||||
{#if replay}
|
|
||||||
<button class="icon" onclick={() => (replay = undefined)}>stop</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="icon"
|
|
||||||
onclick={() => (replay = event.event.content?.["m.replay"])}
|
|
||||||
>replay</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<button class="icon">more_horiz</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $annotations && $annotations.length > 0}
|
|
||||||
<div class="reactions">
|
|
||||||
{#each $annotations as [reaction, events]}
|
|
||||||
<button class="reaction"
|
|
||||||
>{reaction} <span class="count">{events.size}</span></button
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
details {
|
|
||||||
opacity: 0.5;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
text-wrap: wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rocket {
|
|
||||||
0% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
transform: translate(4px, -4px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.icon.rocket {
|
|
||||||
animation: rocket 2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
position: absolute;
|
|
||||||
top: -26px;
|
|
||||||
right: 0;
|
|
||||||
background: var(--md-sys-color-secondary-container);
|
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: 4px;
|
|
||||||
display: flex;
|
|
||||||
z-index: 100;
|
|
||||||
|
|
||||||
a,
|
|
||||||
button {
|
|
||||||
font-size: 16px;
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: bounce 1s infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender,
|
|
||||||
.avatar {
|
|
||||||
margin-block: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
grid-area: avatar;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 50%;
|
|
||||||
translate: 0 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.avatar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
|
||||||
display: flex;
|
|
||||||
grid-area: sender;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions {
|
|
||||||
grid-area: reactions;
|
|
||||||
margin-top: 2px;
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction {
|
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
> .count {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.event {
|
|
||||||
display: grid;
|
|
||||||
position: relative;
|
|
||||||
padding-inline: 0.5em;
|
|
||||||
margin-inline: 0.5em;
|
|
||||||
padding-block: 0.25em;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
grid-template-areas:
|
|
||||||
"avatar sender date"
|
|
||||||
"avatar content content"
|
|
||||||
"none reactions reactions";
|
|
||||||
grid-template-columns: 32px 1fr auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
grid-area: content;
|
|
||||||
text-wrap: wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions,
|
|
||||||
.content,
|
|
||||||
.sender {
|
|
||||||
margin-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backdrop {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: -1;
|
|
||||||
opacity: 0.25;
|
|
||||||
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import type { Replay } from "$lib/charrecorder/core/types";
|
|
||||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import { matrixClient } from "../chat";
|
|
||||||
|
|
||||||
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
|
|
||||||
$props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{#if event.event.content?.msgtype === "m.image"}
|
|
||||||
<img
|
|
||||||
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
|
|
||||||
alt={event.event.content["body"]}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span class="content" style:opacity={replay && 0}
|
|
||||||
>{event.event.content?.["body"]}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if replay}
|
|
||||||
<div class="replay" out:fade>
|
|
||||||
<CharRecorder
|
|
||||||
{replay}
|
|
||||||
cursor={true}
|
|
||||||
keys={true}
|
|
||||||
ondone={() => (replay = undefined)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
div {
|
|
||||||
position: relative;
|
|
||||||
min-height: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 16em;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
|
|
||||||
import {
|
|
||||||
filter,
|
|
||||||
map,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
distinctUntilChanged,
|
|
||||||
merge,
|
|
||||||
} from "rxjs";
|
|
||||||
import { fromMatrixClientEvent } from "./events";
|
|
||||||
|
|
||||||
function roomListDistinct(prev: Room[], curr: Room[]) {
|
|
||||||
if (prev.length !== curr.length) return false;
|
|
||||||
for (let i = 0; i < prev.length; i++) {
|
|
||||||
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MatrixRx {
|
|
||||||
topLevelRooms$: Observable<Room[]>;
|
|
||||||
|
|
||||||
topLevelSpaces$: Observable<Room[]>;
|
|
||||||
|
|
||||||
topLevelChats$: Observable<Room[]>;
|
|
||||||
|
|
||||||
constructor(private client: MatrixClient) {
|
|
||||||
this.topLevelRooms$ = merge(
|
|
||||||
of([]),
|
|
||||||
fromMatrixClientEvent(client, "Room"),
|
|
||||||
fromMatrixClientEvent(client, "deleteRoom"),
|
|
||||||
fromMatrixClientEvent(client, "Room.myMembership"),
|
|
||||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
|
||||||
filter(
|
|
||||||
([_room, prev, curr]) =>
|
|
||||||
prev.getStateEvents("m.space.parent").length !==
|
|
||||||
curr.getStateEvents("m.space.parent").length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).pipe(
|
|
||||||
map(() =>
|
|
||||||
this.client.getVisibleRooms().filter(
|
|
||||||
(room) =>
|
|
||||||
room.getMyMembership() !== "leave" &&
|
|
||||||
room
|
|
||||||
.getLiveTimeline()
|
|
||||||
.getState("f" as Direction.Forward)
|
|
||||||
?.getStateEvents("m.space.parent").length === 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
distinctUntilChanged(roomListDistinct),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
|
|
||||||
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
|
|
||||||
distinctUntilChanged(roomListDistinct),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.topLevelChats$ = this.topLevelRooms$.pipe(
|
|
||||||
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
|
|
||||||
distinctUntilChanged(roomListDistinct),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SpaceRx {
|
|
||||||
constructor(
|
|
||||||
private client: MatrixClient,
|
|
||||||
private space: Room,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
|
|
||||||
import { fromEvent, type Observable } from "rxjs";
|
|
||||||
|
|
||||||
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
|
|
||||||
client: MatrixClient,
|
|
||||||
eventName: `${T}`, // hack so we can use strings instead of enums
|
|
||||||
): Observable<Parameters<ClientEventHandlerMap[T]>> {
|
|
||||||
return fromEvent(client, eventName) as Observable<
|
|
||||||
Parameters<ClientEventHandlerMap[T]>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type {
|
|
||||||
MatrixClient,
|
|
||||||
MatrixEvent,
|
|
||||||
Room,
|
|
||||||
Direction,
|
|
||||||
RoomState,
|
|
||||||
RoomStateEventHandlerMap,
|
|
||||||
EventType,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { fromMatrixClientEvent } from "./events";
|
|
||||||
import {
|
|
||||||
map,
|
|
||||||
filter,
|
|
||||||
merge,
|
|
||||||
startWith,
|
|
||||||
Observable,
|
|
||||||
of,
|
|
||||||
fromEvent,
|
|
||||||
concat,
|
|
||||||
defer,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
export function matrixRoom$(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string | undefined,
|
|
||||||
): Observable<Room | undefined> {
|
|
||||||
return merge([
|
|
||||||
fromMatrixClientEvent(client, "Room").pipe(
|
|
||||||
filter(([room]) => room.roomId === roomId),
|
|
||||||
),
|
|
||||||
fromMatrixClientEvent(client, "deleteRoom").pipe(
|
|
||||||
filter(([id]) => id === roomId),
|
|
||||||
),
|
|
||||||
]).pipe(
|
|
||||||
startWith([]),
|
|
||||||
map(() => client.getRoom(roomId) ?? undefined),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roomTimeline$(
|
|
||||||
client: MatrixClient,
|
|
||||||
room: Room | undefined,
|
|
||||||
): Observable<MatrixEvent[] | undefined> {
|
|
||||||
if (!room) return of(undefined);
|
|
||||||
const eventTimeline = room.getLiveTimeline();
|
|
||||||
|
|
||||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
|
||||||
filter(
|
|
||||||
([, eventRoom]) =>
|
|
||||||
eventRoom !== undefined && eventRoom.roomId === room.roomId,
|
|
||||||
),
|
|
||||||
startWith([]),
|
|
||||||
map(() => eventTimeline.getEvents()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roomCurrentStateEvents$(
|
|
||||||
client: MatrixClient,
|
|
||||||
room: Room,
|
|
||||||
eventType: EventType | string,
|
|
||||||
): Observable<MatrixEvent[]> {
|
|
||||||
return concat(
|
|
||||||
defer(() =>
|
|
||||||
of(
|
|
||||||
room
|
|
||||||
.getLiveTimeline()
|
|
||||||
.getState("f" as Direction.Forward)
|
|
||||||
?.getStateEvents(eventType) ?? [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
|
||||||
filter(([room]) => room.roomId === room.roomId),
|
|
||||||
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
|
|
||||||
state: RoomState,
|
|
||||||
eventName: `${T}`,
|
|
||||||
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
|
|
||||||
return fromEvent(state, eventName) as Observable<
|
|
||||||
Parameters<RoomStateEventHandlerMap[T]>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
|
||||||
import { filter, map, of, startWith, type Observable } from "rxjs";
|
|
||||||
import { fromMatrixClientEvent } from "./events";
|
|
||||||
|
|
||||||
export function roomTimeline(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string | undefined,
|
|
||||||
): Observable<MatrixEvent[]> {
|
|
||||||
if (!roomId) return of([]);
|
|
||||||
const room = client.getRoom(roomId);
|
|
||||||
if (!room) return of([]);
|
|
||||||
const eventTimeline = room.getLiveTimeline();
|
|
||||||
|
|
||||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
|
||||||
filter(([, room]) => room?.roomId === roomId),
|
|
||||||
startWith([]),
|
|
||||||
map(() => eventTimeline.getEvents()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
54
src/lib/chord-editor/AutospaceSelector.svelte
Normal file
54
src/lib/chord-editor/AutospaceSelector.svelte
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { actionTooltip } from "$lib/title";
|
||||||
|
|
||||||
|
let {
|
||||||
|
onchange,
|
||||||
|
value,
|
||||||
|
variant,
|
||||||
|
}: {
|
||||||
|
value: boolean;
|
||||||
|
variant: "start" | "end";
|
||||||
|
onchange: (
|
||||||
|
event: Event & { currentTarget: EventTarget & HTMLInputElement },
|
||||||
|
) => void;
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet tooltip()}
|
||||||
|
{#if value}
|
||||||
|
{#if variant === "start"}
|
||||||
|
<b>Remove</b> preceding space
|
||||||
|
{:else}
|
||||||
|
<b>Add</b> trailing space
|
||||||
|
{/if}
|
||||||
|
{:else if variant === "start"}
|
||||||
|
<b>Keep</b> preceding space
|
||||||
|
{:else}
|
||||||
|
<b>Add</b> trailing space
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
<label class="autospace" {@attach actionTooltip(tooltip)}
|
||||||
|
><span class="icon">space_bar</span><input
|
||||||
|
checked={!value}
|
||||||
|
{onchange}
|
||||||
|
type="checkbox"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
label.autospace {
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-inline: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--md-sys-color-tertiary-container);
|
||||||
|
padding-inline: 0;
|
||||||
|
height: 1em;
|
||||||
|
color: var(--md-sys-color-on-tertiary-container);
|
||||||
|
font-size: 1.3em;
|
||||||
|
|
||||||
|
&:has(:checked) {
|
||||||
|
opacity: var(--auto-space-show, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
107
src/lib/chord-editor/action-plugin.ts
Normal file
107
src/lib/chord-editor/action-plugin.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import type { Range } from "@codemirror/state";
|
||||||
|
|
||||||
|
export class ActionWidget extends WidgetType {
|
||||||
|
component?: {};
|
||||||
|
element?: HTMLElement;
|
||||||
|
|
||||||
|
constructor(readonly id: string | number) {
|
||||||
|
super();
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
override eq(other: ActionWidget) {
|
||||||
|
return this.id == other.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
if (!this.element) {
|
||||||
|
this.element = document.createElement("span");
|
||||||
|
this.element.style.paddingInline = "2px";
|
||||||
|
|
||||||
|
this.component = mount(Action, {
|
||||||
|
target: this.element,
|
||||||
|
props: { action: this.id, display: "keys", inText: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
override ignoreEvent() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
if (this.component) {
|
||||||
|
unmount(this.component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionWidgets(view: EditorView) {
|
||||||
|
const widgets: Range<Decoration>[] = [];
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (node) => {
|
||||||
|
if (node.name !== "ExplicitAction") return;
|
||||||
|
const value =
|
||||||
|
node.node.getChild("ActionId") ??
|
||||||
|
node.node.getChild("HexNumber") ??
|
||||||
|
node.node.getChild("DecimalNumber");
|
||||||
|
if (!value) return;
|
||||||
|
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = view.state.doc.sliceString(value.from, value.to);
|
||||||
|
let deco = Decoration.replace({
|
||||||
|
widget: new ActionWidget(
|
||||||
|
value.name === "ActionId" ? id : parseInt(id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
widgets.push(deco.range(node.from, node.to));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Decoration.set(widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actionPlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations = Decoration.none;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = actionWidgets(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||||
|
)
|
||||||
|
this.decorations = actionWidgets(update.view);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(instance) {
|
||||||
|
return instance.decorations;
|
||||||
|
},
|
||||||
|
provide(plugin) {
|
||||||
|
return EditorView.atomicRanges.of(
|
||||||
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
16
src/lib/chord-editor/action-serializer.ts
Normal file
16
src/lib/chord-editor/action-serializer.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||||
|
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actionToValue(action: number | KeyInfo) {
|
||||||
|
const info =
|
||||||
|
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
|
||||||
|
if (info && info.id?.length === 1)
|
||||||
|
return /^[<>\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
|
||||||
|
if (!info || !canUseIdAsString(info))
|
||||||
|
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
|
||||||
|
return `<${info.id}>`;
|
||||||
|
}
|
||||||
72
src/lib/chord-editor/autocomplete.ts
Normal file
72
src/lib/chord-editor/autocomplete.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
|
import type {
|
||||||
|
Completion,
|
||||||
|
CompletionSection,
|
||||||
|
CompletionSource,
|
||||||
|
} from "@codemirror/autocomplete";
|
||||||
|
import { derived, get } from "svelte/store";
|
||||||
|
import { actionToValue, canUseIdAsString } from "./action-serializer";
|
||||||
|
|
||||||
|
const completionSections = derived(
|
||||||
|
KEYMAP_CATEGORIES,
|
||||||
|
(categories) =>
|
||||||
|
new Map(
|
||||||
|
categories.map(
|
||||||
|
(category) =>
|
||||||
|
[
|
||||||
|
category,
|
||||||
|
{
|
||||||
|
name: category.name,
|
||||||
|
} satisfies CompletionSection,
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionAutocompleteItems = derived(
|
||||||
|
[KEYMAP_CODES, completionSections],
|
||||||
|
([codes, sections]) =>
|
||||||
|
codes
|
||||||
|
.values()
|
||||||
|
.map((info) => {
|
||||||
|
const canUseId = canUseIdAsString(info);
|
||||||
|
const completionValue =
|
||||||
|
(canUseId && info.id) ||
|
||||||
|
`0x${info.code.toString(16).padStart(2, "0")}`;
|
||||||
|
return {
|
||||||
|
label:
|
||||||
|
[
|
||||||
|
canUseId || !info.id ? undefined : `"${info.id}"`,
|
||||||
|
info.title,
|
||||||
|
info.variant?.replace(/^[a-z]/g, (c) => c.toUpperCase()),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") || completionValue,
|
||||||
|
detail: actionToValue(info),
|
||||||
|
section: info.category ? sections.get(info.category) : undefined,
|
||||||
|
info: info.description,
|
||||||
|
type: "keyword",
|
||||||
|
apply: completionValue + ">",
|
||||||
|
} satisfies Completion;
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(item) => typeof item.label === "string" && item.apply !== undefined,
|
||||||
|
)
|
||||||
|
.toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionAutocomplete = ((context) => {
|
||||||
|
let word = context.tokenBefore([
|
||||||
|
"ExplicitDelimStart",
|
||||||
|
"ActionId",
|
||||||
|
"HexNumber",
|
||||||
|
"DecimalNumber",
|
||||||
|
]);
|
||||||
|
if (!word) return null;
|
||||||
|
console.log(get(actionAutocompleteItems));
|
||||||
|
return {
|
||||||
|
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
|
||||||
|
validFor: /^<?[a-zA-Z0-9_]*$/,
|
||||||
|
options: get(actionAutocompleteItems),
|
||||||
|
};
|
||||||
|
}) satisfies CompletionSource;
|
||||||
17
src/lib/chord-editor/changes-plugin.ts
Normal file
17
src/lib/chord-editor/changes-plugin.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
type PluginValue,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
|
||||||
|
export const changesPlugin = ViewPlugin.fromClass(
|
||||||
|
class implements PluginValue {
|
||||||
|
constructor(readonly view: EditorView) {}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventHandlers: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
157
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
157
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
Decoration,
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
WidgetType,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import type { Range } from "@codemirror/state";
|
||||||
|
import { mount, unmount } from "svelte";
|
||||||
|
import Action from "../components/Action.svelte";
|
||||||
|
import type { SyntaxNodeRef } from "@lezer/common";
|
||||||
|
import classNames from "./concatenator-button.module.scss";
|
||||||
|
|
||||||
|
export class DelimWidget extends WidgetType {
|
||||||
|
component?: {};
|
||||||
|
element?: HTMLElement;
|
||||||
|
|
||||||
|
constructor(readonly hasConcatenator: boolean) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
override eq(other: DelimWidget) {
|
||||||
|
return this.hasConcatenator == other.hasConcatenator;
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM() {
|
||||||
|
if (!this.element) {
|
||||||
|
this.element = document.createElement("span");
|
||||||
|
this.element.innerHTML =
|
||||||
|
" ⇛" + (this.hasConcatenator ? "" : " ");
|
||||||
|
this.element.style.scale = "1.8";
|
||||||
|
this.element.style.color =
|
||||||
|
"color-mix(in srgb, currentColor 50%, transparent)";
|
||||||
|
|
||||||
|
if (this.hasConcatenator) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.className = classNames["concatenator-button"]!;
|
||||||
|
this.component = mount(Action, {
|
||||||
|
target: button,
|
||||||
|
props: { action: 574, display: "keys", inText: true, ghost: true },
|
||||||
|
});
|
||||||
|
this.element.appendChild(button);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.element;
|
||||||
|
}
|
||||||
|
|
||||||
|
override ignoreEvent() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
override destroy() {
|
||||||
|
if (this.component) {
|
||||||
|
unmount(this.component);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJoinNode(
|
||||||
|
view: EditorView,
|
||||||
|
phraseDelimNode: SyntaxNodeRef,
|
||||||
|
): SyntaxNodeRef | null | undefined {
|
||||||
|
const firstPhraseAction = phraseDelimNode.node.nextSibling
|
||||||
|
?.getChild("ActionString")
|
||||||
|
?.node.firstChild?.node.getChild("ExplicitAction");
|
||||||
|
const idNode = firstPhraseAction?.node.getChild("ActionId");
|
||||||
|
const actionId = idNode
|
||||||
|
? view.state.doc.sliceString(idNode.from, idNode.to)
|
||||||
|
: null;
|
||||||
|
const isJoinAction =
|
||||||
|
actionId === "JOIN" &&
|
||||||
|
!!firstPhraseAction!.node.getChild("ExplicitDelimEnd");
|
||||||
|
return isJoinAction ? firstPhraseAction : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionWidgets(view: EditorView) {
|
||||||
|
const widgets: Range<Decoration>[] = [];
|
||||||
|
for (const { from, to } of view.visibleRanges) {
|
||||||
|
syntaxTree(view.state).iterate({
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
enter: (node) => {
|
||||||
|
if (node.name !== "PhraseDelim") return;
|
||||||
|
const joinNode = getJoinNode(view, node);
|
||||||
|
|
||||||
|
let deco = Decoration.replace({
|
||||||
|
widget: new DelimWidget(!joinNode),
|
||||||
|
});
|
||||||
|
widgets.push(deco.range(node.from, node.to));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Decoration.set(widgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const delimPlugin = ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations = Decoration.none;
|
||||||
|
|
||||||
|
constructor(view: EditorView) {
|
||||||
|
this.decorations = actionWidgets(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (
|
||||||
|
update.docChanged ||
|
||||||
|
update.viewportChanged ||
|
||||||
|
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||||
|
)
|
||||||
|
this.decorations = actionWidgets(update.view);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
decorations(instance) {
|
||||||
|
return instance.decorations;
|
||||||
|
},
|
||||||
|
provide(plugin) {
|
||||||
|
return EditorView.atomicRanges.of(
|
||||||
|
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
eventHandlers: {
|
||||||
|
click: (event, view) => {
|
||||||
|
if (!(event.target instanceof HTMLElement)) return;
|
||||||
|
if (
|
||||||
|
!(
|
||||||
|
event.target instanceof HTMLButtonElement ||
|
||||||
|
(event.target as HTMLElement).parentElement instanceof
|
||||||
|
HTMLButtonElement
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const chordNode = syntaxTree(view.state).resolve(
|
||||||
|
view.posAtDOM(event.target),
|
||||||
|
);
|
||||||
|
const delimNode = (
|
||||||
|
chordNode.name === "ActionString"
|
||||||
|
? chordNode.parent?.parent
|
||||||
|
: chordNode
|
||||||
|
)?.getChild("PhraseDelim");
|
||||||
|
if (!delimNode) return;
|
||||||
|
const joinNode = getJoinNode(view, delimNode);
|
||||||
|
if (!event.target.checked && !joinNode) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: delimNode.to,
|
||||||
|
insert: "<JOIN>",
|
||||||
|
},
|
||||||
|
selection: { anchor: delimNode.to + "<JOIN>".length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
57
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
57
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { parser } from "./chords.grammar";
|
||||||
|
import {
|
||||||
|
LRLanguage,
|
||||||
|
LanguageSupport,
|
||||||
|
HighlightStyle,
|
||||||
|
} from "@codemirror/language";
|
||||||
|
import { styleTags, tags } from "@lezer/highlight";
|
||||||
|
import { actionAutocomplete } from "./autocomplete";
|
||||||
|
|
||||||
|
export const chordHighlightStyle = HighlightStyle.define([
|
||||||
|
{
|
||||||
|
tag: tags.keyword,
|
||||||
|
paddingInline: "2px",
|
||||||
|
opacity: "0.5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: tags.className,
|
||||||
|
backgroundColor:
|
||||||
|
"color-mix(in srgb, var(--md-sys-color-surface-variant) 50%, transparent)",
|
||||||
|
borderRadius: "4px",
|
||||||
|
paddingInline: "4px",
|
||||||
|
marginInline: "-4px",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: tags.integer,
|
||||||
|
color: "var(--md-sys-color-tertiary)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
tag: tags.angleBracket,
|
||||||
|
opacity: "0.5",
|
||||||
|
},
|
||||||
|
{ tag: tags.modifier, opacity: "0.25" },
|
||||||
|
{ tag: tags.escape, color: "var(--md-sys-color-primary)" },
|
||||||
|
{ tag: tags.strong, fontWeight: "bold" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const chordLanguage = LRLanguage.define({
|
||||||
|
name: "chords",
|
||||||
|
parser: parser.configure({
|
||||||
|
props: [
|
||||||
|
styleTags({
|
||||||
|
"PhraseDelim CompoundDelim": [tags.keyword, tags.strong],
|
||||||
|
"HexNumber DecimalNumber": [tags.className, tags.integer],
|
||||||
|
"ExplicitDelimStart ExplicitDelimEnd": tags.angleBracket,
|
||||||
|
ActionId: tags.className,
|
||||||
|
EscapedLetter: tags.escape,
|
||||||
|
Escape: [tags.escape, tags.modifier],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function chordLanguageSupport() {
|
||||||
|
return new LanguageSupport(chordLanguage, [
|
||||||
|
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
27
src/lib/chord-editor/chords.grammar
Normal file
27
src/lib/chord-editor/chords.grammar
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
@top Program { Chord* }
|
||||||
|
|
||||||
|
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
|
||||||
|
EscapedSingleAction { Escape EscapedLetter }
|
||||||
|
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||||
|
ActionString { Action* }
|
||||||
|
ChordInput { (ActionString CompoundDelim)* ActionString }
|
||||||
|
ChordPhrase { ActionString }
|
||||||
|
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||||
|
|
||||||
|
@tokens {
|
||||||
|
@precedence {HexNumber, DecimalNumber}
|
||||||
|
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||||
|
@precedence {EscapedLetter}
|
||||||
|
ExplicitDelimStart {"<"}
|
||||||
|
ExplicitDelimEnd {">"}
|
||||||
|
CompoundDelim {"+>"}
|
||||||
|
PhraseDelim {"=>"}
|
||||||
|
Escape { "\\" }
|
||||||
|
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||||
|
DecimalNumber { $[0-9]+ }
|
||||||
|
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||||
|
SingleLetter { ![\\] }
|
||||||
|
EscapedLetter { ![] }
|
||||||
|
ChordDelim { ($[\n] | @eof) }
|
||||||
|
}
|
||||||
|
|
||||||
13
src/lib/chord-editor/concatenator-button.module.scss
Normal file
13
src/lib/chord-editor/concatenator-button.module.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
.concatenator-button {
|
||||||
|
display: inline;
|
||||||
|
opacity: calc(var(--auto-space-show, 0) * 0.7);
|
||||||
|
margin: 0;
|
||||||
|
padding: 4px;
|
||||||
|
height: auto;
|
||||||
|
|
||||||
|
> :global(kbd) {
|
||||||
|
outline: 1px dashed var(--md-sys-color-outline);
|
||||||
|
outline-offset: -1px;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
declare module "*.grammar" {
|
||||||
|
export const parser: import("@lezer/lr").LRParser;
|
||||||
|
}
|
||||||
16
src/lib/chord-editor/test.txt
Normal file
16
src/lib/chord-editor/test.txt
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.=<LEFT_SHIFT> => =>
|
||||||
|
;ims => <0x219><IMPULSE>
|
||||||
|
-;<KSC_2C><LEFT_SHIFT> => <0x23e>_<0x23e>
|
||||||
|
.;g => <0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||||
|
'dg => <0x23e>'<0x23e>
|
||||||
|
'gl => <0x23e>'ll<0x23e>
|
||||||
|
'ar => <0x23e>'re<0x23e>
|
||||||
|
'gs => <0x23e>'s<0x23e>
|
||||||
|
'ev => <0x23e>'ve<0x23e>
|
||||||
|
<SPACE>-; => <0x23e><0x223>-<0x223><KSC_00>
|
||||||
|
<SPACE>;<LEFT_SHIFT> => <0x23e><0x223><0x23d><0x223><KSC_00>
|
||||||
|
<SPACE>;g => <0x23e><0x223><SPACE><0x223><KSC_00>
|
||||||
|
deg => <0x23e>ed<0x23e>
|
||||||
|
;gr => <0x23e>er<0x23e>
|
||||||
|
;es => <0x23e>es<0x23e>
|
||||||
|
;est => <0x23e>est<0x23e>
|
||||||
@@ -1,95 +1,219 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import { action as title } from "$lib/title";
|
|
||||||
import { osLayout } from "$lib/os-layout";
|
import { osLayout } from "$lib/os-layout";
|
||||||
|
import { isVerbose } from "./verbose-action";
|
||||||
|
import { actionTooltip } from "$lib/title";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
action,
|
action,
|
||||||
display,
|
display,
|
||||||
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
|
inText = false,
|
||||||
|
}: {
|
||||||
|
action: string | number | KeyInfo;
|
||||||
|
display: "inline-keys" | "keys" | "verbose";
|
||||||
|
inText?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let info = $derived(
|
let retrievedInfo = $derived(
|
||||||
typeof action === "number"
|
typeof action === "number"
|
||||||
? ($KEYMAP_CODES.get(action) ?? { code: action })
|
? $KEYMAP_CODES.get(action)
|
||||||
: action,
|
: typeof action === "string"
|
||||||
|
? $KEYMAP_IDS.get(action)
|
||||||
|
: action,
|
||||||
|
);
|
||||||
|
let info = $derived(
|
||||||
|
retrievedInfo ??
|
||||||
|
(typeof action === "number"
|
||||||
|
? ({ code: action } satisfies KeyInfo)
|
||||||
|
: typeof action === "string"
|
||||||
|
? ({ code: 1024, id: action } satisfies KeyInfo)
|
||||||
|
: action),
|
||||||
);
|
);
|
||||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||||
|
let hasPopover = $derived(
|
||||||
let tooltip = $derived(
|
!retrievedInfo || !info.id || info.title || info.description,
|
||||||
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
|
||||||
(info.title ?? "") +
|
|
||||||
(info.variant === "left"
|
|
||||||
? " (left)"
|
|
||||||
: info.variant === "right"
|
|
||||||
? " (right)"
|
|
||||||
: ""),
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if display === "keys"}
|
{#snippet popover()}
|
||||||
|
{#if retrievedInfo}
|
||||||
|
{#if info.icon || info.display || !info.id}
|
||||||
|
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||||
|
{/if}
|
||||||
|
{#if info.title}
|
||||||
|
{info.title}
|
||||||
|
{/if}
|
||||||
|
{#if info.variant === "left"}
|
||||||
|
(Left)
|
||||||
|
{:else if info.variant === "right"}
|
||||||
|
(Right)
|
||||||
|
{/if}
|
||||||
|
{#if info.description}
|
||||||
|
<br />
|
||||||
|
<small>{info.description}</small>
|
||||||
|
{/if}
|
||||||
|
{#if info.breaking}
|
||||||
|
<br /> <i>Prevents prepended autospaces</i>
|
||||||
|
{/if}
|
||||||
|
{#if info.separator || info.breaking}
|
||||||
|
<br /> <i>Stops autocorrect</i>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<b>Unknown Action</b><br />
|
||||||
|
{#if info.code > 1023}
|
||||||
|
This action cannot be translated and will be ingored.
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet kbdText()}
|
||||||
|
{dynamicMapping ??
|
||||||
|
info.icon ??
|
||||||
|
info.display ??
|
||||||
|
info.id ??
|
||||||
|
`0x${info.code.toString(16)}`}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet kbdSnippet(withPopover = true)}
|
||||||
<kbd
|
<kbd
|
||||||
|
class:in-text={inText}
|
||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
use:title={{ title: tooltip }}
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
|
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
|
||||||
>
|
>
|
||||||
{dynamicMapping ??
|
{@render kbdText()}
|
||||||
info.icon ??
|
|
||||||
info.display ??
|
|
||||||
info.id ??
|
|
||||||
`0x${info.code.toString(16)}`}
|
|
||||||
</kbd>
|
</kbd>
|
||||||
{:else if display === "inline-keys"}
|
{/snippet}
|
||||||
|
{#snippet inlineKbdSnippet()}
|
||||||
{#if !info.icon && dynamicMapping?.length === 1}
|
{#if !info.icon && dynamicMapping?.length === 1}
|
||||||
<span
|
<span
|
||||||
use:title={{ title: tooltip }}
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
|
class:in-text={inText}
|
||||||
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||||
>
|
>
|
||||||
{:else if !info.icon && info.id?.length === 1}
|
{:else if !info.icon && info.id?.length === 1}
|
||||||
<span
|
<span
|
||||||
use:title={{ title: tooltip }}
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
|
class:in-text={inText}
|
||||||
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{info.id}</span
|
class:right={info.variant === "right"}>{info.id}</span
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<kbd
|
<kbd
|
||||||
class="inline-kbd"
|
class="inline-kbd"
|
||||||
|
class:in-text={inText}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
use:title={{ title: tooltip }}
|
class:warn={!retrievedInfo}
|
||||||
>
|
class:error={info.code > 1023}
|
||||||
{dynamicMapping ??
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
info.icon ??
|
|
||||||
info.display ??
|
|
||||||
info.id ??
|
|
||||||
`0x${info.code.toString(16)}`}</kbd
|
|
||||||
>
|
>
|
||||||
|
{@render kbdText()}
|
||||||
|
</kbd>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#if display === "keys"}
|
||||||
|
{@render kbdSnippet()}
|
||||||
|
{:else if display === "verbose"}
|
||||||
|
{#if isVerbose(info)}
|
||||||
|
<div class="verbose" {@attach hasPopover ? actionTooltip(popover) : null}>
|
||||||
|
{@render kbdSnippet(false)}
|
||||||
|
<div class="verbose-title">{info.title}</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{@render inlineKbdSnippet()}
|
||||||
|
{/if}
|
||||||
|
{:else if display === "inline-keys" || display === "inline-text"}
|
||||||
|
{@render inlineKbdSnippet()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
kbd:not(.inline-kbd) {
|
kbd:not(.inline-kbd) {
|
||||||
height: 24px;
|
|
||||||
padding-block: auto;
|
|
||||||
transition: color 250ms ease;
|
transition: color 250ms ease;
|
||||||
|
padding-block: auto;
|
||||||
|
height: 24px;
|
||||||
|
|
||||||
|
&.in-text {
|
||||||
|
display: inline-flex;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-block: auto;
|
||||||
|
padding-block: revert;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.warn:not(.error) {
|
||||||
|
border-color: var(--md-sys-color-error);
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
opacity: 0.6;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant-offset: 12px;
|
||||||
|
$variant-padding: calc(2px + $variant-offset);
|
||||||
|
$variant-color: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--md-sys-color-on-surface) 50%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
|
||||||
.left {
|
.left {
|
||||||
border-left-width: 3px;
|
padding-inline-end: $variant-padding;
|
||||||
|
text-shadow: $variant-offset 0 2px $variant-color;
|
||||||
}
|
}
|
||||||
.right {
|
.right {
|
||||||
border-right-width: 3px;
|
padding-inline-start: $variant-padding;
|
||||||
|
text-shadow: -$variant-offset 0 2px $variant-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.inline-kbd {
|
.inline-kbd {
|
||||||
margin-inline-end: 2px;
|
margin-inline-end: 2px;
|
||||||
|
|
||||||
|
&.in-text.icon {
|
||||||
|
translate: 0 -4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(span) + .inline-kbd {
|
:global(span) + .inline-kbd {
|
||||||
margin-inline-start: 2px;
|
margin-inline-start: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.verbose {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-inline: 2px;
|
||||||
|
min-width: 160px;
|
||||||
|
height: 32px;
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verbose-title {
|
||||||
|
display: -webkit-box;
|
||||||
|
opacity: 0.9;
|
||||||
|
max-width: 15ch;
|
||||||
|
-webkit-line-clamp: 2; /* number of lines to show */
|
||||||
|
line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -48,33 +48,33 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
button {
|
button {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
margin: 0;
|
|
||||||
padding: 8px;
|
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
@media not (forced-colors: active) {
|
@media not (forced-colors: active) {
|
||||||
color: inherit;
|
|
||||||
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
border: 1px solid ButtonBorder;
|
|
||||||
margin-block: 4px;
|
margin-block: 4px;
|
||||||
|
border: 1px solid ButtonBorder;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: ActiveText;
|
color: ActiveText;
|
||||||
@@ -86,8 +86,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
text-align: start;
|
text-align: start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fade, slide } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
|
|
||||||
let { value }: { value: number } = $props();
|
let { value }: { value: number } = $props();
|
||||||
|
|
||||||
@@ -29,15 +29,15 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.digits {
|
.digits {
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
transition: width 500ms ease;
|
transition: width 500ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.digit-wrapper {
|
.digit-wrapper {
|
||||||
display: inline-grid;
|
display: inline-grid;
|
||||||
height: 1em;
|
|
||||||
width: 1ch;
|
width: 1ch;
|
||||||
|
height: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.digit {
|
.digit {
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: var(--md-sys-color-on-background);
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { serialLog, serialPort } from "$lib/serial/connection";
|
import { serialLog, serialPort } from "$lib/serial/connection";
|
||||||
|
import { onMount } from "svelte";
|
||||||
import { slide } from "svelte/transition";
|
import { slide } from "svelte/transition";
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
io.scrollTo({ top: io.scrollHeight });
|
||||||
|
});
|
||||||
|
|
||||||
function submit(event: Event) {
|
function submit(event: Event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
$serialPort?.send(0, value.trim());
|
$serialPort?.send(0, [value.trim()]);
|
||||||
value = "";
|
value = "";
|
||||||
io.scrollTo({ top: io.scrollHeight });
|
io.scrollTo({ top: io.scrollHeight });
|
||||||
}
|
}
|
||||||
@@ -33,63 +38,61 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
form {
|
form {
|
||||||
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
contain: strict;
|
contain: strict;
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
border-radius: 16px;
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldset::before {
|
fieldset::before {
|
||||||
content: "$";
|
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 8px;
|
bottom: 8px;
|
||||||
left: 8px;
|
left: 8px;
|
||||||
|
content: "$";
|
||||||
|
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
width: 100%;
|
appearance: none;
|
||||||
margin-block-start: -16px;
|
margin-block-start: -16px;
|
||||||
|
border: none;
|
||||||
|
background: var(--md-sys-color-secondary);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
padding-block-start: 24px;
|
|
||||||
padding-inline-start: calc(8px + 1.5ch);
|
padding-inline-start: calc(8px + 1.5ch);
|
||||||
|
padding-block-start: 24px;
|
||||||
|
width: 100%;
|
||||||
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
font-family: "Noto Sans Mono", monospace;
|
||||||
font-weight: 600;
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
|
|
||||||
appearance: none;
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.io {
|
.io {
|
||||||
--scrollbar-color: var(--md-sys-color-secondary);
|
--scrollbar-color: var(--md-sys-color-secondary);
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
|
border-radius: 0 0 16px 16px;
|
||||||
|
|
||||||
overflow-y: auto;
|
background: var(--md-sys-color-secondary-container);
|
||||||
flex: 1;
|
|
||||||
|
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
|
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
overflow-y: auto;
|
||||||
|
|
||||||
background: var(--md-sys-color-secondary-container);
|
color: var(--md-sys-color-on-secondary-container);
|
||||||
border-radius: 0 0 16px 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
@@ -99,10 +102,10 @@
|
|||||||
fieldset {
|
fieldset {
|
||||||
all: unset;
|
all: unset;
|
||||||
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|
||||||
transition: opacity 250ms ease;
|
transition: opacity 250ms ease;
|
||||||
@@ -113,16 +116,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.anchor {
|
.anchor {
|
||||||
overflow-anchor: auto;
|
|
||||||
height: 1px;
|
height: 1px;
|
||||||
|
overflow-anchor: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
code,
|
code,
|
||||||
samp,
|
samp,
|
||||||
p {
|
p {
|
||||||
display: block;
|
display: block;
|
||||||
overflow-anchor: none;
|
|
||||||
margin-block: 0.15rem;
|
margin-block: 0.15rem;
|
||||||
|
overflow-anchor: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
@@ -130,24 +133,24 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
margin-block-end: 1rem;
|
margin-block-end: 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-secondary);
|
||||||
padding: 0.25rem;
|
padding: 0.25rem;
|
||||||
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
color: var(--md-sys-color-on-secondary);
|
||||||
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
code::before {
|
code::before {
|
||||||
content: "> ";
|
|
||||||
margin-block-end: 0.25rem;
|
margin-block-end: 0.25rem;
|
||||||
font-weight: 900;
|
content: "> ";
|
||||||
color: var(--md-sys-color-primary);
|
color: var(--md-sys-color-primary);
|
||||||
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
color: var(--md-sys-color-background);
|
|
||||||
background: var(--md-sys-color-on-background);
|
background: var(--md-sys-color-on-background);
|
||||||
|
color: var(--md-sys-color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
|
import type { Snippet } from "svelte";
|
||||||
|
|
||||||
|
let { title, shortcut }: { title?: string | Snippet; shortcut?: string } =
|
||||||
|
$props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if title}
|
{#if typeof title === "string"}
|
||||||
<p>{@html title}</p>
|
<p>{@html title}</p>
|
||||||
|
{:else}
|
||||||
|
{@render title?.()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if shortcut}
|
{#if shortcut}
|
||||||
@@ -20,8 +25,8 @@
|
|||||||
|
|
||||||
:global(kbd.icon) {
|
:global(kbd.icon) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
font-size: inherit;
|
|
||||||
translate: 0 0.2em;
|
translate: 0 0.2em;
|
||||||
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
357
src/lib/components/layout/ActionList.svelte
Normal file
357
src/lib/components/layout/ActionList.svelte
Normal file
@@ -0,0 +1,357 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
KEYMAP_CATEGORIES,
|
||||||
|
KEYMAP_CODES,
|
||||||
|
KEYMAP_IDS,
|
||||||
|
type KeyInfo,
|
||||||
|
} from "$lib/serial/keymap-codes";
|
||||||
|
import FlexSearch from "flexsearch";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||||
|
import LL from "$i18n/i18n-svelte";
|
||||||
|
import { actionTooltip } from "$lib/title";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import type { KeymapCategory } from "$lib/meta/types/actions";
|
||||||
|
import Action from "../Action.svelte";
|
||||||
|
import { isVerbose } from "../verbose-action";
|
||||||
|
import { actionToValue } from "$lib/chord-editor/action-serializer";
|
||||||
|
|
||||||
|
let {
|
||||||
|
currentAction = undefined,
|
||||||
|
nextAction = undefined,
|
||||||
|
autofocus = false,
|
||||||
|
onselect,
|
||||||
|
onclose,
|
||||||
|
}: {
|
||||||
|
currentAction?: number;
|
||||||
|
nextAction?: number;
|
||||||
|
autofocus?: boolean;
|
||||||
|
onselect?: (id: number) => void;
|
||||||
|
onclose?: () => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
search();
|
||||||
|
if (autofocus) {
|
||||||
|
searchBox.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
createIndex($KEYMAP_CODES);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||||
|
for (const [, action] of codes) {
|
||||||
|
await index?.addAsync(
|
||||||
|
action.code,
|
||||||
|
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||||
|
action.description || ""
|
||||||
|
}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search() {
|
||||||
|
const groups = new Map(
|
||||||
|
$KEYMAP_CATEGORIES.map(
|
||||||
|
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result =
|
||||||
|
searchBox.value === ""
|
||||||
|
? Array.from($KEYMAP_CODES.keys())
|
||||||
|
: await index!.searchAsync(searchBox.value);
|
||||||
|
for (const id of result) {
|
||||||
|
const action = $KEYMAP_CODES.get(id as number);
|
||||||
|
if (action?.category) {
|
||||||
|
groups.get(action.category)?.push(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortValue(action: KeyInfo): number {
|
||||||
|
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
|
||||||
|
}
|
||||||
|
for (const actions of groups.values()) {
|
||||||
|
actions.sort((a, b) => sortValue(b) - sortValue(a));
|
||||||
|
}
|
||||||
|
results = groups;
|
||||||
|
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||||
|
code = Number(searchBox.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function select(id?: number) {
|
||||||
|
if (id !== undefined) {
|
||||||
|
onselect?.(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyboardNavigation(event: KeyboardEvent) {
|
||||||
|
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||||
|
onselect?.(exact);
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
const element =
|
||||||
|
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||||
|
resultList.querySelector("li:not(.exact)");
|
||||||
|
if (element instanceof HTMLLIElement) {
|
||||||
|
element.querySelector("button")?.focus();
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
const element =
|
||||||
|
resultList.querySelector("li:focus-within")?.previousSibling ??
|
||||||
|
resultList.querySelector("li:not(.exact)");
|
||||||
|
if (element instanceof HTMLLIElement) {
|
||||||
|
element.querySelector("button")?.focus();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
searchBox.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
|
||||||
|
let exact: number | undefined = $state(undefined);
|
||||||
|
let code: number = $state(Number.NaN);
|
||||||
|
|
||||||
|
let searchBox: HTMLInputElement;
|
||||||
|
let resultList: HTMLUListElement;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="search-row">
|
||||||
|
<!-- svelte-ignore a11y_autofocus -->
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
bind:this={searchBox}
|
||||||
|
oninput={search}
|
||||||
|
onkeypress={keyboardNavigation}
|
||||||
|
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||||
|
/>
|
||||||
|
{#if onclose}
|
||||||
|
<button onclick={() => select(0)} {@attach actionTooltip("", "shift+esc")}
|
||||||
|
>{$LL.actionSearch.DELETE()}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
{@attach actionTooltip($LL.modal.CLOSE(), "esc")}
|
||||||
|
class="icon"
|
||||||
|
onclick={onclose}>close</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if currentAction !== undefined}
|
||||||
|
<aside>
|
||||||
|
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||||
|
<ActionListItem id={currentAction} />
|
||||||
|
</aside>
|
||||||
|
{#if nextAction}
|
||||||
|
<aside>
|
||||||
|
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
||||||
|
<ActionListItem id={nextAction} />
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<ul bind:this={resultList}>
|
||||||
|
{#if exact !== undefined}
|
||||||
|
<li class="exact">
|
||||||
|
<i>Exact match</i>
|
||||||
|
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if !exact && code}
|
||||||
|
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||||
|
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||||
|
{:else}
|
||||||
|
<li>Action code is out of range</li>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#each results as [category, actions] (category)}
|
||||||
|
{#if actions.length > 0}
|
||||||
|
<div class="category">
|
||||||
|
<h3>{category.name}</h3>
|
||||||
|
<div class="description">{category.description}</div>
|
||||||
|
<ul>
|
||||||
|
{#each actions as action (action.code)}
|
||||||
|
<button
|
||||||
|
class="action-item"
|
||||||
|
draggable={!onclose}
|
||||||
|
onclick={() => select(action.code)}
|
||||||
|
ondragstart={onclose === undefined
|
||||||
|
? (event) => {
|
||||||
|
if (!event.dataTransfer) return;
|
||||||
|
event.stopPropagation();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
event.dataTransfer.clearData();
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
"text/plain",
|
||||||
|
actionToValue(action.code),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: undefined}
|
||||||
|
>
|
||||||
|
<Action {action} display="verbose"></Action>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.action-item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: auto;
|
||||||
|
font: inherit;
|
||||||
|
|
||||||
|
&[draggable="true"] {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
aside {
|
||||||
|
opacity: 0.4;
|
||||||
|
|
||||||
|
margin: 8px;
|
||||||
|
border: 1px dashed var(--md-sys-color-outline);
|
||||||
|
border-radius: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
> h3 {
|
||||||
|
margin-inline-start: 16px;
|
||||||
|
margin-block-start: -13px;
|
||||||
|
margin-block-end: 0;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
padding-inline: 8px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
opacity: 1;
|
||||||
|
color: GrayText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-inline: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
position: relative;
|
||||||
|
flex-direction: column;
|
||||||
|
transform-origin: top left;
|
||||||
|
border-radius: 16px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
|
||||||
|
width: calc(min(30cm, 90%));
|
||||||
|
height: calc(min(100% - 128px, 90%));
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
border: 1px solid CanvasText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="search"] {
|
||||||
|
transition: all 250ms ease;
|
||||||
|
margin-block-end: 8px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||||
|
|
||||||
|
background: none;
|
||||||
|
padding-inline: 16px;
|
||||||
|
width: 100%;
|
||||||
|
height: 64px;
|
||||||
|
color: currentcolor;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||||
|
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
padding-inline: 4px;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
scrollbar-gutter: both-edges stable;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category {
|
||||||
|
.description {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-block-start: -16px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-block: 24px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exact {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-block-start: 8px;
|
||||||
|
|
||||||
|
border: 1px solid var(--md-sys-color-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
> i {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-primary);
|
||||||
|
|
||||||
|
padding-inline: 6px;
|
||||||
|
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (forced-colors: active) {
|
||||||
|
background: Mark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import ActionList from "./ActionList.svelte";
|
||||||
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 {
|
let {
|
||||||
currentAction = undefined,
|
currentAction = undefined,
|
||||||
@@ -23,330 +12,34 @@
|
|||||||
onselect: (id: number) => void;
|
onselect: (id: number) => void;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
searchBox.focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
createIndex($KEYMAP_CODES);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function createIndex(codes: Map<number, KeyInfo>) {
|
|
||||||
for (const [, action] of codes) {
|
|
||||||
await index?.addAsync(
|
|
||||||
action.code,
|
|
||||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
|
||||||
action.description || ""
|
|
||||||
}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function search() {
|
|
||||||
results = (await index!.searchAsync(searchBox.value)) as number[];
|
|
||||||
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
|
||||||
code = Number(searchBox.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function select(id?: number) {
|
|
||||||
if (id !== undefined) {
|
|
||||||
onselect(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function keyboardNavigation(event: KeyboardEvent) {
|
|
||||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
|
||||||
onselect(exact);
|
|
||||||
} else if (event.key === "ArrowDown") {
|
|
||||||
const element =
|
|
||||||
resultList.querySelector("li:focus-within")?.nextSibling ??
|
|
||||||
resultList.querySelector("li:not(.exact)");
|
|
||||||
if (element instanceof HTMLLIElement) {
|
|
||||||
element.querySelector("button")?.focus();
|
|
||||||
}
|
|
||||||
} else if (event.key === "ArrowUp") {
|
|
||||||
const element =
|
|
||||||
resultList.querySelector("li:focus-within")?.previousSibling ??
|
|
||||||
resultList.querySelector("li:not(.exact)");
|
|
||||||
if (element instanceof HTMLLIElement) {
|
|
||||||
element.querySelector("button")?.focus();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
searchBox.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
let results: number[] = $state([]);
|
|
||||||
let exact: number | undefined = $state(undefined);
|
|
||||||
let code: number = $state(Number.NaN);
|
|
||||||
|
|
||||||
let searchBox: HTMLInputElement;
|
|
||||||
let resultList: HTMLUListElement;
|
|
||||||
let filter: Set<number> | undefined = $state(undefined);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={keyboardNavigation} />
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<dialog
|
<dialog
|
||||||
open
|
open
|
||||||
onclick={(event) => {
|
onclick={(event) => {
|
||||||
if (event.target === event.currentTarget) onclose();
|
if (event.target === event.currentTarget) onclose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="content">
|
<ActionList
|
||||||
<div class="search-row">
|
autofocus={true}
|
||||||
<input
|
{currentAction}
|
||||||
type="search"
|
{nextAction}
|
||||||
bind:this={searchBox}
|
{onselect}
|
||||||
oninput={search}
|
{onclose}
|
||||||
onkeypress={(event) => {
|
/>
|
||||||
if (event.key === "Enter") {
|
|
||||||
select(exact);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
|
||||||
/>
|
|
||||||
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
|
||||||
>{$LL.actionSearch.DELETE()}</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
|
||||||
class="icon"
|
|
||||||
onclick={onclose}>close</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<fieldset class="filters">
|
|
||||||
<label
|
|
||||||
>{$LL.actionSearch.filter.ALL()}<input
|
|
||||||
checked
|
|
||||||
name="category"
|
|
||||||
type="radio"
|
|
||||||
value={undefined}
|
|
||||||
bind:group={filter}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
{#each $KEYMAP_CATEGORIES as category}
|
|
||||||
<label
|
|
||||||
>{category.name}<input
|
|
||||||
name="category"
|
|
||||||
type="radio"
|
|
||||||
value={new Set(Object.keys(category.actions).map(Number))}
|
|
||||||
bind:group={filter}
|
|
||||||
/></label
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</fieldset>
|
|
||||||
{#if currentAction !== undefined}
|
|
||||||
<aside>
|
|
||||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
|
||||||
<ActionListItem id={currentAction} />
|
|
||||||
</aside>
|
|
||||||
{#if nextAction}
|
|
||||||
<aside>
|
|
||||||
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
|
||||||
<ActionListItem id={nextAction} />
|
|
||||||
</aside>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<ul bind:this={resultList}>
|
|
||||||
{#if exact !== undefined}
|
|
||||||
<li class="exact">
|
|
||||||
<i>Exact match</i>
|
|
||||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if !exact && code}
|
|
||||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
|
||||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
|
||||||
{:else}
|
|
||||||
<li>Action code is out of range</li>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if filter !== undefined || results.length > 0}
|
|
||||||
{@const resultValue =
|
|
||||||
results.length === 0
|
|
||||||
? Array.from($KEYMAP_CODES, ([it]) => it)
|
|
||||||
: results}
|
|
||||||
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
|
||||||
<li><ActionListItem {id} onclick={() => select(id)} /></li>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
label {
|
|
||||||
height: unset;
|
|
||||||
padding-block: 2px;
|
|
||||||
padding-inline: 4px;
|
|
||||||
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
border: 1px solid currentcolor;
|
|
||||||
border-radius: 6px;
|
|
||||||
|
|
||||||
&:has(:checked) {
|
|
||||||
color: var(--md-sys-color-on-secondary);
|
|
||||||
background: var(--md-sys-color-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dialog {
|
dialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
width: 100%;
|
border: none;
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
background: rgba(0 0 0 / 60%);
|
background: rgba(0 0 0 / 60%);
|
||||||
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
aside {
|
|
||||||
pointer-events: none;
|
|
||||||
|
|
||||||
margin: 8px;
|
|
||||||
|
|
||||||
opacity: 0.4;
|
|
||||||
border: 1px dashed var(--md-sys-color-outline);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
> h3 {
|
|
||||||
width: fit-content;
|
|
||||||
margin-block-start: -13px;
|
|
||||||
margin-block-end: 0;
|
|
||||||
margin-inline-start: 16px;
|
|
||||||
padding-inline: 8px;
|
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-contrast: more) {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
opacity: 1;
|
|
||||||
color: GrayText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
margin-inline: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
position: relative;
|
|
||||||
transform-origin: top left;
|
|
||||||
|
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
width: calc(min(30cm, 90%));
|
|
||||||
height: calc(min(100% - 128px, 90%));
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-background);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
border-radius: 16px;
|
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
border: 1px solid CanvasText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="search"] {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 64px;
|
|
||||||
margin-block-end: 8px;
|
|
||||||
padding-inline: 16px;
|
|
||||||
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 16px;
|
|
||||||
color: currentcolor;
|
|
||||||
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
|
||||||
|
|
||||||
transition: all 250ms ease;
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
|
||||||
|
|
||||||
scrollbar-gutter: both-edges stable;
|
|
||||||
|
|
||||||
overflow-y: auto;
|
|
||||||
|
|
||||||
box-sizing: border-box;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
padding-inline: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exact {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
margin-block-start: 8px;
|
|
||||||
|
|
||||||
border: 1px solid var(--md-sys-color-primary);
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
> i {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
padding-inline: 6px;
|
|
||||||
|
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
|
||||||
background: Mark;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { compileLayout } from "$lib/serialization/visual-layout";
|
|
||||||
import type {
|
|
||||||
VisualLayout,
|
|
||||||
CompiledLayoutKey,
|
|
||||||
} from "$lib/serialization/visual-layout";
|
|
||||||
import { deviceLayout } from "$lib/serial/connection";
|
import { deviceLayout } from "$lib/serial/connection";
|
||||||
import { dev } from "$app/environment";
|
import { dev } from "$app/environment";
|
||||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { Writable } from "svelte/store";
|
|
||||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||||
import { getContext, mount, unmount } from "svelte";
|
import { getContext, mount, unmount } from "svelte";
|
||||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||||
import { fly } from "svelte/transition";
|
import { fly } from "svelte/transition";
|
||||||
import { expoOut } from "svelte/easing";
|
import { expoOut } from "svelte/easing";
|
||||||
|
import { activeLayer, activeProfile } from "$lib/serial/connection";
|
||||||
|
import type {
|
||||||
|
CompiledLayout,
|
||||||
|
CompiledLayoutKey,
|
||||||
|
} from "$lib/assets/layouts/layout.d.ts";
|
||||||
|
|
||||||
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
|
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
|
||||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
|
||||||
|
|
||||||
if (dev) {
|
if (dev) {
|
||||||
// you have absolutely no idea what a difference this makes for performance
|
// you have absolutely no idea what a difference this makes for performance
|
||||||
@@ -30,8 +28,7 @@
|
|||||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
||||||
}
|
}
|
||||||
|
|
||||||
let { visualLayout }: { visualLayout: VisualLayout } = $props();
|
let { layoutInfo }: { layoutInfo: CompiledLayout } = $props();
|
||||||
let layoutInfo = $state(compileLayout(visualLayout));
|
|
||||||
|
|
||||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||||
@@ -125,8 +122,10 @@
|
|||||||
const keyInfo = layoutInfo.keys[index];
|
const keyInfo = layoutInfo.keys[index];
|
||||||
if (!keyInfo) return;
|
if (!keyInfo) return;
|
||||||
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||||
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
const nextAction =
|
||||||
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
|
||||||
|
const currentAction =
|
||||||
|
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
|
||||||
const component = mount(ActionSelector, {
|
const component = mount(ActionSelector, {
|
||||||
target: document.body,
|
target: document.body,
|
||||||
props: {
|
props: {
|
||||||
@@ -142,6 +141,7 @@
|
|||||||
type: ChangeType.Layout,
|
type: ChangeType.Layout,
|
||||||
id: keyInfo.id,
|
id: keyInfo.id,
|
||||||
layer: get(activeLayer),
|
layer: get(activeLayer),
|
||||||
|
profile: get(activeProfile),
|
||||||
action,
|
action,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -217,9 +217,9 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
svg {
|
svg {
|
||||||
overflow: visible;
|
|
||||||
grid-area: "d";
|
grid-area: "d";
|
||||||
width: calc(min(100%, 35cm));
|
width: calc(min(100%, 35cm));
|
||||||
max-height: calc(100% - 170px);
|
max-height: calc(100% - 170px);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
|
||||||
import { layout } from "$lib/undo-redo.js";
|
import { layout } from "$lib/undo-redo.js";
|
||||||
import { osLayout } from "$lib/os-layout.js";
|
import { osLayout } from "$lib/os-layout.js";
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
import { action } from "$lib/title";
|
import { actionTooltip } from "$lib/title";
|
||||||
|
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||||
|
import { getContext } from "svelte";
|
||||||
|
|
||||||
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
|
||||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
|
||||||
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
const currentAction = getContext<Writable<Set<number>> | undefined>(
|
||||||
"highlight-action",
|
"highlight-action",
|
||||||
);
|
);
|
||||||
@@ -28,12 +28,19 @@
|
|||||||
middle: [number, number];
|
middle: [number, number];
|
||||||
pos: [number, number];
|
pos: [number, number];
|
||||||
rotate: number;
|
rotate: number;
|
||||||
positions: [[number, number], [number, number], [number, number]];
|
positions: [
|
||||||
|
[number, number],
|
||||||
|
[number, number],
|
||||||
|
[number, number],
|
||||||
|
[number, number],
|
||||||
|
];
|
||||||
} = $props();
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#each positions as position, layer}
|
{#each positions as position, layer}
|
||||||
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
|
{@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
|
||||||
|
key.id
|
||||||
|
] ?? {
|
||||||
action: 0,
|
action: 0,
|
||||||
isApplied: true,
|
isApplied: true,
|
||||||
}}
|
}}
|
||||||
@@ -65,9 +72,9 @@
|
|||||||
? "0 0 0"
|
? "0 0 0"
|
||||||
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
|
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
|
||||||
style:rotate="{rotate}deg"
|
style:rotate="{rotate}deg"
|
||||||
use:action={{ title: tooltip }}
|
{@attach actionTooltip(tooltip)}
|
||||||
>
|
>
|
||||||
{#if code !== 0}
|
{#if code !== 0 && code != 1023}
|
||||||
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
||||||
{/if}
|
{/if}
|
||||||
{#if !isApplied}
|
{#if !isApplied}
|
||||||
@@ -81,15 +88,15 @@
|
|||||||
$transition: 200ms;
|
$transition: 200ms;
|
||||||
|
|
||||||
text {
|
text {
|
||||||
will-change: translate, scale;
|
|
||||||
user-select: none;
|
|
||||||
transform-origin: center;
|
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
transition:
|
transition:
|
||||||
fill #{$focus-transition} ease,
|
fill #{$focus-transition} ease,
|
||||||
opacity #{$transition} ease,
|
opacity #{$transition} ease,
|
||||||
translate #{$transition} ease,
|
translate #{$transition} ease,
|
||||||
scale #{$transition} ease;
|
scale #{$transition} ease;
|
||||||
|
will-change: translate, scale;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
@media (prefers-contrast: more) {
|
@media (prefers-contrast: more) {
|
||||||
--inactive-opacity: 0.8;
|
--inactive-opacity: 0.8;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
|
||||||
import { getContext } from "svelte";
|
import { getContext } from "svelte";
|
||||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||||
import KeyText from "$lib/components/layout/KeyText.svelte";
|
import KeyText from "$lib/components/layout/KeyText.svelte";
|
||||||
@@ -64,6 +64,7 @@
|
|||||||
[-1, 1],
|
[-1, 1],
|
||||||
[-1, -1],
|
[-1, -1],
|
||||||
[1, -1],
|
[1, -1],
|
||||||
|
[1, 1],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{:else if key.shape === "quarter-circle"}
|
{:else if key.shape === "quarter-circle"}
|
||||||
@@ -103,6 +104,7 @@
|
|||||||
[-rotY, -rotX],
|
[-rotY, -rotX],
|
||||||
[-rotX, -rotY],
|
[-rotX, -rotY],
|
||||||
[rotX, rotY],
|
[rotX, rotY],
|
||||||
|
[rotY, rotX],
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -113,14 +115,14 @@
|
|||||||
$transition: 200ms;
|
$transition: 200ms;
|
||||||
|
|
||||||
rect {
|
rect {
|
||||||
transform-origin: center;
|
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
path,
|
path,
|
||||||
g {
|
g {
|
||||||
transform-origin: top left;
|
|
||||||
transform-box: fill-box;
|
transform-box: fill-box;
|
||||||
|
transform-origin: top left;
|
||||||
}
|
}
|
||||||
|
|
||||||
path,
|
path,
|
||||||
@@ -138,15 +140,15 @@
|
|||||||
|
|
||||||
g.faded,
|
g.faded,
|
||||||
g:hover {
|
g:hover {
|
||||||
cursor: default;
|
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
transition: opacity #{$transition} ease;
|
transition: opacity #{$transition} ease;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
g.highlight,
|
g.highlight,
|
||||||
g:focus-within {
|
g:focus-within {
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
outline: none;
|
outline: none;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
|
|
||||||
> path,
|
> path,
|
||||||
> rect {
|
> rect {
|
||||||
|
|||||||
@@ -1,67 +1,71 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||||
import { action } from "$lib/title";
|
import { actionTooltip } from "$lib/title";
|
||||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||||
import { getContext } from "svelte";
|
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||||
import type { Writable } from "svelte/store";
|
|
||||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
|
||||||
import { fade, fly } from "svelte/transition";
|
import { fade, fly } from "svelte/transition";
|
||||||
import { restoreFromFile } from "$lib/backup/backup";
|
import { restoreFromFile } from "$lib/backup/backup";
|
||||||
|
import type { CompiledLayout } from "$lib/assets/layouts/layout.d.ts";
|
||||||
|
|
||||||
let device = $derived($serialPort?.device);
|
const layouts: Record<string, (() => Promise<CompiledLayout>) | undefined> = {
|
||||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
|
||||||
|
|
||||||
const layers = [
|
|
||||||
["Numeric Layer", "123", 1],
|
|
||||||
["Primary Layer", "abc", 0],
|
|
||||||
["Function Layer", "function", 2],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const layouts = {
|
|
||||||
ONE: () =>
|
ONE: () =>
|
||||||
import("$lib/assets/layouts/one.yml").then(
|
import("$lib/assets/layouts/one.layout.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as CompiledLayout,
|
||||||
),
|
),
|
||||||
TWO: () =>
|
TWO: () =>
|
||||||
import("$lib/assets/layouts/one.yml").then(
|
import("$lib/assets/layouts/one.layout.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as CompiledLayout,
|
||||||
),
|
),
|
||||||
LITE: () =>
|
LITE: () =>
|
||||||
import("$lib/assets/layouts/lite.yml").then(
|
import("$lib/assets/layouts/lite.layout.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as CompiledLayout,
|
||||||
),
|
),
|
||||||
X: () =>
|
X: () =>
|
||||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
import("$lib/assets/layouts/103-key.layout.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
ZERO: () =>
|
||||||
|
import("$lib/assets/layouts/103-key.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
),
|
),
|
||||||
M4G: () =>
|
M4G: () =>
|
||||||
import("$lib/assets/layouts/m4g.yml").then(
|
import("$lib/assets/layouts/m4g.layout.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as CompiledLayout,
|
||||||
),
|
),
|
||||||
M4GR: () =>
|
M4GR: () =>
|
||||||
import("$lib/assets/layouts/m4gr.yml").then(
|
import("$lib/assets/layouts/m4gr.layout.yml").then(
|
||||||
(it) => it.default as VisualLayout,
|
(it) => it.default as CompiledLayout,
|
||||||
|
),
|
||||||
|
T4G: () =>
|
||||||
|
import("$lib/assets/layouts/t4g.layout.yml").then(
|
||||||
|
(it) => it.default as CompiledLayout,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
{#if device}
|
{#if $serialPort}
|
||||||
{#await layouts[device]() then visualLayout}
|
{#await layouts[$serialPort.device]?.() then layoutInfo}
|
||||||
<fieldset transition:fade>
|
<fieldset transition:fade>
|
||||||
{#each layers as [title, icon, value]}
|
<div class="layers">
|
||||||
<button
|
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
|
||||||
class="icon"
|
<label>
|
||||||
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
<input
|
||||||
onclick={() => ($activeLayer = value)}
|
type="radio"
|
||||||
class:active={$activeLayer === value}
|
onclick={() => ($activeLayer = layer)}
|
||||||
>
|
name="layer"
|
||||||
{icon}
|
value={layer}
|
||||||
</button>
|
checked={$activeLayer === layer}
|
||||||
{/each}
|
/>
|
||||||
|
{String.fromCodePoint(
|
||||||
|
"A".codePointAt(0)! + $activeProfile,
|
||||||
|
)}{layer + 1}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
{#if $deviceMeta?.factoryDefaults?.layout}
|
{#if $deviceMeta?.factoryDefaults?.layout}
|
||||||
<button
|
<button
|
||||||
use:action={{ title: "Reset Layout" }}
|
{@attach actionTooltip("Reset Layout")}
|
||||||
transition:fly={{ x: -8 }}
|
transition:fly={{ x: -8 }}
|
||||||
class="icon reset-layout"
|
class="icon reset-layout"
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
@@ -71,7 +75,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<GenericLayout {visualLayout} />
|
{#if layoutInfo}
|
||||||
|
<GenericLayout {layoutInfo} />
|
||||||
|
{/if}
|
||||||
{/await}
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -80,8 +86,8 @@
|
|||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -89,71 +95,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
fieldset {
|
fieldset {
|
||||||
position: relative;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
position: relative;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
border: none;
|
||||||
|
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button.icon {
|
.layers {
|
||||||
cursor: pointer;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
z-index: 1;
|
gap: 2px;
|
||||||
|
|
||||||
font-size: 24px;
|
margin-inline: auto;
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
|
||||||
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
border: none;
|
|
||||||
|
|
||||||
transition: all 250ms ease;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
z-index: 2;
|
|
||||||
|
|
||||||
aspect-ratio: 1;
|
|
||||||
|
|
||||||
font-size: 32px;
|
|
||||||
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child,
|
|
||||||
&:nth-child(3) {
|
|
||||||
aspect-ratio: unset;
|
|
||||||
height: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:first-child {
|
|
||||||
margin-inline-end: -8px;
|
|
||||||
padding-inline: 4px 24px;
|
|
||||||
border-radius: 16px 0 0 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
&: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);
|
|
||||||
background: var(--md-sys-color-tertiary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
9
src/lib/components/verbose-action.ts
Normal file
9
src/lib/components/verbose-action.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
|
|
||||||
|
export function isVerbose(info: KeyInfo) {
|
||||||
|
return (
|
||||||
|
info.id?.length !== 1 &&
|
||||||
|
info.title &&
|
||||||
|
(!info.id || /F\d{1,2}/.test(info.id) === false)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -83,9 +83,9 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
h1 {
|
h1 {
|
||||||
|
color: var(--md-sys-color-error);
|
||||||
font-size: 2em;
|
font-size: 2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--md-sys-color-error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.buttons {
|
.buttons {
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
a {
|
a {
|
||||||
display: inline;
|
display: inline;
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
color: var(--md-sys-color-primary);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,15 +16,15 @@
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
dialog {
|
dialog {
|
||||||
|
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 38px;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
max-width: 512px;
|
max-width: 512px;
|
||||||
|
|
||||||
color: var(--md-sys-color-on-background);
|
color: var(--md-sys-color-on-background);
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
|
||||||
border: none;
|
|
||||||
border-radius: 38px;
|
|
||||||
box-shadow: 0 0 48px rgba(0 0 0 / 60%);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dialog::backdrop {
|
dialog::backdrop {
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
|
||||||
import type {
|
|
||||||
Change,
|
|
||||||
ChordChange,
|
|
||||||
LayoutChange,
|
|
||||||
SettingChange,
|
|
||||||
} from "$lib/undo-redo";
|
|
||||||
import { ChangeType, chords } from "$lib/undo-redo";
|
|
||||||
import ActionString from "$lib/components/ActionString.svelte";
|
|
||||||
import LL from "$i18n/i18n-svelte";
|
|
||||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
|
||||||
|
|
||||||
export let changes: Change[] = [
|
|
||||||
{ type: ChangeType.Layout, layer: 0, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
|
||||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
|
||||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
|
||||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
|
||||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
|
||||||
{
|
|
||||||
type: ChangeType.Chord,
|
|
||||||
id: [1],
|
|
||||||
actions: [55],
|
|
||||||
phrase: [55, 63, 37, 36],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: ChangeType.Chord,
|
|
||||||
id: [
|
|
||||||
KEYMAP_IDS.get("y")!.code,
|
|
||||||
KEYMAP_IDS.get("r")!.code,
|
|
||||||
KEYMAP_IDS.get("t")!.code,
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
KEYMAP_IDS.get("y")!.code,
|
|
||||||
KEYMAP_IDS.get("r")!.code,
|
|
||||||
KEYMAP_IDS.get("t")!.code,
|
|
||||||
],
|
|
||||||
phrase: [55, 63, 37, 36],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: ChangeType.Chord,
|
|
||||||
id: [
|
|
||||||
KEYMAP_IDS.get("y")!.code,
|
|
||||||
KEYMAP_IDS.get("r")!.code,
|
|
||||||
KEYMAP_IDS.get("t")!.code,
|
|
||||||
],
|
|
||||||
actions: [
|
|
||||||
KEYMAP_IDS.get("y")!.code,
|
|
||||||
KEYMAP_IDS.get("r")!.code,
|
|
||||||
KEYMAP_IDS.get("t")!.code,
|
|
||||||
],
|
|
||||||
phrase: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
$: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
|
|
||||||
|
|
||||||
$: layoutChanges = Array.from(
|
|
||||||
{ length: 3 },
|
|
||||||
(_, i) =>
|
|
||||||
changes.filter(
|
|
||||||
(it) => it.type === ChangeType.Layout && it.layer === i,
|
|
||||||
) as LayoutChange[],
|
|
||||||
);
|
|
||||||
$: settingChanges = changes.filter(
|
|
||||||
(it) => it.type === ChangeType.Setting,
|
|
||||||
) as SettingChange[];
|
|
||||||
$: chordChanges = {
|
|
||||||
added: changes.filter(
|
|
||||||
(it) =>
|
|
||||||
it.type === ChangeType.Chord &&
|
|
||||||
it.phrase.length > 0 &&
|
|
||||||
!existingChords.has(JSON.stringify(it.id)),
|
|
||||||
) as ChordChange[],
|
|
||||||
changed: changes.filter(
|
|
||||||
(it) =>
|
|
||||||
it.type === ChangeType.Chord &&
|
|
||||||
it.phrase.length > 0 &&
|
|
||||||
existingChords.has(JSON.stringify(it.id)),
|
|
||||||
) as ChordChange[],
|
|
||||||
deleted: changes.filter(
|
|
||||||
(it) => it.type === ChangeType.Chord && it.phrase.length === 0,
|
|
||||||
) as ChordChange[],
|
|
||||||
};
|
|
||||||
$: totalChordChanges = Object.values(chordChanges).reduce(
|
|
||||||
(acc, curr) => acc + curr.length,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<Dialog>
|
|
||||||
<h1>{$LL.changes.TITLE()}</h1>
|
|
||||||
<h2>
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox"
|
|
||||||
/>{$LL.changes.ALL_CHANGES()}</label
|
|
||||||
>
|
|
||||||
</h2>
|
|
||||||
<ul>
|
|
||||||
{#if layoutChanges.some((it) => it.length > 0)}
|
|
||||||
<li>
|
|
||||||
<h3>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="checkbox" />
|
|
||||||
{$LL.changes.layout.TITLE(
|
|
||||||
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</h3>
|
|
||||||
<ul>
|
|
||||||
{#each layoutChanges as changes, i}
|
|
||||||
{@const layer = i + 1}
|
|
||||||
{#if changes.length > 0}
|
|
||||||
<li>
|
|
||||||
<h4>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="checkbox" />
|
|
||||||
{$LL.changes.layout.LAYER({
|
|
||||||
changes: changes.length,
|
|
||||||
layer,
|
|
||||||
})}
|
|
||||||
</label>
|
|
||||||
</h4>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if settingChanges.length > 0}
|
|
||||||
<li>
|
|
||||||
<h3>
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox"
|
|
||||||
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{#if totalChordChanges > 0}
|
|
||||||
<li>
|
|
||||||
<h3>
|
|
||||||
<label
|
|
||||||
><input
|
|
||||||
type="checkbox"
|
|
||||||
class="checkbox"
|
|
||||||
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
<ul>
|
|
||||||
{#each Object.entries(chordChanges) as [category, changes]}
|
|
||||||
{#if changes.length > 0}
|
|
||||||
<li>
|
|
||||||
<h4>
|
|
||||||
<label
|
|
||||||
><input type="checkbox" class="checkbox" />
|
|
||||||
{#if category === "added"}
|
|
||||||
{$LL.changes.chords.NEW_CHORDS(changes.length)}
|
|
||||||
{:else if category === "changed"}
|
|
||||||
{$LL.changes.chords.CHANGED_CHORDS(changes.length)}
|
|
||||||
{:else if category === "deleted"}
|
|
||||||
{$LL.changes.chords.DELETED_CHORDS(changes.length)}
|
|
||||||
{/if}
|
|
||||||
</label>
|
|
||||||
</h4>
|
|
||||||
<ul>
|
|
||||||
{#each changes as change}
|
|
||||||
<li>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="checkbox" />
|
|
||||||
<ActionString display="keys" actions={change.actions} />
|
|
||||||
<ActionString actions={change.phrase} />
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
{/if}
|
|
||||||
</ul>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
h1 {
|
|
||||||
font-size: 2em;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
padding-inline-start: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
margin-inline-start: 24px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,33 +1,33 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Material Symbols Rounded";
|
|
||||||
font-weight: 100 700;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
font-weight: 100 700;
|
||||||
src: url("$lib/assets/icons.min.woff2") format("woff2");
|
src: url("$lib/assets/icons.min.woff2") format("woff2");
|
||||||
|
font-family: "Material Symbols Rounded";
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
direction: ltr;
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
/* stylelint-disable-next-line */
|
/* stylelint-disable-next-line */
|
||||||
font-family: "Material Symbols Rounded";
|
font-family: "Material Symbols Rounded";
|
||||||
font-size: 24px;
|
|
||||||
font-feature-settings: "liga";
|
|
||||||
font-variation-settings:
|
font-variation-settings:
|
||||||
"FILL" var(--icon-fill, 0),
|
"FILL" var(--icon-fill, 0),
|
||||||
"wght" var(--icon-weigth, 400),
|
"wght" var(--icon-weigth, 400),
|
||||||
"GRAD" var(--icon-grade, 0);
|
"GRAD" var(--icon-grade, 0);
|
||||||
font-weight: normal;
|
font-feature-settings: "liga";
|
||||||
font-style: normal;
|
|
||||||
line-height: 1;
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
letter-spacing: normal;
|
||||||
|
|
||||||
|
direction: ltr;
|
||||||
|
user-select: none;
|
||||||
|
text-transform: none;
|
||||||
word-wrap: normal;
|
word-wrap: normal;
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
transition: font-variation-settings 250ms ease;
|
transition: font-variation-settings 250ms ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,28 +66,30 @@
|
|||||||
|
|
||||||
/* noto-sans-mono-latin-ext-wght-normal */
|
/* noto-sans-mono-latin-ext-wght-normal */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Mono Variable";
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-weight: 100 900;
|
||||||
font-stretch: 62.5% 100%;
|
font-stretch: 62.5% 100%;
|
||||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
unicode-range: U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
|
font-family: "Noto Sans Mono Variable";
|
||||||
U+0329, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
font-display: swap;
|
||||||
|
unicode-range:
|
||||||
|
U+0100-02AF, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
|
||||||
|
U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F,
|
||||||
U+A720-A7FF;
|
U+A720-A7FF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* noto-sans-mono-latin-wght-normal */
|
/* noto-sans-mono-latin-wght-normal */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: "Noto Sans Mono";
|
|
||||||
font-weight: 100 900;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-display: swap;
|
font-weight: 100 900;
|
||||||
font-stretch: 62.5% 100%;
|
font-stretch: 62.5% 100%;
|
||||||
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
|
||||||
format("woff2-variations");
|
format("woff2-variations");
|
||||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
|
font-family: "Noto Sans Mono";
|
||||||
U+02DC, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F,
|
font-display: swap;
|
||||||
U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
|
||||||
|
U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+2000-206F, U+2074,
|
||||||
|
U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/lib/hover-popover.ts
Normal file
39
src/lib/hover-popover.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
|
export const hotkeys = new Map<string, HTMLElement>();
|
||||||
|
|
||||||
|
export function tooltip(
|
||||||
|
target: HTMLElement | undefined,
|
||||||
|
shortcut?: string,
|
||||||
|
): Attachment<HTMLElement> {
|
||||||
|
return (node: HTMLElement) => {
|
||||||
|
function show() {
|
||||||
|
if (!target) return;
|
||||||
|
target.showPopover({ source: node });
|
||||||
|
}
|
||||||
|
function hide() {
|
||||||
|
if (!target) return;
|
||||||
|
target.hidePopover();
|
||||||
|
}
|
||||||
|
|
||||||
|
node.addEventListener("mouseenter", show);
|
||||||
|
node.addEventListener("focus", show);
|
||||||
|
node.addEventListener("mouseleave", hide);
|
||||||
|
node.addEventListener("blur", hide);
|
||||||
|
|
||||||
|
if (shortcut && node instanceof HTMLElement) {
|
||||||
|
hotkeys.set(shortcut, node);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
node.removeEventListener("mouseenter", show);
|
||||||
|
node.removeEventListener("focus", show);
|
||||||
|
node.removeEventListener("mouseleave", hide);
|
||||||
|
node.removeEventListener("blur", hide);
|
||||||
|
|
||||||
|
if (shortcut && node instanceof HTMLElement) {
|
||||||
|
hotkeys.delete(shortcut);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { osLayout } from "$lib/os-layout";
|
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import { type ChordInfo, chords } from "$lib/undo-redo";
|
|
||||||
import { derived } from "svelte/store";
|
|
||||||
|
|
||||||
export const words = derived(
|
|
||||||
[chords, osLayout],
|
|
||||||
([chords, layout]) =>
|
|
||||||
new Map<string, ChordInfo>(
|
|
||||||
chords
|
|
||||||
.map((chord) => ({
|
|
||||||
chord,
|
|
||||||
output: chord.phrase.map((action) =>
|
|
||||||
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.filter(({ output }) => output.every((it) => !!it))
|
|
||||||
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Score {
|
|
||||||
lastTyped: number;
|
|
||||||
score: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const scores = persistentWritable<Record<string, Score>>("scores", {});
|
|
||||||
|
|
||||||
export const learnConfigDefault = {
|
|
||||||
maxScore: 3,
|
|
||||||
minScore: -3,
|
|
||||||
scoreBlend: 0.5,
|
|
||||||
weakRate: 0.8,
|
|
||||||
weakBoost: 0.5,
|
|
||||||
maxWeak: 3,
|
|
||||||
newRate: 0.3,
|
|
||||||
initialNewRate: 0.9,
|
|
||||||
initialCount: 10,
|
|
||||||
};
|
|
||||||
export const learnConfigStored = persistentWritable<
|
|
||||||
Partial<typeof learnConfigDefault>
|
|
||||||
>("learn-config", {});
|
|
||||||
export const learnConfig = derived(learnConfigStored, (config) => ({
|
|
||||||
...learnConfigDefault,
|
|
||||||
...config,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let lastWord: string | undefined;
|
|
||||||
|
|
||||||
function shuffle<T>(array: T[]): T[] {
|
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[array[i], array[j]] = [array[j]!, array[i]!];
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomLog2<T>(array: T[], max = array.length): T | undefined {
|
|
||||||
return array[
|
|
||||||
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const nextWord = derived(
|
|
||||||
[words, scores, learnConfig],
|
|
||||||
([words, scores, config]) => {
|
|
||||||
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
|
|
||||||
|
|
||||||
values.sort(([, a], [, b]) => a.score - b.score);
|
|
||||||
const weakCount =
|
|
||||||
(values.findIndex(([, { score }]) => score > 0) + 1 ||
|
|
||||||
values.length + 1) - 1;
|
|
||||||
const weak = randomLog2(values, weakCount);
|
|
||||||
if (weak && Math.random() / weakCount < config.weakRate) {
|
|
||||||
lastWord = weak[0];
|
|
||||||
return weak[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
|
|
||||||
const recent = randomLog2(values);
|
|
||||||
const newRate =
|
|
||||||
values.length < config.initialCount
|
|
||||||
? config.initialNewRate
|
|
||||||
: config.newRate;
|
|
||||||
if (
|
|
||||||
recent &&
|
|
||||||
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
|
|
||||||
Math.random() > newRate)
|
|
||||||
) {
|
|
||||||
lastWord = recent[0];
|
|
||||||
return recent[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
|
|
||||||
const word = newWord || recent?.[0] || weak?.[0];
|
|
||||||
lastWord = word;
|
|
||||||
return word;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
|
|
||||||
interface ChordStats {
|
|
||||||
level: number;
|
|
||||||
lastUprank: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
|
||||||
"chord-stats",
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
@@ -17,7 +17,7 @@ export async function getMeta(
|
|||||||
try {
|
try {
|
||||||
if (!browser) return fetchMeta(device, version, fetch);
|
if (!browser) return fetchMeta(device, version, fetch);
|
||||||
|
|
||||||
const dbRequest = indexedDB.open("version-meta", 3);
|
const dbRequest = indexedDB.open("version-meta", 6);
|
||||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||||
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||||
dbRequest.onerror = () => reject(dbRequest.error);
|
dbRequest.onerror = () => reject(dbRequest.error);
|
||||||
@@ -120,6 +120,9 @@ async function fetchMeta(
|
|||||||
}
|
}
|
||||||
return settings;
|
return settings;
|
||||||
})),
|
})),
|
||||||
|
changelog: await (meta?.changelog
|
||||||
|
? fetch(`${path}/${meta.changelog}`).then((it) => it.json())
|
||||||
|
: {}),
|
||||||
actions: await (meta?.actions
|
actions: await (meta?.actions
|
||||||
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
? fetch(`${path}/${meta.actions}`).then((it) => it.json())
|
||||||
: Promise.all<KeymapCategory[]>(
|
: Promise.all<KeymapCategory[]>(
|
||||||
@@ -127,6 +130,9 @@ async function fetchMeta(
|
|||||||
async (load) => load().then((it) => (it as any).default),
|
async (load) => load().then((it) => (it as any).default),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
|
recipes: await (meta?.recipes
|
||||||
|
? fetch(`${path}/${meta.recipes}`).then((it) => it.json())
|
||||||
|
: undefined),
|
||||||
update: {
|
update: {
|
||||||
uf2:
|
uf2:
|
||||||
meta?.update?.uf2 ??
|
meta?.update?.uf2 ??
|
||||||
@@ -141,6 +147,10 @@ async function fetchMeta(
|
|||||||
)?.name ??
|
)?.name ??
|
||||||
undefined,
|
undefined,
|
||||||
esptool: meta?.update?.esptool ?? undefined,
|
esptool: meta?.update?.esptool ?? undefined,
|
||||||
|
js: meta?.update?.js ?? undefined,
|
||||||
|
wasm: meta?.update?.wasm ?? undefined,
|
||||||
|
dll: meta?.update?.dll ?? undefined,
|
||||||
|
so: meta?.update?.so ?? undefined,
|
||||||
},
|
},
|
||||||
spiFlash: meta?.spi_flash ?? undefined,
|
spiFlash: meta?.spi_flash ?? undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface SettingsMeta {
|
|||||||
|
|
||||||
export interface SettingsItemMeta {
|
export interface SettingsItemMeta {
|
||||||
id: number;
|
id: number;
|
||||||
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
enum?: string[];
|
enum?: string[];
|
||||||
range: [number, number];
|
range: [number, number];
|
||||||
@@ -22,6 +23,16 @@ export interface SettingsItemMeta {
|
|||||||
scale?: number;
|
scale?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangelogEntry {
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Changelog {
|
||||||
|
features: ChangelogEntry[];
|
||||||
|
fixes: ChangelogEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RawVersionMeta {
|
export interface RawVersionMeta {
|
||||||
version: string;
|
version: string;
|
||||||
target: string;
|
target: string;
|
||||||
@@ -32,6 +43,8 @@ export interface RawVersionMeta {
|
|||||||
development_mode: number;
|
development_mode: number;
|
||||||
actions: string;
|
actions: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
|
changelog: string;
|
||||||
|
recipes: string;
|
||||||
factory_defaults: {
|
factory_defaults: {
|
||||||
layout: string;
|
layout: string;
|
||||||
settings: string;
|
settings: string;
|
||||||
@@ -41,11 +54,48 @@ export interface RawVersionMeta {
|
|||||||
ota: string | null;
|
ota: string | null;
|
||||||
uf2: string | null;
|
uf2: string | null;
|
||||||
esptool: EspToolData | null;
|
esptool: EspToolData | null;
|
||||||
|
js: string | null;
|
||||||
|
wasm: string | null;
|
||||||
|
dll: string | null;
|
||||||
|
so: string | null;
|
||||||
};
|
};
|
||||||
files: string[];
|
files: string[];
|
||||||
spi_flash: SPIFlashInfo | null;
|
spi_flash: SPIFlashInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface E2eAddChord {
|
||||||
|
input: string[][];
|
||||||
|
output: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E2eTestItem {
|
||||||
|
keys?: string[];
|
||||||
|
modifiers?: Record<string, boolean>;
|
||||||
|
press?: string[];
|
||||||
|
release?: string[];
|
||||||
|
step?: number;
|
||||||
|
skip?: number;
|
||||||
|
idle?: boolean;
|
||||||
|
clearChords?: boolean;
|
||||||
|
addChords?: E2eAddChord[];
|
||||||
|
settings: Record<string, Record<string, string | number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E2eTest {
|
||||||
|
matrix?: string[];
|
||||||
|
test: E2eTestItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface E2eDemo {
|
||||||
|
demo?: {
|
||||||
|
id?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
matrix?: string[];
|
||||||
|
tests: E2eTest[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface VersionMeta {
|
export interface VersionMeta {
|
||||||
version: string;
|
version: string;
|
||||||
device: string;
|
device: string;
|
||||||
@@ -57,6 +107,8 @@ export interface VersionMeta {
|
|||||||
developmentBuild: boolean;
|
developmentBuild: boolean;
|
||||||
actions: KeymapCategory[];
|
actions: KeymapCategory[];
|
||||||
settings: SettingsMeta[];
|
settings: SettingsMeta[];
|
||||||
|
changelog: Changelog;
|
||||||
|
recipes?: E2eDemo[];
|
||||||
factoryDefaults?: {
|
factoryDefaults?: {
|
||||||
layout: CharaLayoutFile;
|
layout: CharaLayoutFile;
|
||||||
settings: CharaSettingsFile;
|
settings: CharaSettingsFile;
|
||||||
@@ -66,6 +118,10 @@ export interface VersionMeta {
|
|||||||
ota?: string;
|
ota?: string;
|
||||||
uf2?: string;
|
uf2?: string;
|
||||||
esptool?: EspToolData;
|
esptool?: EspToolData;
|
||||||
|
js?: string;
|
||||||
|
wasm?: string;
|
||||||
|
dll?: string;
|
||||||
|
so?: string;
|
||||||
};
|
};
|
||||||
spiFlash?: SPIFlashInfo;
|
spiFlash?: SPIFlashInfo;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ async function updateLayout() {
|
|||||||
layout.size !== currentLayout.size ||
|
layout.size !== currentLayout.size ||
|
||||||
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
|
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
|
||||||
) {
|
) {
|
||||||
|
console.log(layout);
|
||||||
osLayout.set(layout);
|
osLayout.set(layout);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,5 +69,8 @@ export function hashChord(actions: number[]) {
|
|||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < 16; i++) {
|
||||||
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
|
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
|
||||||
}
|
}
|
||||||
|
if ((hash & 0xff) === 0xff) {
|
||||||
|
hash ^= 0xff;
|
||||||
|
}
|
||||||
return hash & 0x3fff_ffff;
|
return hash & 0x3fff_ffff;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { get, writable } from "svelte/store";
|
import { get, writable } from "svelte/store";
|
||||||
import { CharaDevice } from "$lib/serial/device";
|
import { CharaDevice, type SerialPortLike } from "$lib/serial/device";
|
||||||
import type { Chord } from "$lib/serial/chord";
|
import type { Chord } from "$lib/serial/chord";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
import type { CharaLayout } from "$lib/serialization/layout";
|
import type { CharaLayout } from "$lib/serialization/layout";
|
||||||
@@ -10,6 +10,10 @@ import type { VersionMeta } from "$lib/meta/types/meta";
|
|||||||
|
|
||||||
export const serialPort = writable<CharaDevice | undefined>();
|
export const serialPort = writable<CharaDevice | undefined>();
|
||||||
|
|
||||||
|
navigator.serial?.addEventListener("disconnect", async (event) => {
|
||||||
|
serialPort.set(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
export interface SerialLogEntry {
|
export interface SerialLogEntry {
|
||||||
type: "input" | "output" | "system";
|
type: "input" | "output" | "system";
|
||||||
value: string;
|
value: string;
|
||||||
@@ -29,21 +33,24 @@ export const deviceChords = persistentWritable<Chord[]>(
|
|||||||
/**
|
/**
|
||||||
* Layout as read from the device
|
* Layout as read from the device
|
||||||
*/
|
*/
|
||||||
export const deviceLayout = persistentWritable<CharaLayout>(
|
export const deviceLayout = persistentWritable<CharaLayout[]>(
|
||||||
"layout",
|
"layout-profiles",
|
||||||
[[], [], []],
|
[],
|
||||||
() => get(userPreferences).backup,
|
() => get(userPreferences).backup,
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings as read from the device
|
* Settings as read from the device
|
||||||
*/
|
*/
|
||||||
export const deviceSettings = persistentWritable<number[]>(
|
export const deviceSettings = persistentWritable<number[][]>(
|
||||||
"device-settings",
|
"settings-profiles",
|
||||||
[],
|
[],
|
||||||
() => get(userPreferences).backup,
|
() => get(userPreferences).backup,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const activeProfile = persistentWritable<number>("active-profile", 0);
|
||||||
|
export const activeLayer = persistentWritable<number>("active-profile", 0);
|
||||||
|
|
||||||
export const syncStatus: Writable<
|
export const syncStatus: Writable<
|
||||||
"done" | "error" | "downloading" | "uploading"
|
"done" | "error" | "downloading" | "uploading"
|
||||||
> = writable("done");
|
> = writable("done");
|
||||||
@@ -56,9 +63,13 @@ export interface ProgressInfo {
|
|||||||
}
|
}
|
||||||
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
||||||
|
|
||||||
export async function initSerial(manual = false, withSync = true) {
|
export async function initSerial(port: SerialPortLike, withSync: boolean) {
|
||||||
const device = get(serialPort) ?? new CharaDevice();
|
const prev = get(serialPort);
|
||||||
await device.init(manual);
|
try {
|
||||||
|
prev?.close();
|
||||||
|
} catch {}
|
||||||
|
const device = new CharaDevice(port);
|
||||||
|
await device.init();
|
||||||
serialPort.set(device);
|
serialPort.set(device);
|
||||||
if (withSync) {
|
if (withSync) {
|
||||||
await sync();
|
await sync();
|
||||||
@@ -80,30 +91,49 @@ export async function sync() {
|
|||||||
.map((it) => it.items.length)
|
.map((it) => it.items.length)
|
||||||
.reduce((a, b) => a + b, 0);
|
.reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
const max = maxSettings + device.keyCount * 3 + chordCount;
|
const max =
|
||||||
|
(maxSettings + device.keyCount * device.layerCount) * device.profileCount +
|
||||||
|
chordCount;
|
||||||
let current = 0;
|
let current = 0;
|
||||||
|
activeProfile.update((it) => Math.min(it, device.profileCount - 1));
|
||||||
|
activeLayer.update((it) => Math.min(it, device.layerCount - 1));
|
||||||
syncProgress.set({ max, current });
|
syncProgress.set({ max, current });
|
||||||
function progressTick() {
|
function progressTick() {
|
||||||
current++;
|
current++;
|
||||||
syncProgress.set({ max, current });
|
syncProgress.set({ max, current });
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedSettings: number[] = [];
|
const parsedSettings: number[][] = Array.from(
|
||||||
for (const category of meta.settings) {
|
{ length: device.profileCount },
|
||||||
for (const setting of category.items) {
|
() => [],
|
||||||
try {
|
);
|
||||||
parsedSettings[setting.id] = await device.getSetting(setting.id);
|
for (const [profile, settings] of parsedSettings.entries()) {
|
||||||
} catch {}
|
for (const category of meta.settings) {
|
||||||
|
for (const setting of category.items) {
|
||||||
|
try {
|
||||||
|
settings[setting.id] = await device.getSetting(profile, setting.id);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
progressTick();
|
||||||
}
|
}
|
||||||
progressTick();
|
|
||||||
}
|
}
|
||||||
deviceSettings.set(parsedSettings);
|
deviceSettings.set(parsedSettings);
|
||||||
|
|
||||||
const parsedLayout: CharaLayout = [[], [], []];
|
const parsedLayout: CharaLayout[] = Array.from(
|
||||||
for (let layer = 1; layer <= 3; layer++) {
|
{ length: device.profileCount },
|
||||||
for (let i = 0; i < device.keyCount; i++) {
|
() =>
|
||||||
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
|
Array.from({ length: device.layerCount }, () =>
|
||||||
progressTick();
|
Array.from({ length: device.keyCount }, () => 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const [profile, layout] of parsedLayout.entries()) {
|
||||||
|
for (const [layer, keys] of layout.entries()) {
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
try {
|
||||||
|
keys[i] = await device.getLayoutKey(profile, layer + 1, i);
|
||||||
|
} catch {}
|
||||||
|
progressTick();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
deviceLayout.set(parsedLayout);
|
deviceLayout.set(parsedLayout);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
|
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
|
||||||
import { serialLog } from "$lib/serial/connection";
|
import { serialLog } from "$lib/serial/connection";
|
||||||
import type { Chord } from "$lib/serial/chord";
|
import type { Chord } from "$lib/serial/chord";
|
||||||
import { SemVer } from "$lib/serial/sem-ver";
|
|
||||||
import {
|
import {
|
||||||
parseChordActions,
|
parseChordActions,
|
||||||
parsePhrase,
|
parsePhrase,
|
||||||
@@ -10,24 +9,66 @@ import {
|
|||||||
} from "$lib/serial/chord";
|
} from "$lib/serial/chord";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||||
|
import semverGte from "semver/functions/gte";
|
||||||
|
|
||||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
export const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
["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 }],
|
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||||
["LITE S2", { usbProductId: 33070, usbVendorId: 12346 }],
|
["LITE S2", { usbProductId: 0x812e, usbVendorId: 0x303a }],
|
||||||
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
["LITE M0", { usbProductId: 32796, usbVendorId: 9114 }],
|
||||||
["X", { usbProductId: 33163, usbVendorId: 12346 }],
|
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
|
||||||
["M4G S3", { usbProductId: 4097, usbVendorId: 12346 }],
|
["M4G S3 (pre-production)", { usbProductId: 0x1001, usbVendorId: 0x303a }],
|
||||||
|
["M4G S3", { usbProductId: 0x829a, usbVendorId: 0x303a }],
|
||||||
|
["T4G S2", { usbProductId: 0x82f2, usbVendorId: 0x303a }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const DEVICE_ALIASES = new Map<string, Set<string>>([
|
||||||
|
["CC1", new Set(["ONE M0", "one_m0"])],
|
||||||
|
["CC2", new Set(["TWO S3", "two_s3", "TWO S3 (pre-production)"])],
|
||||||
|
["Lite (S2)", new Set(["LITE S2", "lite_s2"])],
|
||||||
|
["Lite (M0)", new Set(["LITE M0", "lite_m0"])],
|
||||||
|
["CCX", new Set(["X", "ccx"])],
|
||||||
|
["M4G", new Set(["M4G S3", "m4g_s3", "M4G S3 (pre-production)"])],
|
||||||
|
["M4G (right)", new Set(["M4GR S3", "m4gr_s3"])],
|
||||||
|
["T4G", new Set(["T4G S2", "t4g_s2"])],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function getName(alias: string): string {
|
||||||
|
for (const [name, aliases] of DEVICE_ALIASES.entries()) {
|
||||||
|
if (aliases.has(alias)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return alias;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortName(port: SerialPort): string {
|
||||||
|
const { usbProductId, usbVendorId } = port.getInfo();
|
||||||
|
console.log(port.getInfo());
|
||||||
|
for (const [name, filter] of PORT_FILTERS.entries()) {
|
||||||
|
if (
|
||||||
|
filter.usbProductId === usbProductId &&
|
||||||
|
filter.usbVendorId === usbVendorId
|
||||||
|
) {
|
||||||
|
return getName(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `Unknown Device (0x${usbVendorId?.toString(
|
||||||
|
16,
|
||||||
|
)}/0x${usbProductId?.toString(16)})`;
|
||||||
|
}
|
||||||
|
|
||||||
const KEY_COUNTS = {
|
const KEY_COUNTS = {
|
||||||
ONE: 90,
|
ONE: 90,
|
||||||
TWO: 90,
|
TWO: 90,
|
||||||
LITE: 67,
|
LITE: 67,
|
||||||
X: 256,
|
X: 256,
|
||||||
|
ENGINE: 256,
|
||||||
M4G: 90,
|
M4G: 90,
|
||||||
M4GR: 90,
|
M4GR: 90,
|
||||||
|
T4G: 7,
|
||||||
|
ZERO: 256,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -85,8 +126,12 @@ async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|||||||
]).finally(() => clearTimeout(timer));
|
]).finally(() => clearTimeout(timer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SerialPortLike = Pick<
|
||||||
|
SerialPort,
|
||||||
|
"readable" | "writable" | "open" | "close" | "getInfo" | "forget"
|
||||||
|
>;
|
||||||
|
|
||||||
export class CharaDevice {
|
export class CharaDevice {
|
||||||
private port!: SerialPort;
|
|
||||||
private reader!: ReadableStreamDefaultReader<string>;
|
private reader!: ReadableStreamDefaultReader<string>;
|
||||||
|
|
||||||
private readonly abortController1 = new AbortController();
|
private readonly abortController1 = new AbortController();
|
||||||
@@ -99,28 +144,25 @@ export class CharaDevice {
|
|||||||
private readonly suspendDebounce = 100;
|
private readonly suspendDebounce = 100;
|
||||||
private suspendDebounceId?: number;
|
private suspendDebounceId?: number;
|
||||||
|
|
||||||
version!: SemVer;
|
version!: string;
|
||||||
company!: "CHARACHORDER" | "FORGE";
|
company!: "CHARACHORDER" | "FORGE";
|
||||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
|
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
|
||||||
chipset!: "M0" | "S2" | "S3";
|
chipset!: "M0" | "S2" | "S3" | "WASM";
|
||||||
keyCount!: 90 | 67 | 256;
|
keyCount!: 90 | 67 | 256;
|
||||||
|
layerCount = 3;
|
||||||
|
profileCount = 1;
|
||||||
|
|
||||||
get portInfo() {
|
get portInfo() {
|
||||||
return this.port.getInfo();
|
return this.port.getInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(private readonly baudRate = 115200) {}
|
constructor(
|
||||||
|
readonly port: SerialPortLike,
|
||||||
|
public baudRate = 115200,
|
||||||
|
) {}
|
||||||
|
|
||||||
async init(manual = false) {
|
async init() {
|
||||||
try {
|
try {
|
||||||
const ports = await getViablePorts();
|
|
||||||
this.port =
|
|
||||||
!manual && ports.length === 1
|
|
||||||
? ports[0]!
|
|
||||||
: await navigator.serial.requestPort({
|
|
||||||
filters: [...PORT_FILTERS.values()],
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.port.open({ baudRate: this.baudRate });
|
await this.port.open({ baudRate: this.baudRate });
|
||||||
const info = this.port.getInfo();
|
const info = this.port.getInfo();
|
||||||
serialLog.update((it) => {
|
serialLog.update((it) => {
|
||||||
@@ -134,13 +176,19 @@ export class CharaDevice {
|
|||||||
});
|
});
|
||||||
await this.port.close();
|
await this.port.close();
|
||||||
|
|
||||||
this.version = new SemVer(
|
this.version = await this.send(1, ["VERSION"]).then(
|
||||||
await this.send(1, ["VERSION"]).then(([version]) => version),
|
([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.company = company as typeof this.company;
|
||||||
this.device = device as typeof this.device;
|
this.device = device as typeof this.device;
|
||||||
this.chipset = chipset as typeof this.chipset;
|
this.chipset = chipset as typeof this.chipset;
|
||||||
|
if (semverGte(this.version, "2.2.0-beta.4") && this.chipset !== "M0") {
|
||||||
|
this.profileCount = 3;
|
||||||
|
}
|
||||||
|
if (semverGte(this.version, "2.2.0-beta.20") && this.chipset !== "M0") {
|
||||||
|
this.layerCount = 4;
|
||||||
|
}
|
||||||
this.keyCount = KEY_COUNTS[this.device];
|
this.keyCount = KEY_COUNTS[this.device];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -234,6 +282,10 @@ export class CharaDevice {
|
|||||||
await this.port.forget();
|
await this.port.forget();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
await this.port.close();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read/write to serial port
|
* Read/write to serial port
|
||||||
*/
|
*/
|
||||||
@@ -290,23 +342,24 @@ export class CharaDevice {
|
|||||||
.join(" ")
|
.join(" ")
|
||||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||||
const readResult = await read(timeout);
|
const readResult = await read(timeout);
|
||||||
if (readResult === undefined) {
|
return readResult
|
||||||
|
?.replace(new RegExp(`^${commandString} `), "")
|
||||||
|
.split(" ");
|
||||||
|
}).then((it) => {
|
||||||
|
if (it === undefined) {
|
||||||
console.error("No response");
|
console.error("No response");
|
||||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||||
string,
|
string,
|
||||||
T
|
T
|
||||||
>;
|
>;
|
||||||
}
|
}
|
||||||
const array = readResult
|
if (it.length < expectedLength) {
|
||||||
.replace(new RegExp(`^${commandString} `), "")
|
|
||||||
.split(" ");
|
|
||||||
if (array.length < expectedLength) {
|
|
||||||
console.error("Response too short");
|
console.error("Response too short");
|
||||||
return array.concat(
|
return it.concat(
|
||||||
Array(expectedLength - array.length).fill("TOO_SHORT"),
|
Array(expectedLength - it.length).fill("TOO_SHORT"),
|
||||||
) as LengthArray<string, T>;
|
) as LengthArray<string, T>;
|
||||||
}
|
}
|
||||||
return array as LengthArray<string, T>;
|
return it as LengthArray<string, T>;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,7 +402,7 @@ export class CharaDevice {
|
|||||||
stringifyChordActions(chord.actions),
|
stringifyChordActions(chord.actions),
|
||||||
stringifyPhrase(chord.phrase),
|
stringifyPhrase(chord.phrase),
|
||||||
]);
|
]);
|
||||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||||
@@ -368,11 +421,16 @@ export class CharaDevice {
|
|||||||
* @param id id of the key, refer to the individual device for where each key is
|
* @param id id of the key, refer to the individual device for where each key is
|
||||||
* @param action the assigned action id
|
* @param action the assigned action id
|
||||||
*/
|
*/
|
||||||
async setLayoutKey(layer: number, id: number, action: number) {
|
async setLayoutKey(
|
||||||
|
profile: number,
|
||||||
|
layer: number,
|
||||||
|
id: number,
|
||||||
|
action: number,
|
||||||
|
) {
|
||||||
const [status] = await this.send(1, [
|
const [status] = await this.send(1, [
|
||||||
"VAR",
|
"VAR",
|
||||||
"B4",
|
"B4",
|
||||||
`A${layer}`,
|
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
|
||||||
id.toString(),
|
id.toString(),
|
||||||
action.toString(),
|
action.toString(),
|
||||||
]);
|
]);
|
||||||
@@ -385,11 +443,11 @@ export class CharaDevice {
|
|||||||
* @param id id of the key, refer to the individual device for where each key is
|
* @param id id of the key, refer to the individual device for where each key is
|
||||||
* @returns the assigned action id
|
* @returns the assigned action id
|
||||||
*/
|
*/
|
||||||
async getLayoutKey(layer: number, id: number) {
|
async getLayoutKey(profile: number, layer: number, id: number) {
|
||||||
const [position, status] = await this.send(2, [
|
const [position, status] = await this.send(2, [
|
||||||
"VAR",
|
"VAR",
|
||||||
"B3",
|
"B3",
|
||||||
`A${layer}`,
|
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
|
||||||
id.toString(),
|
id.toString(),
|
||||||
]);
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
@@ -414,11 +472,11 @@ export class CharaDevice {
|
|||||||
* Settings are applied until the next reboot or loss of power.
|
* Settings are applied until the next reboot or loss of power.
|
||||||
* To permanently store the settings, you *must* call commit.
|
* To permanently store the settings, you *must* call commit.
|
||||||
*/
|
*/
|
||||||
async setSetting(id: number, value: number) {
|
async setSetting(profile: number, id: number, value: number) {
|
||||||
const [status] = await this.send(1, [
|
const [status] = await this.send(1, [
|
||||||
"VAR",
|
"VAR",
|
||||||
"B2",
|
"B2",
|
||||||
id.toString(16).toUpperCase(),
|
(id + profile * 0x100).toString(16).toUpperCase(),
|
||||||
value.toString(),
|
value.toString(),
|
||||||
]);
|
]);
|
||||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||||
@@ -427,11 +485,11 @@ export class CharaDevice {
|
|||||||
/**
|
/**
|
||||||
* Retrieves a setting from the device
|
* Retrieves a setting from the device
|
||||||
*/
|
*/
|
||||||
async getSetting(id: number): Promise<number> {
|
async getSetting(profile: number, id: number): Promise<number> {
|
||||||
const [value, status] = await this.send(2, [
|
const [value, status] = await this.send(2, [
|
||||||
"VAR",
|
"VAR",
|
||||||
"B1",
|
"B1",
|
||||||
id.toString(16).toUpperCase(),
|
(id + profile * 0x100).toString(16).toUpperCase(),
|
||||||
]);
|
]);
|
||||||
if (status !== "0")
|
if (status !== "0")
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
export class SemVer {
|
|
||||||
major = 0;
|
|
||||||
minor = 0;
|
|
||||||
patch = 0;
|
|
||||||
preRelease?: string;
|
|
||||||
meta?: string;
|
|
||||||
|
|
||||||
constructor(versionString: string) {
|
|
||||||
const result =
|
|
||||||
/^([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+))?$/.exec(
|
|
||||||
versionString,
|
|
||||||
);
|
|
||||||
if (!result) {
|
|
||||||
console.error("Invalid version string:", versionString);
|
|
||||||
} else {
|
|
||||||
const [, major, minor, patch, preRelease, meta] = result;
|
|
||||||
this.major = Number.parseInt(major ?? "NaN");
|
|
||||||
this.minor = Number.parseInt(minor ?? "NaN");
|
|
||||||
this.patch = Number.parseInt(patch ?? "NaN");
|
|
||||||
if (preRelease) this.preRelease = preRelease;
|
|
||||||
if (meta) this.meta = meta;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toString() {
|
|
||||||
return (
|
|
||||||
`${this.major}.${this.minor}.${this.patch}` +
|
|
||||||
(this.preRelease ? `-${this.preRelease}` : "") +
|
|
||||||
(this.meta ? `+${this.meta}` : "")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -29,5 +29,10 @@ export async function fromBase64(
|
|||||||
.replaceAll(".", "+")
|
.replaceAll(".", "+")
|
||||||
.replaceAll("_", "/")
|
.replaceAll("_", "/")
|
||||||
.replaceAll("-", "=")}`,
|
.replaceAll("-", "=")}`,
|
||||||
).then((it) => it.blob());
|
)
|
||||||
|
.then((it) => {
|
||||||
|
console.log(it);
|
||||||
|
return it;
|
||||||
|
})
|
||||||
|
.then((it) => it.blob());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,10 @@ import { fromBase64, toBase64 } from "$lib/serialization/base64";
|
|||||||
export interface NewCharaLayout {
|
export interface NewCharaLayout {
|
||||||
charaLayoutVersion: 1;
|
charaLayoutVersion: 1;
|
||||||
device: "one" | "lite" | string;
|
device: "one" | "lite" | string;
|
||||||
/**
|
layers: number[][];
|
||||||
* Layers A1-A3, with numeric action codes on each
|
|
||||||
*/
|
|
||||||
layers: [number[], number[], number[]];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CharaLayout = [number[], number[], number[]];
|
export type CharaLayout = number[][];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialize a layout into a micro package
|
* Serialize a layout into a micro package
|
||||||
|
|||||||
@@ -1,5 +1,88 @@
|
|||||||
import type { Action } from "svelte/action";
|
import type { Action } from "svelte/action";
|
||||||
import { changes, ChangeType, settings } from "$lib/undo-redo";
|
import { changes, ChangeType, settings } from "$lib/undo-redo";
|
||||||
|
import { activeProfile } from "./serial/connection";
|
||||||
|
import { combineLatest, map } from "rxjs";
|
||||||
|
import { fromReadable } from "./util/from-readable";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<
|
export const setting: Action<
|
||||||
HTMLInputElement | HTMLSelectElement,
|
HTMLInputElement | HTMLSelectElement,
|
||||||
@@ -9,7 +92,12 @@ export const setting: Action<
|
|||||||
{ id, inverse, scale },
|
{ id, inverse, scale },
|
||||||
) {
|
) {
|
||||||
node.setAttribute("disabled", "");
|
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 =
|
const isNumeric =
|
||||||
type === "number" || type === "range" || node instanceof HTMLSelectElement;
|
type === "number" || type === "range" || node instanceof HTMLSelectElement;
|
||||||
const min = node.hasAttribute("min")
|
const min = node.hasAttribute("min")
|
||||||
@@ -19,30 +107,42 @@ export const setting: Action<
|
|||||||
? Number(node.getAttribute("max"))
|
? Number(node.getAttribute("max"))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const unsubscribe = settings.subscribe(async (settings) => {
|
const subscription = combineLatest([
|
||||||
if (id in settings) {
|
fromReadable(settings),
|
||||||
const { value, isApplied } = settings[id]!;
|
fromReadable(activeProfile),
|
||||||
if (isNumeric) {
|
])
|
||||||
node.value = (
|
.pipe(map(([settings, profile]) => settings[profile]!))
|
||||||
inverse !== undefined
|
.subscribe(async (settings) => {
|
||||||
? inverse / value
|
if (id in settings) {
|
||||||
: scale !== undefined
|
const { value, isApplied } = settings[id]!;
|
||||||
? scale * value
|
if (isNumeric) {
|
||||||
: value
|
node.value = (
|
||||||
).toString();
|
inverse !== undefined
|
||||||
|
? inverse / value
|
||||||
|
: scale !== undefined
|
||||||
|
? 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;
|
||||||
|
}
|
||||||
|
if (isApplied) {
|
||||||
|
node.classList.remove("pending-changes");
|
||||||
|
} else {
|
||||||
|
node.classList.add("pending-changes");
|
||||||
|
}
|
||||||
|
node.removeAttribute("disabled");
|
||||||
} else {
|
} else {
|
||||||
node.checked = value !== 0;
|
node.setAttribute("disabled", "");
|
||||||
}
|
}
|
||||||
if (isApplied) {
|
});
|
||||||
node.classList.remove("pending-changes");
|
|
||||||
} else {
|
|
||||||
node.classList.add("pending-changes");
|
|
||||||
}
|
|
||||||
node.removeAttribute("disabled");
|
|
||||||
} else {
|
|
||||||
node.setAttribute("disabled", "");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function listener() {
|
async function listener() {
|
||||||
let value: number;
|
let value: number;
|
||||||
@@ -58,6 +158,23 @@ export const setting: Action<
|
|||||||
? value / scale
|
? value / scale
|
||||||
: value,
|
: 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,
|
||||||
|
profile: get(activeProfile),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return changes;
|
||||||
|
});
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
value = node.checked ? 1 : 0;
|
value = node.checked ? 1 : 0;
|
||||||
}
|
}
|
||||||
@@ -68,6 +185,7 @@ export const setting: Action<
|
|||||||
type: ChangeType.Setting,
|
type: ChangeType.Setting,
|
||||||
id: id,
|
id: id,
|
||||||
setting: value,
|
setting: value,
|
||||||
|
profile: get(activeProfile),
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
return changes;
|
return changes;
|
||||||
@@ -79,7 +197,7 @@ export const setting: Action<
|
|||||||
return {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
node.removeEventListener("change", listener);
|
node.removeEventListener("change", listener);
|
||||||
unsubscribe();
|
subscription.unsubscribe();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
kbd {
|
kbd {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
height: 20px;
|
|
||||||
margin-block: 6px;
|
margin-block: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
//border: 1px solid currentcolor;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--md-sys-color-surface-variant) 50%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
||||||
font-size: 14px;
|
height: 20px;
|
||||||
font-weight: normal;
|
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
|
font-weight: normal;
|
||||||
|
|
||||||
border: 1px solid currentcolor;
|
font-size: 14px;
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
&.icon {
|
&.icon {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
@@ -21,8 +26,8 @@ kbd {
|
|||||||
|
|
||||||
&:has(> kbd) {
|
&:has(> kbd) {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 0;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
> kbd {
|
> kbd {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
h1 {
|
h1 {
|
||||||
margin-block-start: 0;
|
margin-block-start: 0;
|
||||||
font-size: 4rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--md-sys-color-secondary);
|
color: var(--md-sys-color-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 4rem;
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/lib/style/elements/_popover.scss
Normal file
67
src/lib/style/elements/_popover.scss
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
$animation-duration: 150ms;
|
||||||
|
$translate: translateY(8px);
|
||||||
|
|
||||||
|
[popover] {
|
||||||
|
position: absolute;
|
||||||
|
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
transition:
|
||||||
|
transform $animation-duration ease,
|
||||||
|
opacity $animation-duration linear,
|
||||||
|
overlay $animation-duration allow-discrete,
|
||||||
|
display $animation-duration allow-discrete;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
inset: unset;
|
||||||
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--md-sys-color-surface);
|
||||||
|
padding: 8px;
|
||||||
|
color: var(--md-sys-color-on-surface);
|
||||||
|
font-weight: initial;
|
||||||
|
font-size: initial;
|
||||||
|
|
||||||
|
font-family: "Noto Sans Mono", monospace;
|
||||||
|
|
||||||
|
position-area: bottom span-all;
|
||||||
|
position-try-fallbacks:
|
||||||
|
top span-all,
|
||||||
|
bottom span-right,
|
||||||
|
top span-right,
|
||||||
|
bottom span-left,
|
||||||
|
top span-left;
|
||||||
|
|
||||||
|
position-visibility: no-overflow;
|
||||||
|
|
||||||
|
&:popover-open {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> h1:first-child,
|
||||||
|
h2:first-child,
|
||||||
|
h3:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[popover="auto"] {
|
||||||
|
transform: $translate;
|
||||||
|
}
|
||||||
|
|
||||||
|
[popover="hint"] {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@starting-style {
|
||||||
|
[popover]:popover-open {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[popover="auto"] {
|
||||||
|
transform: $translate;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,31 +5,30 @@ a {
|
|||||||
a,
|
a,
|
||||||
label:has(input),
|
label:has(input),
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
transition: all 250ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 32px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
padding-block: 8px;
|
||||||
|
|
||||||
width: max-content;
|
width: max-content;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
padding-block: 8px;
|
font-weight: 600;
|
||||||
padding-inline: 16px;
|
|
||||||
|
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 600;
|
|
||||||
border-radius: 32px;
|
|
||||||
transition: all 250ms ease;
|
|
||||||
|
|
||||||
@media not (forced-colors: active) {
|
@media not (forced-colors: active) {
|
||||||
color: currentcolor;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: currentcolor;
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
background: var(--md-sys-color-primary);
|
background: var(--md-sys-color-primary);
|
||||||
|
color: var(--md-sys-color-on-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,17 +39,17 @@ button {
|
|||||||
|
|
||||||
&.icon {
|
&.icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
border-radius: 50%;
|
||||||
|
padding-inline: 0;
|
||||||
|
padding-block: 0;
|
||||||
|
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
padding-block: 0;
|
|
||||||
padding-inline: 0;
|
|
||||||
|
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
padding: 2px;
|
|
||||||
margin: 2px;
|
margin: 2px;
|
||||||
|
padding: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,8 +87,8 @@ button {
|
|||||||
}
|
}
|
||||||
&.active,
|
&.active,
|
||||||
&:active {
|
&:active {
|
||||||
color: SelectedItemText;
|
|
||||||
background: SelectedItem;
|
background: SelectedItem;
|
||||||
|
color: SelectedItemText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/lib/style/form/_radio.scss
Normal file
35
src/lib/style/form/_radio.scss
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
label:has(input[type="radio"]) {
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
transition: all 250ms ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-surface-variant);
|
||||||
|
padding-inline: 12px;
|
||||||
|
|
||||||
|
aspect-ratio: unset;
|
||||||
|
height: 1.5em;
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
> input[type="radio"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
border-radius: 16px 0 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-radius: 0 16px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(:checked) {
|
||||||
|
background: var(--md-sys-color-tertiary);
|
||||||
|
color: var(--md-sys-color-on-tertiary);
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,57 +3,55 @@ $border: 2px;
|
|||||||
$height: 1.5em;
|
$height: 1.5em;
|
||||||
|
|
||||||
label:has(input[type="checkbox"]) {
|
label:has(input[type="checkbox"]) {
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $padding;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: $padding;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
input[type="checkbox"] {
|
input[type="checkbox"] {
|
||||||
$width: calc($height * (5 / 3));
|
$width: calc($height * (5 / 3));
|
||||||
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
|
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
|
||||||
$radius: calc($diameter / 2);
|
$radius: calc($diameter / 2);
|
||||||
|
display: flex;
|
||||||
cursor: pointer;
|
|
||||||
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
overflow: hidden;
|
cursor: pointer;
|
||||||
display: flex;
|
|
||||||
|
outline: $border solid currentcolor;
|
||||||
|
outline-offset: calc(-1 * $border);
|
||||||
|
border-radius: calc($height / 2);
|
||||||
|
|
||||||
width: $width;
|
width: $width;
|
||||||
height: $height;
|
height: $height;
|
||||||
|
|
||||||
font-size: inherit;
|
overflow: hidden;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
|
|
||||||
border-radius: calc($height / 2);
|
font-size: inherit;
|
||||||
outline: $border solid currentcolor;
|
|
||||||
outline-offset: calc(-1 * $border);
|
|
||||||
|
|
||||||
&::after {
|
&::after {
|
||||||
content: "";
|
display: block;
|
||||||
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc($padding + $border);
|
top: calc($padding + $border);
|
||||||
left: calc($padding + $border);
|
left: calc($padding + $border);
|
||||||
|
|
||||||
display: block;
|
transition: all 250ms ease;
|
||||||
|
|
||||||
width: $diameter;
|
|
||||||
height: $diameter;
|
|
||||||
|
|
||||||
border-radius: calc($radius);
|
|
||||||
outline-color: inherit;
|
outline-color: inherit;
|
||||||
outline-style: solid;
|
outline-style: solid;
|
||||||
outline-width: $radius;
|
outline-width: $radius;
|
||||||
outline-offset: calc(-1 * $radius);
|
outline-offset: calc(-1 * $radius);
|
||||||
|
border-radius: calc($radius);
|
||||||
|
|
||||||
transition: all 250ms ease;
|
width: $diameter;
|
||||||
|
height: $diameter;
|
||||||
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
&:checked::after {
|
&:checked::after {
|
||||||
@@ -62,4 +60,83 @@ label:has(input[type="checkbox"]) {
|
|||||||
outline-offset: calc($padding / 2);
|
outline-offset: calc($padding / 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:has(span.icon) {
|
||||||
|
$line-width: 10%;
|
||||||
|
$side: calc(($line-width * 2) / sqrt(2));
|
||||||
|
$mid: calc($side / 2);
|
||||||
|
|
||||||
|
> input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
> span.icon {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
clip-path: polygon(
|
||||||
|
0% $side,
|
||||||
|
$mid $mid,
|
||||||
|
calc(100% - $mid) calc(100% - $mid),
|
||||||
|
calc(100% - $side) 100%,
|
||||||
|
0% 100%,
|
||||||
|
0% $side,
|
||||||
|
$side 0%,
|
||||||
|
100% calc(100% - $side),
|
||||||
|
calc(100% - $side) 100%,
|
||||||
|
calc(100% - $side) 100%,
|
||||||
|
100% calc(100% - $side),
|
||||||
|
100% calc(100% - $side),
|
||||||
|
100% 0%,
|
||||||
|
$side 0%
|
||||||
|
);
|
||||||
|
|
||||||
|
transition: all 250ms ease;
|
||||||
|
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
|
||||||
|
font-size: inherit;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, 0) rotate(45deg);
|
||||||
|
|
||||||
|
transition: all 250ms ease;
|
||||||
|
|
||||||
|
background-color: currentcolor;
|
||||||
|
|
||||||
|
width: calc(100% * sqrt(2));
|
||||||
|
height: $line-width;
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(:checked) > span.icon {
|
||||||
|
clip-path: polygon(
|
||||||
|
0% $side,
|
||||||
|
$mid $mid,
|
||||||
|
calc(100% - $mid) calc(100% - $mid),
|
||||||
|
calc(100% - $side) 100%,
|
||||||
|
0% 100%,
|
||||||
|
0% $side,
|
||||||
|
$side 0%,
|
||||||
|
100% calc(100% - $side),
|
||||||
|
calc(100% - $side) 100%,
|
||||||
|
0% $side,
|
||||||
|
$side 0%,
|
||||||
|
100% calc(100% - $side),
|
||||||
|
100% 0%,
|
||||||
|
$side 0%
|
||||||
|
);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
transform: translate(-50%, 0) rotate(45deg) translateX(-100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
@media not (forced-colors: active) {
|
@media not (forced-colors: active) {
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: var(--scrollbar-color, white);
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: all 250ms ease;
|
transition: all 250ms ease;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--scrollbar-color, white);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
filter: brightness(120%);
|
filter: brightness(120%);
|
||||||
|
|||||||
@@ -2,34 +2,35 @@
|
|||||||
|
|
||||||
@use "form/button";
|
@use "form/button";
|
||||||
@use "form/toggle";
|
@use "form/toggle";
|
||||||
@use "form/checkbox";
|
@use "form/radio";
|
||||||
|
|
||||||
@use "kbd";
|
@use "kbd";
|
||||||
@use "print";
|
@use "print";
|
||||||
|
|
||||||
@use "elements/h1";
|
@use "elements/h1";
|
||||||
|
@use "elements/popover";
|
||||||
|
|
||||||
body {
|
body {
|
||||||
overflow: hidden;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
background: var(--md-sys-color-background);
|
||||||
|
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
margin: 0;
|
overflow: hidden;
|
||||||
|
|
||||||
font-family: "Noto Sans Mono", monospace;
|
|
||||||
color: var(--md-sys-color-on-background);
|
color: var(--md-sys-color-on-background);
|
||||||
|
|
||||||
background: var(--md-sys-color-background);
|
font-family: "Noto Sans Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
contain: strict;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
contain: strict;
|
||||||
|
|
||||||
padding-inline: 16px;
|
padding-inline: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
$padding: 16px;
|
$padding: 16px;
|
||||||
|
|
||||||
.tippy-box[data-theme~="surface-variant"] {
|
.tippy-box[data-theme~="surface-variant"] {
|
||||||
color: var(--md-sys-color-on-surface-variant);
|
|
||||||
background-color: var(--md-sys-color-surface-variant);
|
|
||||||
filter: drop-shadow(0 0 12px #000a);
|
filter: drop-shadow(0 0 12px #000a);
|
||||||
border-radius: calc(24px + $padding);
|
border-radius: calc(24px + $padding);
|
||||||
|
background-color: var(--md-sys-color-surface-variant);
|
||||||
|
color: var(--md-sys-color-on-surface-variant);
|
||||||
|
|
||||||
.tippy-content {
|
.tippy-content {
|
||||||
padding: $padding;
|
padding: $padding;
|
||||||
@@ -24,10 +24,10 @@ $padding: 16px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (forced-colors: active) {
|
@media (forced-colors: active) {
|
||||||
color: CanvasText;
|
|
||||||
background-color: Canvas;
|
|
||||||
filter: none;
|
filter: none;
|
||||||
border: 1px solid CanvasText;
|
border: 1px solid CanvasText;
|
||||||
|
background-color: Canvas;
|
||||||
|
color: CanvasText;
|
||||||
|
|
||||||
> .tippy-arrow {
|
> .tippy-arrow {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -36,16 +36,16 @@ $padding: 16px;
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tippy-box[data-theme~="tooltip"] {
|
.tippy-box[data-theme~="tooltip"] {
|
||||||
color: var(--md-sys-color-on-background);
|
|
||||||
background-color: var(--md-sys-color-background);
|
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
border: 1px solid var(--md-sys-color-outline);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
background-color: var(--md-sys-color-background);
|
||||||
|
color: var(--md-sys-color-on-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tippy-box[data-theme~="search-completion"] {
|
.tippy-box[data-theme~="search-completion"] {
|
||||||
overflow: hidden;
|
|
||||||
filter: none;
|
filter: none;
|
||||||
border-radius: 0 0 16px 16px;
|
border-radius: 0 0 16px 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
.tippy-content {
|
.tippy-content {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import type { Action } from "svelte/action";
|
|
||||||
import tippy from "tippy.js";
|
import tippy from "tippy.js";
|
||||||
import { mount, unmount, type SvelteComponent } from "svelte";
|
import { mount, unmount, type Snippet } from "svelte";
|
||||||
import Tooltip from "$lib/components/Tooltip.svelte";
|
import Tooltip from "$lib/components/Tooltip.svelte";
|
||||||
|
import type { Attachment } from "svelte/attachments";
|
||||||
|
|
||||||
export const hotkeys = new Map<string, HTMLElement>();
|
export const hotkeys = new Map<string, HTMLElement>();
|
||||||
|
|
||||||
export const action: Action<Element, { title?: string; shortcut?: string }> = (
|
export function actionTooltip(
|
||||||
node: Element,
|
title: string | Snippet,
|
||||||
{ title, shortcut },
|
shortcut?: string,
|
||||||
) => {
|
): Attachment<Element> {
|
||||||
let component: {} | undefined;
|
return (node: Element) => {
|
||||||
const tooltip = tippy(node, {
|
let component: {} | undefined;
|
||||||
arrow: false,
|
const tooltip = tippy(node, {
|
||||||
theme: "tooltip",
|
arrow: false,
|
||||||
animation: "fade",
|
theme: "tooltip",
|
||||||
onShow(instance) {
|
animation: "fade",
|
||||||
component ??= mount(Tooltip, {
|
onShow(instance) {
|
||||||
target: instance.popper.querySelector(".tippy-content") as Element,
|
component ??= mount(Tooltip, {
|
||||||
props: { title, shortcut },
|
target: instance.popper.querySelector(".tippy-content") as Element,
|
||||||
});
|
props: { shortcut, title },
|
||||||
},
|
});
|
||||||
onHidden() {
|
},
|
||||||
if (component) {
|
onHidden() {
|
||||||
unmount(component);
|
if (component) {
|
||||||
component = undefined;
|
unmount(component);
|
||||||
}
|
component = undefined;
|
||||||
},
|
}
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (shortcut && node instanceof HTMLElement) {
|
if (shortcut && node instanceof HTMLElement) {
|
||||||
hotkeys.set(shortcut, node);
|
hotkeys.set(shortcut, node);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return () => {
|
||||||
destroy() {
|
|
||||||
tooltip.destroy();
|
tooltip.destroy();
|
||||||
if (shortcut && node instanceof HTMLElement) {
|
if (shortcut && node instanceof HTMLElement) {
|
||||||
hotkeys.delete(shortcut);
|
hotkeys.delete(shortcut);
|
||||||
}
|
}
|
||||||
},
|
};
|
||||||
};
|
};
|
||||||
};
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface LayoutChange {
|
|||||||
id: number;
|
id: number;
|
||||||
layer: number;
|
layer: number;
|
||||||
action: number;
|
action: number;
|
||||||
|
profile?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChordChange {
|
export interface ChordChange {
|
||||||
@@ -33,6 +34,7 @@ export interface SettingChange {
|
|||||||
type: ChangeType.Setting;
|
type: ChangeType.Setting;
|
||||||
id: number;
|
id: number;
|
||||||
setting: number;
|
setting: number;
|
||||||
|
profile?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangeInfo {
|
export interface ChangeInfo {
|
||||||
@@ -45,23 +47,29 @@ export type Change = LayoutChange | ChordChange | SettingChange;
|
|||||||
export const changes = persistentWritable<Change[][]>("changes", []);
|
export const changes = persistentWritable<Change[][]>("changes", []);
|
||||||
|
|
||||||
export interface Overlay {
|
export interface Overlay {
|
||||||
layout: [Map<number, number>, Map<number, number>, Map<number, number>];
|
layout: Array<Array<Map<number, number> | undefined> | undefined>;
|
||||||
chords: Map<string, Chord & { deleted: boolean }>;
|
chords: Map<string, Chord & { deleted: boolean }>;
|
||||||
settings: Map<number, number>;
|
settings: Array<Map<number, number> | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const overlay = derived(changes, (changes) => {
|
export const overlay = derived(changes, (changes) => {
|
||||||
const overlay: Overlay = {
|
const overlay: Overlay = {
|
||||||
layout: [new Map(), new Map(), new Map()],
|
layout: [],
|
||||||
chords: new Map(),
|
chords: new Map(),
|
||||||
settings: new Map(),
|
settings: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const changeset of changes) {
|
for (const changeset of changes) {
|
||||||
for (const change of changeset) {
|
for (const change of changeset) {
|
||||||
switch (change.type) {
|
switch (change.type) {
|
||||||
case ChangeType.Layout:
|
case ChangeType.Layout:
|
||||||
overlay.layout[change.layer]?.set(change.id, change.action);
|
change.profile ??= 0;
|
||||||
|
overlay.layout[change.profile] ??= [];
|
||||||
|
overlay.layout[change.profile]![change.layer] ??= new Map();
|
||||||
|
overlay.layout[change.profile]![change.layer]!.set(
|
||||||
|
change.id,
|
||||||
|
change.action,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case ChangeType.Chord:
|
case ChangeType.Chord:
|
||||||
overlay.chords.set(JSON.stringify(change.id), {
|
overlay.chords.set(JSON.stringify(change.id), {
|
||||||
@@ -71,7 +79,9 @@ export const overlay = derived(changes, (changes) => {
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case ChangeType.Setting:
|
case ChangeType.Setting:
|
||||||
overlay.settings.set(change.id, change.setting);
|
change.profile ??= 0;
|
||||||
|
overlay.settings[change.profile] ??= new Map();
|
||||||
|
overlay.settings[change.profile]!.set(change.id, change.setting);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -82,21 +92,25 @@ export const overlay = derived(changes, (changes) => {
|
|||||||
|
|
||||||
export const settings = derived(
|
export const settings = derived(
|
||||||
[overlay, deviceSettings],
|
[overlay, deviceSettings],
|
||||||
([overlay, settings]) =>
|
([overlay, profiles]) =>
|
||||||
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
|
profiles.map((settings, profile) =>
|
||||||
value: overlay.settings.get(id) ?? value,
|
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
|
||||||
isApplied: !overlay.settings.has(id),
|
value: overlay.settings[profile]?.get(id) ?? value,
|
||||||
})),
|
isApplied: !overlay.settings[profile]?.has(id),
|
||||||
|
})),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
export type KeyInfo = { action: number } & ChangeInfo;
|
export type KeyInfo = { action: number } & ChangeInfo;
|
||||||
export const layout = derived([overlay, deviceLayout], ([overlay, layout]) =>
|
export const layout = derived([overlay, deviceLayout], ([overlay, profiles]) =>
|
||||||
layout.map(
|
profiles.map((layout, profile) =>
|
||||||
(actions, layer) =>
|
layout.map(
|
||||||
actions.map<KeyInfo>((action, id) => ({
|
(actions, layer) =>
|
||||||
action: overlay.layout[layer]?.get(id) ?? action,
|
actions.map<KeyInfo>((action, id) => ({
|
||||||
isApplied: !overlay.layout[layer]?.has(id),
|
action: overlay.layout[profile]?.[layer]?.get(id) ?? action,
|
||||||
})) as [KeyInfo, KeyInfo, KeyInfo],
|
isApplied: !overlay.layout[profile]?.[layer]?.has(id),
|
||||||
|
})) as [KeyInfo, KeyInfo, KeyInfo],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -165,6 +179,22 @@ export const chords = derived(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const duplicateChords = derived(chords, (chords) => {
|
||||||
|
const duplicates = new Set<string>();
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const chord of chords) {
|
||||||
|
const key = JSON.stringify(chord.actions);
|
||||||
|
if (seen.has(key)) {
|
||||||
|
duplicates.add(key);
|
||||||
|
} else {
|
||||||
|
seen.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return duplicates;
|
||||||
|
});
|
||||||
|
|
||||||
export const chordHashes = derived(
|
export const chordHashes = derived(
|
||||||
chords,
|
chords,
|
||||||
(chords) =>
|
(chords) =>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user