29 Commits

Author SHA1 Message Date
e08dda40d9 feat: new left/right graphic 2025-12-17 19:58:05 +01:00
a403bf1ac0 improve cv2 2025-12-17 19:42:15 +01:00
1aff1703ac feat: new chord editor prototype 2025-12-17 17:34:32 +01:00
fe42dcd2ab fix: crash when saving empty chords 2025-12-12 17:41:54 +01:00
b13c34ca15 fix: oops 2025-12-12 15:19:28 +01:00
4023ab9bd5 feat: better handling of corrupted updates 2025-12-12 15:18:24 +01:00
2893afa2ba feat: qol improvements 2025-12-11 20:51:32 +01:00
7beab5ac07 fix: autospace cursor wonkyness 2025-11-28 17:33:55 +01:00
6895fa4a82 feat: cookbook 2025-11-28 14:38:51 +01:00
245dd97532 feat: 4th layer support 2025-11-12 18:21:22 +01:00
d84495894a fix: zero/engine backups should be x backups 2025-10-29 19:52:43 +01:00
1de52f7f81 feat: wasm zero 2025-10-29 18:51:03 +01:00
45682f0d1a 2.6.0 2025-10-22 17:22:34 +02:00
5f0bc45851 fix: issue with importing M4G backups 2025-10-22 16:59:41 +02:00
c6f1f3f6fc feat: add t4g vid 2025-10-22 16:58:04 +02:00
32c2ce2f45 2.5.0 2025-10-02 13:59:05 +02:00
c6e2f59b05 refactor: rename auto-backup to fast connect 2025-10-02 13:58:01 +02:00
2a872bafac v2.4.0 2025-09-23 14:14:11 +02:00
a940d1b480 feat: add upgrade flow for pre-2.0.0 (non-OTA) devices 2025-09-23 14:08:14 +02:00
f3b1d76666 feat: t4g support 2025-09-02 18:41:46 +02:00
0b2695a380 refactor: swap stylelint css order plugin for prettier plugin 2025-07-31 13:41:39 +02:00
048dee0a6d fix: can't select empty chord outputs
update dependencies
use new title popover
2025-07-29 20:18:13 +02:00
977bdf3043 fix: revamp reset popup 2025-07-29 18:59:11 +02:00
9ca30f412e feat: update compound calculation
feat: add "will my compound break" page
2025-07-29 16:40:54 +02:00
f2a18cafe8 fix: use semver sort for ccos updates 2025-07-29 14:55:02 +02:00
b27182dc35 fix: can't change profiles B/C
fix: only add profiles starting beta.4
2025-07-11 17:56:42 +02:00
74ce6af318 feat: profile support 2025-07-11 16:27:19 +02:00
782f1fc38b feat: autospace toggle 2025-06-13 20:29:05 +02:00
Shane O'Donnell
087ff36d5d Fix stale search (#189)
Fixes an issue where, after chord list is updated (e.g. by a deletion),
the search results continue to use the old list and indices, resulting
in incorrect search results.
2025-06-13 13:22:25 +02:00
123 changed files with 6333 additions and 2234 deletions

View File

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

View File

@@ -3,7 +3,6 @@
"stylelint-config-standard-scss",
"stylelint-config-recommended-scss",
"stylelint-config-html/svelte",
"stylelint-config-clean-order",
"stylelint-config-prettier-scss"
],
"rules": {

View File

@@ -34,6 +34,7 @@ const config = {
"abc",
"function",
"cloud_done",
"counter_4",
"backup",
"cloud_download",
"cloud_off",
@@ -43,9 +44,14 @@ const config = {
"arrow_back",
"arrow_back_ios_new",
"save",
"step_over",
"step_into",
"step_out",
"timer_play",
"settings_backup_restore",
"sound_detection_loud_sound",
"ring_volume",
"skillet",
"wifi",
"power_settings_circle",
"graphic_eq",
@@ -72,9 +78,13 @@ const config = {
"light_mode",
"palette",
"translate",
"smart_toy",
"visibility_off",
"play_arrow",
"extension",
"upload_file",
"file_export",
"file_save",
"commit",
"bug_report",
"delete",
@@ -86,6 +96,7 @@ const config = {
"undo",
"redo",
"replay",
"clock_loader_80",
"reply",
"navigate_before",
"navigate_next",
@@ -145,6 +156,7 @@ const config = {
counter_1: "f784",
counter_2: "f783",
counter_3: "f782",
counter_4: "f781",
ios_share: "e6b8",
light_mode: "e518",
upload_file: "e9fc",
@@ -157,6 +169,8 @@ const config = {
routine: "e20c",
experiment: "e686",
dictionary: "f539",
visibility_off: "e8f5",
file_save: "f17f",
},
};

View File

@@ -1,6 +1,6 @@
{
"name": "charachorder-device-manager",
"version": "2.3.0",
"version": "2.6.0",
"license": "AGPL-3.0-or-later",
"private": true,
"engines": {
@@ -36,59 +36,66 @@
"devDependencies": {
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.3",
"@codemirror/language": "^6.11.0",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.11.2",
"@codemirror/merge": "^6.11.2",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.5",
"@fontsource-variable/material-symbols-rounded": "^5.2.8",
"@fontsource-variable/noto-sans-mono": "^5.2.6",
"@codemirror/view": "^6.38.1",
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
"@fontsource-variable/noto-sans-mono": "^5.2.7",
"@lezer/common": "^1.4.0",
"@lezer/generator": "^1.8.0",
"@lezer/highlight": "^1.2.1",
"@lezer/lr": "^1.4.5",
"@material/material-color-utilities": "^0.3.0",
"@melt-ui/pp": "^0.3.2",
"@melt-ui/svelte": "^0.86.6",
"@modyfi/vite-plugin-yaml": "^1.1.1",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@sveltejs/kit": "^2.26.1",
"@sveltejs/vite-plugin-svelte": "^6.1.0",
"@tauri-apps/api": "^1.6.0",
"@tauri-apps/cli": "^1.6.0",
"@types/dom-view-transitions": "^1.0.6",
"@types/semver": "^7.7.0",
"@types/w3c-web-serial": "^1.0.8",
"@types/w3c-web-usb": "^1.0.10",
"@types/wicg-file-system-access": "^2023.10.5",
"@types/wicg-file-system-access": "^2023.10.6",
"@vite-pwa/sveltekit": "^1.0.0",
"autoprefixer": "^10.4.21",
"codemirror": "^6.0.1",
"cypress": "^14.2.1",
"codemirror": "^6.0.2",
"cypress": "^14.5.3",
"d3": "^7.9.0",
"esptool-js": "^0.5.4",
"flexsearch": "^0.8.147",
"esptool-js": "^0.5.6",
"flexsearch": "^0.8.205",
"fontkit": "^2.0.4",
"glob": "^11.0.1",
"jsdom": "^26.0.0",
"matrix-js-sdk": "^37.2.0",
"glob": "^11.0.3",
"jsdom": "^26.1.0",
"matrix-js-sdk": "^37.12.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier": "^3.6.2",
"prettier-plugin-css-order": "^2.1.2",
"prettier-plugin-svelte": "^3.4.0",
"rxjs": "^7.8.2",
"sass": "^1.86.0",
"sass": "^1.89.2",
"semver": "^7.7.2",
"socket.io-client": "^4.8.1",
"stylelint": "^16.17.0",
"stylelint": "^16.23.0",
"stylelint-config-clean-order": "^7.0.0",
"stylelint-config-html": "^1.1.0",
"stylelint-config-prettier-scss": "^1.0.0",
"stylelint-config-recommended-scss": "^14.1.0",
"stylelint-config-standard-scss": "^14.0.0",
"svelte": "5.25.3",
"svelte-check": "^4.1.5",
"stylelint-config-recommended-scss": "^15.0.1",
"stylelint-config-standard-scss": "^15.0.1",
"svelte": "5.37.1",
"svelte-check": "^4.3.0",
"svelte-preprocess": "^6.0.3",
"tippy.js": "^6.3.7",
"typesafe-i18n": "^5.26.2",
"typescript": "^5.8.2",
"vite": "^6.2.4",
"typescript": "^5.8.3",
"vite": "^7.0.6",
"vite-plugin-mkcert": "^1.17.8",
"vite-plugin-pwa": "^1.0.0",
"vitest": "^3.1.1",
"vite-plugin-pwa": "^1.0.2",
"vitest": "^3.2.4",
"web-serial-polyfill": "^1.0.15",
"workbox-window": "^7.3.0"
},

1464
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "app"
version = "2.3.0"
version = "2.6.0"
description = "A Tauri App"
authors = ["Thea Schöbl <dev@theaninova.de>"]
license = "AGPL-3"

View File

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

View File

@@ -6,7 +6,7 @@ const de = {
saveActions: {
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
REDO: "Wiederholen",
SAVE: "Speichern",
SAVE: "Anwended",
},
update: {
TITLE: "Gerät aktualisieren",
@@ -18,10 +18,10 @@ const de = {
},
backup: {
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
AUTO_BACKUP: "Beschleunigtes Verbinden",
DISCLAIMER:
"Das Backup in diesem Browser gespeichert und bleibt nur auf diesem Computer.",
DOWNLOAD: "Alles",
"<b>Nicht auf öffentlichen oder geteilten Computern einschalten.</b> Gerätedaten werden für schnelleren Zugriff lokal zwischengespeichert.",
DOWNLOAD: "Komplettes Profil",
RESTORE: "Wiederherstellen",
},
modal: {

View File

@@ -7,17 +7,17 @@ const en = {
saveActions: {
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo",
SAVE: "Save",
SAVE: "Apply",
},
update: {
TITLE: "Update your device",
},
backup: {
TITLE: "Backup",
AUTO_BACKUP: "Auto-backup",
AUTO_BACKUP: "Fast Connect",
DISCLAIMER:
"Whenever you connect this device to browser, a backup is made locally and kept only on your computer.",
DOWNLOAD: "Everything",
"<b>Turn off if using a shared or public computer.</b> Caches your device's data locally for quick access next time you connect.",
DOWNLOAD: "Full profile",
RESTORE: "Restore",
},
sync: {

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
onclick,
children,
working,
progress,
error,
disabled = false,
element = $bindable(),
...restProps
}: {
onclick: () => void;
children: Snippet;
working: boolean;
progress: number;
error?: string;
disabled?: boolean;
element?: HTMLButtonElement;
} & HTMLButtonAttributes = $props();
</script>
<button
class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!error}
class:error={!!error}
disabled={disabled || working}
bind:this={element}
{...restProps}
{onclick}>{@render children()}</button
>
<style lang="scss">
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button {
--height: 42px;
--border-radius: calc(var(--height) / 2);
position: relative;
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
border: 2px solid currentcolor;
border-radius: var(--border-radius);
background: var(--md-sys-color-background);
height: var(--height);
overflow: hidden;
&.primary {
background: none;
color: var(--md-sys-color-primary);
}
&.progress,
&.working {
border-color: transparent;
}
&.working::before {
position: absolute;
z-index: -1;
border-radius: calc(var(--border-radius) - 2px);
background: var(--md-sys-color-background);
width: calc(100% - 4px);
height: calc(100% - 4px);
content: "";
}
&.working::after {
position: absolute;
z-index: -2;
animation: rotate 1s ease-out forwards infinite;
background: var(--md-sys-color-primary);
width: 120%;
height: 30%;
content: "";
}
&.progress::after {
position: absolute;
left: 0;
opacity: 0.2;
z-index: -2;
background: var(--md-sys-color-primary);
width: var(--progress);
height: 100%;
content: "";
}
}
</style>

View File

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

View File

@@ -0,0 +1,10 @@
name: T4G
col:
- row:
- switch: { e: 3, n: 5, w: 4, s: 6 }
- offset: [0.5, 0]
row:
- key: 2
- row:
- key: 0
- key: 1

View File

@@ -14,7 +14,7 @@ import {
settings,
} from "$lib/undo-redo.js";
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 { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
@@ -50,11 +50,9 @@ export function createLayoutBackup(): CharaLayoutFile {
charaVersion: 1,
type: "layout",
device: get(serialPort)?.device,
layout: get(layout).map((it) => it.map((it) => it.action)) as [
number[],
number[],
number[],
],
layout: (get(layout)[get(activeProfile)]?.map((it) =>
it.map((it) => it.action),
) ?? []) as [number[], number[], number[]],
};
}
@@ -70,26 +68,30 @@ export function createSettingsBackup(): CharaSettingsFile {
return {
charaVersion: 1,
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];
if (!input) return;
const text = await input.text();
if (input.name.endsWith(".json")) {
restoreFromFile(JSON.parse(text));
restoreFromFile(JSON.parse(text), only);
} else if (isCsvLayout(text)) {
restoreFromFile(csvLayoutToJson(text));
restoreFromFile(csvLayoutToJson(text), only);
} else if (isCsvChords(text)) {
restoreFromFile(csvChordsToJson(text));
restoreFromFile(csvChordsToJson(text), only);
} else {
}
}
export function restoreFromFile(
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
only?: "chords" | "layout" | "settings",
) {
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
switch (file.type) {
@@ -97,9 +99,15 @@ export function restoreFromFile(
const recent = file.history[0];
if (!recent) return;
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;
if (currentDevice === "TWO") currentDevice = "ONE";
if (currentDevice === "TWO" || currentDevice === "M4G")
currentDevice = "ONE";
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
currentDevice = "X";
if (backupDevice !== currentDevice) {
alert("Backup is incompatible with this device");
@@ -108,33 +116,45 @@ export function restoreFromFile(
changes.update((changes) => {
changes.push([
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
...(!only || only === "chords"
? getChangesFromChordFile(recent[0])
: []),
...(!only || only === "layout"
? getChangesFromLayoutFile(recent[1])
: []),
...(!only || only === "settings"
? getChangesFromSettingsFile(recent[2])
: []),
]);
return changes;
});
break;
}
case "chords": {
changes.update((changes) => {
changes.push(getChangesFromChordFile(file));
return changes;
});
if (!only || only === "chords") {
changes.update((changes) => {
changes.push(getChangesFromChordFile(file));
return changes;
});
}
break;
}
case "layout": {
changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file));
return changes;
});
if (!only || only === "layout") {
changes.update((changes) => {
changes.push(getChangesFromLayoutFile(file));
return changes;
});
}
break;
}
case "settings": {
changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file));
return changes;
});
if (!only || only === "settings") {
changes.update((changes) => {
changes.push(getChangesFromSettingsFile(file));
return changes;
});
}
break;
}
default: {
@@ -167,12 +187,13 @@ export function getChangesFromChordFile(file: CharaChordFile) {
export function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = [];
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) {
changes.push({
type: ChangeType.Setting,
id,
setting: value,
profile: get(activeProfile),
});
}
}
@@ -183,12 +204,13 @@ export function getChangesFromLayoutFile(file: CharaLayoutFile) {
const changes: Change[] = [];
for (const [layer, keys] of file.layout.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({
type: ChangeType.Layout,
layer,
id,
action,
profile: get(activeProfile),
});
}
}

View File

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

View File

@@ -0,0 +1,37 @@
export interface CCOSInitEvent {
type: "init";
url: string;
}
export interface CCOSKeyPressEvent {
type: "press";
code: number;
}
export interface CCOSKeyReleaseEvent {
type: "release";
code: number;
}
export interface CCOSSerialEvent {
type: "serial";
data: Uint8Array;
}
export type CCOSInEvent =
| CCOSInitEvent
| CCOSKeyPressEvent
| CCOSKeyReleaseEvent
| CCOSSerialEvent;
export interface CCOSReportEvent {
type: "report";
modifiers: number;
keys: number[];
}
export interface CCOSReadyEvent {
type: "ready";
}
export type CCOSOutEvent = CCOSReportEvent | CCOSReadyEvent | CCOSSerialEvent;

View File

@@ -0,0 +1,111 @@
export const KEYCODE_TO_SCANCODE = new Map<string, number | undefined>(
Object.entries({
KeyA: 0x04,
KeyB: 0x05,
KeyC: 0x06,
KeyD: 0x07,
KeyE: 0x08,
KeyF: 0x09,
KeyG: 0x0a,
KeyH: 0x0b,
KeyI: 0x0c,
KeyJ: 0x0d,
KeyK: 0x0e,
KeyL: 0x0f,
KeyM: 0x10,
KeyN: 0x11,
KeyO: 0x12,
KeyP: 0x13,
KeyQ: 0x14,
KeyR: 0x15,
KeyS: 0x16,
KeyT: 0x17,
KeyU: 0x18,
KeyV: 0x19,
KeyW: 0x1a,
KeyX: 0x1b,
KeyY: 0x1c,
KeyZ: 0x1d,
Digit1: 0x1e,
Digit2: 0x1f,
Digit3: 0x20,
Digit4: 0x21,
Digit5: 0x22,
Digit6: 0x23,
Digit7: 0x24,
Digit8: 0x25,
Digit9: 0x26,
Digit0: 0x27,
Enter: 0x28,
Escape: 0x29,
Backspace: 0x2a,
Tab: 0x2b,
Space: 0x2c,
Minus: 0x2d,
Equal: 0x2e,
BracketLeft: 0x2f,
BracketRight: 0x30,
Backslash: 0x31,
Semicolon: 0x33,
Quote: 0x34,
Backquote: 0x35,
Comma: 0x36,
Period: 0x37,
Slash: 0x38,
CapsLock: 0x39,
F1: 0x3a,
F2: 0x3b,
F3: 0x3c,
F4: 0x3d,
F5: 0x3e,
F6: 0x3f,
F7: 0x40,
F8: 0x41,
F9: 0x42,
F10: 0x43,
F11: 0x44,
F12: 0x45,
PrintScreen: 0x46,
ScrollLock: 0x47,
Pause: 0x48,
Insert: 0x49,
Home: 0x4a,
PageUp: 0x4b,
Delete: 0x4c,
End: 0x4d,
PageDown: 0x4e,
ArrowRight: 0x4f,
ArrowLeft: 0x50,
ArrowDown: 0x51,
ArrowUp: 0x52,
NumLock: 0x53,
NumpadDivide: 0x54,
NumpadMultiply: 0x55,
NumpadSubtract: 0x56,
NumpadAdd: 0x57,
NumpadEnter: 0x58,
Numpad1: 0x59,
Numpad2: 0x5a,
Numpad3: 0x5b,
Numpad4: 0x5c,
Numpad5: 0x5d,
Numpad6: 0x5e,
Numpad7: 0x5f,
Numpad8: 0x60,
Numpad9: 0x61,
Numpad0: 0x62,
NumpadDecimal: 0x63,
ControlLeft: 0xe0,
ShiftLeft: 0xe1,
AltLeft: 0xe2,
MetaLeft: 0xe3,
ControlRight: 0xe4,
ShiftRight: 0xe5,
AltRight: 0xe6,
MetaRight: 0xe7,
}),
);
export const SCANCODE_TO_KEYCODE = new Map<number, string>(
KEYCODE_TO_SCANCODE.entries().map(([key, value]) => [value!, key]),
);

232
src/lib/ccos/ccos.ts Normal file
View File

@@ -0,0 +1,232 @@
import { getMeta } from "$lib/meta/meta-storage";
import type { SerialPortLike } from "$lib/serial/device";
import type {
CCOSInEvent,
CCOSInitEvent,
CCOSKeyPressEvent,
CCOSKeyReleaseEvent,
CCOSOutEvent,
} from "./ccos-events";
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
const device = "zero_wasm";
class CCOSKeyboardEvent extends KeyboardEvent {
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
super(...params);
}
}
const MASK_CTRL = 0b0001_0001;
const MASK_SHIFT = 0b0010_0010;
const MASK_ALT = 0b0100_0100;
const MASK_ALT_GRAPH = 0b0000_0100;
const MASK_GUI = 0b1000_1000;
export class CCOS implements SerialPortLike {
private readonly currKeys = new Set<number>();
private readonly layout = new Map<string, string>();
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
private resolveReady!: () => void;
private ready = new Promise<void>((resolve) => {
this.resolveReady = resolve;
});
private lastEvent?: KeyboardEvent;
private onKey(
type: ConstructorParameters<typeof KeyboardEvent>[0],
modifiers: number,
scanCode: number,
) {
if (!this.lastEvent) {
return;
}
const code = SCANCODE_TO_KEYCODE.get(scanCode);
if (code === undefined) {
return;
}
const layoutKey = [code];
if (modifiers & MASK_SHIFT) {
layoutKey.push("Shift");
}
if (modifiers & MASK_ALT_GRAPH) {
layoutKey.push("AltGraph");
}
const key = this.layout.get(JSON.stringify(layoutKey)) ?? code;
const params: Required<KeyboardEventInit> = {
bubbles: true,
cancelable: true,
location: this.lastEvent.location,
repeat: this.lastEvent.repeat,
detail: this.lastEvent.detail,
view: this.lastEvent.view,
isComposing: this.lastEvent.isComposing,
which: this.lastEvent.which,
composed: this.lastEvent.composed,
key,
code,
charCode: key.charCodeAt(0),
keyCode: this.lastEvent.keyCode,
shiftKey: (modifiers & MASK_SHIFT) !== 0,
ctrlKey: (modifiers & MASK_CTRL) !== 0,
metaKey: (modifiers & MASK_GUI) !== 0,
altKey: (modifiers & MASK_ALT) !== 0,
modifierAltGraph: (modifiers & MASK_ALT_GRAPH) !== 0,
modifierCapsLock: this.lastEvent.getModifierState("CapsLock"),
modifierFn: this.lastEvent.getModifierState("Fn"),
modifierFnLock: this.lastEvent.getModifierState("FnLock"),
modifierHyper: this.lastEvent.getModifierState("Hyper"),
modifierNumLock: this.lastEvent.getModifierState("NumLock"),
modifierSuper: (modifiers & MASK_GUI) !== 0,
modifierSymbol: this.lastEvent.getModifierState("Symbol"),
modifierSymbolLock: this.lastEvent.getModifierState("SymbolLock"),
modifierScrollLock: this.lastEvent.getModifierState("ScrollLock"),
};
this.lastEvent.target?.dispatchEvent(new CCOSKeyboardEvent(type, params));
}
private onReport(modifiers: number, keys: number[]) {
const nextKeys = new Set<number>(keys);
nextKeys.delete(0);
for (const key of this.currKeys) {
if (!nextKeys.has(key)) {
this.onKey("keyup", modifiers, key);
}
}
for (const key of nextKeys) {
if (!this.currKeys.has(key)) {
this.onKey("keydown", modifiers, key);
}
}
this.currKeys.clear();
for (const key of keys) {
this.currKeys.add(key);
}
this.currKeys.delete(0);
}
private controller?: ReadableStreamDefaultController<Uint8Array>;
readable!: ReadableStream<Uint8Array>;
writable!: WritableStream<Uint8Array>;
constructor(url: string) {
this.worker.addEventListener(
"message",
(event: MessageEvent<CCOSOutEvent>) => {
if (event.data instanceof Uint8Array) {
this.controller?.enqueue(event.data);
return;
}
console.log("CCOS worker message", event.data);
switch (event.data.type) {
case "ready": {
this.resolveReady();
break;
}
case "report": {
this.onReport(event.data.modifiers, event.data.keys);
break;
}
}
},
);
(navigator as any).keyboard
?.getLayoutMap()
?.then((it: Map<string, string>) =>
it.entries().forEach(([key, value]) => {
this.layout.set(JSON.stringify([key]), value);
}),
);
this.worker.postMessage({
type: "init",
url,
} satisfies CCOSInitEvent);
}
getInfo(): SerialPortInfo {
return {};
}
async open(_options: SerialOptions) {
this.readable = new ReadableStream<Uint8Array>({
start: (controller) => {
this.controller = controller;
},
});
this.writable = new WritableStream<Uint8Array>({
write: (chunk) => {
this.worker.postMessage(chunk, [chunk.buffer]);
},
});
return this.ready;
}
async close() {
await this.ready;
}
async forget() {
await this.ready;
this.close();
this.worker.terminate();
}
async handleKeyEvent(event: KeyboardEvent) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
console.error("CCOS does not support input elements");
return;
}
if (!this.ready || event instanceof CCOSKeyboardEvent) {
return;
}
event.stopImmediatePropagation();
event.preventDefault();
this.lastEvent = event;
const layoutKey = [event.code];
if (event.getModifierState("Shift")) {
layoutKey.push("Shift");
}
if (event.getModifierState("AltGraph")) {
layoutKey.push("AltGraph");
}
this.layout.set(JSON.stringify(layoutKey), event.key);
const scanCode = KEYCODE_TO_SCANCODE.get(event.code);
if (scanCode === undefined) return;
if (event.type === "keydown") {
this.worker.postMessage({
type: "press",
code: scanCode,
} satisfies CCOSKeyPressEvent);
} else {
this.worker.postMessage({
type: "release",
code: scanCode,
} satisfies CCOSKeyReleaseEvent);
}
}
}
export async function fetchCCOS(
version = ".2.2.0-beta.12+266bdda",
fetch: typeof window.fetch = window.fetch,
): Promise<CCOS | undefined> {
const meta = await getMeta(device, version, fetch);
if (!meta?.update.js || !meta?.update.wasm) {
return undefined;
}
return new CCOS(`${meta.path}/${meta.update.js}`);
}

View File

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

View File

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

View File

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

View File

@@ -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) {
return (
this.shiny?.includes(index) ||

View File

@@ -37,16 +37,16 @@
<style lang="scss">
.avatar {
flex-shrink: 0;
border-radius: 50%;
width: 32px;
height: 32px;
flex-shrink: 0;
}
.avatar-placeholder {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
font-size: 24px;
}
@@ -61,8 +61,8 @@
flex-direction: column;
gap: 0.5rem;
padding: 8px;
overflow-y: auto;
height: 100%;
overflow-y: auto;
}
span {

View File

@@ -53,17 +53,17 @@
.room {
display: flex;
align-items: center;
justify-content: flex-start;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding-block: 2px;
min-height: 0;
height: unset;
padding-inline: 16px;
padding-block: 4px;
border-radius: 8px;
padding-inline: 16px;
padding-block: 2px;
padding-block: 4px;
width: 100%;
height: unset;
min-height: 0;
&.active {
background: var(--md-sys-color-primary-container);

View File

@@ -178,17 +178,17 @@
$border-radius: 16px;
.input {
border: 1px solid var(--md-sys-color-outline);
flex-grow: 1;
cursor: text;
border: 1px solid var(--md-sys-color-outline);
border-radius: $border-radius;
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;
overflow-wrap: break-word;
&:focus-visible {
outline: none;
@@ -197,9 +197,9 @@
.input-box {
display: flex;
flex-shrink: 0;
gap: 4px;
padding-block: 8px;
flex-shrink: 0;
width: 100%;
}
@@ -209,23 +209,23 @@
}
.timeline {
contain: content;
height: auto;
display: flex;
flex-direction: column-reverse;
overflow-y: scroll;
overflow-x: hidden;
flex-grow: 1;
flex-direction: column-reverse;
contain: content;
width: 100%;
height: auto;
overflow-x: hidden;
overflow-y: scroll;
}
section {
display: flex;
flex-direction: column;
overflow: hidden;
justify-content: flex-end;
width: 100%;
height: 100%;
overflow: hidden;
}
</style>

View File

@@ -255,21 +255,21 @@
}
.toolbar {
display: flex;
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;
border-radius: 4px;
background: var(--md-sys-color-secondary-container);
padding: 4px;
color: var(--md-sys-color-on-secondary-container);
a,
button {
font-size: 16px;
width: 24px;
height: 24px;
font-size: 16px;
}
}
@@ -289,10 +289,10 @@
}
.dot {
animation: bounce 1s infinite;
border-radius: 50%;
width: 6px;
height: 6px;
border-radius: 50%;
animation: bounce 1s infinite;
}
.sender,
@@ -302,10 +302,10 @@
.avatar {
grid-area: avatar;
translate: 0 2px;
border-radius: 50%;
width: 32px;
height: 32px;
border-radius: 50%;
translate: 0 2px;
}
div.avatar {
@@ -322,18 +322,18 @@
}
.reactions {
grid-area: reactions;
margin-top: 2px;
display: flex;
grid-area: reactions;
gap: 4px;
margin-top: 2px;
}
.reaction {
border: 1px solid var(--md-sys-color-outline);
padding: 6px;
border-radius: 6px;
height: 24px;
display: flex;
border: 1px solid var(--md-sys-color-outline);
border-radius: 6px;
padding: 6px;
height: 24px;
font-size: 12px;
> .count {
@@ -344,16 +344,16 @@
.event {
display: grid;
position: relative;
padding-inline: 0.5em;
margin-inline: 0.5em;
padding-block: 0.25em;
border-radius: 4px;
grid-template-columns: 32px 1fr auto;
grid-template-areas:
"avatar sender date"
"avatar content content"
"none reactions reactions";
grid-template-columns: 32px 1fr auto;
margin-inline: 0.5em;
border-radius: 4px;
padding-inline: 0.5em;
padding-block: 0.25em;
}
.content {
@@ -370,12 +370,12 @@
.backdrop {
position: absolute;
inset: 0;
z-index: -1;
opacity: 0.25;
background: var(--md-sys-color-surface-variant);
z-index: -1;
inset: 0;
border-radius: 8px;
background: var(--md-sys-color-surface-variant);
}
</style>

View File

@@ -39,9 +39,9 @@
}
img {
border-radius: 8px;
max-width: 100%;
max-height: 16em;
border-radius: 8px;
}
.content {

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { actionTooltip } from "$lib/title";
let {
onchange,
value,
variant,
}: {
value: boolean;
variant: "start" | "end";
onchange: (
event: Event & { currentTarget: EventTarget & HTMLInputElement },
) => void;
} = $props();
</script>
{#snippet tooltip()}
{#if value}
{#if variant === "start"}
<b>Remove</b> preceding space
{:else}
<b>Add</b> trailing space
{/if}
{:else if variant === "start"}
<b>Keep</b> preceding space
{:else}
<b>Add</b> trailing space
{/if}
{/snippet}
<label class="autospace" {@attach actionTooltip(tooltip)}
><span class="icon">space_bar</span><input
checked={!value}
{onchange}
type="checkbox"
/></label
>
<style lang="scss">
label.autospace {
display: inline-flex;
vertical-align: middle;
margin-inline: 8px;
border-radius: 4px;
background: var(--md-sys-color-tertiary-container);
padding-inline: 0;
height: 1em;
color: var(--md-sys-color-on-tertiary-container);
font-size: 1.3em;
&:has(:checked) {
opacity: var(--auto-space-show, 0);
}
}
</style>

View File

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

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

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

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

View 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 =
"&emsp;⇛" + (this.hasConcatenator ? "" : "&emsp;");
this.element.style.scale = "1.8";
this.element.style.color =
"color-mix(in srgb, currentColor 50%, transparent)";
if (this.hasConcatenator) {
const button = document.createElement("button");
button.className = classNames["concatenator-button"]!;
this.component = mount(Action, {
target: button,
props: { action: 574, display: "keys", inText: true, ghost: true },
});
this.element.appendChild(button);
}
}
return this.element;
}
override ignoreEvent() {
return false;
}
override destroy() {
if (this.component) {
unmount(this.component);
}
}
}
function getJoinNode(
view: EditorView,
phraseDelimNode: SyntaxNodeRef,
): SyntaxNodeRef | null | undefined {
const firstPhraseAction = phraseDelimNode.node.nextSibling
?.getChild("ActionString")
?.node.firstChild?.node.getChild("ExplicitAction");
const idNode = firstPhraseAction?.node.getChild("ActionId");
const actionId = idNode
? view.state.doc.sliceString(idNode.from, idNode.to)
: null;
const isJoinAction =
actionId === "JOIN" &&
!!firstPhraseAction!.node.getChild("ExplicitDelimEnd");
return isJoinAction ? firstPhraseAction : null;
}
function actionWidgets(view: EditorView) {
const widgets: Range<Decoration>[] = [];
for (const { from, to } of view.visibleRanges) {
syntaxTree(view.state).iterate({
from,
to,
enter: (node) => {
if (node.name !== "PhraseDelim") return;
const joinNode = getJoinNode(view, node);
let deco = Decoration.replace({
widget: new DelimWidget(!joinNode),
});
widgets.push(deco.range(node.from, node.to));
},
});
}
return Decoration.set(widgets);
}
export const delimPlugin = ViewPlugin.fromClass(
class {
decorations = Decoration.none;
constructor(view: EditorView) {
this.decorations = actionWidgets(view);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) != syntaxTree(update.state)
)
this.decorations = actionWidgets(update.view);
}
},
{
decorations(instance) {
return instance.decorations;
},
provide(plugin) {
return EditorView.atomicRanges.of(
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
);
},
eventHandlers: {
click: (event, view) => {
if (!(event.target instanceof HTMLElement)) return;
if (
!(
event.target instanceof HTMLButtonElement ||
(event.target as HTMLElement).parentElement instanceof
HTMLButtonElement
)
)
return;
const chordNode = syntaxTree(view.state).resolve(
view.posAtDOM(event.target),
);
const delimNode = (
chordNode.name === "ActionString"
? chordNode.parent?.parent
: chordNode
)?.getChild("PhraseDelim");
if (!delimNode) return;
const joinNode = getJoinNode(view, delimNode);
if (!event.target.checked && !joinNode) {
view.dispatch({
changes: {
from: delimNode.to,
insert: "<JOIN>",
},
selection: { anchor: delimNode.to + "<JOIN>".length },
});
}
},
},
},
);

View File

@@ -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 }),
]);
}

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

View File

@@ -0,0 +1,13 @@
.concatenator-button {
display: inline;
opacity: calc(var(--auto-space-show, 0) * 0.7);
margin: 0;
padding: 4px;
height: auto;
> :global(kbd) {
outline: 1px dashed var(--md-sys-color-outline);
outline-offset: -1px;
background: none;
}
}

3
src/lib/chord-editor/grammar.d.ts vendored Normal file
View File

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

View File

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

View File

@@ -1,94 +1,226 @@
<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 { action as title } from "$lib/title";
import { osLayout } from "$lib/os-layout";
import { isVerbose } from "./verbose-action";
import { actionTooltip } from "$lib/title";
let {
action,
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"
? ($KEYMAP_CODES.get(action) ?? { code: action })
: action,
? $KEYMAP_CODES.get(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 tooltip = $derived(
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(info.title ?? "") +
(info.variant === "left"
? " (left)"
: info.variant === "right"
? " (right)"
: ""),
let hasPopover = $derived(
!retrievedInfo || !info.id || info.title || info.description,
);
</script>
{#if display === "keys"}
{#snippet popover()}
{#if retrievedInfo}
{#if info.icon || info.display || !info.id}
&lt;<b>{info.id ?? `0x${info.code.toString(16)}`}</b>&gt;
{/if}
{#if info.title}
{info.title}
{/if}
{#if info.variant === "left"}
(Left)
{:else if info.variant === "right"}
(Right)
{/if}
{#if info.description}
<br />
<small>{info.description}</small>
{/if}
{: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
class:in-text={inText}
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
use:title={{ title: tooltip }}
class:error={info.code > 1023}
class:warn={!retrievedInfo}
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{@render kbdText()}
</kbd>
{:else if display === "inline-keys"}
{/snippet}
{#snippet inlineKbdSnippet()}
{#if !info.icon && dynamicMapping?.length === 1}
<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:right={info.variant === "right"}>{dynamicMapping}</span
>
{:else if !info.icon && info.id?.length === 1}
<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:right={info.variant === "right"}>{info.id}</span
>
{:else}
<kbd
class="inline-kbd"
class:in-text={inText}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
use:title={{ title: tooltip }}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}</kbd
class:warn={!retrievedInfo}
class:error={info.code > 1023}
{@attach hasPopover ? actionTooltip(popover) : null}
>
{@render kbdText()}
</kbd>
{/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}
<style lang="scss">
kbd:not(.inline-kbd) {
height: 24px;
padding-block: auto;
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 {
border-left-width: 3px;
padding-inline-end: $variant-padding;
text-shadow: $variant-offset 0 2px $variant-color;
}
.right {
border-right-width: 3px;
padding-inline-start: $variant-padding;
text-shadow: -$variant-offset 0 2px $variant-color;
}
.inline-kbd {
margin-inline-end: 2px;
&.in-text.icon {
translate: 0 -4em;
}
}
:global(span) + .inline-kbd {
margin-inline-start: 2px;
}
div[popover] {
width: fit-content;
max-width: 200px;
height: fit-content;
text-align: left;
text-wrap: break-word;
small {
opacity: 0.8;
font-size: 0.9em;
}
}
.verbose {
display: flex;
align-items: center;
gap: 8px;
margin-inline: 2px;
min-width: 160px;
height: 32px;
kbd {
justify-content: flex-start;
}
.verbose-title {
display: -webkit-box;
opacity: 0.9;
max-width: 15ch;
overflow: hidden;
font-style: italic;
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>

View File

@@ -48,33 +48,33 @@
<style lang="scss">
button {
display: flex;
gap: 4px;
align-items: center;
gap: 4px;
margin: 0;
border-radius: 8px;
padding: 8px;
width: 100%;
height: auto;
margin: 0;
padding: 8px;
font-family: "Noto Sans Mono", monospace;
border-radius: 8px;
@media not (forced-colors: active) {
color: inherit;
background: transparent;
border: none;
background: transparent;
color: inherit;
&:focus-visible {
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
outline: none;
background: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
}
}
@media (forced-colors: active) {
border: 1px solid ButtonBorder;
margin-block: 4px;
border: 1px solid ButtonBorder;
&:hover {
color: ActiveText;
@@ -86,8 +86,8 @@
display: flex;
flex: 1;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
align-items: flex-start;
text-align: start;
}

View File

@@ -29,15 +29,15 @@
<style lang="scss">
.digits {
position: relative;
display: inline-block;
position: relative;
transition: width 500ms ease;
}
.digit-wrapper {
display: inline-grid;
height: 1em;
width: 1ch;
height: 1em;
}
.digit {

View File

@@ -0,0 +1,367 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { onMount, tick } from "svelte";
import { scale } from "svelte/transition";
import ActionString from "$lib/components/ActionString.svelte";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
import { action } from "$lib/title";
import semverGte from "semver/functions/gte";
import { inputToAction } from "../../routes/(app)/config/chords/input-converter";
import { selectAction } from "../../routes/(app)/config/chords/action-selector";
interface InteractiveProps {
interactive: true;
ondeleteaction: (at: number, count?: number) => void;
oninsertaction: (at: number, action: number) => void;
}
interface NonInteractiveProps {
interactive: false;
ondeleteaction?: never;
oninsertaction?: never;
}
let {
phrase,
edited,
interactive,
oninsertaction,
ondeleteaction,
}: { phrase: number[]; edited: boolean } & (
| NonInteractiveProps
| InteractiveProps
) = $props();
const JOIN_ACTION = 574;
const NO_CONCATENATOR_ACTION = 256;
onMount(() => {
if (interactive && phrase.length === 0) {
box?.focus();
}
});
function keypress(event: KeyboardEvent) {
if (!event.shiftKey && event.key === "ArrowUp") {
addSpecial(event);
} else if (!event.shiftKey && event.key === "ArrowLeft") {
moveCursor(cursorPosition - 1);
} else if (!event.shiftKey && event.key === "ArrowRight") {
moveCursor(cursorPosition + 1);
} else if (event.key === "Backspace") {
if (interactive) {
ondeleteaction!(cursorPosition - 1);
}
moveCursor(cursorPosition - 1);
} else if (event.key === "Delete") {
if (interactive) {
ondeleteaction!(cursorPosition);
}
} else {
if (event.key === "Shift") return;
const action = inputToAction(event, get(serialPort)?.device === "X");
if (action !== undefined) {
oninsertaction!(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1));
}
}
}
function moveCursor(to: number) {
if (!box) return;
cursorPosition = Math.max(0, Math.min(to, phrase.length));
const item = box.children.item(cursorPosition) as HTMLElement;
cursorOffset = item.offsetLeft + item.offsetWidth;
}
function clickCursor(event: MouseEvent) {
if (box === undefined || event.target === button) return;
const distance = (event as unknown as { layerX: number }).layerX;
let i = 0;
for (const child of box.children) {
const { offsetLeft, offsetWidth } = child as HTMLElement;
if (distance < offsetLeft + offsetWidth / 2) {
moveCursor(i - 1);
return;
}
i++;
}
moveCursor(i - 1);
}
function addSpecial(event: MouseEvent | KeyboardEvent) {
selectAction(
event,
(action) => {
oninsertaction!(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1));
},
() => box?.focus(),
);
}
function resolveAutospace(autospace: boolean) {
if (autospace) {
if (phrase.at(-1) === JOIN_ACTION) {
if (
phrase.every(
(action, i, arr) =>
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
)
) {
ondeleteaction!(phrase.length - 1);
} else {
return;
}
} else {
if (isPrintable) {
return;
} else if (phrase.at(-1) === NO_CONCATENATOR_ACTION) {
ondeleteaction!(phrase.length - 1);
} else {
oninsertaction!(phrase.length, JOIN_ACTION);
}
}
} else {
if (phrase.at(-1) === JOIN_ACTION) {
ondeleteaction!(phrase.length - 1);
} else {
if (phrase.at(-1) === NO_CONCATENATOR_ACTION) {
if (
phrase.every(
(action, i, arr) =>
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
)
) {
return;
} else {
ondeleteaction!(phrase.length - 1);
}
} else {
oninsertaction!(phrase.length, NO_CONCATENATOR_ACTION);
}
}
}
}
let button: HTMLButtonElement | undefined = $state();
let box: HTMLDivElement | undefined = $state();
let cursorPosition = 0;
let cursorOffset = $state(0);
let hasFocus = $state(false);
let isPrintable = $derived(
phrase.every((action) => $KEYMAP_CODES.get(action)?.printable === true),
);
let supportsAutospace = $derived(
semverGte($deviceMeta?.version ?? "0.0.0", "2.1.0"),
);
let hasAutospace = $derived(isPrintable || phrase.at(-1) === JOIN_ACTION);
let displayPhrase = $derived(
phrase.filter(
(it, i, arr) =>
!(
(i === 0 && it === JOIN_ACTION) ||
(i === arr.length - 1 &&
(it === JOIN_ACTION || it === NO_CONCATENATOR_ACTION))
),
),
);
</script>
<div
class="wrapper"
class:edited
onclick={interactive
? () => {
box.focus();
}
: undefined}
>
{#if supportsAutospace}
<label
class="auto-space-edit"
use:action={{ title: "Remove previous concatenator" }}
><span class="icon">join_inner</span><input
checked={phrase[0] === JOIN_ACTION}
disabled={!interactive}
onchange={interactive
? (event) => {
const autospace = hasAutospace;
if ((event.target as HTMLInputElement).checked) {
if (phrase[0] !== JOIN_ACTION) {
oninsertaction!(0, JOIN_ACTION);
}
} else {
if (phrase[0] === JOIN_ACTION) {
ondeleteaction!(0, 1);
}
}
tick().then(() => resolveAutospace(autospace));
}
: undefined}
type="checkbox"
/></label
>
{/if}
<div
onkeydown={interactive ? keypress : undefined}
onmousedown={interactive ? clickCursor : undefined}
role="textbox"
tabindex="0"
bind:this={box}
onfocusin={interactive ? () => (hasFocus = true) : undefined}
onfocusout={interactive
? (event) => {
if (event.relatedTarget !== button) hasFocus = false;
}
: undefined}
>
{#if hasFocus}
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} onclick={addSpecial}>add</button
>
</div>
{:else}
<div></div>
<!-- placeholder for cursor placement -->
{/if}
<ActionString actions={displayPhrase} />
</div>
{#if supportsAutospace}
<label class="auto-space-edit" use:action={{ title: "Add concatenator" }}
><span class="icon">space_bar</span><input
checked={hasAutospace}
disabled={!interactive}
onchange={interactive
? (event) =>
resolveAutospace((event.target as HTMLInputElement).checked)
: undefined}
type="checkbox"
/></label
>
{/if}
<sup></sup>
</div>
<style lang="scss">
sup {
translate: 0 -40%;
opacity: 0;
transition: opacity 250ms ease;
}
.cursor {
position: absolute;
transform: translateX(-50%);
translate: 0 0;
transition: translate 50ms ease;
background: var(--md-sys-color-on-secondary-container);
width: 2px;
height: 100%;
button {
position: absolute;
top: -24px;
left: 0;
border: 2px solid currentcolor;
border-radius: 12px 12px 12px 0;
background: var(--md-sys-color-secondary-container);
padding: 0;
height: 24px;
color: var(--md-sys-color-on-secondary-container);
}
}
.edited {
color: var(--md-sys-color-primary);
sup {
opacity: 1;
}
}
.auto-space-edit {
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;
&:first-of-type:not(:has(:checked)),
&:last-of-type:has(:checked) {
opacity: 0;
}
}
.wrapper:hover .auto-space-edit {
opacity: 1;
}
.wrapper {
display: flex;
position: relative;
align-items: center;
padding-block: 4px;
height: 1em;
&::after,
&::before {
position: absolute;
bottom: -4px;
opacity: 0;
transition:
opacity 150ms ease,
scale 250ms ease;
background: currentcolor;
width: calc(100% - 8px);
height: 1px;
content: "";
}
&::after {
scale: 0 1;
transition-duration: 250ms;
}
&:hover::before {
opacity: 0.3;
}
&:has(> :focus-within)::after {
scale: 1;
opacity: 1;
}
}
[role="textbox"] {
display: flex;
position: relative;
align-items: center;
cursor: text;
white-space: pre;
&:focus-within {
outline: none;
}
}
</style>

View File

@@ -16,8 +16,8 @@
<style lang="scss">
button {
cursor: pointer;
color: var(--md-sys-color-on-background);
background: transparent;
border: none;
background: transparent;
color: var(--md-sys-color-on-background);
}
</style>

View File

@@ -1,7 +1,12 @@
<script lang="ts">
import { serialLog, serialPort } from "$lib/serial/connection";
import { onMount } from "svelte";
import { slide } from "svelte/transition";
onMount(() => {
io.scrollTo({ top: io.scrollHeight });
});
function submit(event: Event) {
event.preventDefault();
$serialPort?.send(0, value.trim());
@@ -33,63 +38,61 @@
<style lang="scss">
form {
display: flex;
position: relative;
flex-direction: column;
contain: strict;
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 16px;
width: 100%;
height: 100%;
overflow: hidden;
color: var(--md-sys-color-on-secondary);
font-size: 0.75rem;
font-family: "Noto Sans Mono", monospace;
font-size: 0.75rem;
color: var(--md-sys-color-on-secondary);
border-radius: 16px;
}
fieldset::before {
content: "$";
position: absolute;
bottom: 8px;
left: 8px;
content: "$";
font-weight: 900;
}
input {
width: 100%;
appearance: none;
margin-block-start: -16px;
border: none;
background: var(--md-sys-color-secondary);
padding: 8px;
padding-block-start: 24px;
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-weight: 600;
color: var(--md-sys-color-on-secondary);
appearance: none;
background: var(--md-sys-color-secondary);
border: none;
}
.io {
--scrollbar-color: var(--md-sys-color-secondary);
flex: 1;
z-index: 1;
border-radius: 0 0 16px 16px;
overflow-y: auto;
flex: 1;
background: var(--md-sys-color-secondary-container);
padding: 12px;
color: var(--md-sys-color-on-secondary-container);
overflow-y: auto;
background: var(--md-sys-color-secondary-container);
border-radius: 0 0 16px 16px;
color: var(--md-sys-color-on-secondary-container);
}
:focus-visible {
@@ -99,10 +102,10 @@
fieldset {
all: unset;
position: relative;
display: block;
position: relative;
opacity: 0.8;
transition: opacity 250ms ease;
@@ -113,16 +116,16 @@
}
.anchor {
overflow-anchor: auto;
height: 1px;
overflow-anchor: auto;
}
code,
samp,
p {
display: block;
overflow-anchor: none;
margin-block: 0.15rem;
overflow-anchor: none;
}
p {
@@ -130,24 +133,24 @@
justify-content: center;
margin-block-end: 1rem;
border-radius: 8px;
background: var(--md-sys-color-secondary);
padding: 0.25rem;
color: var(--md-sys-color-on-secondary);
background: var(--md-sys-color-secondary);
border-radius: 8px;
}
code::before {
content: "> ";
margin-block-end: 0.25rem;
font-weight: 900;
content: "> ";
color: var(--md-sys-color-primary);
font-weight: 900;
}
::selection {
color: var(--md-sys-color-background);
background: var(--md-sys-color-on-background);
color: var(--md-sys-color-background);
}
@keyframes blink {

View File

@@ -1,9 +1,14 @@
<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>
{#if title}
{#if typeof title === "string"}
<p>{@html title}</p>
{:else}
{@render title?.()}
{/if}
{#if shortcut}
@@ -20,8 +25,8 @@
:global(kbd.icon) {
display: inline-flex;
font-size: inherit;
translate: 0 0.2em;
font-size: inherit;
}
}
</style>

View File

@@ -0,0 +1,387 @@
<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 { action } 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();
});
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"
{autofocus}
bind:this={searchBox}
oninput={search}
onkeypress={keyboardNavigation}
placeholder={$LL.actionSearch.PLACEHOLDER()}
/>
{#if onclose}
<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
>
{/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="true"
ondragstart={(event) => {
if (!event.dataTransfer) return;
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
event.dataTransfer.clearData();
event.dataTransfer.setData(
"text/plain",
actionToValue(action.code),
);
}}
>
<Action {action} display="verbose"></Action>
</button>
{/each}
</ul>
</div>
{/if}
{/each}
</ul>
</div>
<style lang="scss">
.filters {
display: flex;
gap: 4px;
border: none;
label {
border: 1px solid currentcolor;
border-radius: 6px;
padding-inline: 4px;
padding-block: 2px;
height: unset;
font-size: 14px;
&:has(:checked) {
background: var(--md-sys-color-secondary);
color: var(--md-sys-color-on-secondary);
}
input {
display: none;
}
}
}
.action-item {
cursor: grab;
margin: 0;
padding: 0;
height: auto;
font: inherit;
}
dialog {
display: flex;
justify-content: center;
align-items: center;
border: none;
background: rgba(0 0 0 / 60%);
width: 100%;
height: 100%;
}
aside {
opacity: 0.4;
margin: 8px;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
pointer-events: none;
> h3 {
margin-inline-start: 16px;
margin-block-start: -13px;
margin-block-end: 0;
background: var(--md-sys-color-background);
padding-inline: 8px;
width: fit-content;
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
display: flex;
align-items: center;
gap: 4px;
margin-inline: 16px;
}
.content {
display: flex;
position: relative;
flex-direction: column;
transform-origin: top left;
border-radius: 16px;
background: var(--md-sys-color-background);
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
overflow: hidden;
color: var(--md-sys-color-on-background);
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
transition: all 250ms ease;
margin-block-end: 8px;
border: none;
border-bottom: 1px solid var(--md-sys-color-surface-variant);
background: none;
padding-inline: 16px;
width: 100%;
height: 64px;
color: currentcolor;
font-size: 16px;
font-family: inherit;
&:focus {
outline: none;
border-bottom: 1px solid var(--md-sys-color-primary);
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
box-sizing: border-box;
margin: 0;
padding: 0;
padding-inline: 4px;
height: 100%;
overflow-y: auto;
scrollbar-gutter: both-edges stable;
}
.category {
.description {
opacity: 0.8;
margin-block-start: -16px;
font-style: italic;
font-size: 14px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-block: 24px;
overflow: hidden;
}
}
li {
display: contents;
}
.exact {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
width: 100%;
> i {
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 0 0 8px 8px;
background: var(--md-sys-color-primary);
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
}
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -1,16 +1,5 @@
<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 { action } from "$lib/title";
import { get } from "svelte/store";
import ActionList from "./ActionList.svelte";
let {
currentAction = undefined,
@@ -23,330 +12,34 @@
onselect: (id: number) => void;
onclose: () => void;
} = $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>
<svelte:window on:keydown={keyboardNavigation} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<dialog
open
onclick={(event) => {
if (event.target === event.currentTarget) onclose();
}}
>
<div class="content">
<div class="search-row">
<input
type="search"
bind:this={searchBox}
oninput={search}
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>
<ActionList
autofocus={true}
{currentAction}
{nextAction}
{onselect}
{onclose}
/>
</dialog>
<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 {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
border: none;
background: rgba(0 0 0 / 60%);
border: none;
}
aside {
pointer-events: none;
margin: 8px;
opacity: 0.4;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 8px;
> h3 {
width: fit-content;
margin-block-start: -13px;
margin-block-end: 0;
margin-inline-start: 16px;
padding-inline: 8px;
background: var(--md-sys-color-background);
}
@media (prefers-contrast: more) {
opacity: 0.8;
}
@media (forced-colors: active) {
opacity: 1;
color: GrayText;
}
}
.search-row {
display: flex;
gap: 4px;
align-items: center;
margin-inline: 16px;
}
.content {
position: relative;
transform-origin: top left;
overflow: hidden;
display: flex;
flex-direction: column;
width: calc(min(30cm, 90%));
height: calc(min(100% - 128px, 90%));
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
border-radius: 16px;
@media (forced-colors: active) {
border: 1px solid CanvasText;
}
}
input[type="search"] {
width: 100%;
height: 64px;
margin-block-end: 8px;
padding-inline: 16px;
font-family: inherit;
font-size: 16px;
color: currentcolor;
background: none;
border: none;
border-bottom: 1px solid var(--md-sys-color-surface-variant);
transition: all 250ms ease;
&:focus {
border-bottom: 1px solid var(--md-sys-color-primary);
outline: none;
}
}
ul {
--scrollbar-color: var(--md-sys-color-surface-variant);
scrollbar-gutter: both-edges stable;
overflow-y: auto;
box-sizing: border-box;
height: 100%;
margin: 0;
padding: 0;
padding-inline: 4px;
}
li {
display: contents;
}
.exact {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
margin-block-start: 8px;
border: 1px solid var(--md-sys-color-primary);
border-radius: 8px;
> i {
display: flex;
gap: 4px;
align-items: center;
justify-content: center;
padding-inline: 6px;
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
border-radius: 0 0 8px 8px;
}
@media (forced-colors: active) {
background: Mark;
}
}
</style>

View File

@@ -8,17 +8,16 @@
import { dev } from "$app/environment";
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { get } from "svelte/store";
import type { Writable } from "svelte/store";
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
import { getContext, mount, unmount } from "svelte";
import type { VisualLayoutConfig } from "./visual-layout.js";
import { changes, ChangeType, layout } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { expoOut } from "svelte/easing";
import { activeLayer, activeProfile } from "$lib/serial/connection";
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
if (dev) {
// you have absolutely no idea what a difference this makes for performance
@@ -125,8 +124,10 @@
const keyInfo = layoutInfo.keys[index];
if (!keyInfo) return;
const clickedGroup = groupParent.children.item(index) as SVGGElement;
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
const nextAction =
get(layout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const currentAction =
get(deviceLayout)[get(activeProfile)]![get(activeLayer)]?.[keyInfo.id];
const component = mount(ActionSelector, {
target: document.body,
props: {
@@ -142,6 +143,7 @@
type: ChangeType.Layout,
id: keyInfo.id,
layer: get(activeLayer),
profile: get(activeProfile),
action,
},
]);
@@ -217,9 +219,9 @@
<style lang="scss">
svg {
overflow: visible;
grid-area: "d";
width: calc(min(100%, 35cm));
max-height: calc(100% - 170px);
overflow: visible;
}
</style>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
@@ -7,10 +6,11 @@
import { osLayout } from "$lib/os-layout.js";
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { action } from "$lib/title";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import { getContext } from "svelte";
const { fontSize, margin, inactiveOpacity, inactiveScale, iconFontSize } =
getContext<VisualLayoutConfig>("visual-layout-config");
const activeLayer = getContext<Writable<number>>("active-layer");
const currentAction = getContext<Writable<Set<number>> | undefined>(
"highlight-action",
);
@@ -33,7 +33,9 @@
</script>
{#each positions as position, layer}
{@const { action: actionId, isApplied } = $layout[layer]?.[key.id] ?? {
{@const { action: actionId, isApplied } = $layout[$activeProfile]?.[layer]?.[
key.id
] ?? {
action: 0,
isApplied: true,
}}
@@ -67,7 +69,7 @@
style:rotate="{rotate}deg"
use:action={{ title: tooltip }}
>
{#if code !== 0}
{#if code !== 0 && code != 1023}
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
{/if}
{#if !isApplied}
@@ -81,15 +83,15 @@
$transition: 200ms;
text {
will-change: translate, scale;
user-select: none;
transform-origin: center;
transform-box: fill-box;
transform-origin: center;
transition:
fill #{$focus-transition} ease,
opacity #{$transition} ease,
translate #{$transition} ease,
scale #{$transition} ease;
will-change: translate, scale;
user-select: none;
@media (prefers-contrast: more) {
--inactive-opacity: 0.8;

View File

@@ -103,6 +103,7 @@
[-rotY, -rotX],
[-rotX, -rotY],
[rotX, rotY],
[rotY, rotX],
]}
/>
{/if}
@@ -113,14 +114,14 @@
$transition: 200ms;
rect {
transform-origin: center;
transform-box: fill-box;
transform-origin: center;
}
path,
g {
transform-origin: top left;
transform-box: fill-box;
transform-origin: top left;
}
path,
@@ -138,15 +139,15 @@
g.faded,
g:hover {
cursor: default;
opacity: 0.6;
transition: opacity #{$transition} ease;
cursor: default;
}
g.highlight,
g:focus-within {
color: var(--md-sys-color-primary);
outline: none;
color: var(--md-sys-color-primary);
> path,
> rect {

View File

@@ -2,21 +2,11 @@
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { action } from "$lib/title";
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import { activeProfile, activeLayer } from "$lib/serial/connection";
import type { VisualLayout } from "$lib/serialization/visual-layout";
import { fade, fly } from "svelte/transition";
import { restoreFromFile } from "$lib/backup/backup";
let device = $derived($serialPort?.device);
const activeLayer = getContext<Writable<number>>("active-layer");
const layers = [
["Numeric Layer", "123", 1],
["Primary Layer", "abc", 0],
["Function Layer", "function", 2],
] as const;
const layouts = {
ONE: () =>
import("$lib/assets/layouts/one.yml").then(
@@ -34,6 +24,10 @@
import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout,
),
ZERO: () =>
import("$lib/assets/layouts/generic/103-key.yml").then(
(it) => it.default as VisualLayout,
),
M4G: () =>
import("$lib/assets/layouts/m4g.yml").then(
(it) => it.default as VisualLayout,
@@ -42,23 +36,33 @@
import("$lib/assets/layouts/m4gr.yml").then(
(it) => it.default as VisualLayout,
),
T4G: () =>
import("$lib/assets/layouts/t4g.yml").then(
(it) => it.default as VisualLayout,
),
};
</script>
<div class="container">
{#if device}
{#await layouts[device]() then visualLayout}
{#if $serialPort}
{#await layouts[$serialPort.device]() then visualLayout}
<fieldset transition:fade>
{#each layers as [title, icon, value]}
<button
class="icon"
use:action={{ title, shortcut: `alt+${value + 1}` }}
onclick={() => ($activeLayer = value)}
class:active={$activeLayer === value}
>
{icon}
</button>
{/each}
<div class="layers">
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
<label>
<input
type="radio"
onclick={() => ($activeLayer = layer)}
name="layer"
value={layer}
checked={$activeLayer === layer}
/>
{String.fromCodePoint(
"A".codePointAt(0)! + $activeProfile,
)}{layer + 1}
</label>
{/each}
</div>
{#if $deviceMeta?.factoryDefaults?.layout}
<button
use:action={{ title: "Reset Layout" }}
@@ -80,8 +84,8 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
@@ -89,71 +93,23 @@
}
fieldset {
position: relative;
display: flex;
align-items: center;
position: relative;
justify-content: center;
align-items: center;
border: none;
padding: 8px;
border: none;
}
button.icon {
cursor: pointer;
.layers {
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
gap: 2px;
font-size: 24px;
color: var(--md-sys-color-on-surface-variant);
background: var(--md-sys-color-surface-variant);
border: none;
transition: all 250ms ease;
&:nth-child(2) {
z-index: 2;
aspect-ratio: 1;
font-size: 32px;
border-radius: 50%;
}
&:first-child,
&: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);
}
margin-inline: auto;
}
</style>

View File

@@ -0,0 +1,9 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
export function isVerbose(info: KeyInfo) {
return (
info.id?.length !== 1 &&
info.title &&
(!info.id || /F\d{1,2}/.test(info.id) === false)
);
}

View File

@@ -83,9 +83,9 @@
<style lang="scss">
h1 {
color: var(--md-sys-color-error);
font-size: 2em;
text-align: center;
color: var(--md-sys-color-error);
}
.buttons {
@@ -100,7 +100,7 @@
a {
display: inline;
color: var(--md-sys-color-primary);
padding: 0;
color: var(--md-sys-color-primary);
}
</style>

View File

@@ -16,15 +16,15 @@
<style lang="scss">
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;
max-width: 512px;
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 {

View File

@@ -1,33 +1,33 @@
@font-face {
font-family: "Material Symbols Rounded";
font-weight: 100 700;
font-style: normal;
font-weight: 100 700;
src: url("$lib/assets/icons.min.woff2") format("woff2");
font-family: "Material Symbols Rounded";
}
.icon {
user-select: none;
direction: ltr;
display: inline-block;
font-style: normal;
font-weight: normal;
font-size: 24px;
line-height: 1;
/* stylelint-disable-next-line */
font-family: "Material Symbols Rounded";
font-size: 24px;
font-feature-settings: "liga";
font-variation-settings:
"FILL" var(--icon-fill, 0),
"wght" var(--icon-weigth, 400),
"GRAD" var(--icon-grade, 0);
font-weight: normal;
font-style: normal;
line-height: 1;
text-transform: none;
font-feature-settings: "liga";
letter-spacing: normal;
direction: ltr;
user-select: none;
text-transform: none;
word-wrap: normal;
white-space: nowrap;
transition: font-variation-settings 250ms ease;
white-space: nowrap;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -66,13 +66,13 @@
/* noto-sans-mono-latin-ext-wght-normal */
@font-face {
font-family: "Noto Sans Mono Variable";
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-weight: 100 900;
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-ext-wght-normal.woff2")
format("woff2-variations");
font-family: "Noto Sans Mono Variable";
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,
@@ -81,13 +81,13 @@
/* noto-sans-mono-latin-wght-normal */
@font-face {
font-family: "Noto Sans Mono";
font-weight: 100 900;
font-style: normal;
font-display: swap;
font-weight: 100 900;
font-stretch: 62.5% 100%;
src: url("@fontsource-variable/noto-sans-mono/files/noto-sans-mono-latin-wght-normal.woff2")
format("woff2-variations");
font-family: "Noto Sans Mono";
font-display: swap;
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,

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

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

View File

@@ -17,7 +17,7 @@ export async function getMeta(
try {
if (!browser) return fetchMeta(device, version, fetch);
const dbRequest = indexedDB.open("version-meta", 4);
const dbRequest = indexedDB.open("version-meta", 6);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
dbRequest.onsuccess = () => resolve(dbRequest.result);
dbRequest.onerror = () => reject(dbRequest.error);
@@ -130,6 +130,9 @@ async function fetchMeta(
async (load) => load().then((it) => (it as any).default),
),
)),
recipes: await (meta?.recipes
? fetch(`${path}/${meta.recipes}`).then((it) => it.json())
: undefined),
update: {
uf2:
meta?.update?.uf2 ??
@@ -144,6 +147,10 @@ async function fetchMeta(
)?.name ??
undefined,
esptool: meta?.update?.esptool ?? undefined,
js: meta?.update?.js ?? undefined,
wasm: meta?.update?.wasm ?? undefined,
dll: meta?.update?.dll ?? undefined,
so: meta?.update?.so ?? undefined,
},
spiFlash: meta?.spi_flash ?? undefined,
};

View File

@@ -13,6 +13,7 @@ export interface SettingsMeta {
export interface SettingsItemMeta {
id: number;
name?: string;
description?: string;
enum?: string[];
range: [number, number];
@@ -43,6 +44,7 @@ export interface RawVersionMeta {
actions: string;
settings: string;
changelog: string;
recipes: string;
factory_defaults: {
layout: string;
settings: string;
@@ -52,11 +54,47 @@ export interface RawVersionMeta {
ota: string | null;
uf2: string | null;
esptool: EspToolData | null;
js: string | null;
wasm: string | null;
dll: string | null;
so: string | null;
};
files: string[];
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;
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 {
version: string;
device: string;
@@ -69,6 +107,7 @@ export interface VersionMeta {
actions: KeymapCategory[];
settings: SettingsMeta[];
changelog: Changelog;
recipes?: E2eTest[];
factoryDefaults?: {
layout: CharaLayoutFile;
settings: CharaSettingsFile;
@@ -78,6 +117,10 @@ export interface VersionMeta {
ota?: string;
uf2?: string;
esptool?: EspToolData;
js?: string;
wasm?: string;
dll?: string;
so?: string;
};
spiFlash?: SPIFlashInfo;
}

View File

@@ -11,6 +11,7 @@ async function updateLayout() {
layout.size !== currentLayout.size ||
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
) {
console.log(layout);
osLayout.set(layout);
}
}

View File

@@ -69,5 +69,8 @@ export function hashChord(actions: number[]) {
for (let i = 0; i < 16; i++) {
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
}
if ((hash & 0xff) === 0xff) {
hash ^= 0xff;
}
return hash & 0x3fff_ffff;
}

View File

@@ -1,5 +1,5 @@
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 { Writable } from "svelte/store";
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>();
navigator.serial?.addEventListener("disconnect", async (event) => {
serialPort.set(undefined);
});
export interface SerialLogEntry {
type: "input" | "output" | "system";
value: string;
@@ -29,21 +33,24 @@ export const deviceChords = persistentWritable<Chord[]>(
/**
* Layout as read from the device
*/
export const deviceLayout = persistentWritable<CharaLayout>(
"layout",
[[], [], []],
export const deviceLayout = persistentWritable<CharaLayout[]>(
"layout-profiles",
[],
() => get(userPreferences).backup,
);
/**
* Settings as read from the device
*/
export const deviceSettings = persistentWritable<number[]>(
"device-settings",
export const deviceSettings = persistentWritable<number[][]>(
"settings-profiles",
[],
() => get(userPreferences).backup,
);
export const activeProfile = persistentWritable<number>("active-profile", 0);
export const activeLayer = persistentWritable<number>("active-profile", 0);
export const syncStatus: Writable<
"done" | "error" | "downloading" | "uploading"
> = writable("done");
@@ -56,9 +63,13 @@ export interface ProgressInfo {
}
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
export async function initSerial(manual = false, withSync = true) {
const device = get(serialPort) ?? new CharaDevice();
await device.init(manual);
export async function initSerial(port: SerialPortLike, withSync: boolean) {
const prev = get(serialPort);
try {
prev?.close();
} catch {}
const device = new CharaDevice(port);
await device.init();
serialPort.set(device);
if (withSync) {
await sync();
@@ -80,30 +91,49 @@ export async function sync() {
.map((it) => it.items.length)
.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;
activeProfile.update((it) => Math.min(it, device.profileCount - 1));
activeLayer.update((it) => Math.min(it, device.layerCount - 1));
syncProgress.set({ max, current });
function progressTick() {
current++;
syncProgress.set({ max, current });
}
const parsedSettings: number[] = [];
for (const category of meta.settings) {
for (const setting of category.items) {
try {
parsedSettings[setting.id] = await device.getSetting(setting.id);
} catch {}
const parsedSettings: number[][] = Array.from(
{ length: device.profileCount },
() => [],
);
for (const [profile, settings] of parsedSettings.entries()) {
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);
const parsedLayout: CharaLayout = [[], [], []];
for (let layer = 1; layer <= 3; layer++) {
for (let i = 0; i < device.keyCount; i++) {
parsedLayout[layer - 1]![i] = await device.getLayoutKey(layer, i);
progressTick();
const parsedLayout: CharaLayout[] = Array.from(
{ length: device.profileCount },
() =>
Array.from({ length: device.layerCount }, () =>
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);

View File

@@ -1,7 +1,6 @@
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
import { serialLog } from "$lib/serial/connection";
import type { Chord } from "$lib/serial/chord";
import { SemVer } from "$lib/serial/sem-ver";
import {
parseChordActions,
parsePhrase,
@@ -10,8 +9,9 @@ import {
} from "$lib/serial/chord";
import { browser } from "$app/environment";
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 }],
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
@@ -20,15 +20,55 @@ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
["X", { usbProductId: 0x818b, usbVendorId: 0x303a }],
["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 = {
ONE: 90,
TWO: 90,
LITE: 67,
X: 256,
ENGINE: 256,
M4G: 90,
M4GR: 90,
T4G: 7,
ZERO: 256,
} as const;
if (
@@ -86,8 +126,12 @@ async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
]).finally(() => clearTimeout(timer));
}
export type SerialPortLike = Pick<
SerialPort,
"readable" | "writable" | "open" | "close" | "getInfo" | "forget"
>;
export class CharaDevice {
private port!: SerialPort;
private reader!: ReadableStreamDefaultReader<string>;
private readonly abortController1 = new AbortController();
@@ -100,28 +144,25 @@ export class CharaDevice {
private readonly suspendDebounce = 100;
private suspendDebounceId?: number;
version!: SemVer;
version!: string;
company!: "CHARACHORDER" | "FORGE";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
chipset!: "M0" | "S2" | "S3";
keyCount!: 90 | 67 | 256;
layerCount = 3;
profileCount = 1;
get portInfo() {
return this.port.getInfo();
}
constructor(private readonly baudRate = 115200) {}
constructor(
private readonly port: SerialPortLike,
public baudRate = 115200,
) {}
async init(manual = false) {
async init() {
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 });
const info = this.port.getInfo();
serialLog.update((it) => {
@@ -135,13 +176,19 @@ export class CharaDevice {
});
await this.port.close();
this.version = new SemVer(
await this.send(1, ["VERSION"]).then(([version]) => version),
this.version = await this.send(1, ["VERSION"]).then(
([version]) => version,
);
const [company, device, chipset] = await this.send(3, ["ID"]);
this.company = company as typeof this.company;
this.device = device as typeof this.device;
this.chipset = chipset as typeof this.chipset;
if (semverGte(this.version, "2.2.0-beta.4")) {
this.profileCount = this.chipset === "M0" ? 2 : 3;
}
if (semverGte(this.version, "2.2.0-beta.20")) {
this.layerCount = this.chipset === "M0" ? 3 : 4;
}
this.keyCount = KEY_COUNTS[this.device];
} catch (e) {
console.error(e);
@@ -235,6 +282,10 @@ export class CharaDevice {
await this.port.forget();
}
async close() {
await this.port.close();
}
/**
* Read/write to serial port
*/
@@ -291,23 +342,24 @@ export class CharaDevice {
.join(" ")
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
const readResult = await read(timeout);
if (readResult === undefined) {
return readResult
?.replace(new RegExp(`^${commandString} `), "")
.split(" ");
}).then((it) => {
if (it === undefined) {
console.error("No response");
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
string,
T
>;
}
const array = readResult
.replace(new RegExp(`^${commandString} `), "")
.split(" ");
if (array.length < expectedLength) {
if (it.length < expectedLength) {
console.error("Response too short");
return array.concat(
Array(expectedLength - array.length).fill("TOO_SHORT"),
return it.concat(
Array(expectedLength - it.length).fill("TOO_SHORT"),
) as LengthArray<string, T>;
}
return array as LengthArray<string, T>;
return it as LengthArray<string, T>;
});
}
@@ -350,7 +402,7 @@ export class CharaDevice {
stringifyChordActions(chord.actions),
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">) {
@@ -369,11 +421,16 @@ export class CharaDevice {
* @param id id of the key, refer to the individual device for where each key is
* @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, [
"VAR",
"B4",
`A${layer}`,
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
id.toString(),
action.toString(),
]);
@@ -386,11 +443,11 @@ export class CharaDevice {
* @param id id of the key, refer to the individual device for where each key is
* @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, [
"VAR",
"B3",
`A${layer}`,
`${String.fromCodePoint("A".codePointAt(0)! + profile)}${layer}`,
id.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
@@ -415,11 +472,11 @@ export class CharaDevice {
* Settings are applied until the next reboot or loss of power.
* 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, [
"VAR",
"B2",
id.toString(16).toUpperCase(),
(id + profile * 0x100).toString(16).toUpperCase(),
value.toString(),
]);
if (status !== "0") throw new Error(`Failed with status ${status}`);
@@ -428,11 +485,11 @@ export class CharaDevice {
/**
* 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, [
"VAR",
"B1",
id.toString(16).toUpperCase(),
(id + profile * 0x100).toString(16).toUpperCase(),
]);
if (status !== "0")
throw new Error(

View File

@@ -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}` : "")
);
}
}

View File

@@ -29,5 +29,10 @@ export async function fromBase64(
.replaceAll(".", "+")
.replaceAll("_", "/")
.replaceAll("-", "=")}`,
).then((it) => it.blob());
)
.then((it) => {
console.log(it);
return it;
})
.then((it) => it.blob());
}

View File

@@ -4,13 +4,10 @@ import { fromBase64, toBase64 } from "$lib/serialization/base64";
export interface NewCharaLayout {
charaLayoutVersion: 1;
device: "one" | "lite" | string;
/**
* Layers A1-A3, with numeric action codes on each
*/
layers: [number[], number[], number[]];
layers: number[][];
}
export type CharaLayout = [number[], number[], number[]];
export type CharaLayout = number[][];
/**
* Serialize a layout into a micro package

View File

@@ -1,5 +1,9 @@
import type { Action } from "svelte/action";
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
@@ -58,22 +62,22 @@ function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
switch (i % 6) {
case 0:
(r = v), (g = t), (b = p);
((r = v), (g = t), (b = p));
break;
case 1:
(r = q), (g = v), (b = p);
((r = q), (g = v), (b = p));
break;
case 2:
(r = p), (g = v), (b = t);
((r = p), (g = v), (b = t));
break;
case 3:
(r = p), (g = q), (b = v);
((r = p), (g = q), (b = v));
break;
case 4:
(r = t), (g = p), (b = v);
((r = t), (g = p), (b = v));
break;
case 5:
(r = v), (g = p), (b = q);
((r = v), (g = p), (b = q));
break;
}
@@ -103,37 +107,42 @@ export const setting: Action<
? Number(node.getAttribute("max"))
: undefined;
const unsubscribe = settings.subscribe(async (settings) => {
if (id in settings) {
const { value, isApplied } = settings[id]!;
if (isNumeric) {
node.value = (
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("")}`;
const subscription = combineLatest([
fromReadable(settings),
fromReadable(activeProfile),
])
.pipe(map(([settings, profile]) => settings[profile]!))
.subscribe(async (settings) => {
if (id in settings) {
const { value, isApplied } = settings[id]!;
if (isNumeric) {
node.value = (
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 {
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() {
let value: number;
@@ -160,6 +169,7 @@ export const setting: Action<
type: ChangeType.Setting,
id: id + i,
setting: value,
profile: get(activeProfile),
})),
);
return changes;
@@ -175,6 +185,7 @@ export const setting: Action<
type: ChangeType.Setting,
id: id,
setting: value,
profile: get(activeProfile),
},
]);
return changes;
@@ -186,7 +197,7 @@ export const setting: Action<
return {
destroy() {
node.removeEventListener("change", listener);
unsubscribe();
subscription.unsubscribe();
},
};
};

View File

@@ -1,18 +1,23 @@
kbd {
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
align-items: center;
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;
font-size: 14px;
font-weight: normal;
height: 20px;
color: currentcolor;
font-weight: normal;
border: 1px solid currentcolor;
border-radius: 4px;
font-size: 14px;
&.icon {
padding: 2px;
@@ -21,8 +26,8 @@ kbd {
&:has(> kbd) {
gap: 4px;
padding: 0;
border: none;
padding: 0;
}
> kbd {

View File

@@ -1,4 +1,4 @@
* {
box-sizing: border-box;
appearance: none;
box-sizing: border-box;
}

View File

@@ -1,6 +1,6 @@
h1 {
margin-block-start: 0;
font-size: 4rem;
font-weight: 700;
color: var(--md-sys-color-secondary);
font-weight: 700;
font-size: 4rem;
}

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

View File

@@ -5,31 +5,30 @@ a {
a,
label:has(input),
button {
cursor: pointer;
display: flex;
gap: 4px;
align-items: 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;
height: 48px;
padding-block: 8px;
padding-inline: 16px;
font-weight: 600;
font-family: inherit;
font-weight: 600;
border-radius: 32px;
transition: all 250ms ease;
@media not (forced-colors: active) {
color: currentcolor;
background: transparent;
border: none;
background: transparent;
color: currentcolor;
&.primary {
color: var(--md-sys-color-on-primary);
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
}
@@ -40,17 +39,17 @@ button {
&.icon {
display: inline-flex;
border-radius: 50%;
padding-inline: 0;
padding-block: 0;
aspect-ratio: 1;
padding-block: 0;
padding-inline: 0;
font-size: 24px;
border-radius: 50%;
@media (forced-colors: active) {
padding: 2px;
margin: 2px;
padding: 2px;
}
}
@@ -88,8 +87,8 @@ button {
}
&.active,
&:active {
color: SelectedItemText;
background: SelectedItem;
color: SelectedItemText;
}
}
}

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

View File

@@ -3,57 +3,55 @@ $border: 2px;
$height: 1.5em;
label:has(input[type="checkbox"]) {
cursor: pointer;
user-select: none;
display: flex;
gap: $padding;
align-items: center;
justify-content: center;
align-items: center;
gap: $padding;
cursor: pointer;
font-size: 12px;
user-select: none;
input[type="checkbox"] {
$width: calc($height * (5 / 3));
$diameter: calc($height - ((2 * $padding) + (2 * $border)));
$radius: calc($diameter / 2);
cursor: pointer;
display: flex;
position: relative;
overflow: hidden;
display: flex;
cursor: pointer;
outline: $border solid currentcolor;
outline-offset: calc(-1 * $border);
border-radius: calc($height / 2);
width: $width;
height: $height;
font-size: inherit;
overflow: hidden;
color: inherit;
border-radius: calc($height / 2);
outline: $border solid currentcolor;
outline-offset: calc(-1 * $border);
font-size: inherit;
&::after {
content: "";
display: block;
position: absolute;
top: 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-style: solid;
outline-width: $radius;
outline-offset: calc(-1 * $radius);
border-radius: calc($radius);
transition: all 250ms ease;
width: $diameter;
height: $diameter;
content: "";
}
&:checked::after {
@@ -62,4 +60,83 @@ label:has(input[type="checkbox"]) {
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%);
}
}
}
}

View File

@@ -1,14 +1,14 @@
@media not (forced-colors: active) {
::-webkit-scrollbar {
width: 8px;
background: transparent;
border-radius: 4px;
background: transparent;
width: 8px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-color, white);
border-radius: 4px;
transition: all 250ms ease;
border-radius: 4px;
background: var(--scrollbar-color, white);
&:hover {
filter: brightness(120%);

View File

@@ -2,34 +2,35 @@
@use "form/button";
@use "form/toggle";
@use "form/checkbox";
@use "form/radio";
@use "kbd";
@use "print";
@use "elements/h1";
@use "elements/popover";
body {
overflow: hidden;
display: flex;
flex-direction: column;
margin: 0;
background: var(--md-sys-color-background);
width: 100vw;
height: 100vh;
margin: 0;
font-family: "Noto Sans Mono", monospace;
overflow: hidden;
color: var(--md-sys-color-on-background);
background: var(--md-sys-color-background);
font-family: "Noto Sans Mono", monospace;
}
main {
contain: strict;
display: flex;
flex-direction: column;
flex-grow: 1;
flex-direction: column;
align-items: center;
contain: strict;
padding-inline: 16px;
}

View File

@@ -1,10 +1,10 @@
$padding: 16px;
.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);
border-radius: calc(24px + $padding);
background-color: var(--md-sys-color-surface-variant);
color: var(--md-sys-color-on-surface-variant);
.tippy-content {
padding: $padding;
@@ -24,10 +24,10 @@ $padding: 16px;
}
@media (forced-colors: active) {
color: CanvasText;
background-color: Canvas;
filter: none;
border: 1px solid CanvasText;
background-color: Canvas;
color: CanvasText;
> .tippy-arrow {
display: none;
@@ -36,16 +36,16 @@ $padding: 16px;
}
.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-radius: 8px;
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
}
.tippy-box[data-theme~="search-completion"] {
overflow: hidden;
filter: none;
border-radius: 0 0 16px 16px;
overflow: hidden;
.tippy-content {
padding: 0;

View File

@@ -1,10 +1,14 @@
import type { Action } from "svelte/action";
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 type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>();
/**
* @deprecated Use `tooltip` instead.
*/
export const action: Action<Element, { title?: string; shortcut?: string }> = (
node: Element,
{ title, shortcut },
@@ -41,3 +45,40 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
},
};
};
export function actionTooltip(
title: string | Snippet,
shortcut?: string,
): Attachment<Element> {
return (node: Element) => {
let component: {} | undefined;
const tooltip = tippy(node, {
arrow: false,
theme: "tooltip",
animation: "fade",
onShow(instance) {
component ??= mount(Tooltip, {
target: instance.popper.querySelector(".tippy-content") as Element,
props: { shortcut, title },
});
},
onHidden() {
if (component) {
unmount(component);
component = undefined;
}
},
});
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
return () => {
tooltip.destroy();
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
};
};
}

View File

@@ -19,6 +19,7 @@ export interface LayoutChange {
id: number;
layer: number;
action: number;
profile?: number;
}
export interface ChordChange {
@@ -33,6 +34,7 @@ export interface SettingChange {
type: ChangeType.Setting;
id: number;
setting: number;
profile?: number;
}
export interface ChangeInfo {
@@ -45,23 +47,29 @@ export type Change = LayoutChange | ChordChange | SettingChange;
export const changes = persistentWritable<Change[][]>("changes", []);
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 }>;
settings: Map<number, number>;
settings: Array<Map<number, number> | undefined>;
}
export const overlay = derived(changes, (changes) => {
const overlay: Overlay = {
layout: [new Map(), new Map(), new Map()],
layout: [],
chords: new Map(),
settings: new Map(),
settings: [],
};
for (const changeset of changes) {
for (const change of changeset) {
switch (change.type) {
case ChangeType.Layout:
overlay.layout[change.layer]?.set(change.id, change.action);
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;
case ChangeType.Chord:
overlay.chords.set(JSON.stringify(change.id), {
@@ -71,7 +79,9 @@ export const overlay = derived(changes, (changes) => {
});
break;
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;
}
}
@@ -82,21 +92,25 @@ export const overlay = derived(changes, (changes) => {
export const settings = derived(
[overlay, deviceSettings],
([overlay, settings]) =>
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
value: overlay.settings.get(id) ?? value,
isApplied: !overlay.settings.has(id),
})),
([overlay, profiles]) =>
profiles.map((settings, profile) =>
settings.map<{ value: number } & ChangeInfo>((value, id) => ({
value: overlay.settings[profile]?.get(id) ?? value,
isApplied: !overlay.settings[profile]?.has(id),
})),
),
);
export type KeyInfo = { action: number } & ChangeInfo;
export const layout = derived([overlay, deviceLayout], ([overlay, layout]) =>
layout.map(
(actions, layer) =>
actions.map<KeyInfo>((action, id) => ({
action: overlay.layout[layer]?.get(id) ?? action,
isApplied: !overlay.layout[layer]?.has(id),
})) as [KeyInfo, KeyInfo, KeyInfo],
export const layout = derived([overlay, deviceLayout], ([overlay, profiles]) =>
profiles.map((layout, profile) =>
layout.map(
(actions, layer) =>
actions.map<KeyInfo>((action, id) => ({
action: overlay.layout[profile]?.[layer]?.get(id) ?? action,
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(
chords,
(chords) =>

View File

@@ -0,0 +1,10 @@
import { Observable } from "rxjs";
import type { Readable } from "svelte/store";
export function fromReadable<T>(store: Readable<T>): Observable<T> {
return new Observable((subscriber) =>
store.subscribe((value) => {
subscriber.next(value);
}),
);
}

View File

@@ -11,7 +11,7 @@
argbFromHex,
themeFromSourceColor,
} from "@material/material-color-utilities";
import { canAutoConnect } from "$lib/serial/device";
import { canAutoConnect, getViablePorts } from "$lib/serial/device";
import { initSerial } from "$lib/serial/connection";
import type { LayoutData } from "./$types";
import { browser } from "$app/environment";
@@ -52,9 +52,21 @@
onMount(async () => {
theme.subscribe((it) => {
const theme = themeFromSourceColor(argbFromHex(it.color));
const theme = themeFromSourceColor(argbFromHex(it.color), [
{
name: "success",
value: argbFromHex("#4CAF50"),
blend: true,
},
]);
const dark = it.mode === "dark"; // window.matchMedia("(prefers-color-scheme: dark)").matches
applyTheme(theme, { target: document.body, dark });
for (const custom of theme.customColors) {
document.body.style.setProperty(
`--md-sys-color-${custom.color.name}`,
`#${custom.value.toString(16).padStart(8, "0").substring(2)}`,
);
}
});
if (import.meta.env.TAURI_FAMILY === undefined) {
@@ -63,7 +75,8 @@
}
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
await initSerial();
const [port] = await getViablePorts();
await initSerial(port!, true);
}
if (data.importFile) {
@@ -131,14 +144,13 @@
<style lang="scss">
.layout {
width: 100vw;
height: 100vh;
display: grid;
grid-template-rows: 1fr;
grid-template-columns: auto 1fr;
grid-template-areas:
"sidebar main"
"sidebar footer";
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
width: 100vw;
height: 100vh;
}
</style>

View File

@@ -26,17 +26,17 @@
dialog {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
border: none;
background: var(--md-sys-color-error);
width: 100vw;
height: 100vh;
color: var(--md-sys-color-on-error);
background: var(--md-sys-color-error);
border: none;
> * {
max-width: 20cm;
}
@@ -54,8 +54,8 @@
div > p {
display: flex;
gap: 8px;
align-items: center;
gap: 8px;
list-style: none;
}

View File

@@ -0,0 +1,235 @@
<script lang="ts">
import LL from "$i18n/i18n-svelte";
import { preference, userPreferences } from "$lib/preferences";
import { initSerial } from "$lib/serial/connection";
import {
getPortName,
PORT_FILTERS,
type SerialPortLike,
} from "$lib/serial/device";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { onMount } from "svelte";
import { persistentWritable } from "$lib/storage";
let ports = $state<SerialPort[]>([]);
let element: HTMLDivElement | undefined = $state();
onMount(() => {
refreshPorts();
});
let hasDiscoveredAutoConnect = persistentWritable(
"hasDiscoveredAutoConnect",
false,
);
$effect(() => {
if ($userPreferences.backup || $userPreferences.autoConnect) {
$hasDiscoveredAutoConnect = true;
}
});
async function refreshPorts() {
ports = await navigator.serial.getPorts();
}
async function connect(port: SerialPortLike, withSync: boolean) {
try {
await initSerial(port, withSync);
} catch (error) {
console.error(error);
await showConnectionFailedDialog(String(error));
}
}
function closePopover() {
element?.closest<HTMLElement>("[popover]")?.hidePopover();
}
async function connectCC0(event: MouseEvent) {
const { fetchCCOS } = await import("$lib/ccos/ccos");
closePopover();
const ccos = await fetchCCOS();
if (ccos) {
connect(ccos, !event.shiftKey);
}
}
async function connectDevice(event: MouseEvent) {
const port = await navigator.serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
});
if (!port) return;
closePopover();
refreshPorts();
connect(port, true);
}
</script>
<div
bind:this={element}
class="device-list"
onmouseenter={() => refreshPorts()}
role="region"
>
{#if ports.length === 1}
<fieldset class:promote={!$hasDiscoveredAutoConnect}>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
</label>
</fieldset>
{/if}
{#if ports.length !== 0}
<h4>Recent Devices</h4>
<div class="devices">
<div class="device">
<button onclick={connectCC0}> CC0</button>
</div>
{#each ports as port}
<div class="device">
<button
onclick={(event) => {
connect(port, !event.shiftKey);
}}
>
{getPortName(port)}</button
>
<button
class="error"
onclick={() => {
port.forget();
refreshPorts();
}}><span class="icon">visibility_off</span> Hide</button
>
</div>
{/each}
</div>
{/if}
<div class="pair">
<button onclick={connectDevice} class="primary"
><span class="icon">add</span>Connect</button
>
<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>
</div>
</div>
<style lang="scss">
button,
a {
padding: 10px;
padding-inline-end: 16px;
height: 38px;
font-size: 12px;
.icon {
font-size: 18px;
}
}
h4 {
margin-block-start: 16px;
margin-block-end: 8px;
font-weight: 600;
}
.device-list {
margin: 8px;
}
.pair {
display: flex;
}
.devices {
margin-bottom: 16px;
}
.device {
display: flex;
align-items: center;
gap: 8px;
button {
flex: 1;
justify-content: flex-start;
font-size: 14px;
}
}
button.error {
color: var(--md-sys-color-error);
}
label {
display: flex;
position: relative;
flex-wrap: wrap;
justify-content: flex-start;
align-items: flex-start;
gap: 8px;
appearance: none;
padding: 0;
height: auto;
overflow: hidden;
.title {
font-weight: 600;
}
}
@keyframes attention {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(0.6);
}
}
@keyframes swoosh {
0% {
transform: translateX(-200%) skewX(-20deg);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateX(200%) skewX(-20deg);
opacity: 0;
}
}
.promote {
label:not(:has(input:checked)) {
animation: attention 1s ease;
&::after {
position: absolute;
z-index: -1;
animation: swoosh 1s ease forwards;
background-color: var(--md-sys-color-surface-variant);
width: 25%;
height: 200%;
content: "";
}
}
}
fieldset {
display: flex;
gap: 16px;
border: none;
padding: 0;
}
</style>

View File

@@ -7,16 +7,14 @@
import { detectLocale, locales } from "$i18n/i18n-util";
import { loadLocaleAsync } from "$i18n/i18n-util.async";
import { tick } from "svelte";
import SyncOverlay from "./SyncOverlay.svelte";
import {
initSerial,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import ConnectPopup from "./ConnectPopup.svelte";
let locale = $state(
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
@@ -48,20 +46,11 @@
}
}
async function connect() {
try {
await initSerial(true);
} catch (error) {
console.error(error);
await showConnectionFailedDialog(String(error));
}
}
function disconnect(event: MouseEvent) {
if (event.shiftKey) {
sync();
} else {
$serialPort?.forget();
$serialPort?.close();
$serialPort = undefined;
}
}
@@ -88,11 +77,22 @@
</a>
</li>
</ul>
<div class="sync-box">
<div
class="sync-box"
class:primary={!$serialPort}
class:attention={$syncStatus !== "done"}
>
{#if !$serialPort}
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
<button
class="no-connection"
id="connect-button"
popovertarget="connect-popup"
transition:slide={{ axis: "x" }}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
>
<div popover id="connect-popup">
<ConnectPopup />
</div>
{:else}
<button
transition:slide={{ axis: "x" }}
@@ -108,7 +108,7 @@
>
{/if}
{#if $syncStatus !== "done"}
{#if $syncStatus === "downloading"}
<progress
transition:fade
max={$syncProgress?.max ?? 1}
@@ -173,27 +173,55 @@
</footer>
<style lang="scss">
@keyframes attention {
0%,
100% {
filter: brightness(0.5);
}
50% {
filter: brightness(1);
}
}
$sync-border-radius: 16px;
.sync-box {
display: flex;
align-items: center;
justify-content: center;
position: relative;
justify-content: center;
align-items: center;
translate: 0;
transition: all 250ms ease;
border-radius: 24px;
overflow: hidden;
button {
text-wrap: nowrap;
}
&.primary {
translate: 0 -32px;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
&.attention {
animation: attention 2s infinite;
border-radius: $sync-border-radius;
color: var(--md-sys-color-primary);
}
}
progress {
$inset: 8px;
position: absolute;
opacity: 0.3;
z-index: -1;
bottom: 0;
left: 16px;
right: 16px;
inset: $inset;
border-radius: #{$sync-border-radius - $inset};
width: calc(100% - $inset * 2);
height: calc(100% - $inset * 2);
overflow: hidden;
width: calc(100% - 32px);
height: 8px;
border-radius: 4px;
}
progress::-webkit-progress-bar {
@@ -205,32 +233,32 @@
}
.warning {
color: var(--md-sys-color-error);
gap: 8px;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
gap: 8px;
color: var(--md-sys-color-error);
}
input[type="color"] {
cursor: pointer;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
cursor: pointer;
margin: 0;
border: none;
border-radius: 50%;
background: transparent;
padding: 0;
inline-size: 20px;
block-size: 20px;
margin: 0;
padding: 0;
overflow: hidden;
color: inherit;
background: transparent;
border: none;
border-radius: 50%;
&::-webkit-color-swatch-wrapper {
padding: 0;
}
@@ -242,16 +270,15 @@
footer {
display: grid;
align-items: center;
justify-content: center;
grid-template-columns: 1fr auto 1fr;
justify-content: center;
align-items: center;
width: 100%;
padding: 8px;
padding-inline-end: 16px;
padding-block-start: 0;
opacity: 0.4;
width: 100%;
@media (prefers-contrast: more) {
opacity: 0.8;
@@ -264,8 +291,8 @@
ul {
display: flex;
gap: 8px;
align-items: center;
gap: 8px;
margin: 0;
padding: 0;
@@ -288,13 +315,13 @@
a {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
padding-inline: 12px;
font-size: 12px;
text-decoration: none;
padding-inline: 12px;
}
.icon {

View File

@@ -1,16 +1,20 @@
<script lang="ts">
import { page } from "$app/stores";
import { deviceMeta } from "$lib/serial/connection";
const routes = [
let routes = $derived([
[
{
href: "/config/settings/",
icon: "cable",
title: "Device",
icon: "tune",
title: "Settings",
primary: true,
},
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
...($deviceMeta?.recipes
? [{ href: "/recipes", icon: "skillet", title: "Cookbook" }]
: []),
],
[
{
@@ -47,7 +51,7 @@
wip?: boolean;
external?: boolean;
primary?: boolean;
}[][];
}[][]);
let connectButton: HTMLButtonElement;
</script>
@@ -81,15 +85,15 @@
<style lang="scss">
.sidebar {
margin: 8px;
padding-inline-end: 8px;
width: 64px;
display: flex;
flex-direction: column;
justify-content: space-between;
grid-area: sidebar;
flex-direction: column;
justify-content: space-between;
margin: 8px;
border-right: 1px solid var(--md-sys-color-outline);
padding-inline-end: 8px;
width: 64px;
}
li {
@@ -104,18 +108,18 @@
font-size: 12px;
&.wip {
color: var(--md-sys-color-error);
opacity: 0.5;
color: var(--md-sys-color-error);
}
.icon {
display: flex;
justify-content: center;
font-size: 24px;
padding: 8px;
border-radius: 8px;
transition: all 250ms ease;
border-radius: 8px;
padding: 8px;
font-size: 24px;
}
> .content {
@@ -132,24 +136,24 @@
}
.icon {
border-radius: 50%;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
border-radius: 50%;
}
}
}
ul {
list-style: none;
padding: 0;
margin: 0;
padding: 0;
list-style: none;
}
ul + ul::before {
content: "";
display: block;
height: 1px;
background: var(--md-sys-color-outline);
margin: 16px 0;
background: var(--md-sys-color-outline);
height: 1px;
content: "";
}
</style>

View File

@@ -33,8 +33,8 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
align-items: center;
}
div {
@@ -42,10 +42,10 @@
}
progress {
overflow: hidden;
border-radius: 4px;
width: 100%;
height: 8px;
border-radius: 4px;
overflow: hidden;
}
progress::-webkit-progress-bar {
@@ -53,7 +53,7 @@
}
progress::-webkit-progress-value {
background: var(--md-sys-color-primary);
transition: width 2s ease;
background: var(--md-sys-color-primary);
}
</style>

View File

@@ -97,8 +97,8 @@
}
.correct-device {
color: var(--md-sys-color-primary);
opacity: 1;
color: var(--md-sys-color-primary);
}
.incorrect-device {

View File

@@ -28,8 +28,8 @@
<style lang="scss">
ul {
display: flex;
list-style: none;
gap: 8px;
list-style: none;
}
li {
@@ -38,13 +38,13 @@
}
a {
outline: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
transition:
background-color 200ms ease,
color 200ms ease,
outline-offset 200ms ease,
outline-color 200ms ease;
outline: 1px solid var(--md-sys-color-outline);
border-radius: 8px;
}
@keyframes highlight {
@@ -71,9 +71,9 @@
}
.highlight {
outline-width: 2px;
outline-color: var(--md-sys-color-primary);
animation: wiggle 500ms ease 2 alternate;
outline-color: var(--md-sys-color-primary);
outline-width: 2px;
background-color: var(--md-sys-color-primary-container);
color: var(--md-sys-color-on-primary-container);
}

View File

@@ -19,12 +19,7 @@
{#each data.versions as version}
{@const isPrerelease = version.name.includes("-")}
<li class:pre-release={isPrerelease}>
<a href="./{version.name}/"
>{version.name}
<time datetime={version.mtime}
>{new Date(version.mtime).toLocaleDateString()}</time
></a
>
<a href="./{version.name}/">{version.name}</a>
</li>
{/each}
</ul>
@@ -38,21 +33,21 @@
}
ul {
list-style: none;
padding: 0;
list-style: none;
}
li {
height: 2em;
overflow: hidden;
transition:
height 200ms ease,
opacity 200ms ease;
height: 2em;
overflow: hidden;
}
label {
padding: 0;
opacity: 0.6;
padding: 0;
}
.title {
@@ -64,22 +59,14 @@
margin-block-end: 0;
em {
font-style: normal;
color: var(--md-sys-color-primary);
font-style: normal;
}
}
}
time {
opacity: 0.5;
&:before {
content: "•";
padding-inline: 0.4ch;
}
}
div.title:has(input:not(:checked)) ~ ul .pre-release {
height: 0;
opacity: 0;
height: 0;
}
</style>

View File

@@ -1,5 +1,6 @@
import type { PageLoad } from "./$types";
import type { DirectoryListing } from "$lib/meta/types/listing";
import { compare } from "semver";
export const load = (async ({ fetch, params }) => {
const result = await fetch(
@@ -9,7 +10,7 @@ export const load = (async ({ fetch, params }) => {
return {
versions: (data as DirectoryListing[]).sort((a, b) =>
b.name.localeCompare(a.name),
compare(b.name, a.name),
),
device: params.device,
};

View File

@@ -2,7 +2,9 @@
import { downloadBackup } from "$lib/backup/backup";
import { initSerial, serialPort } from "$lib/serial/connection";
import { fade, slide } from "svelte/transition";
import { lt as semverLt } from "semver";
import type { LoaderOptions, ESPLoader } from "esptool-js";
import ProgressButton from "$lib/ProgressButton.svelte";
let { data } = $props();
@@ -10,6 +12,10 @@
let success = $state(false);
let error = $state<Error | undefined>(undefined);
let isTooOld = $derived(
$serialPort ? semverLt($serialPort.version, "2.0.0") : false,
);
let unsafeUpdate = $state(false);
let terminalOutput = $state("");
@@ -26,21 +32,45 @@
success = false;
const port = $serialPort!;
$serialPort = undefined;
try {
const file = await fetch(
`${data.meta.path}/${data.meta.update.ota}`,
).then((it) => it.arrayBuffer());
await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
let file: ArrayBuffer | undefined;
let retries = 3;
let err: Error | undefined = undefined;
success = true;
} catch (e) {
error = e as Error;
} finally {
working = false;
while (!file && retries-- > 0) {
try {
file = await fetch(`${data.meta.path}/${data.meta.update.ota}`).then(
(it) => it.arrayBuffer(),
);
} catch (e) {
err = e as Error;
}
}
if (!file) {
error = err;
working = false;
return;
}
retries = 2;
while (retries-- > 0 && !success) {
try {
await port.updateFirmware(file, (transferred, total) => {
progress = transferred / total;
});
success = true;
} catch (e) {
err = e as Error;
port.baudRate = 9600;
}
}
if (!success) {
error = err;
}
working = false;
}
let currentDevice = $derived(
@@ -67,7 +97,8 @@
async function connect() {
try {
await initSerial(true, false);
const port = await navigator.serial.requestPort();
await initSerial(port!, true);
step = 1;
} catch (e) {
error = e as Error;
@@ -192,20 +223,27 @@
</script>
<div class="container">
{#if data.meta.update.js && data.meta.update.wasm}
<button>Add Virtual Device</button>
{/if}
{#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)}
<section>
<button
class="update-button"
class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!buttonError}
class:error={buttonError}
disabled={working || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</button
<ProgressButton
{working}
{progress}
style="--height: 42px; --border-radius: 8px; margin-block: 16px;"
error={buttonError ? buttonError.toString() : undefined}
disabled={isTooOld || $serialPort === undefined || !isCorrectDevice}
onclick={update}>Apply Update</ProgressButton
>
{#if $serialPort && isCorrectDevice}
{#if isTooOld}
<div class="error" transition:slide>
Your device's firmware is too old to be updated via OTA. Follow the
instruction below to update it manually.
</div>
{:else if $serialPort && isCorrectDevice}
<div transition:slide>
Your
<b
@@ -223,7 +261,32 @@
{:else if success}
<div class="primary" transition:slide>Update successful</div>
{:else if error}
<div class="error" transition:slide>{error.message}</div>
<div class="error" transition:slide>
{#if error.message.includes("ESP_ERR_OTA_VALIDATE_FAILED")}
<b>Update corrupted during transmission</b>
<ul>
<li>
Double-check your USB cable is <b>fully seated</b> on both ends
</li>
<li>Remove any USB hubs between the device and the computer</li>
<li>Unplug all other USB devices</li>
<li>Don't touch the device or your computer during the update</li>
<li>Try using a different USB cable</li>
<li>Try using a different USB Port</li>
<li>Try the update again a few times</li>
{#if navigator.userAgent.includes("Macintosh")}
<li>
Try updating on either Windows, Linux or ChromeOS instead of
MacOS
</li>
{/if}
</ul>
<b>DO NOT USE THE UNSAFE RECOVERY OPTIONS</b>, they bypass
corruption checks an can soft-brick your device.
{:else}
{error.message}
{/if}
</div>
{:else if working}
<div class="primary" transition:slide>Updating your device...</div>
{:else}
@@ -233,9 +296,11 @@
{/if}
</section>
<label class="unsafe-opt-in"
><input type="checkbox" /> Unsafe recovery options</label
>
{#if !isTooOld}
<label class="unsafe-opt-in"
><input type="checkbox" /> Unsafe recovery options</label
>
{/if}
{/if}
<div class="unsafe-updates">
@@ -245,47 +310,49 @@
</div>
{/if}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
{#if data.meta.update.uf2}
<section>
<ol>
<li>
<button class="inline-button" onclick={connect}
><span class="icon">usb</span>Connect</button
>
your device
{#if step >= 1}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 1}>
Make a <button class="inline-button" onclick={backup}
><span class="icon">download</span>Backup</button
>
{#if step >= 2}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 2}>
Reboot to <button class="inline-button" onclick={bootloader}
><span class="icon">restart_alt</span>Bootloader</button
>
{#if step >= 3}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
<li class:faded={step < 3}>
Replace <button class="inline-button" onclick={getFileSystem}
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
>
on the new drive
{#if step >= 4}
<span class="icon ok" transition:fade>check_circle</span>
{/if}
</li>
</ol>
</section>
{/if}
{#if false && data.meta.update.esptool}
<section>
@@ -322,6 +389,15 @@
<section class="changelog">
<h2>Changelog</h2>
<time datetime={data.meta.date.toISOString()}
>Published {data.meta.date.toLocaleDateString()}</time
>
{#if data.meta.recipes}
<p>Includes {data.meta.recipes.length} recipes</p>
{/if}
{#if data.meta.changelog.features}
<h3>Features</h3>
<ul>
@@ -353,8 +429,8 @@
}
.changelog ul {
list-style: none;
padding-inline-start: 0em;
list-style: none;
}
.changelog li {
@@ -364,11 +440,11 @@
.changelog b {
display: inline-block;
color: var(--md-sys-color-on-tertiary-container);
translate: -0.5em -0.2em;
border-radius: 8px;
background: var(--md-sys-color-tertiary-container);
padding: 0.2em 0.5em;
border-radius: 8px;
translate: -0.5em -0.2em;
color: var(--md-sys-color-on-tertiary-container);
}
pre {
@@ -376,8 +452,8 @@
}
.unsafe-opt-in {
margin-block: 1em;
opacity: 0.6;
margin-block: 1em;
font-size: 0.7em;
& + .unsafe-updates {
@@ -402,45 +478,24 @@
overflow: auto;
}
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button.inline-button {
display: inline;
padding: 0;
margin: 0;
padding: 0;
height: unset;
font-size: inherit;
color: var(--md-sys-color-primary);
font-size: inherit;
.icon {
font-size: 1.2em;
translate: 0 0.1em;
padding-inline-end: 0.2em;
font-size: 1.2em;
}
}
.icon.ok {
font-size: 1.2em;
translate: 0 0.1em;
font-size: 1.2em;
--icon-fill: 1;
}
@@ -448,67 +503,6 @@
opacity: 0.8;
}
button.update-button {
overflow: hidden;
position: relative;
height: 42px;
border: 2px solid currentcolor;
border-radius: 8px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
background: var(--md-sys-color-background);
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
margin-block: 16px;
&.primary {
color: var(--md-sys-color-primary);
background: none;
}
&.progress,
&.working {
border-color: transparent;
}
&.working::before {
z-index: -1;
position: absolute;
background: var(--md-sys-color-background);
width: calc(100% - 4px);
height: calc(100% - 4px);
border-radius: 8px;
content: "";
}
&.working::after {
z-index: -2;
position: absolute;
content: "";
background: var(--md-sys-color-primary);
animation: rotate 1s ease-out forwards infinite;
height: 30%;
width: 120%;
}
&.progress::after {
z-index: -2;
position: absolute;
left: 0;
content: "";
background: var(--md-sys-color-primary);
opacity: 0.2;
height: 100%;
width: var(--progress);
}
}
.version {
color: var(--md-sys-color-secondary);
}

View File

@@ -55,8 +55,8 @@
.layout {
display: flex;
height: 100%;
width: 100%;
height: 100%;
}
hr {
@@ -65,20 +65,20 @@
}
ul {
list-style: none;
padding: 0;
margin: 0;
padding: 0;
list-style: none;
}
button,
a {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
align-items: center;
background: var(--md-sys-color-surface-variant);
width: 56px;
height: 56px;
background: var(--md-sys-color-surface-variant);
overflow: hidden;
}
.chats {
@@ -86,7 +86,7 @@
}
.space {
font-size: 20px;
margin-bottom: 8px;
font-size: 20px;
}
</style>

View File

@@ -174,7 +174,7 @@
}
.members {
width: 200px;
flex-shrink: 0;
width: 200px;
}
</style>

View File

@@ -7,19 +7,24 @@
layout,
overlay,
settings,
duplicateChords,
} from "$lib/undo-redo";
import type { Change } from "$lib/undo-redo";
import type { Change, ChordChange } from "$lib/undo-redo";
import { fly } from "svelte/transition";
import { action } from "$lib/title";
import { action, actionTooltip } from "$lib/title";
import {
deviceChords,
deviceLayout,
deviceSettings,
serialLog,
serialPort,
sync,
syncProgress,
syncStatus,
} from "$lib/serial/connection";
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
import ProgressButton from "$lib/ProgressButton.svelte";
import { tick } from "svelte";
import { goto } from "$app/navigation";
function undo(event: MouseEvent) {
if (event.shiftKey) {
@@ -40,155 +45,285 @@
}
}
let redoQueue: Change[][] = $state([]);
let error = $state<Error | undefined>(undefined);
let progressButton: HTMLButtonElement | undefined = $state();
async function saveLayoutChanges(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
for (const [profile, layout] of $overlay.layout.entries()) {
if (layout === undefined) continue;
for (const [layer, actions] of layout.entries()) {
if (actions === undefined) continue;
for (const [id, action] of actions) {
if (action === undefined) continue;
await port.setLayoutKey(profile, layer + 1, id, action);
progress();
}
}
}
$deviceLayout = $layout.map((profile) =>
profile.map((layer) => layer.map<number>(({ action }) => action)),
);
changes.update((changes) =>
changes
.map((it) => it.filter((it) => it.type !== ChangeType.Layout))
.filter((it) => it.length > 0),
);
} catch (e) {
console.error(e);
return false;
}
await tick();
return true;
}
async function saveSettings(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
for (const [profile, settings] of $overlay.settings.entries()) {
if (settings === undefined) continue;
for (const [id, setting] of settings.entries()) {
if (setting === undefined) continue;
await port.setSetting(profile, id, setting);
progress();
}
}
$deviceSettings = $settings.map((profile) =>
profile.map(({ value }) => value),
);
changes.update((changes) =>
changes
.map((it) => it.filter((it) => it.type !== ChangeType.Setting))
.filter((it) => it.length > 0),
);
} catch (e) {
console.error(e);
return false;
}
await tick();
return true;
}
async function safeDeleteChord(actions: number[]): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
try {
await port.deleteChord({ actions });
return true;
} catch (e) {
console.error(e);
try {
if ((await port.getChordPhrase(actions)) === undefined) {
return true;
}
} catch (e) {
console.error(e);
}
}
return false;
}
async function saveChords(progress: () => void): Promise<boolean> {
const port = $serialPort;
if (!port) return false;
let ok = true;
const empty = new Set<string>();
for (const [id, chord] of $overlay.chords) {
if (chord.actions.length === 0 || chord.phrase.length === 0) {
empty.add(id);
}
}
changes.update((changes) => {
changes.push([
...empty.keys().map(
(id) =>
({
type: ChangeType.Chord,
id: JSON.parse(id),
deleted: true,
actions: [],
phrase: [],
}) satisfies ChordChange,
),
]);
return changes;
});
await tick();
const deleted = new Set<string>();
const changed = new Map<string, number[]>();
for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) continue;
if (await safeDeleteChord(JSON.parse(id))) {
deleted.add(id);
} else {
ok = false;
}
progress();
}
deviceChords.update((chords) =>
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions))),
);
deleted.clear();
await tick();
for (const [id, chord] of $overlay.chords) {
if (chord.deleted) continue;
if ($duplicateChords.has(JSON.stringify(chord.actions))) {
ok = false;
} else {
let skip = false;
if (id !== JSON.stringify(chord.actions)) {
if (await safeDeleteChord(JSON.parse(id))) {
deleted.add(id);
} else {
skip = true;
ok = false;
}
}
if (!skip) {
try {
await port.setChord({
actions: chord.actions,
phrase: chord.phrase,
});
deleted.add(JSON.stringify(chord.actions));
changed.set(JSON.stringify(chord.actions), chord.phrase);
} catch (e) {
console.error(e);
ok = false;
}
} else {
ok = false;
}
}
progress();
}
deviceChords.update((chords) => {
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions)));
for (const [id, phrase] of changed) {
chords.push({ actions: JSON.parse(id), phrase });
}
return chords;
});
await tick();
return ok;
}
async function save() {
let needsSync = false;
try {
const port = $serialPort;
if (!port) return;
if (!port) {
document
.getElementById("connect-popup")
?.showPopover({ source: progressButton });
return;
}
$syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce(
(acc, layer) => acc + layer.size,
(acc, profile) =>
acc +
(profile?.reduce((acc, layer) => acc + (layer?.size ?? 0), 0) ?? 0),
0,
);
const settingChanges = $overlay.settings.reduce(
(acc, profile) => acc + (profile?.size ?? 0),
0,
);
const settingChanges = $overlay.settings.size;
const chordChanges = $overlay.chords.size;
needsSync = chordChanges > 0;
const needsCommit = settingChanges > 0 || layoutChanges > 0;
const progressMax = layoutChanges + settingChanges + chordChanges;
let progressCurrent = 0;
syncProgress.set({
max: progressMax,
current: progressCurrent,
});
for (const [id, chord] of $overlay.chords) {
if (!chord.deleted) {
if (id !== JSON.stringify(chord.actions)) {
const existingChord = await port.getChordPhrase(chord.actions);
if (
existingChord !== undefined &&
!(await askForConfirmation(
$LL.configure.chords.conflict.TITLE(),
$LL.configure.chords.conflict.DESCRIPTION(),
$LL.configure.chords.conflict.CONFIRM(),
$LL.configure.chords.conflict.ABORT(),
chord,
))
) {
changes.update((changes) =>
changes
.map((it) =>
it.filter(
(it) =>
!(
it.type === ChangeType.Chord &&
JSON.stringify(it.id) === id
),
),
)
.filter((it) => it.length > 0),
);
continue;
}
await port.deleteChord({ actions: JSON.parse(id) });
}
await port.setChord({ actions: chord.actions, phrase: chord.phrase });
} else {
await port.deleteChord({ actions: chord.actions });
}
function updateProgress() {
syncProgress.set({
max: progressMax,
current: progressCurrent++,
current: Math.min(progressMax, progressCurrent++),
});
}
updateProgress();
for (const [layer, actions] of $overlay.layout.entries()) {
for (const [id, action] of actions) {
await port.setLayoutKey(layer + 1, id, action);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
}
}
let layoutSuccess = await saveLayoutChanges(updateProgress);
let settingsSuccess = await saveSettings(updateProgress);
for (const [id, setting] of $overlay.settings) {
await port.setSetting(id, setting);
syncProgress.set({
max: progressMax,
current: progressCurrent++,
});
}
// Yes, this is a completely arbitrary and unnecessary delay.
// The only purpose of it is to create a sense of weight,
// aka make it more "energy intensive" to click.
// The only conceivable way users could reach the commit limit in this case
// would be if they click it every time they change a setting.
// Because of that, we don't need to show a fearmongering message such as
// "Your device will break after you click this 10,000 times!"
if (needsCommit) {
await port.commit();
try {
await port.commit();
} catch (e) {
console.error("Error during commit:", e);
layoutSuccess = false;
}
}
let chordsSuccess = await saveChords(updateProgress);
$deviceLayout = $layout.map((layer) =>
layer.map<number>(({ action }) => action),
) as [number[], number[], number[]];
$deviceChords = $chords
.filter(({ deleted }) => !deleted)
.map(({ actions, phrase }) => ({ actions, phrase }));
$deviceSettings = $settings.map(({ value }) => value);
$changes = [];
if (layoutSuccess && settingsSuccess && chordsSuccess) {
changes.set([]);
needsSync = true;
} else {
throw new Error("Some changes could not be saved.");
}
} catch (e) {
alert(e);
console.error(e);
error = e as Error;
console.error("Error while saving changes:", error);
serialLog.update((log) => {
log.push({ type: "system", value: error?.message ?? "Error" });
return log;
});
goto("/terminal");
} finally {
$syncStatus = "done";
}
if (needsSync) {
await sync();
}
}
let progressPopover: HTMLElement | undefined = $state();
</script>
<button
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
{@attach actionTooltip($LL.saveActions.UNDO(), "ctrl+z")}
class="icon"
disabled={$changes.length === 0}
onclick={undo}>undo</button
>
<button
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
{@attach actionTooltip($LL.saveActions.REDO(), "ctrl+y")}
class="icon"
disabled={redoQueue.length === 0}
onclick={redo}>redo</button
>
{#if $changes.length !== 0}
<button
{#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
<div
transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
onclick={save}
class="click-me"
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
{@attach actionTooltip($LL.saveActions.SAVE(), "ctrl+shift+s")}
>
<ProgressButton
disabled={$syncStatus !== "done"}
working={$syncStatus === "uploading" || $syncStatus === "downloading"}
progress={$syncProgress && $syncStatus === "uploading"
? $syncProgress.current / $syncProgress.max
: 0}
style="--height: 36px"
error={error !== undefined
? (error.message ?? error.toString())
: undefined}
onclick={save}
bind:element={progressButton}
>
<span class="icon">save</span>{$LL.saveActions.SAVE()}
</ProgressButton>
<div bind:this={progressPopover} popover="hint">
{$LL.saveActions.SAVE()}
</div>
</div>
{/if}
<style lang="scss">
.click-me {
display: flex;
align-items: center;
justify-content: center;
height: fit-content;
margin-inline: 8px;
padding-block: 2px;
padding-inline-start: 8px;
padding-inline-end: 12px;
font-family: inherit;
font-weight: bold;
color: var(--md-sys-color-primary);
border: 2px solid var(--md-sys-color-primary);
border-radius: 18px;
outline: 2px dashed var(--md-sys-color-primary);
outline-offset: 2px;
}
</style>

View File

@@ -1,9 +1,75 @@
<script lang="ts">
import { fly } from "svelte/transition";
import { fly, slide } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import { actionTooltip } from "$lib/title";
import { activeProfile, serialPort } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte";
import { page } from "$app/state";
import { expoOut } from "svelte/easing";
import {
createChordBackup,
createLayoutBackup,
createSettingsBackup,
downloadFile,
restoreBackup,
} from "$lib/backup/backup";
const routeOrder = [
"/(app)/config/settings",
"/(app)/config/chords",
"/(app)/config/layout",
];
let pageIndex = $derived(
routeOrder.findIndex((it) => page.route.id?.startsWith(it)),
);
let importExport: HTMLDivElement | undefined = $state(undefined);
$effect(() => {
pageIndex;
importExport?.animate(
[
{ transform: "translateX(0)", opacity: 1 },
{ transform: "translateX(-8px)", opacity: 0, offset: 0.2 },
{ transform: "translateX(8px)", opacity: 0, offset: 0.7 },
{ transform: "translateX(0)", opacity: 1 },
],
{
duration: 1500,
easing: "cubic-bezier(0.19, 1, 0.22, 1)",
},
);
});
function importBackup(event: Event) {
switch (pageIndex) {
case 0:
restoreBackup(event, "settings");
break;
case 1:
restoreBackup(event, "chords");
break;
case 2:
restoreBackup(event, "layout");
break;
}
(event.target as HTMLInputElement).value = "";
}
function exportBackup() {
switch (pageIndex) {
case 0:
downloadFile(createSettingsBackup());
break;
case 1:
downloadFile(createChordBackup());
break;
case 2:
downloadFile(createLayoutBackup());
break;
}
}
</script>
<nav>
@@ -11,16 +77,36 @@
<EditActions />
</div>
<div class="profiles">
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
<div
transition:fly={{ y: -8, duration: 250, easing: expoOut }}
class="profiles"
>
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
<label
><input
type="radio"
name="profile"
checked={profile == $activeProfile}
onclick={() => ($activeProfile = profile)}
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
>
{/each}
</div>
{/if}
</div>
<div class="actions">
{#if $canShare}
<button
use:action={{ title: $LL.share.TITLE() }}
{@attach actionTooltip($LL.share.TITLE())}
transition:fly={{ x: -8 }}
class="icon"
onclick={triggerShare}>share</button
>
<button
use:action={{ title: $LL.print.TITLE() }}
{@attach actionTooltip($LL.print.TITLE())}
transition:fly={{ x: -8 }}
class="icon"
onclick={() => print()}>print</button
@@ -31,40 +117,61 @@
<PwaStatus />
{/await}
{/if}
<div class="import-export" bind:this={importExport}>
<label
><input type="file" oninput={importBackup} />
<span class="icon">upload_file</span>Import</label
>
<button onclick={exportBackup}
><span class="icon">file_save</span>Export</button
>
</div>
</div>
</nav>
<style lang="scss">
.profiles {
display: flex;
justify-content: center;
}
input[type="file"] {
display: none;
}
.import-export {
display: flex;
}
nav {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr auto 1fr;
margin-inline: auto;
margin-block: 8px;
padding-inline: 16px;
width: calc(min(100%, 28cm));
margin-block: 8px;
margin-inline: auto;
padding-inline: 16px;
}
.icon {
cursor: pointer;
display: flex;
position: relative;
display: flex;
align-items: center;
justify-content: center;
align-items: center;
aspect-ratio: 1;
padding: 0;
color: inherit;
text-decoration: none;
background: transparent;
transition: all 250ms ease;
cursor: pointer;
border: none;
border-radius: 50%;
transition: all 250ms ease;
background: transparent;
padding: 0;
aspect-ratio: 1;
color: inherit;
text-decoration: none;
}
.actions {
@@ -76,8 +183,15 @@
}
}
.profiles {
display: flex;
justify-content: center;
align-items: center;
gap: 2px;
}
:disabled {
pointer-events: none;
opacity: 0.5;
pointer-events: none;
}
</style>

View File

@@ -183,9 +183,11 @@
}
const searchFilter = writable<number[] | undefined>(undefined);
let currentSearchQuery = $state("");
async function search(index: FlexSearch.Index, event: Event) {
const query = (event.target as HTMLInputElement).value;
currentSearchQuery = query;
searchFilter.set(
query && searchIndex
? ((await index.searchAsync(query)) as number[])
@@ -194,6 +196,13 @@
page = 0;
}
// Re-run search when chords change to fix stale indices
$effect(() => {
if (currentSearchQuery && $searchIndex) {
search($searchIndex, { target: { value: currentSearchQuery } } as any);
}
});
function insertChord(actions: number[]) {
const id = JSON.stringify(actions);
if ($chords.some((it) => JSON.stringify(it.actions) === id)) {
@@ -273,6 +282,7 @@
<input
type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress)}
value={currentSearchQuery}
oninput={(event) => $searchIndex && search($searchIndex, event)}
class:loading={progress !== $chords.length}
/>
@@ -311,7 +321,7 @@
><td></td><td></td></tr
>
{/if}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord]}
{#if chord}
<ChordEdit {chord} onduplicate={() => (page = 0)} />
{/if}
@@ -350,8 +360,8 @@
<style lang="scss">
.search-container {
display: flex;
align-items: center;
justify-content: center;
align-items: center;
}
.paginator {
@@ -376,14 +386,14 @@
textarea {
flex: 1;
transition: outline-color 250ms ease;
background: none;
color: inherit;
border: 1px dashed var(--md-sys-color-outline);
margin: 2px;
outline: 2px solid transparent;
outline-offset: -1px;
margin: 2px;
padding: 8px;
border: 1px dashed var(--md-sys-color-outline);
border-radius: 4px;
background: none;
padding: 8px;
color: inherit;
&:focus {
outline-color: var(--md-sys-color-primary);
@@ -403,34 +413,33 @@
}
input[type="search"] {
width: 512px;
transition: all 250ms ease;
margin-block-start: 16px;
padding-block: 8px;
padding-inline: 16px;
font-size: 16px;
color: inherit;
background: none;
border: 0 solid var(--md-sys-color-surface-variant);
border-bottom-width: 1px;
transition: all 250ms ease;
background: none;
padding-inline: 16px;
padding-block: 8px;
width: 512px;
color: inherit;
font-size: 16px;
@media (prefers-contrast: more) {
border-color: var(--md-sys-color-outline);
border-style: dashed;
border-color: var(--md-sys-color-outline);
}
&::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 0.8;
color: var(--md-sys-color-on-surface-variant);
}
&:focus {
border-color: var(--md-sys-color-primary);
border-style: solid;
outline: none;
border-style: solid;
border-color: var(--md-sys-color-primary);
}
&.loading {
@@ -439,25 +448,25 @@
}
section {
position: relative;
display: flex;
overflow: hidden;
height: 100%;
padding-inline: 8px;
position: relative;
border-radius: 16px;
padding-inline: 8px;
height: 100%;
overflow: hidden;
}
.results {
min-width: min(90vw, 20cm);
height: 100%;
min-width: min(90vw, 16.5cm);
}
table {
transition: all 1s ease;
height: fit-content;
overflow-y: hidden;
transition: all 1s ease;
}
</style>

View File

@@ -176,9 +176,9 @@
}
.add {
font-size: 18px;
height: 20px;
opacity: 0;
height: 20px;
font-size: 18px;
--icon-fill: 1;
}
@@ -187,13 +187,12 @@
}
.chord {
position: relative;
display: inline-flex;
position: relative;
gap: 4px;
margin-inline: 4px;
height: 32px;
margin-inline: 4px;
&:focus-within {
outline: none;
@@ -201,22 +200,21 @@
}
.chord::after {
content: "";
position: absolute;
top: 50%;
transform-origin: center left;
translate: -20px 0;
scale: 0 1;
width: calc(100% - 60px);
height: 1px;
background: currentcolor;
transition:
scale 250ms ease,
color 250ms ease;
background: currentcolor;
width: calc(100% - 60px);
height: 1px;
content: "";
}
.edited {

View File

@@ -133,11 +133,11 @@
.separator {
display: inline-flex;
width: 1px;
height: 24px;
opacity: 0.2;
background: currentcolor;
width: 1px;
height: 24px;
}
button {
@@ -153,12 +153,12 @@
}
.table-buttons {
opacity: 0;
transition: opacity 75ms ease;
position: absolute;
top: 0;
right: 0;
transform: translate(100%, -50%);
opacity: 0;
transition: opacity 75ms ease;
background: var(--md-sys-color-surface-variant);
}

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import { onMount, tick } from "svelte";
import { changes, ChangeType } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo";
@@ -6,11 +7,18 @@
import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector";
import { inputToAction } from "./input-converter";
import { serialPort } from "$lib/serial/connection";
import { deviceMeta, serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
import { action, actionTooltip } from "$lib/title";
import semverGte from "semver/functions/gte";
import Action from "$lib/components/Action.svelte";
import AutospaceSelector from "$lib/chord-editor/AutospaceSelector.svelte";
let { chord }: { chord: ChordInfo } = $props();
const JOIN_ACTION = 574;
const NO_CONCATENATOR_ACTION = 256;
onMount(() => {
if (chord.phrase.length === 0) {
box?.focus();
@@ -21,16 +29,16 @@
if (!event.shiftKey && event.key === "ArrowUp") {
addSpecial(event);
} else if (!event.shiftKey && event.key === "ArrowLeft") {
moveCursor(cursorPosition - 1);
moveCursor(cursorPosition - 1, true);
} else if (!event.shiftKey && event.key === "ArrowRight") {
moveCursor(cursorPosition + 1);
moveCursor(cursorPosition + 1, true);
} else if (event.key === "Backspace") {
deleteAction(cursorPosition - 1);
moveCursor(cursorPosition - 1);
deleteAction(cursorPosition - 1, 1, true);
moveCursor(cursorPosition - 1, true);
} else if (event.key === "Delete") {
deleteAction(cursorPosition);
deleteAction(cursorPosition, 1, true);
} else {
if (event.key === "Shift") return;
if (event.key === "Shift" || event.key === "Meta") return;
const action = inputToAction(event, get(serialPort)?.device === "X");
if (action !== undefined) {
insertAction(cursorPosition, action);
@@ -39,14 +47,24 @@
}
}
function moveCursor(to: number) {
function moveCursor(to: number, user = false) {
if (!box) return;
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
cursorPosition = Math.max(
user ? chord.phrase.findIndex((it, i, arr) => !isHidden(it, i, arr)) : 0,
Math.min(
to,
user
? chord.phrase.findLastIndex((it, i, arr) => !isHidden(it, i, arr)) +
1 || chord.phrase.length
: chord.phrase.length,
),
);
const item = box.children.item(cursorPosition) as HTMLElement;
cursorOffset = item.offsetLeft + item.offsetWidth;
}
function deleteAction(at: number, count = 1) {
function deleteAction(at: number, count = 1, user = false) {
if (user && isHidden(chord.phrase[at]!, at, chord.phrase)) return;
if (!(at in chord.phrase)) return;
changes.update((changes) => {
changes.push([
@@ -83,12 +101,12 @@
for (const child of box.children) {
const { offsetLeft, offsetWidth } = child as HTMLElement;
if (distance < offsetLeft + offsetWidth / 2) {
moveCursor(i - 1);
moveCursor(i - 1, true);
return;
}
i++;
}
moveCursor(i - 1);
moveCursor(i - 1, true);
}
function addSpecial(event: MouseEvent | KeyboardEvent) {
@@ -102,35 +120,153 @@
);
}
function resolveAutospace(autospace: boolean) {
if (autospace) {
if (chord.phrase.at(-1) === JOIN_ACTION) {
if (
chord.phrase.every(
(action, i, arr) =>
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
)
) {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
} else {
return;
}
} else {
if (isPrintable) {
return;
} else if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
} else {
insertAction(chord.phrase.length, JOIN_ACTION);
moveCursor(cursorPosition, true);
}
}
} else {
if (chord.phrase.at(-1) === JOIN_ACTION) {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
} else {
if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
if (
chord.phrase.every(
(action, i, arr) =>
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
)
) {
return;
} else {
deleteAction(chord.phrase.length - 1);
moveCursor(cursorPosition, true);
}
} else {
insertAction(chord.phrase.length, NO_CONCATENATOR_ACTION);
moveCursor(cursorPosition, true);
}
}
}
}
let button: HTMLButtonElement | undefined = $state();
let box: HTMLDivElement | undefined = $state();
let cursorPosition = 0;
let cursorOffset = $state(0);
let hasFocus = $state(false);
let isPrintable = $derived(
chord.phrase.every(
(action) => $KEYMAP_CODES.get(action)?.printable === true,
),
);
let supportsAutospace = $derived(
semverGte($deviceMeta?.version ?? "0.0.0", "2.1.0"),
);
let hasAutospace = $derived(
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
);
function isHidden(action: number, index: number, array: number[]) {
return (
(index === 0 && action === JOIN_ACTION) ||
(index === array.length - 1 &&
(action === JOIN_ACTION || action === NO_CONCATENATOR_ACTION))
);
}
</script>
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
onkeydown={keypress}
onmousedown={clickCursor}
role="textbox"
tabindex="0"
bind:this={box}
class="wrapper"
class:edited={!chord.deleted && chord.phraseChanged}
onfocusin={() => (hasFocus = true)}
onfocusout={(event) => {
if (event.relatedTarget !== button) hasFocus = false;
onclick={() => {
box?.focus();
}}
>
{#if hasFocus}
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} onclick={addSpecial}>add</button>
</div>
{:else}
<div></div>
<!-- placeholder for cursor placement -->
{#if supportsAutospace}
<AutospaceSelector
variant="start"
value={chord.phrase[0] === JOIN_ACTION}
onchange={async (event) => {
const autospace = hasAutospace;
if ((event.target as HTMLInputElement).checked) {
if (chord.phrase[0] === JOIN_ACTION) {
deleteAction(0, 1);
await tick();
moveCursor(cursorPosition - 1, true);
}
} else {
if (chord.phrase[0] !== JOIN_ACTION) {
insertAction(0, JOIN_ACTION);
moveCursor(cursorPosition + 1, true);
}
}
await tick();
resolveAutospace(autospace);
}}
/>
{/if}
<div
onkeydown={keypress}
onmousedown={clickCursor}
role="textbox"
tabindex="0"
bind:this={box}
onfocusin={() => (hasFocus = true)}
onfocusout={(event) => {
if (event.relatedTarget !== button) hasFocus = false;
}}
>
{#if hasFocus}
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} onclick={addSpecial}>add</button
>
</div>
{:else}
<div></div>
<!-- placeholder for cursor placement -->
{/if}
{#each chord.phrase as action, i}
{#if isHidden(action, i, chord.phrase)}
<span style:display="none"></span>
{:else}
<Action display="inline-keys" {action} />
{/if}
{/each}
</div>
{#if supportsAutospace}
<AutospaceSelector
variant="end"
value={!hasAutospace}
onchange={(event) =>
resolveAutospace((event.target as HTMLInputElement).checked)}
/>
{/if}
<ActionString actions={chord.phrase} />
<sup></sup>
</div>
@@ -146,26 +282,26 @@
transform: translateX(-50%);
translate: 0 0;
width: 2px;
height: 100%;
transition: translate 50ms ease;
background: var(--md-sys-color-on-secondary-container);
transition: translate 50ms ease;
width: 2px;
height: 100%;
button {
position: absolute;
top: -24px;
left: 0;
height: 24px;
padding: 0;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border: 2px solid currentcolor;
border-radius: 12px 12px 12px 0;
background: var(--md-sys-color-secondary-container);
padding: 0;
height: 24px;
color: var(--md-sys-color-on-secondary-container);
}
}
@@ -177,33 +313,31 @@
}
}
[role="textbox"] {
cursor: text;
.wrapper {
display: flex;
position: relative;
display: flex;
align-items: center;
height: 1em;
padding-block: 4px;
height: 1em;
&::after,
&::before {
content: "";
position: absolute;
bottom: -4px;
width: 100%;
height: 1px;
opacity: 0;
background: currentcolor;
transition:
opacity 150ms ease,
scale 250ms ease;
background: currentcolor;
width: calc(100% - 8px);
height: 1px;
content: "";
}
&::after {
@@ -211,17 +345,30 @@
transition-duration: 250ms;
}
&:hover::before {
opacity: 0.3;
&:hover {
--auto-space-show: 1;
&::before {
opacity: 0.3;
}
}
&:has(> :focus-within)::after {
scale: 1;
opacity: 1;
}
}
[role="textbox"] {
display: flex;
position: relative;
align-items: center;
cursor: text;
white-space: pre;
&:focus-within {
outline: none;
&::after {
scale: 1;
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { serializeActions } from "$lib/serial/chord";
import { chords } from "$lib/undo-redo";
import ChordEdit from "../ChordEdit.svelte";
export function hashChord(actions: number[]) {
const chord = new Uint8Array(16);
const view = new DataView(chord.buffer);
const serialized = serializeActions(actions);
view.setBigUint64(0, serialized & 0xffff_ffff_ffff_ffffn, true);
view.setBigUint64(8, serialized >> 64n, true);
let hash = 2166136261;
for (let i = 0; i < 16; i++) {
hash = Math.imul(hash ^ view.getUint8(i), 16777619);
}
return hash & 0x3fff_ffff;
}
const broken = $derived(
$chords.filter((it) => (hashChord(it.actions) & 0xff) === 0xff),
);
</script>
<h1>Will my compound break</h1>
<p>
Pre-2.2.0 there was a bug where creating a compound with specific chords as a
base could corrupt your library.
</p>
{#if broken.length > 0}
<p class="warning">Chords have been detected.</p>
<p>
If you have ever tried to create a compound chord with <b class="warning"
>any of these as a base</b
>, your library might have been corrupted.
</p>
{#each broken as chord}
<ChordEdit {chord} onduplicate={() => {}} />
{/each}
{:else}
<p>No problematic chords found</p>
{/if}
<style lang="scss">
.warning {
color: var(--md-sys-color-error);
font-weight: bold;
}
.chord {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.compound {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
p {
max-width: 600px;
}
</style>

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