mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-16 14:12:41 +00:00
Compare commits
27 Commits
d84495894a
...
cv2
| Author | SHA1 | Date | |
|---|---|---|---|
|
b7c8ebfb3c
|
|||
|
632297d266
|
|||
|
0ee7e02c53
|
|||
|
f618ffbada
|
|||
|
afa0d9ffd7
|
|||
|
cda2a527d9
|
|||
|
1ca2a70bc1
|
|||
|
a16c79575f
|
|||
|
5371b9d305
|
|||
|
b9c6c05819
|
|||
|
16bf766de9
|
|||
|
ee8d400ad7
|
|||
|
9a1c2b5bf6
|
|||
|
1d1fcb72e3
|
|||
|
ee3f84645d
|
|||
|
82dd08f2a2
|
|||
|
9f65b4bb6c
|
|||
|
e08dda40d9
|
|||
|
a403bf1ac0
|
|||
|
1aff1703ac
|
|||
|
fe42dcd2ab
|
|||
|
b13c34ca15
|
|||
|
4023ab9bd5
|
|||
|
2893afa2ba
|
|||
|
7beab5ac07
|
|||
|
6895fa4a82
|
|||
|
245dd97532
|
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -52,6 +52,8 @@ jobs:
|
||||
|
||||
- name: Publish Branch
|
||||
if: ${{ !github.event.pull_request.head.repo.fork }}
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${GITHUB_REF##*/}
|
||||
env:
|
||||
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/$BRANCH_NAME
|
||||
- name: Publish Commit
|
||||
run: rsync -rav --mkpath --delete build/ deploy@charachorder.io:/home/deploy/ref/${{ github.sha }}
|
||||
|
||||
@@ -6,6 +6,7 @@ const config = {
|
||||
icons: [
|
||||
"rocket_launch",
|
||||
"deployed_code_update",
|
||||
"difference",
|
||||
"adjust",
|
||||
"add",
|
||||
"piano",
|
||||
@@ -34,6 +35,7 @@ const config = {
|
||||
"abc",
|
||||
"function",
|
||||
"cloud_done",
|
||||
"counter_4",
|
||||
"backup",
|
||||
"cloud_download",
|
||||
"cloud_off",
|
||||
@@ -46,14 +48,17 @@ const config = {
|
||||
"step_over",
|
||||
"step_into",
|
||||
"step_out",
|
||||
"timer_play",
|
||||
"settings_backup_restore",
|
||||
"sound_detection_loud_sound",
|
||||
"ring_volume",
|
||||
"skillet",
|
||||
"wifi",
|
||||
"power_settings_circle",
|
||||
"graphic_eq",
|
||||
"mail",
|
||||
"calculate",
|
||||
"playground_2",
|
||||
"open_in_browser",
|
||||
"chevron_backward",
|
||||
"chevron_forward",
|
||||
@@ -76,9 +81,12 @@ const config = {
|
||||
"palette",
|
||||
"translate",
|
||||
"smart_toy",
|
||||
"visibility_off",
|
||||
"play_arrow",
|
||||
"extension",
|
||||
"upload_file",
|
||||
"file_export",
|
||||
"file_save",
|
||||
"commit",
|
||||
"bug_report",
|
||||
"delete",
|
||||
@@ -90,6 +98,7 @@ const config = {
|
||||
"undo",
|
||||
"redo",
|
||||
"replay",
|
||||
"clock_loader_80",
|
||||
"reply",
|
||||
"navigate_before",
|
||||
"navigate_next",
|
||||
@@ -140,15 +149,29 @@ const config = {
|
||||
"developer_board",
|
||||
"developer_board_off",
|
||||
"memory",
|
||||
"gamepad_circle_up",
|
||||
"gamepad_circle_left",
|
||||
"gamepad_circle_down",
|
||||
"gamepad_circle_right",
|
||||
"trail_length_medium",
|
||||
"blur_short",
|
||||
"combine_columns",
|
||||
"animation",
|
||||
"text_select_move_back_word",
|
||||
],
|
||||
codePoints: {
|
||||
speed: "e9e4",
|
||||
arrow_split: "e985",
|
||||
arrow_circle_down: "f181",
|
||||
arrow_circle_up: "f182",
|
||||
gamepad_circle_up: "eecd",
|
||||
gamepad_circle_right: "eece",
|
||||
gamepad_circle_left: "eecf",
|
||||
gamepad_circle_down: "eed0",
|
||||
counter_1: "f784",
|
||||
counter_2: "f783",
|
||||
counter_3: "f782",
|
||||
counter_4: "f781",
|
||||
ios_share: "e6b8",
|
||||
light_mode: "e518",
|
||||
upload_file: "e9fc",
|
||||
@@ -161,6 +184,8 @@ const config = {
|
||||
routine: "e20c",
|
||||
experiment: "e686",
|
||||
dictionary: "f539",
|
||||
visibility_off: "e8f5",
|
||||
file_save: "f17f",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
84
package.json
84
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "charachorder-device-manager",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@@ -25,6 +25,7 @@
|
||||
"build:tauri": "tauri build",
|
||||
"tauri": "tauri",
|
||||
"test": "vitest run --coverage",
|
||||
"test:chord-sync": "vitest chord-sync",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"minify-icons": "node src/tools/minify-icon-font.js",
|
||||
@@ -34,66 +35,73 @@
|
||||
"typesafe-i18n": "typesafe-i18n"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.18.6",
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/collab": "^6.1.1",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.7",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/view": "^6.39.9",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.10",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.7",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@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.26.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||
"@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/js-yaml": "^4.0.9",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/w3c-web-serial": "^1.0.8",
|
||||
"@types/w3c-web-usb": "^1.0.10",
|
||||
"@types/wicg-file-system-access": "^2023.10.6",
|
||||
"@vite-pwa/sveltekit": "^1.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"@types/w3c-web-usb": "^1.0.13",
|
||||
"@types/wicg-file-system-access": "^2023.10.7",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"codemirror": "^6.0.2",
|
||||
"cypress": "^14.5.3",
|
||||
"d3": "^7.9.0",
|
||||
"esptool-js": "^0.5.6",
|
||||
"flexsearch": "^0.8.205",
|
||||
"esptool-js": "^0.5.7",
|
||||
"flexsearch": "^0.8.212",
|
||||
"fontkit": "^2.0.4",
|
||||
"glob": "^11.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"matrix-js-sdk": "^37.12.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-css-order": "^2.1.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-css-order": "^2.2.0",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.89.2",
|
||||
"semver": "^7.7.2",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stylelint": "^16.23.0",
|
||||
"stylelint-config-clean-order": "^7.0.0",
|
||||
"sass": "^1.97.2",
|
||||
"semver": "^7.7.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-recommended-scss": "^15.0.1",
|
||||
"stylelint-config-standard-scss": "^15.0.1",
|
||||
"svelte": "5.37.1",
|
||||
"svelte-check": "^4.3.0",
|
||||
"stylelint-config-recommended-scss": "^16.0.2",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"svelte": "5.46.1",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-mkcert": "^1.17.8",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vitest": "^3.2.4",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-mkcert": "^1.17.9",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.0.16",
|
||||
"web-serial-polyfill": "^1.0.15",
|
||||
"workbox-window": "^7.3.0"
|
||||
"workbox-window": "^7.4.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
4367
pnpm-lock.yaml
generated
4367
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
onlyBuiltDependencies:
|
||||
- svelte-preprocess
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||
license = "AGPL-3"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": { "productName": "amacc1ng", "version": "2.6.0" },
|
||||
"package": { "productName": "amacc1ng", "version": "2.7.0" },
|
||||
"tauri": {
|
||||
"allowlist": { "all": false },
|
||||
"bundle": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,7 +7,7 @@ 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",
|
||||
|
||||
121
src/lib/ProgressButton.svelte
Normal file
121
src/lib/ProgressButton.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
import type { HTMLButtonAttributes } from "svelte/elements";
|
||||
|
||||
let {
|
||||
onclick,
|
||||
children,
|
||||
working,
|
||||
progress,
|
||||
error,
|
||||
disabled = false,
|
||||
element = $bindable(),
|
||||
...restProps
|
||||
}: {
|
||||
onclick: () => void;
|
||||
children: Snippet;
|
||||
working: boolean;
|
||||
progress: number;
|
||||
error?: string;
|
||||
disabled?: boolean;
|
||||
element?: HTMLButtonElement;
|
||||
} & HTMLButtonAttributes = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class:working={working && (progress <= 0 || progress >= 1)}
|
||||
class:progress={working && progress > 0 && progress < 1}
|
||||
style:--progress="{progress * 100}%"
|
||||
class:primary={!error}
|
||||
class:error={!!error}
|
||||
disabled={disabled || working}
|
||||
bind:this={element}
|
||||
{...restProps}
|
||||
{onclick}>{@render children()}</button
|
||||
>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes rotate {
|
||||
0% {
|
||||
transform: rotate(120deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
20% {
|
||||
transform: rotate(120deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(270deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
--height: 42px;
|
||||
--border-radius: calc(var(--height) / 2);
|
||||
|
||||
position: relative;
|
||||
transition:
|
||||
border 200ms ease,
|
||||
color 200ms ease;
|
||||
|
||||
margin: 6px;
|
||||
|
||||
outline: 2px dashed currentcolor;
|
||||
outline-offset: 4px;
|
||||
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
height: var(--height);
|
||||
overflow: hidden;
|
||||
|
||||
&.primary {
|
||||
background: none;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
&.progress,
|
||||
&.working {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.working::before {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
border-radius: calc(var(--border-radius) - 2px);
|
||||
background: var(--md-sys-color-background);
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
content: "";
|
||||
}
|
||||
|
||||
&.working::after {
|
||||
position: absolute;
|
||||
z-index: -2;
|
||||
animation: rotate 1s ease-out forwards infinite;
|
||||
background: var(--md-sys-color-primary);
|
||||
width: 120%;
|
||||
height: 30%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&.progress::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
opacity: 0.2;
|
||||
z-index: -2;
|
||||
background: var(--md-sys-color-primary);
|
||||
width: var(--progress);
|
||||
height: 100%;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
</style>
|
||||
19
src/lib/assets/layouts/layout.d.ts
vendored
Normal file
19
src/lib/assets/layouts/layout.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface CompiledLayout {
|
||||
name: string;
|
||||
size: [number, number];
|
||||
keys: CompiledLayoutKey[];
|
||||
}
|
||||
|
||||
export interface CompiledLayoutKey {
|
||||
id: number;
|
||||
shape: "quarter-circle" | "square";
|
||||
cornerRadius: number;
|
||||
size: [number, number];
|
||||
pos: [number, number];
|
||||
rotate: number;
|
||||
}
|
||||
|
||||
declare module "*.layout.yml" {
|
||||
const layout: CompiledLayout;
|
||||
export default layout;
|
||||
}
|
||||
@@ -6,15 +6,9 @@ import type {
|
||||
CharaSettingsFile,
|
||||
} from "$lib/share/chara-file.js";
|
||||
import type { Change } from "$lib/undo-redo.js";
|
||||
import {
|
||||
changes,
|
||||
ChangeType,
|
||||
chords,
|
||||
layout,
|
||||
settings,
|
||||
} from "$lib/undo-redo.js";
|
||||
import { changes, ChangeType, layout, settings } from "$lib/undo-redo.js";
|
||||
import { get } from "svelte/store";
|
||||
import { activeProfile, serialPort } from "../serial/connection";
|
||||
import { activeProfile, deviceChords, serialPort } from "../serial/connection";
|
||||
import { csvLayoutToJson, isCsvLayout } from "$lib/backup/compat/legacy-layout";
|
||||
import { isCsvChords, csvChordsToJson } from "./compat/legacy-chords";
|
||||
|
||||
@@ -60,7 +54,7 @@ export function createChordBackup(): CharaChordFile {
|
||||
return {
|
||||
charaVersion: 1,
|
||||
type: "chords",
|
||||
chords: get(chords).map((it) => [it.actions, it.phrase]),
|
||||
chords: get(deviceChords).map((it) => [it.actions, it.phrase]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,22 +66,26 @@ export function createSettingsBackup(): CharaSettingsFile {
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -112,33 +110,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: {
|
||||
@@ -152,7 +162,9 @@ export function restoreFromFile(
|
||||
export function getChangesFromChordFile(file: CharaChordFile) {
|
||||
const changes: Change[] = [];
|
||||
const existingChords = new Set(
|
||||
get(chords).map(({ phrase, actions }) => JSON.stringify([actions, phrase])),
|
||||
get(deviceChords).map(({ phrase, actions }) =>
|
||||
JSON.stringify([actions, phrase]),
|
||||
),
|
||||
);
|
||||
for (const [input, output] of file.chords) {
|
||||
if (existingChords.has(JSON.stringify([input, output]))) {
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
import { browser } from "$app/environment";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import type { CharaDevice } from "$lib/serial/device";
|
||||
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
|
||||
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
|
||||
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);
|
||||
export function ccosKeyInterceptor(
|
||||
port: CharaDevice | undefined,
|
||||
recorder: ReplayRecorder,
|
||||
) {
|
||||
return ((element: HTMLElement) => {
|
||||
const ccos =
|
||||
port?.port && "handleKeyEvent" in port?.port
|
||||
? (port.port as CCOS)
|
||||
: undefined;
|
||||
console.log("Attaching CCOS key interceptor", ccos);
|
||||
|
||||
function onEvent(event: KeyboardEvent) {
|
||||
ccos.then((it) => it?.handleKeyEvent(event));
|
||||
ccos?.handleKeyEvent(event);
|
||||
if (!event.defaultPrevented) {
|
||||
recorder.next(event);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("keydown", onEvent, true);
|
||||
element.addEventListener("keyup", onEvent, true);
|
||||
if (ccos) {
|
||||
element.addEventListener("keydown", onEvent, true);
|
||||
element.addEventListener("keyup", onEvent, true);
|
||||
element.add;
|
||||
}
|
||||
|
||||
return () => {
|
||||
ccos.then((it) => it?.destroy());
|
||||
element.removeEventListener("keydown", onEvent, true);
|
||||
element.removeEventListener("keyup", onEvent, true);
|
||||
};
|
||||
}) satisfies Attachment<Window>;
|
||||
}) satisfies Attachment<HTMLElement>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getMeta } from "$lib/meta/meta-storage";
|
||||
import type { SerialPortLike } from "$lib/serial/device";
|
||||
import type {
|
||||
CCOSInEvent,
|
||||
CCOSInitEvent,
|
||||
CCOSKeyPressEvent,
|
||||
CCOSKeyReleaseEvent,
|
||||
@@ -11,7 +10,7 @@ import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
||||
|
||||
const device = "zero_wasm";
|
||||
|
||||
class CCOSKeyboardEvent extends KeyboardEvent {
|
||||
export class CCOSKeyboardEvent extends KeyboardEvent {
|
||||
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||
super(...params);
|
||||
}
|
||||
@@ -26,7 +25,46 @@ 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 layout = new Map<string, string>([
|
||||
...Array.from(
|
||||
{ length: 26 },
|
||||
(_, i) =>
|
||||
[
|
||||
JSON.stringify([`Key${String.fromCharCode(65 + i)}`, "Shift"]),
|
||||
String.fromCharCode(65 + i),
|
||||
] as const,
|
||||
),
|
||||
...Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => [JSON.stringify([`Key${i}`]), i.toString()] as const,
|
||||
),
|
||||
|
||||
[JSON.stringify(["Space"]), " "],
|
||||
[JSON.stringify(["Backquote"]), "`"],
|
||||
[JSON.stringify(["Minus"]), "-"],
|
||||
[JSON.stringify(["Comma"]), ","],
|
||||
[JSON.stringify(["Period"]), "."],
|
||||
[JSON.stringify(["Semicolon"]), ";"],
|
||||
[JSON.stringify(["Equal"]), "="],
|
||||
|
||||
[JSON.stringify(["Backquote", "Shift"]), "~"],
|
||||
[JSON.stringify(["Minus", "Shift"]), "_"],
|
||||
[JSON.stringify(["Comma", "Shift"]), "<"],
|
||||
[JSON.stringify(["Period", "Shift"]), ">"],
|
||||
[JSON.stringify(["Semicolon", "Shift"]), ":"],
|
||||
[JSON.stringify(["Equal", "Shift"]), "+"],
|
||||
|
||||
[JSON.stringify(["Digit0", "Shift"]), ")"],
|
||||
[JSON.stringify(["Digit1", "Shift"]), "!"],
|
||||
[JSON.stringify(["Digit2", "Shift"]), "@"],
|
||||
[JSON.stringify(["Digit3", "Shift"]), "#"],
|
||||
[JSON.stringify(["Digit4", "Shift"]), "$"],
|
||||
[JSON.stringify(["Digit5", "Shift"]), "%"],
|
||||
[JSON.stringify(["Digit6", "Shift"]), "^"],
|
||||
[JSON.stringify(["Digit7", "Shift"]), "&"],
|
||||
[JSON.stringify(["Digit8", "Shift"]), "*"],
|
||||
[JSON.stringify(["Digit9", "Shift"]), "("],
|
||||
]);
|
||||
|
||||
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||
|
||||
@@ -126,7 +164,6 @@ export class CCOS implements SerialPortLike {
|
||||
this.controller?.enqueue(event.data);
|
||||
return;
|
||||
}
|
||||
console.log("CCOS worker message", event.data);
|
||||
switch (event.data.type) {
|
||||
case "ready": {
|
||||
this.resolveReady();
|
||||
@@ -220,7 +257,7 @@ export class CCOS implements SerialPortLike {
|
||||
}
|
||||
|
||||
export async function fetchCCOS(
|
||||
version = ".2.2.0-beta.12+266bdda",
|
||||
version = "3.0.0-rc.0",
|
||||
fetch: typeof window.fetch = window.fetch,
|
||||
): Promise<CCOS | undefined> {
|
||||
const meta = await getMeta(device, version, fetch);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export class ReplayRecorder {
|
||||
|
||||
finish(trim = true, round = true) {
|
||||
return {
|
||||
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round),
|
||||
start: maybeRound(trim ? this.replay[0]?.[2] : this.start, round) ?? 0,
|
||||
finish: maybeRound(
|
||||
trim
|
||||
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
||||
@@ -74,6 +74,6 @@ export class ReplayRecorder {
|
||||
] as const,
|
||||
)
|
||||
.sort((a, b) => a[2] - b[2]),
|
||||
};
|
||||
} satisfies Replay;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,18 @@ export class TextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cursorNode.remove();
|
||||
for (const node of this.nodes.values()) {
|
||||
node.remove();
|
||||
}
|
||||
for (const node of this.heldNodes.values()) {
|
||||
node.remove();
|
||||
}
|
||||
this.nodes.clear();
|
||||
this.heldNodes.clear();
|
||||
}
|
||||
|
||||
private isShiny(char: TextToken, index: number) {
|
||||
return (
|
||||
this.shiny?.includes(index) ||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { RoomMember } from "matrix-js-sdk";
|
||||
import { matrixClient, memberColor } from "./chat";
|
||||
import { theme } from "$lib/preferences";
|
||||
import { hexFromArgb } from "@material/material-color-utilities";
|
||||
|
||||
let { members }: { members: RoomMember[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="member-list">
|
||||
{#each members as member (member.userId)}
|
||||
{@const avatar = member.getMxcAvatarUrl()}
|
||||
<div class="member">
|
||||
{#if avatar}
|
||||
<img
|
||||
class="avatar"
|
||||
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
|
||||
alt={member.name}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{:else}
|
||||
{@const color = memberColor(member, $theme)}
|
||||
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
|
||||
<div
|
||||
style:background={hexFromArgb(modeColor.color)}
|
||||
style:color={hexFromArgb(modeColor.onColor)}
|
||||
class="avatar avatar-placeholder icon"
|
||||
>
|
||||
person
|
||||
</div>
|
||||
{/if}
|
||||
<span>{member.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
span {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Room } from "matrix-js-sdk";
|
||||
import { matrixClient, currentRoomId } from "./chat";
|
||||
|
||||
let { rooms }: { rooms: Room[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="rooms">
|
||||
{#each $matrixClient.getRooms() as room}
|
||||
{@const avatar = room.getMxcAvatarUrl()}
|
||||
<button
|
||||
class:active={$currentRoomId === room.roomId}
|
||||
class="room"
|
||||
onclick={() => ($currentRoomId = room.roomId)}
|
||||
>
|
||||
{#if avatar}
|
||||
<img
|
||||
alt={room.name}
|
||||
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{:else}
|
||||
<div>#</div>
|
||||
{/if}
|
||||
<div>{room.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#await $matrixClient.publicRooms()}
|
||||
<div>Loading...</div>
|
||||
{:then rooms}
|
||||
{#each rooms.chunk as room}
|
||||
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
|
||||
<div>#</div>
|
||||
<div>{room.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:catch error}
|
||||
<div>{error.message}</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rooms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
padding-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding-inline: 16px;
|
||||
padding-block: 2px;
|
||||
padding-block: 4px;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
min-height: 0;
|
||||
|
||||
&.active {
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,231 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
EventTimeline,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomMember,
|
||||
RoomMemberEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { matrixClient } from "./chat";
|
||||
import MatrixEventComponent from "./events/MatrixEvent.svelte";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import { type Socket, io } from "socket.io-client";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
let { timeline }: { timeline: EventTimeline } = $props();
|
||||
|
||||
const excludeEvents = ["m.reaction", "m.room.redaction"];
|
||||
|
||||
let events = $state(
|
||||
timeline
|
||||
.getEvents()
|
||||
.filter((it) => !excludeEvents.includes(it.getType()))
|
||||
.reverse(),
|
||||
);
|
||||
|
||||
let recorder = $state(new ReplayRecorder());
|
||||
let showCursor = $state(false);
|
||||
|
||||
let timelineElement: HTMLElement = $state()!;
|
||||
|
||||
async function onTimeline(
|
||||
event: MatrixEvent,
|
||||
room?: Room,
|
||||
toStartOfTimeline?: boolean,
|
||||
) {
|
||||
if (room?.roomId !== timeline.getRoomId()) return;
|
||||
const sender = event.getSender();
|
||||
if (sender) {
|
||||
live.delete(sender);
|
||||
}
|
||||
if (excludeEvents.includes(event.getType())) return;
|
||||
if (toStartOfTimeline) {
|
||||
events.push(event);
|
||||
} else {
|
||||
const needScroll = timelineElement.scrollTop < 20;
|
||||
events.unshift(event);
|
||||
if (needScroll) {
|
||||
await tick();
|
||||
timelineElement.scroll({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let typing = $state<string[]>([]);
|
||||
|
||||
function onTyping(event: MatrixEvent, member: RoomMember) {
|
||||
typing = event.event.content?.["user_ids"] ?? [];
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const roomId = timeline.getRoomId();
|
||||
if (!roomId) return;
|
||||
const finalText = recorder.player.stepper.text
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
const finalRecording = recorder.finish();
|
||||
if (!finalText) return;
|
||||
recorder = new ReplayRecorder();
|
||||
await $matrixClient.sendMessage(roomId, {
|
||||
msgtype: "m.text" as MsgType.Text,
|
||||
body: finalText,
|
||||
// @ts-expect-error
|
||||
"m.replay": finalRecording,
|
||||
});
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
|
||||
send();
|
||||
return;
|
||||
} else {
|
||||
recorder.next(event);
|
||||
}
|
||||
|
||||
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
|
||||
recorder = new ReplayRecorder();
|
||||
} else {
|
||||
socket.emit("message", {
|
||||
timeStamp: event.timeStamp,
|
||||
type: event.type,
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
username: $matrixClient.getUserId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let socket: Socket = $state()!;
|
||||
let live = new SvelteMap<string, ReplayRecorder>();
|
||||
|
||||
onMount(() => {
|
||||
socket = io("https://srv.charachorder.io");
|
||||
socket.emit("join", timeline.getRoomId());
|
||||
|
||||
socket.on("message", async ({ message }) => {
|
||||
let userRecorder = live.get(message.username);
|
||||
if (!userRecorder) {
|
||||
userRecorder = new ReplayRecorder();
|
||||
live.set(message.username, userRecorder);
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
userRecorder.next(message);
|
||||
|
||||
if (userRecorder.player.stepper.text.length === 0) {
|
||||
live.delete(message.username);
|
||||
}
|
||||
});
|
||||
|
||||
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
||||
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
socket?.disconnect();
|
||||
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
||||
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div bind:this={timelineElement} class="timeline">
|
||||
{#each live.entries() as [userId, recorder] (userId)}
|
||||
{@const roomId = timeline.getRoomId()}
|
||||
{#if roomId}
|
||||
{@const room = $matrixClient.getRoom(roomId)}
|
||||
{@const member = room?.getMember(userId)}
|
||||
{#if member}
|
||||
<MatrixEventComponent sender={member} replay={recorder.player} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{#each events as event, i (event.event["event_id"])}
|
||||
{@const prev = events[i + 1]}
|
||||
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="static-elements">
|
||||
<div class="indicators"></div>
|
||||
<div class="input-box">
|
||||
<button class="icon">add</button>
|
||||
<div
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
class="input"
|
||||
onkeydown={onKey}
|
||||
onkeyup={onKey}
|
||||
onfocusin={() => (showCursor = true)}
|
||||
onfocusout={() => (showCursor = false)}
|
||||
>
|
||||
<CharRecorder replay={recorder.player} cursor={showCursor} />
|
||||
</div>
|
||||
<button class="icon" onclick={send}>send</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
$border-radius: 16px;
|
||||
|
||||
.input {
|
||||
flex-grow: 1;
|
||||
cursor: text;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: $border-radius;
|
||||
padding: 0.5em;
|
||||
font-size: 1rem;
|
||||
|
||||
text-wrap: wrap;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding-block: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.static-elements {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column-reverse;
|
||||
contain: content;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +0,0 @@
|
||||
import { derived, writable, type Writable } from "svelte/store";
|
||||
import type {
|
||||
ClientEvent,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
argbFromHex,
|
||||
type CustomColorGroup,
|
||||
} from "@material/material-color-utilities";
|
||||
import type { UserTheme } from "$lib/preferences";
|
||||
import { MatrixRx } from "./matrix-rx/client";
|
||||
|
||||
export const matrixClient: Writable<MatrixClient> = writable();
|
||||
|
||||
export const isLoggedIn: Writable<boolean> = writable(false);
|
||||
|
||||
export const matrix = derived(
|
||||
[matrixClient, isLoggedIn],
|
||||
([matrixClient, isLoggedIn]) =>
|
||||
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
|
||||
);
|
||||
|
||||
export const currentRoomId = persistentWritable<string | null>(
|
||||
"currentRoomId",
|
||||
null,
|
||||
);
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
export async function initMatrixClient() {
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
const cryptoStore = new IndexedDBCryptoStore(
|
||||
window.indexedDB,
|
||||
"matrix-crypto",
|
||||
);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
console.log("store");
|
||||
await store.startup();
|
||||
console.log("cryptoStore");
|
||||
await cryptoStore.startup();
|
||||
console.log("client");
|
||||
await client.startClient();
|
||||
client.once("sync" as ClientEvent.Sync, () => {
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
storeLogin(await client.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
}
|
||||
|
||||
matrixClient.set(client);
|
||||
console.log("done");
|
||||
}
|
||||
|
||||
export function memberColor(
|
||||
member: RoomMember,
|
||||
theme: UserTheme,
|
||||
): CustomColorGroup {
|
||||
let hash = 0;
|
||||
member.userId.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
||||
{ value: argbFromHex(color), name: "member", blend: true },
|
||||
]).customColors.find((c) => c.color.name === "member")!;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
argbFromHex,
|
||||
type CustomColorGroup,
|
||||
} from "@material/material-color-utilities";
|
||||
import type { UserTheme } from "$lib/preferences";
|
||||
|
||||
export const matrixClient: Writable<MatrixClient> = writable();
|
||||
|
||||
export const currentRoomId = persistentWritable<string | null>(
|
||||
"currentRoomId",
|
||||
null,
|
||||
);
|
||||
|
||||
export function memberColor(
|
||||
member: RoomMember,
|
||||
theme: UserTheme,
|
||||
): CustomColorGroup {
|
||||
let hash = 0;
|
||||
member.userId.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
||||
{ value: argbFromHex(color), name: "member", blend: true },
|
||||
]).customColors.find((c) => c.color.name === "member")!;
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
EventTimeline,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
Relations,
|
||||
RelationsEvent,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
|
||||
import { matrixClient, memberColor } from "../chat";
|
||||
import { theme } from "$lib/preferences";
|
||||
import { hexFromArgb } from "@material/material-color-utilities";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
let {
|
||||
event,
|
||||
prev,
|
||||
sender,
|
||||
replay: replayPlayer,
|
||||
timeline,
|
||||
}: {
|
||||
event?: MatrixEvent;
|
||||
prev?: MatrixEvent;
|
||||
sender?: RoomMember | null;
|
||||
replay?: Replay | ReplayPlayer;
|
||||
timeline?: EventTimeline;
|
||||
} = $props();
|
||||
|
||||
let toolbarHover = $state(false);
|
||||
let mainHover = $state(false);
|
||||
|
||||
let hover = $derived(toolbarHover || mainHover);
|
||||
|
||||
let replay: Replay | undefined = $state();
|
||||
|
||||
let reactions: Relations | undefined = $state(
|
||||
timeline && event?.event.event_id
|
||||
? timeline
|
||||
.getTimelineSet()
|
||||
.relations.getChildEventsForEvent(
|
||||
event.event.event_id,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
|
||||
|
||||
function createRelations() {
|
||||
if (!timeline || !event?.event.event_id) return;
|
||||
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
|
||||
reactions?.off(
|
||||
"Relations.remove" as RelationsEvent.Remove,
|
||||
createRelations,
|
||||
);
|
||||
reactions = timeline
|
||||
.getTimelineSet()
|
||||
.relations.getChildEventsForEvent(
|
||||
event.event.event_id,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
);
|
||||
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
|
||||
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
|
||||
reactions?.on(
|
||||
"Relations.redaction" as RelationsEvent.Redaction,
|
||||
createRelations,
|
||||
);
|
||||
annotations.set(
|
||||
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
|
||||
);
|
||||
console.log("create");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createRelations();
|
||||
event?.on(
|
||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
||||
createRelations,
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
event?.off(
|
||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
||||
createRelations,
|
||||
);
|
||||
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
|
||||
reactions?.off(
|
||||
"Relations.remove" as RelationsEvent.Remove,
|
||||
createRelations,
|
||||
);
|
||||
reactions?.off(
|
||||
"Relations.redaction" as RelationsEvent.Redaction,
|
||||
createRelations,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="event"
|
||||
role="log"
|
||||
onmouseover={() => (mainHover = true)}
|
||||
onfocus={() => (mainHover = true)}
|
||||
onmouseout={() => (mainHover = false)}
|
||||
onblur={() => (mainHover = false)}
|
||||
>
|
||||
{#if event && hover}
|
||||
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
|
||||
{/if}
|
||||
|
||||
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
|
||||
{@const color = memberColor(sender, $theme)}
|
||||
{@const avatarMxc = sender.getMxcAvatarUrl()}
|
||||
{#if avatarMxc}
|
||||
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
|
||||
<img
|
||||
class="avatar"
|
||||
src={avatar}
|
||||
alt={sender.name}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="avatar avatar-placeholder icon"
|
||||
style:background={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
style:color={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
|
||||
)}
|
||||
>
|
||||
person
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="sender"
|
||||
style:color={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
>
|
||||
<strong>{sender.name}</strong>
|
||||
{#if replay || replayPlayer}
|
||||
<div class="dots">
|
||||
{#each new Array(3) as _, i}
|
||||
<div
|
||||
style:animation-delay={i * 0.2 + "s"}
|
||||
style:background={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
class="dot"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
{#if event}
|
||||
{#if event.getType() === "m.room.message"}
|
||||
<MatrixMessageEvent {event} bind:replay />
|
||||
{:else}
|
||||
<details>
|
||||
<summary>{event.getType()}</summary>
|
||||
<pre>{JSON.stringify(event.event, null, 2)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if replayPlayer}
|
||||
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if event && hover}
|
||||
<div
|
||||
role="toolbar"
|
||||
tabindex="0"
|
||||
class="toolbar"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onmouseover={() => (toolbarHover = true)}
|
||||
onfocus={() => (toolbarHover = true)}
|
||||
onmouseout={() => (toolbarHover = false)}
|
||||
onblur={() => (toolbarHover = false)}
|
||||
>
|
||||
{#if event.getType() === "m.room.message"}
|
||||
{@const message = event.event.content?.["body"]}
|
||||
<a
|
||||
class="icon rocket"
|
||||
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
|
||||
>rocket_launch</a
|
||||
>
|
||||
{/if}
|
||||
<button class="icon">add_reaction</button>
|
||||
<button class="icon">reply</button>
|
||||
{#if event.event.content?.["m.replay"]}
|
||||
{#if replay}
|
||||
<button class="icon" onclick={() => (replay = undefined)}>stop</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => (replay = event.event.content?.["m.replay"])}
|
||||
>replay</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="icon">more_horiz</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $annotations && $annotations.length > 0}
|
||||
<div class="reactions">
|
||||
{#each $annotations as [reaction, events]}
|
||||
<button class="reaction"
|
||||
>{reaction} <span class="count">{events.size}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
details {
|
||||
opacity: 0.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
pre {
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@keyframes rocket {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate(4px, -4px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
.icon.rocket {
|
||||
animation: rocket 2s;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
border-radius: 4px;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
padding: 4px;
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
a,
|
||||
button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
animation: bounce 1s infinite;
|
||||
border-radius: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.sender,
|
||||
.avatar {
|
||||
margin-block: 2px 4px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
translate: 0 2px;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
div.avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sender {
|
||||
display: flex;
|
||||
grid-area: sender;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reactions {
|
||||
display: flex;
|
||||
grid-area: reactions;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
display: flex;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
|
||||
> .count {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
position: relative;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
|
||||
grid-template-areas:
|
||||
"avatar sender date"
|
||||
"avatar content content"
|
||||
"none reactions reactions";
|
||||
margin-inline: 0.5em;
|
||||
border-radius: 4px;
|
||||
padding-inline: 0.5em;
|
||||
padding-block: 0.25em;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.reactions,
|
||||
.content,
|
||||
.sender {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { fade } from "svelte/transition";
|
||||
import { matrixClient } from "../chat";
|
||||
|
||||
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if event.event.content?.msgtype === "m.image"}
|
||||
<img
|
||||
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
|
||||
alt={event.event.content["body"]}
|
||||
/>
|
||||
{:else}
|
||||
<span class="content" style:opacity={replay && 0}
|
||||
>{event.event.content?.["body"]}</span
|
||||
>
|
||||
{/if}
|
||||
{#if replay}
|
||||
<div class="replay" out:fade>
|
||||
<CharRecorder
|
||||
{replay}
|
||||
cursor={true}
|
||||
keys={true}
|
||||
ondone={() => (replay = undefined)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
max-height: 16em;
|
||||
}
|
||||
|
||||
.content {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.replay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
distinctUntilChanged,
|
||||
merge,
|
||||
} from "rxjs";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
|
||||
function roomListDistinct(prev: Room[], curr: Room[]) {
|
||||
if (prev.length !== curr.length) return false;
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class MatrixRx {
|
||||
topLevelRooms$: Observable<Room[]>;
|
||||
|
||||
topLevelSpaces$: Observable<Room[]>;
|
||||
|
||||
topLevelChats$: Observable<Room[]>;
|
||||
|
||||
constructor(private client: MatrixClient) {
|
||||
this.topLevelRooms$ = merge(
|
||||
of([]),
|
||||
fromMatrixClientEvent(client, "Room"),
|
||||
fromMatrixClientEvent(client, "deleteRoom"),
|
||||
fromMatrixClientEvent(client, "Room.myMembership"),
|
||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
||||
filter(
|
||||
([_room, prev, curr]) =>
|
||||
prev.getStateEvents("m.space.parent").length !==
|
||||
curr.getStateEvents("m.space.parent").length,
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
map(() =>
|
||||
this.client.getVisibleRooms().filter(
|
||||
(room) =>
|
||||
room.getMyMembership() !== "leave" &&
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState("f" as Direction.Forward)
|
||||
?.getStateEvents("m.space.parent").length === 0,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
|
||||
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
|
||||
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
|
||||
this.topLevelChats$ = this.topLevelRooms$.pipe(
|
||||
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SpaceRx {
|
||||
constructor(
|
||||
private client: MatrixClient,
|
||||
private space: Room,
|
||||
) {}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
|
||||
import { fromEvent, type Observable } from "rxjs";
|
||||
|
||||
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
|
||||
client: MatrixClient,
|
||||
eventName: `${T}`, // hack so we can use strings instead of enums
|
||||
): Observable<Parameters<ClientEventHandlerMap[T]>> {
|
||||
return fromEvent(client, eventName) as Observable<
|
||||
Parameters<ClientEventHandlerMap[T]>
|
||||
>;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
Direction,
|
||||
RoomState,
|
||||
RoomStateEventHandlerMap,
|
||||
EventType,
|
||||
} from "matrix-js-sdk";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
import {
|
||||
map,
|
||||
filter,
|
||||
merge,
|
||||
startWith,
|
||||
Observable,
|
||||
of,
|
||||
fromEvent,
|
||||
concat,
|
||||
defer,
|
||||
} from "rxjs";
|
||||
|
||||
export function matrixRoom$(
|
||||
client: MatrixClient,
|
||||
roomId: string | undefined,
|
||||
): Observable<Room | undefined> {
|
||||
return merge([
|
||||
fromMatrixClientEvent(client, "Room").pipe(
|
||||
filter(([room]) => room.roomId === roomId),
|
||||
),
|
||||
fromMatrixClientEvent(client, "deleteRoom").pipe(
|
||||
filter(([id]) => id === roomId),
|
||||
),
|
||||
]).pipe(
|
||||
startWith([]),
|
||||
map(() => client.getRoom(roomId) ?? undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function roomTimeline$(
|
||||
client: MatrixClient,
|
||||
room: Room | undefined,
|
||||
): Observable<MatrixEvent[] | undefined> {
|
||||
if (!room) return of(undefined);
|
||||
const eventTimeline = room.getLiveTimeline();
|
||||
|
||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
||||
filter(
|
||||
([, eventRoom]) =>
|
||||
eventRoom !== undefined && eventRoom.roomId === room.roomId,
|
||||
),
|
||||
startWith([]),
|
||||
map(() => eventTimeline.getEvents()),
|
||||
);
|
||||
}
|
||||
|
||||
export function roomCurrentStateEvents$(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
eventType: EventType | string,
|
||||
): Observable<MatrixEvent[]> {
|
||||
return concat(
|
||||
defer(() =>
|
||||
of(
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState("f" as Direction.Forward)
|
||||
?.getStateEvents(eventType) ?? [],
|
||||
),
|
||||
),
|
||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
||||
filter(([room]) => room.roomId === room.roomId),
|
||||
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
|
||||
state: RoomState,
|
||||
eventName: `${T}`,
|
||||
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
|
||||
return fromEvent(state, eventName) as Observable<
|
||||
Parameters<RoomStateEventHandlerMap[T]>
|
||||
>;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { filter, map, of, startWith, type Observable } from "rxjs";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
|
||||
export function roomTimeline(
|
||||
client: MatrixClient,
|
||||
roomId: string | undefined,
|
||||
): Observable<MatrixEvent[]> {
|
||||
if (!roomId) return of([]);
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) return of([]);
|
||||
const eventTimeline = room.getLiveTimeline();
|
||||
|
||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
||||
filter(([, room]) => room?.roomId === roomId),
|
||||
startWith([]),
|
||||
map(() => eventTimeline.getEvents()),
|
||||
);
|
||||
}
|
||||
54
src/lib/chord-editor/AutospaceSelector.svelte
Normal file
54
src/lib/chord-editor/AutospaceSelector.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { actionTooltip } from "$lib/title";
|
||||
|
||||
let {
|
||||
onchange,
|
||||
value,
|
||||
variant,
|
||||
}: {
|
||||
value: boolean;
|
||||
variant: "start" | "end";
|
||||
onchange: (
|
||||
event: Event & { currentTarget: EventTarget & HTMLInputElement },
|
||||
) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet tooltip()}
|
||||
{#if value}
|
||||
{#if variant === "start"}
|
||||
<b>Remove</b> preceding space
|
||||
{:else}
|
||||
<b>Add</b> trailing space
|
||||
{/if}
|
||||
{:else if variant === "start"}
|
||||
<b>Keep</b> preceding space
|
||||
{:else}
|
||||
<b>Add</b> trailing space
|
||||
{/if}
|
||||
{/snippet}
|
||||
<label class="autospace" {@attach actionTooltip(tooltip)}
|
||||
><span class="icon">space_bar</span><input
|
||||
checked={!value}
|
||||
{onchange}
|
||||
type="checkbox"
|
||||
/></label
|
||||
>
|
||||
|
||||
<style lang="scss">
|
||||
label.autospace {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-inline: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--md-sys-color-tertiary-container);
|
||||
padding-inline: 0;
|
||||
height: 1em;
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
font-size: 1.3em;
|
||||
|
||||
&:has(:checked) {
|
||||
opacity: var(--auto-space-show, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
149
src/lib/chord-editor/ChangesPanel.svelte
Normal file
149
src/lib/chord-editor/ChangesPanel.svelte
Normal file
@@ -0,0 +1,149 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
serialPort,
|
||||
sync,
|
||||
syncProgress,
|
||||
syncStatus,
|
||||
} from "$lib/serial/connection";
|
||||
import type { ParseResult } from "./parse-meta";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import ProgressButton from "$lib/ProgressButton.svelte";
|
||||
import type { EditorView } from "codemirror";
|
||||
import { createSaveTask } from "./save-chords";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let { parsed, view }: { parsed: ParseResult; view: EditorView } = $props();
|
||||
|
||||
$inspect(parsed);
|
||||
|
||||
let added = $derived(
|
||||
parsed.chords.reduce(
|
||||
(acc, chord) =>
|
||||
acc +
|
||||
(chord.phrase && chord.phrase.originalValue === undefined ? 1 : 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let changed = $derived(
|
||||
parsed.chords.reduce(
|
||||
(acc, chord) =>
|
||||
acc +
|
||||
(chord.phrase?.originalValue !== undefined &&
|
||||
chord.phrase.originalValue !== chord.phrase.value
|
||||
? 1
|
||||
: 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
|
||||
let error: Error | undefined = $state(undefined);
|
||||
|
||||
async function save() {
|
||||
const port = $serialPort;
|
||||
if (!view || !port) return;
|
||||
error = undefined;
|
||||
const task = createSaveTask(view);
|
||||
const total = task.remove.length + task.set.length;
|
||||
$syncStatus = "uploading";
|
||||
$syncProgress = { current: 0, max: total };
|
||||
let progressCount = 0;
|
||||
for (const input of task.remove) {
|
||||
try {
|
||||
await port.deleteChord({ actions: input });
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
progressCount++;
|
||||
$syncProgress = { current: progressCount, max: total };
|
||||
}
|
||||
for (const [input, phrase] of task.set) {
|
||||
try {
|
||||
await port.setChord({ actions: input, phrase });
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
}
|
||||
progressCount++;
|
||||
$syncProgress = { current: progressCount, max: total };
|
||||
}
|
||||
if (error !== undefined) {
|
||||
goto("/terminal");
|
||||
}
|
||||
await sync();
|
||||
}
|
||||
|
||||
let removed = $derived(parsed.removed.length);
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if added + changed + removed !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
|
||||
<div {@attach actionTooltip($LL.saveActions.SAVE())}>
|
||||
<ProgressButton
|
||||
disabled={$syncStatus !== "done"}
|
||||
working={$syncStatus === "uploading" || $syncStatus === "downloading"}
|
||||
progress={$syncProgress && $syncStatus === "uploading"
|
||||
? $syncProgress.current / $syncProgress.max
|
||||
: 0}
|
||||
style="--height: 36px"
|
||||
error={error !== undefined
|
||||
? (error.message ?? error.toString())
|
||||
: undefined}
|
||||
onclick={save}
|
||||
>
|
||||
<span class="icon">save</span>
|
||||
{$LL.saveActions.SAVE()}
|
||||
</ProgressButton>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
{#if added}
|
||||
<span class="added">+{added}</span>
|
||||
{/if}
|
||||
{#if changed}
|
||||
<span class="changed">~{changed}</span>
|
||||
{/if}
|
||||
{#if removed}
|
||||
<span class="removed">-{removed}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if parsed.aliases.size > 0}
|
||||
<div class="section">
|
||||
<span class="icon">content_copy</span>
|
||||
<span>{parsed.aliases.size}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.added {
|
||||
color: var(--md-sys-color-success);
|
||||
}
|
||||
|
||||
.changed {
|
||||
color: var(--md-sys-color-warning);
|
||||
}
|
||||
|
||||
.removed {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
</style>
|
||||
156
src/lib/chord-editor/action-linter.ts
Normal file
156
src/lib/chord-editor/action-linter.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { linter, type Diagnostic } from "@codemirror/lint";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
|
||||
export function actionLinter(config?: Parameters<typeof linter>[1]) {
|
||||
const finalConfig: Parameters<typeof linter>[1] = {
|
||||
...config,
|
||||
needsRefresh(update) {
|
||||
return (
|
||||
update.startState.field(parsedChordsField) !==
|
||||
update.state.field(parsedChordsField)
|
||||
);
|
||||
},
|
||||
};
|
||||
return linter((view) => {
|
||||
console.log("lint");
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const parsed = view.state.field(parsedChordsField);
|
||||
|
||||
for (const chord of parsed.chords) {
|
||||
if (chord.disabled) {
|
||||
diagnostics.push({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
severity: "info",
|
||||
markClass: "chord-ignored",
|
||||
message: `Chord disabled`,
|
||||
});
|
||||
}
|
||||
if (chord.compounds) {
|
||||
for (const compound of chord.compounds) {
|
||||
if (compound.actions.length === 0 && compound.parent) {
|
||||
const replacement = view.state.doc.sliceString(
|
||||
compound.parent.range[0],
|
||||
compound.parent.input!.range[1],
|
||||
);
|
||||
diagnostics.push({
|
||||
from: compound.range[0],
|
||||
to: compound.range[1],
|
||||
severity: "warning",
|
||||
message: `Compound literal can be replaced with "${replacement}"`,
|
||||
actions: [
|
||||
{
|
||||
name: "Replace",
|
||||
apply(view, from, to) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert: replacement + "|",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
const lastCompound = chord.compounds.at(-1);
|
||||
if (lastCompound) {
|
||||
const from = chord.range[0];
|
||||
const to = lastCompound.range[1];
|
||||
if (lastCompound.parent) {
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "info",
|
||||
markClass: "chord-child",
|
||||
message: `Child of ${view.state.doc.sliceString(lastCompound.parent.range[0], lastCompound.parent.range[1])}`,
|
||||
actions: [
|
||||
{
|
||||
name: "Select Parent",
|
||||
apply(view) {
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: lastCompound.parent!.range[0],
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "warning",
|
||||
message: `Orphan compound`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chord.children) {
|
||||
diagnostics.push({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
severity: "info",
|
||||
markClass: "chord-parent",
|
||||
message: `Parent of ${chord.children.length} compound(s)`,
|
||||
actions: chord.children.map((child) => ({
|
||||
name: `Go to ${view.state.doc.sliceString(child.range[0], child.range[1])}`,
|
||||
apply(view) {
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: child.range[0],
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
if (chord.phrase) {
|
||||
if (!chord.phrase.originalValue) {
|
||||
diagnostics.push({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
severity: "info",
|
||||
markClass: "chord-new",
|
||||
message: `New Chord`,
|
||||
});
|
||||
} else if (chord.phrase.originalValue !== chord.phrase.value) {
|
||||
diagnostics.push({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
severity: "info",
|
||||
markClass: "chord-unchanged",
|
||||
message: `Phrase changed`,
|
||||
});
|
||||
}
|
||||
|
||||
if (chord.aliases) {
|
||||
diagnostics.push({
|
||||
from: chord.phrase.range[0],
|
||||
to: chord.phrase.range[1],
|
||||
severity: "warning",
|
||||
markClass: "chord-alias",
|
||||
message: `Alias of ${chord.aliases.length} chord(s)`,
|
||||
actions: chord.aliases.map((alias) => ({
|
||||
name: `Go to ${view.state.doc.sliceString(alias.range[0], alias.input?.range[1] ?? alias.range[1])}`,
|
||||
apply(view) {
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: alias.range[0],
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}, finalConfig);
|
||||
}
|
||||
10
src/lib/chord-editor/action-meta-plugin.ts
Normal file
10
src/lib/chord-editor/action-meta-plugin.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
import { derived } from "svelte/store";
|
||||
import { reactiveStateField } from "./store-state-field";
|
||||
|
||||
const actionMeta = derived([KEYMAP_IDS, KEYMAP_CODES], ([ids, codes]) => ({
|
||||
ids,
|
||||
codes,
|
||||
}));
|
||||
|
||||
export const actionMetaPlugin = reactiveStateField(actionMeta);
|
||||
102
src/lib/chord-editor/action-plugin.ts
Normal file
102
src/lib/chord-editor/action-plugin.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from "@codemirror/view";
|
||||
import { mount, unmount } from "svelte";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import type { Range } from "@codemirror/state";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
import { iterActions } from "./parse-meta";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
|
||||
export class ActionWidget extends WidgetType {
|
||||
component?: {};
|
||||
|
||||
constructor(readonly info: KeyInfo) {
|
||||
super();
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
if (this.component) {
|
||||
unmount(this.component);
|
||||
}
|
||||
const element = document.createElement("span");
|
||||
element.style.paddingInline = "2px";
|
||||
|
||||
this.component = mount(Action, {
|
||||
target: element,
|
||||
props: {
|
||||
action: this.info,
|
||||
display: "keys",
|
||||
inText: true,
|
||||
withPopover: false,
|
||||
},
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
if (this.component) {
|
||||
unmount(this.component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function actionWidgets(view: EditorView) {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
for (const chord of view.state.field(parsedChordsField).chords) {
|
||||
if (chord.range[1] < from || chord.range[0] > to) continue;
|
||||
iterActions(chord, (action) => {
|
||||
if (
|
||||
view.state.selection.ranges.some(
|
||||
(r) => r.from <= action.range[1] && r.to > action.range[0],
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (action.info && action.explicit) {
|
||||
const deco = Decoration.replace({
|
||||
widget: new ActionWidget(action.info),
|
||||
});
|
||||
widgets.push(deco.range(action.range[0], action.range[1]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
export const actionPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = actionWidgets(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
update.selectionSet ||
|
||||
update.startState.field(parsedChordsField) !=
|
||||
update.state.field(parsedChordsField)
|
||||
)
|
||||
this.decorations = actionWidgets(update.view);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(instance) {
|
||||
return instance.decorations;
|
||||
},
|
||||
provide(plugin) {
|
||||
return EditorView.atomicRanges.of(
|
||||
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
263
src/lib/chord-editor/action-serializer.ts
Normal file
263
src/lib/chord-editor/action-serializer.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import {
|
||||
composeChordInput,
|
||||
hasConcatenator,
|
||||
hashChord,
|
||||
willBeValidChordInput,
|
||||
} from "$lib/serial/chord";
|
||||
import type {
|
||||
ActionMeta,
|
||||
ChordMeta,
|
||||
MetaRange,
|
||||
ParseResult,
|
||||
} from "./parse-meta";
|
||||
import type { Tree } from "@lezer/common";
|
||||
|
||||
function parseChordMeta(
|
||||
tree: Tree,
|
||||
ids: Map<string, KeyInfo>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
sliceString: (from: number, to: number) => string,
|
||||
): ChordMeta[] {
|
||||
console.time("parseChordTree");
|
||||
const result: ChordMeta[] = [];
|
||||
|
||||
let current: ChordMeta = { range: [0, 0], valid: false };
|
||||
let actions: ActionMeta[] = [];
|
||||
let actionRange: MetaRange | undefined = undefined;
|
||||
|
||||
tree.cursor().iterate(
|
||||
(node) => {
|
||||
if (node.name === "Action") {
|
||||
actionRange = [node.from, node.to];
|
||||
} else if (node.name === "ChordPhrase") {
|
||||
current.phrase = {
|
||||
range: [node.from, node.to],
|
||||
value: [],
|
||||
valid: true,
|
||||
actions: [],
|
||||
hasConcatenator: false,
|
||||
};
|
||||
} else if (node.name === "Chord") {
|
||||
current = { range: [node.from, node.to], valid: false };
|
||||
} else if (node.name === "ActionString") {
|
||||
actions = [];
|
||||
} else if (node.name === "HexNumber") {
|
||||
const hexString = sliceString(node.from, node.to);
|
||||
const code = Number.parseInt(hexString, 16);
|
||||
const parentNode = node.node.parent;
|
||||
if (parentNode?.type.name === "CompoundLiteral") {
|
||||
current.compounds ??= [];
|
||||
current.compounds.push({
|
||||
range: [parentNode.from, parentNode.to],
|
||||
value: code,
|
||||
actions: [],
|
||||
valid: true, // TODO: validate compound literal
|
||||
});
|
||||
} else {
|
||||
const valid = !(Number.isNaN(code) || code < 0 || code > 1023);
|
||||
actions.push({
|
||||
code,
|
||||
info: codes.get(code),
|
||||
explicit: true,
|
||||
valid,
|
||||
range: actionRange!,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
node.name === "ActionId" ||
|
||||
node.name === "SingleLetter" ||
|
||||
node.name === "EscapedLetter"
|
||||
) {
|
||||
const id = sliceString(node.from, node.to);
|
||||
const info = ids.get(id);
|
||||
const value: ActionMeta = {
|
||||
code: info?.code ?? Number.NaN,
|
||||
info,
|
||||
valid: info !== undefined,
|
||||
range: actionRange!,
|
||||
};
|
||||
if (node.name === "ActionId") {
|
||||
value.explicit = true;
|
||||
}
|
||||
actions.push(value);
|
||||
}
|
||||
},
|
||||
(node) => {
|
||||
if (node.name === "Chord") {
|
||||
result.push(current);
|
||||
if (current.phrase) {
|
||||
current.phrase.actions = actions;
|
||||
current.phrase.value = actions.map(({ code }) => code);
|
||||
current.phrase.valid = actions.every(({ valid }) => valid);
|
||||
current.phrase.hasConcatenator = hasConcatenator(
|
||||
current.phrase.value,
|
||||
codes,
|
||||
);
|
||||
}
|
||||
current.valid =
|
||||
(current.phrase?.valid ?? false) && (current.input?.valid ?? false);
|
||||
if (!current.valid) {
|
||||
current.disabled = true;
|
||||
}
|
||||
} else if (node.name === "CompoundInput") {
|
||||
const lastCompound = current.compounds?.at(-1);
|
||||
current.compounds ??= [];
|
||||
current.compounds.push({
|
||||
range: [node.from, node.to],
|
||||
value: hashChord(
|
||||
composeChordInput(
|
||||
actions.map(({ code }) => code),
|
||||
lastCompound?.value,
|
||||
),
|
||||
),
|
||||
actions,
|
||||
valid:
|
||||
willBeValidChordInput(actions.length, lastCompound !== undefined) &&
|
||||
actions.every(({ valid }) => valid),
|
||||
});
|
||||
} else if (node.name === "ChordInput") {
|
||||
const lastCompound = current.compounds?.at(-1);
|
||||
current.input = {
|
||||
range: [node.from, node.to],
|
||||
value: composeChordInput(
|
||||
actions.map(({ code }) => code),
|
||||
lastCompound?.value,
|
||||
),
|
||||
valid:
|
||||
willBeValidChordInput(actions.length, lastCompound !== undefined) &&
|
||||
actions.every(({ valid }) => valid),
|
||||
actions,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
console.timeEnd("parseChordTree");
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveChordOverrides(chords: ChordMeta[]): Map<string, ChordMeta> {
|
||||
console.time("resolveOverrides");
|
||||
const seen = new Map<string, ChordMeta>();
|
||||
for (const info of chords) {
|
||||
if (!info.input || info.disabled) continue;
|
||||
const key = JSON.stringify(info.input.value);
|
||||
const override = seen.get(key);
|
||||
if (override) {
|
||||
override.overrides ??= [];
|
||||
override.overrides.push(info);
|
||||
info.overriddenBy = override;
|
||||
info.disabled = true;
|
||||
} else {
|
||||
seen.set(key, info);
|
||||
}
|
||||
}
|
||||
console.timeEnd("resolveOverrides");
|
||||
return seen;
|
||||
}
|
||||
|
||||
function resolveChordAliases(chords: ChordMeta[]): Map<string, ChordMeta[]> {
|
||||
console.time("resolveAliases");
|
||||
const aliases = new Map<string, ChordMeta[]>();
|
||||
for (const info of chords) {
|
||||
if (!info.phrase) continue;
|
||||
const key = JSON.stringify(info.phrase.value);
|
||||
const list = aliases.get(key) ?? [];
|
||||
list.push(info);
|
||||
aliases.set(key, list);
|
||||
}
|
||||
for (const [key, value] of aliases) {
|
||||
if (value.length <= 1) {
|
||||
aliases.delete(key);
|
||||
} else {
|
||||
for (const info of value) {
|
||||
info.aliases = value.filter((i) => i !== info);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.timeEnd("resolveAliases");
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function resolveCompoundParents(chords: ChordMeta[]): Map<number, ChordMeta> {
|
||||
console.time("resolveCompoundParents");
|
||||
const compounds = new Map<number, ChordMeta>();
|
||||
for (const chord of chords) {
|
||||
if (chord.input && !chord.disabled) {
|
||||
compounds.set(hashChord(chord.input.value), chord);
|
||||
}
|
||||
}
|
||||
for (const chord of chords) {
|
||||
if (chord.compounds) {
|
||||
for (const compound of chord.compounds) {
|
||||
const parent = compounds.get(compound.value);
|
||||
if (parent) {
|
||||
compound.parent = parent;
|
||||
}
|
||||
}
|
||||
const lastCompound = chord.compounds?.at(-1);
|
||||
if (lastCompound && lastCompound.parent) {
|
||||
lastCompound.parent.children ??= [];
|
||||
lastCompound.parent.children.push(chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.timeEnd("resolveCompoundParents");
|
||||
return compounds;
|
||||
}
|
||||
|
||||
export function resolveChanges(
|
||||
chords: ChordMeta[],
|
||||
inputs: Map<string, ChordMeta>,
|
||||
deviceChords: CharaChordFile["chords"],
|
||||
): [CharaChordFile["chords"], Map<string, ChordMeta>] {
|
||||
console.time("resolveChanges");
|
||||
const removed: CharaChordFile["chords"] = [];
|
||||
const exact = new Map<string, ChordMeta>();
|
||||
for (const chord of chords) {
|
||||
if (chord.input && chord.phrase && !chord.disabled) {
|
||||
exact.set(
|
||||
JSON.stringify([chord.input.value, chord.phrase?.value ?? []]),
|
||||
chord,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const deviceChord of deviceChords) {
|
||||
const exactMatch = exact.get(JSON.stringify(deviceChord));
|
||||
if (exactMatch) {
|
||||
exactMatch.phrase!.originalValue = exactMatch.phrase!.value;
|
||||
continue;
|
||||
}
|
||||
const byInput = inputs.get(JSON.stringify(deviceChord[0]));
|
||||
if (byInput) {
|
||||
byInput.phrase!.originalValue = deviceChord[1];
|
||||
continue;
|
||||
}
|
||||
removed.push(deviceChord);
|
||||
}
|
||||
|
||||
console.timeEnd("resolveChanges");
|
||||
return [removed, exact];
|
||||
}
|
||||
|
||||
export function parseCharaChords(
|
||||
tree: Tree,
|
||||
ids: Map<string, KeyInfo>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
deviceChords: CharaChordFile["chords"],
|
||||
sliceString: (from: number, to: number) => string,
|
||||
): ParseResult {
|
||||
console.time("parseTotal");
|
||||
|
||||
const chords = parseChordMeta(tree, ids, codes, sliceString);
|
||||
const inputs = resolveChordOverrides(chords);
|
||||
const aliases = resolveChordAliases(chords);
|
||||
const compounds = resolveCompoundParents(chords);
|
||||
const [removed, exact] = resolveChanges(chords, inputs, deviceChords);
|
||||
|
||||
console.timeEnd("parseTotal");
|
||||
|
||||
return { chords, removed, aliases, compounds, inputs, exact };
|
||||
}
|
||||
41
src/lib/chord-editor/action-tooltip.ts
Normal file
41
src/lib/chord-editor/action-tooltip.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { hoverTooltip } from "@codemirror/view";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
import { type ActionMeta, iterActions } from "./parse-meta";
|
||||
import { mount, unmount } from "svelte";
|
||||
import ActionTooltip from "$lib/components/action/ActionTooltip.svelte";
|
||||
|
||||
function inRange(pos: number, side: 1 | -1, range: [number, number]) {
|
||||
if (side < 0) {
|
||||
return pos > range[0] && pos <= range[1];
|
||||
} else {
|
||||
return pos >= range[0] && pos < range[1];
|
||||
}
|
||||
}
|
||||
|
||||
export const actionHover = hoverTooltip((view, pos, side) => {
|
||||
const chord = view.state
|
||||
.field(parsedChordsField)
|
||||
.chords.find((chord) => inRange(pos, side, chord.range));
|
||||
if (!chord) return null;
|
||||
let action = iterActions<ActionMeta>(chord, (action) =>
|
||||
inRange(pos, side, action.range) ? action : undefined,
|
||||
);
|
||||
if (!action?.info) return null;
|
||||
return {
|
||||
pos: action.range[0],
|
||||
end: action.range[1],
|
||||
create() {
|
||||
const dom = document.createElement("div");
|
||||
const element = mount(ActionTooltip, {
|
||||
target: dom,
|
||||
props: { info: action.info, valid: true },
|
||||
});
|
||||
return {
|
||||
dom,
|
||||
destroy() {
|
||||
unmount(element);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
39
src/lib/chord-editor/autocomplete.ts
Normal file
39
src/lib/chord-editor/autocomplete.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
type PluginValue,
|
||||
} from "@codemirror/view";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
|
||||
export function actionAutocompletePlugin(
|
||||
query: (query: string | undefined) => void,
|
||||
) {
|
||||
return ViewPlugin.fromClass(
|
||||
class implements PluginValue {
|
||||
constructor(readonly view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
query(this.resolveAutocomplete(update.state));
|
||||
}
|
||||
|
||||
resolveAutocomplete(state: EditorState): string | undefined {
|
||||
if (state.selection.ranges.length !== 1) return;
|
||||
const from = state.selection.ranges[0]!.from;
|
||||
const to = state.selection.ranges[0]!.to;
|
||||
if (from !== to) return;
|
||||
const tree = syntaxTree(state);
|
||||
const node = tree.resolveInner(from, -1).parent;
|
||||
if (node?.name !== "ExplicitAction") return;
|
||||
if (node.getChild("ExplicitDelimEnd")) return;
|
||||
const queryNode = node.getChild("ExplicitDelimStart")?.nextSibling;
|
||||
return (
|
||||
(queryNode
|
||||
? state.doc.sliceString(queryNode.from, queryNode.to)
|
||||
: undefined) || undefined
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
44
src/lib/chord-editor/changes-panel.svelte.ts
Normal file
44
src/lib/chord-editor/changes-panel.svelte.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { EditorView, showPanel, type Panel } from "@codemirror/view";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
import { mount, unmount } from "svelte";
|
||||
import ChangesPanel from "./ChangesPanel.svelte";
|
||||
|
||||
function changesPanelFunc(view: EditorView): Panel {
|
||||
let dom = document.createElement("div");
|
||||
dom.style.display = "contents";
|
||||
let viewState = $state.raw(view);
|
||||
let parsed = $state.raw(view.state.field(parsedChordsField));
|
||||
let component: {};
|
||||
return {
|
||||
dom,
|
||||
mount() {
|
||||
component = mount(ChangesPanel, {
|
||||
target: dom,
|
||||
props: {
|
||||
get parsed() {
|
||||
return parsed;
|
||||
},
|
||||
get view() {
|
||||
return viewState;
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
update: (update) => {
|
||||
if (
|
||||
update.startState.field(parsedChordsField) !==
|
||||
update.state.field(parsedChordsField)
|
||||
) {
|
||||
console.log("update changes panel");
|
||||
parsed = update.state.field(parsedChordsField);
|
||||
}
|
||||
},
|
||||
destroy() {
|
||||
unmount(component);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function changesPanel() {
|
||||
return showPanel.of(changesPanelFunc);
|
||||
}
|
||||
159
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
159
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from "@codemirror/view";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Range } from "@codemirror/state";
|
||||
import { mount, unmount } from "svelte";
|
||||
import Action from "../components/Action.svelte";
|
||||
import type { SyntaxNodeRef } from "@lezer/common";
|
||||
import classNames from "./concatenator-button.module.scss";
|
||||
|
||||
export class DelimWidget extends WidgetType {
|
||||
component?: {};
|
||||
element?: HTMLElement;
|
||||
|
||||
constructor(readonly hasConcatenator: boolean) {
|
||||
super();
|
||||
}
|
||||
|
||||
override eq(other: DelimWidget) {
|
||||
return this.hasConcatenator == other.hasConcatenator;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
if (!this.element) {
|
||||
/*this.element = document.createElement("span");
|
||||
this.element.innerHTML =
|
||||
" ⇛" + (this.hasConcatenator ? "" : " ");
|
||||
this.element.style.scale = "1.8";
|
||||
this.element.style.color =
|
||||
"color-mix(in srgb, currentColor 50%, transparent)";
|
||||
|
||||
if (this.hasConcatenator) {
|
||||
const button = document.createElement("button");
|
||||
button.className = classNames["concatenator-button"]!;
|
||||
this.component = mount(Action, {
|
||||
target: button,
|
||||
props: { action: 574, display: "keys", inText: true, ghost: true },
|
||||
});
|
||||
this.element.appendChild(button);
|
||||
}*/
|
||||
this.element = document.createElement("div");
|
||||
this.element.style.breakAfter = "column";
|
||||
}
|
||||
return this.element;
|
||||
}
|
||||
|
||||
override ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
if (this.component) {
|
||||
unmount(this.component);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getJoinNode(
|
||||
view: EditorView,
|
||||
phraseDelimNode: SyntaxNodeRef,
|
||||
): SyntaxNodeRef | null | undefined {
|
||||
const firstPhraseAction = phraseDelimNode.node.nextSibling
|
||||
?.getChild("ActionString")
|
||||
?.node.firstChild?.node.getChild("ExplicitAction");
|
||||
const idNode = firstPhraseAction?.node.getChild("ActionId");
|
||||
const actionId = idNode
|
||||
? view.state.doc.sliceString(idNode.from, idNode.to)
|
||||
: null;
|
||||
const isJoinAction =
|
||||
actionId === "JOIN" &&
|
||||
!!firstPhraseAction!.node.getChild("ExplicitDelimEnd");
|
||||
return isJoinAction ? firstPhraseAction : null;
|
||||
}
|
||||
|
||||
function actionWidgets(view: EditorView) {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (node.name !== "PhraseDelim") return;
|
||||
const joinNode = getJoinNode(view, node);
|
||||
|
||||
let deco = Decoration.replace({
|
||||
widget: new DelimWidget(!joinNode),
|
||||
});
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
},
|
||||
});
|
||||
}
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
export const delimPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = actionWidgets(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||
)
|
||||
this.decorations = actionWidgets(update.view);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(instance) {
|
||||
return instance.decorations;
|
||||
},
|
||||
provide(plugin) {
|
||||
return EditorView.atomicRanges.of(
|
||||
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||
);
|
||||
},
|
||||
eventHandlers: {
|
||||
click: (event, view) => {
|
||||
if (!(event.target instanceof HTMLElement)) return;
|
||||
if (
|
||||
!(
|
||||
event.target instanceof HTMLButtonElement ||
|
||||
(event.target as HTMLElement).parentElement instanceof
|
||||
HTMLButtonElement
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const chordNode = syntaxTree(view.state).resolve(
|
||||
view.posAtDOM(event.target),
|
||||
);
|
||||
const delimNode = (
|
||||
chordNode.name === "ActionString"
|
||||
? chordNode.parent?.parent
|
||||
: chordNode
|
||||
)?.getChild("PhraseDelim");
|
||||
if (!delimNode) return;
|
||||
const joinNode = getJoinNode(view, delimNode);
|
||||
if (!event.target.checked && !joinNode) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: delimNode.to,
|
||||
insert: "<JOIN>",
|
||||
},
|
||||
selection: { anchor: delimNode.to + "<JOIN>".length },
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
44
src/lib/chord-editor/chord-sync-plugin.ts
Normal file
44
src/lib/chord-editor/chord-sync-plugin.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
import { actionMetaPlugin } from "./action-meta-plugin";
|
||||
import { syncCharaChords } from "./chord-sync";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
|
||||
const chordSyncEffect = StateEffect.define<CharaChordFile["chords"]>();
|
||||
|
||||
export function editorSyncChords(
|
||||
view: EditorView,
|
||||
newDeviceChords: CharaChordFile["chords"],
|
||||
) {
|
||||
const { ids, codes } = view.state.field(actionMetaPlugin.field);
|
||||
const oldDeviceChords = view.state.field(deviceChordField);
|
||||
const changes = syncCharaChords(
|
||||
oldDeviceChords,
|
||||
newDeviceChords,
|
||||
ids,
|
||||
codes,
|
||||
view.state.doc.toString(),
|
||||
);
|
||||
view.dispatch({
|
||||
effects: chordSyncEffect.of(newDeviceChords),
|
||||
changes,
|
||||
});
|
||||
}
|
||||
|
||||
export const deviceChordField = StateField.define<CharaChordFile["chords"]>({
|
||||
create() {
|
||||
return [];
|
||||
},
|
||||
update(value, transaction) {
|
||||
return (
|
||||
transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ??
|
||||
value
|
||||
);
|
||||
},
|
||||
toJSON(value) {
|
||||
return value;
|
||||
},
|
||||
fromJSON(value) {
|
||||
return value;
|
||||
},
|
||||
});
|
||||
135
src/lib/chord-editor/chord-sync.spec.ts
Normal file
135
src/lib/chord-editor/chord-sync.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseCharaChords } from "./action-serializer";
|
||||
import { parser } from "./chords.grammar";
|
||||
import { syncCharaChords } from "./chord-sync";
|
||||
import { Text } from "@codemirror/state";
|
||||
|
||||
const asciiInfo: KeyInfo[] = Array.from(
|
||||
{ length: 0x7f - 0x20 },
|
||||
(_, i) =>
|
||||
({
|
||||
code: i + 0x20,
|
||||
id: String.fromCharCode(i + 0x20),
|
||||
}) satisfies KeyInfo,
|
||||
);
|
||||
const asciiCodes = new Map<number, KeyInfo>(
|
||||
asciiInfo.map((info) => [info.code, info]),
|
||||
);
|
||||
const asciiIds = new Map<string, KeyInfo>(
|
||||
asciiInfo.map((info) => [info.id!, info]),
|
||||
);
|
||||
|
||||
function chords(...strings: string[]): string {
|
||||
return strings.join("\n");
|
||||
}
|
||||
|
||||
function backup(doc: string): CharaChordFile["chords"] {
|
||||
const tree = parser.parse(doc);
|
||||
const result = parseCharaChords(tree, asciiIds, asciiCodes, [], (from, to) =>
|
||||
doc.slice(from, to),
|
||||
);
|
||||
return result.chords
|
||||
.filter((chord) => !chord.disabled)
|
||||
.map((chord) => [chord.input?.value ?? [], chord.phrase?.value ?? []]);
|
||||
}
|
||||
|
||||
function expectSync(options: {
|
||||
org: string[];
|
||||
mod: string[];
|
||||
cur: string[];
|
||||
exp: string[];
|
||||
}) {
|
||||
expect(
|
||||
syncCharaChords(
|
||||
backup(chords(...options.org)),
|
||||
backup(chords(...options.mod)),
|
||||
asciiIds,
|
||||
asciiCodes,
|
||||
chords(...options.cur),
|
||||
)
|
||||
.apply(Text.of(options.cur))
|
||||
.toString()
|
||||
.replace(/\n$/, ""),
|
||||
).toEqual(chords(...options.exp));
|
||||
}
|
||||
|
||||
describe("chord sync", function () {
|
||||
it("should not do anything when no changes happened", function () {
|
||||
expectSync({
|
||||
org: ["abc=>def", "def=>ghi", "jkl=>mno"],
|
||||
mod: ["abc=>def", "def=>ghi", "jkl=>mno"],
|
||||
cur: ["abc=>def", "def=>ghi", "jkl=>mno"],
|
||||
exp: ["abc=>def", "def=>ghi", "jkl=>mno"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should not touch the doc if device chords are unchanged", function () {
|
||||
expectSync({
|
||||
org: ["abc=>def", "def=>ghi", "jkl=>mno"],
|
||||
mod: ["abc=>def", "def=>ghi", "jkl=>mno"],
|
||||
cur: ["ab=>def", "def=>gh"],
|
||||
exp: ["ab=>def", "def=>gh"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply removals to unchanged chords only", function () {
|
||||
expectSync({
|
||||
org: ["abc=>def", "def=>ghi", "jkl=>mno", "mno=>pqr"],
|
||||
mod: ["abc=>def"],
|
||||
cur: ["abc=>def", "def=>ghij", "jkl=>mno", "mno=>pqr"],
|
||||
exp: ["abc=>def", "def=>ghij"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should keep user modifications over device modifications", function () {
|
||||
expectSync({
|
||||
org: ["abc=>def", "def=>ghi", "jkl=>mno", "mno=>pqr"],
|
||||
mod: ["abc=>def", "def=>ghijk", "jkl=>mnop", "mno=>pqr"],
|
||||
cur: ["abc=>def", "def=>ghij", "jkl=>mno", "mno=>pqr"],
|
||||
exp: ["abc=>def", "def=>ghij", "jkl=>mnop", "mno=>pqr"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle complex changes", function () {
|
||||
expectSync({
|
||||
org: [
|
||||
"unchanged=>unchanged",
|
||||
"usermod=>usermod",
|
||||
"devmod=>devmod",
|
||||
"userremoval=>userremoval",
|
||||
"devremoval=>devremoval",
|
||||
"devremusermod=>devremusermod",
|
||||
],
|
||||
mod: [
|
||||
"unchanged=>unchanged",
|
||||
"devadd=>devadd",
|
||||
"usermod=>usermod",
|
||||
"userremoval=>userremoval",
|
||||
"devmod=>devmod1",
|
||||
"sameadd=>sameadd",
|
||||
],
|
||||
cur: [
|
||||
"useradd1=>useradd1",
|
||||
"unchanged=>unchanged",
|
||||
"usermod=>use",
|
||||
"devremusermod=>xyz",
|
||||
"devmod=>devmod",
|
||||
"sameadd=>sameadd",
|
||||
"devremoval=>devremoval",
|
||||
"useradd=>useradd",
|
||||
],
|
||||
exp: [
|
||||
"devadd=>devadd",
|
||||
"useradd1=>useradd1",
|
||||
"unchanged=>unchanged",
|
||||
"usermod=>use",
|
||||
"devremusermod=>xyz",
|
||||
"devmod=>devmod1",
|
||||
"sameadd=>sameadd",
|
||||
"useradd=>useradd",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
130
src/lib/chord-editor/chord-sync.ts
Normal file
130
src/lib/chord-editor/chord-sync.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { ChangeSet, type ChangeSpec } from "@codemirror/state";
|
||||
import { parseCharaChords } from "./action-serializer";
|
||||
import { parser } from "./chords.grammar";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import { splitCompound } from "$lib/serial/chord";
|
||||
|
||||
function canUseIdAsString(info: KeyInfo): boolean {
|
||||
return !!info.id && /^[^>\n]+$/.test(info.id);
|
||||
}
|
||||
|
||||
export function actionToValue(code: number, info?: KeyInfo) {
|
||||
if (info && info.id?.length === 1)
|
||||
return /^[<>|\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
|
||||
if (!info || !canUseIdAsString(info))
|
||||
return `<0x${code.toString(16).padStart(2, "0")}>`;
|
||||
return `<${info.id}>`;
|
||||
}
|
||||
|
||||
function canonicalInputSorting(input: number[], phrase: number[]): number[] {
|
||||
const tail = [...input];
|
||||
const prefix = phrase.filter((code) => {
|
||||
const index = tail.indexOf(code);
|
||||
if (index !== -1) {
|
||||
tail.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return [...prefix, ...tail];
|
||||
}
|
||||
|
||||
export interface ChangeType {
|
||||
from: number;
|
||||
to: number;
|
||||
insert: string;
|
||||
}
|
||||
|
||||
export function syncCharaChords(
|
||||
originalDeviceChords: CharaChordFile["chords"],
|
||||
newDeviceChords: CharaChordFile["chords"],
|
||||
ids: Map<string, KeyInfo>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
doc: string,
|
||||
): ChangeSet {
|
||||
const tree = parser.parse(doc);
|
||||
const result = parseCharaChords(
|
||||
tree,
|
||||
ids,
|
||||
codes,
|
||||
originalDeviceChords,
|
||||
(from, to) => doc.slice(from, to),
|
||||
);
|
||||
|
||||
const exactChords = new Map<string, number>();
|
||||
for (const chord of originalDeviceChords) {
|
||||
const key = JSON.stringify(chord);
|
||||
const count = exactChords.get(key) ?? 0;
|
||||
exactChords.set(key, count + 1);
|
||||
}
|
||||
|
||||
const changes: ChangeType[] = [];
|
||||
|
||||
const inputModified = new Set<string>();
|
||||
for (const chord of newDeviceChords) {
|
||||
const key = JSON.stringify(chord);
|
||||
const count = exactChords.get(key) ?? 0;
|
||||
if (count > 0) {
|
||||
exactChords.set(key, count - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inputKey = JSON.stringify(chord[0]);
|
||||
inputModified.add(inputKey);
|
||||
const byInput = result.inputs.get(inputKey);
|
||||
if (byInput) {
|
||||
if (
|
||||
byInput.phrase?.originalValue &&
|
||||
byInput.phrase.originalValue === byInput.phrase.value
|
||||
) {
|
||||
changes.push({
|
||||
from: byInput.phrase.range[0],
|
||||
to: byInput.phrase.range[1],
|
||||
insert: chord[1]
|
||||
.map((code) => actionToValue(code, codes.get(code)))
|
||||
.join(""),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const [inputs, compound] = splitCompound(chord[0]);
|
||||
const sortedInput = canonicalInputSorting(inputs, chord[1]);
|
||||
changes.push({
|
||||
from: 0,
|
||||
to: 0,
|
||||
insert:
|
||||
(compound ? `|0x${compound.toString(16)}|` : "") +
|
||||
sortedInput
|
||||
.map((code) => actionToValue(code, codes.get(code)))
|
||||
.join("") +
|
||||
"=>" +
|
||||
chord[1]
|
||||
.map((code) => actionToValue(code, codes.get(code)))
|
||||
.join("") +
|
||||
"\n",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
changes.push(
|
||||
...exactChords
|
||||
.entries()
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key]) => result.exact.get(key))
|
||||
.filter((chord) => chord !== undefined)
|
||||
.filter(
|
||||
(chord) =>
|
||||
chord.input && !inputModified.has(JSON.stringify(chord.input.value)),
|
||||
)
|
||||
.map(
|
||||
(chord) =>
|
||||
({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
insert: "",
|
||||
}) satisfies ChangeSpec,
|
||||
),
|
||||
);
|
||||
|
||||
return ChangeSet.of(changes, doc.length);
|
||||
}
|
||||
54
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
54
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { parser } from "./chords.grammar";
|
||||
import {
|
||||
LRLanguage,
|
||||
LanguageSupport,
|
||||
HighlightStyle,
|
||||
} from "@codemirror/language";
|
||||
import { styleTags, tags } from "@lezer/highlight";
|
||||
|
||||
export const chordHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.keyword,
|
||||
paddingInline: "2px",
|
||||
opacity: "0.5",
|
||||
},
|
||||
{
|
||||
tag: tags.className,
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--md-sys-color-surface-variant) 50%, transparent)",
|
||||
borderRadius: "4px",
|
||||
paddingInline: "4px",
|
||||
marginInline: "-4px",
|
||||
},
|
||||
{
|
||||
tag: tags.integer,
|
||||
color: "var(--md-sys-color-tertiary)",
|
||||
},
|
||||
{
|
||||
tag: tags.angleBracket,
|
||||
opacity: "0.5",
|
||||
},
|
||||
{ tag: tags.modifier, opacity: "0.25" },
|
||||
{ tag: tags.escape, color: "var(--md-sys-color-primary)" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
]);
|
||||
|
||||
export const chordLanguage = LRLanguage.define({
|
||||
name: "chords",
|
||||
parser: parser.configure({
|
||||
props: [
|
||||
styleTags({
|
||||
"PhraseDelim CompoundDelim": [tags.keyword, tags.strong],
|
||||
"HexNumber DecimalNumber": [tags.className, tags.integer],
|
||||
"ExplicitDelimStart ExplicitDelimEnd": tags.angleBracket,
|
||||
ActionId: tags.className,
|
||||
EscapedLetter: tags.escape,
|
||||
Escape: [tags.escape, tags.modifier],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export function chordLanguageSupport() {
|
||||
return new LanguageSupport(chordLanguage, [chordLanguage.data.of({})]);
|
||||
}
|
||||
43
src/lib/chord-editor/chords.grammar
Normal file
43
src/lib/chord-editor/chords.grammar
Normal file
@@ -0,0 +1,43 @@
|
||||
@top Program { Chord* }
|
||||
|
||||
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
|
||||
EscapedSingleAction { Escape EscapedLetter }
|
||||
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||
|
||||
ActionString { Action+ }
|
||||
|
||||
CompoundLiteral { CompoundDelim HexNumber CompoundDelim }
|
||||
CompoundInput { ActionString CompoundDelim }
|
||||
|
||||
ChordInput { CompoundLiteral? CompoundInput* ActionString }
|
||||
ChordPhrase { ActionString }
|
||||
|
||||
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||
|
||||
@skip {
|
||||
Space
|
||||
}
|
||||
|
||||
@tokens {
|
||||
@precedence { HexNumber, ActionId }
|
||||
@precedence { Space, Escape }
|
||||
@precedence { Space, SingleLetter }
|
||||
@precedence { Escape, SingleLetter }
|
||||
@precedence { CompoundDelim, SingleLetter }
|
||||
@precedence { ActionId, Space }
|
||||
@precedence { EscapedLetter, Space }
|
||||
|
||||
Space {" "}
|
||||
ExplicitDelimStart {"<"}
|
||||
ExplicitDelimEnd {">"}
|
||||
CompoundDelim {"|"}
|
||||
PhraseDelim {"=>"}
|
||||
Escape { "\\" }
|
||||
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||
ActionId { ![\n>]+ }
|
||||
SingleLetter { ![\n<] }
|
||||
EscapedLetter { ![\n] }
|
||||
ChordDelim { ("\n" | @eof) }
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
13
src/lib/chord-editor/concatenator-button.module.scss
Normal file
13
src/lib/chord-editor/concatenator-button.module.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
.concatenator-button {
|
||||
display: inline;
|
||||
opacity: calc(var(--auto-space-show, 0) * 0.7);
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
height: auto;
|
||||
|
||||
> :global(kbd) {
|
||||
outline: 1px dashed var(--md-sys-color-outline);
|
||||
outline-offset: -1px;
|
||||
background: none;
|
||||
}
|
||||
}
|
||||
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "*.grammar" {
|
||||
export const parser: import("@lezer/lr").LRParser;
|
||||
}
|
||||
186
src/lib/chord-editor/parse-meta.ts
Normal file
186
src/lib/chord-editor/parse-meta.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import type { ChangeDesc } from "@codemirror/state";
|
||||
|
||||
export type MetaRange = [from: number, to: number];
|
||||
|
||||
function mapMetaRange(range: MetaRange, change: ChangeDesc): MetaRange {
|
||||
const newFrom = change.mapPos(range[0]);
|
||||
const newTo = change.mapPos(range[1]);
|
||||
if (newFrom === range[0] && newTo === range[1]) {
|
||||
return range;
|
||||
}
|
||||
return [newFrom, newTo];
|
||||
}
|
||||
|
||||
export interface ActionMeta {
|
||||
code: number;
|
||||
info?: KeyInfo;
|
||||
explicit?: boolean;
|
||||
range: MetaRange;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
function mapActionMeta(action: ActionMeta, change: ChangeDesc): ActionMeta {
|
||||
const newRange = mapMetaRange(action.range, change);
|
||||
if (newRange === action.range) {
|
||||
return action;
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
range: newRange,
|
||||
};
|
||||
}
|
||||
|
||||
function mapArray<T>(
|
||||
array: T[],
|
||||
change: ChangeDesc,
|
||||
mapFn: (action: T, change: ChangeDesc) => T,
|
||||
): T[] {
|
||||
let changed = false;
|
||||
const newArray = array.map((value) => {
|
||||
const newValue = mapFn(value, change);
|
||||
if (newValue !== value) {
|
||||
changed = true;
|
||||
return newValue;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (changed) {
|
||||
return newArray;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
export interface ActionStringMeta<T> {
|
||||
range: MetaRange;
|
||||
value: T;
|
||||
valid: boolean;
|
||||
actions: ActionMeta[];
|
||||
}
|
||||
|
||||
function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
|
||||
actionString: T,
|
||||
change: ChangeDesc,
|
||||
) {
|
||||
const newRange = mapMetaRange(actionString.range, change);
|
||||
const newActions = mapArray(actionString.actions, change, mapActionMeta);
|
||||
if (newRange === actionString.range && newActions === actionString.actions) {
|
||||
return actionString;
|
||||
}
|
||||
return {
|
||||
...actionString,
|
||||
range: newRange,
|
||||
actions: newActions,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhraseMeta extends ActionStringMeta<number[]> {
|
||||
hasConcatenator: boolean;
|
||||
originalValue?: number[];
|
||||
}
|
||||
|
||||
export interface CompoundMeta extends ActionStringMeta<number> {
|
||||
parent?: ChordMeta;
|
||||
}
|
||||
|
||||
export interface InputMeta extends ActionStringMeta<number[]> {}
|
||||
|
||||
export interface ChordMeta {
|
||||
range: MetaRange;
|
||||
valid: boolean;
|
||||
disabled?: boolean;
|
||||
compounds?: CompoundMeta[];
|
||||
input?: InputMeta;
|
||||
phrase?: PhraseMeta;
|
||||
children?: ChordMeta[];
|
||||
overrides?: ChordMeta[];
|
||||
aliases?: ChordMeta[];
|
||||
overriddenBy?: ChordMeta;
|
||||
}
|
||||
|
||||
export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta {
|
||||
const newRange = mapMetaRange(chord.range, change);
|
||||
const newCompounds = chord.compounds
|
||||
? mapArray(chord.compounds, change, mapActionStringMeta)
|
||||
: undefined;
|
||||
const newInput = chord.input
|
||||
? mapActionStringMeta(chord.input, change)
|
||||
: undefined;
|
||||
const newPhrase = chord.phrase
|
||||
? mapActionStringMeta(chord.phrase, change)
|
||||
: undefined;
|
||||
if (
|
||||
newRange === chord.range &&
|
||||
newCompounds === chord.compounds &&
|
||||
newInput === chord.input &&
|
||||
newPhrase === chord.phrase
|
||||
) {
|
||||
return chord;
|
||||
}
|
||||
|
||||
const newChord: ChordMeta = {
|
||||
...chord,
|
||||
range: newRange,
|
||||
};
|
||||
if (newCompounds) newChord.compounds = newCompounds;
|
||||
if (newInput) newChord.input = newInput;
|
||||
if (newPhrase) newChord.phrase = newPhrase;
|
||||
return newChord;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
chords: ChordMeta[];
|
||||
removed: CharaChordFile["chords"];
|
||||
aliases: Map<string, ChordMeta[]>;
|
||||
compounds: Map<number, ChordMeta>;
|
||||
inputs: Map<string, ChordMeta>;
|
||||
exact: Map<string, ChordMeta>;
|
||||
}
|
||||
|
||||
export function mapParseResult(
|
||||
result: ParseResult,
|
||||
change: ChangeDesc,
|
||||
): ParseResult {
|
||||
const newChords = mapArray(result.chords, change, mapChordMeta);
|
||||
if (newChords === result.chords) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
chords: newChords,
|
||||
};
|
||||
}
|
||||
|
||||
export function iterActions<T = void>(
|
||||
chord: ChordMeta,
|
||||
callback: (action: ActionMeta) => T | void,
|
||||
): T | undefined {
|
||||
if (chord.input) {
|
||||
for (const action of chord.input.actions) {
|
||||
const result = callback(action);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chord.compounds) {
|
||||
for (const compound of chord.compounds) {
|
||||
for (const action of compound.actions) {
|
||||
const result = callback(action);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chord.phrase) {
|
||||
for (const action of chord.phrase.actions) {
|
||||
const result = callback(action);
|
||||
if (result !== undefined) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
40
src/lib/chord-editor/parsed-chords-plugin.ts
Normal file
40
src/lib/chord-editor/parsed-chords-plugin.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { StateField } from "@codemirror/state";
|
||||
import { parseCharaChords } from "./action-serializer";
|
||||
import { actionMetaPlugin } from "./action-meta-plugin";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { deviceChordField } from "./chord-sync-plugin";
|
||||
import { mapParseResult, type ParseResult } from "./parse-meta";
|
||||
|
||||
export const parsedChordsField = StateField.define<ParseResult>({
|
||||
create() {
|
||||
return {
|
||||
chords: [],
|
||||
removed: [],
|
||||
aliases: new Map(),
|
||||
compounds: new Map(),
|
||||
inputs: new Map(),
|
||||
exact: new Map(),
|
||||
};
|
||||
},
|
||||
update(value, transaction) {
|
||||
const tree = syntaxTree(transaction.state);
|
||||
const ids = transaction.state.field(actionMetaPlugin.field).ids;
|
||||
const codes = transaction.state.field(actionMetaPlugin.field).codes;
|
||||
const deviceChords = transaction.state.field(deviceChordField);
|
||||
if (
|
||||
tree !== syntaxTree(transaction.startState) ||
|
||||
ids !== transaction.startState.field(actionMetaPlugin.field).ids ||
|
||||
codes !== transaction.startState.field(actionMetaPlugin.field).codes ||
|
||||
deviceChords !== transaction.startState.field(deviceChordField)
|
||||
) {
|
||||
return parseCharaChords(
|
||||
syntaxTree(transaction.state),
|
||||
ids,
|
||||
codes,
|
||||
deviceChords,
|
||||
(from, to) => transaction.state.doc.sliceString(from, to),
|
||||
);
|
||||
}
|
||||
return mapParseResult(value, transaction.changes);
|
||||
},
|
||||
});
|
||||
187
src/lib/chord-editor/persistent-state-plugin.ts
Normal file
187
src/lib/chord-editor/persistent-state-plugin.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
} from "@codemirror/view";
|
||||
import {
|
||||
history,
|
||||
historyField,
|
||||
historyKeymap,
|
||||
standardKeymap,
|
||||
} from "@codemirror/commands";
|
||||
import { debounceTime, mergeMap, Subject } from "rxjs";
|
||||
import { EditorState, type EditorStateConfig } from "@codemirror/state";
|
||||
import { lintGutter } from "@codemirror/lint";
|
||||
import {
|
||||
chordHighlightStyle,
|
||||
chordLanguageSupport,
|
||||
} from "./chords-grammar-plugin";
|
||||
import { actionLinter } from "./action-linter";
|
||||
import { actionAutocompletePlugin } from "./autocomplete";
|
||||
import { delimPlugin } from "./chord-delim-plugin";
|
||||
import { actionPlugin } from "./action-plugin";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { deviceChordField } from "./chord-sync-plugin";
|
||||
import { actionMetaPlugin } from "./action-meta-plugin";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
import { changesPanel } from "./changes-panel.svelte";
|
||||
import { searchKeymap } from "@codemirror/search";
|
||||
import { actionHover } from "./action-tooltip";
|
||||
|
||||
const serializedFields = {
|
||||
history: historyField,
|
||||
deviceChords: deviceChordField,
|
||||
};
|
||||
|
||||
export interface EditorConfig {
|
||||
rawCode?: boolean;
|
||||
storeName: string;
|
||||
autocomplete(query: string | undefined): void;
|
||||
}
|
||||
|
||||
export function createConfig(params: EditorConfig) {
|
||||
return {
|
||||
extensions: [
|
||||
actionMetaPlugin.plugin,
|
||||
deviceChordField,
|
||||
parsedChordsField,
|
||||
actionHover,
|
||||
changesPanel(),
|
||||
lintGutter(),
|
||||
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
|
||||
chordLanguageSupport(),
|
||||
actionLinter({
|
||||
delay: 100,
|
||||
markerFilter(diagnostics) {
|
||||
return diagnostics.filter((it) => it.from !== it.to);
|
||||
},
|
||||
}),
|
||||
actionAutocompletePlugin(params.autocomplete),
|
||||
persistentStatePlugin(params.storeName),
|
||||
history(),
|
||||
syntaxHighlighting(chordHighlightStyle),
|
||||
highlightActiveLine(),
|
||||
EditorView.theme({
|
||||
".cm-line": {
|
||||
borderBottom: "1px solid transparent",
|
||||
caretColor: "var(--md-sys-color-on-surface)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
width: "100%",
|
||||
fontFamily: "inherit !important",
|
||||
gap: "8px",
|
||||
},
|
||||
".cm-content": {
|
||||
flex: 1,
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderColor: "var(--md-sys-color-on-surface)",
|
||||
},
|
||||
}),
|
||||
keymap.of([...standardKeymap, ...historyKeymap, ...searchKeymap]),
|
||||
],
|
||||
} satisfies EditorStateConfig;
|
||||
}
|
||||
|
||||
export async function loadPersistentState(
|
||||
params: EditorConfig,
|
||||
): Promise<EditorState> {
|
||||
const stored = await getState(params.storeName);
|
||||
const config = createConfig(params);
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
return EditorState.fromJSON(stored, config, serializedFields);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse persistent state:", e);
|
||||
}
|
||||
}
|
||||
return EditorState.create(config);
|
||||
}
|
||||
|
||||
export function persistentStatePlugin(storeName: string) {
|
||||
return ViewPlugin.fromClass(
|
||||
class {
|
||||
updateSubject = new Subject<void>();
|
||||
subscription = this.updateSubject
|
||||
.pipe(
|
||||
debounceTime(500),
|
||||
mergeMap(() =>
|
||||
storeState(storeName, this.view.state.toJSON(serializedFields)),
|
||||
),
|
||||
)
|
||||
.subscribe(() => {});
|
||||
|
||||
constructor(readonly view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.state !== update.startState) {
|
||||
this.updateSubject.next();
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.subscription.unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const dbName = "chord-state";
|
||||
const dbVersion = 1;
|
||||
const storeName = "state";
|
||||
|
||||
async function openDb(): Promise<IDBDatabase> {
|
||||
const dbRequest = indexedDB.open(dbName, dbVersion);
|
||||
return new Promise<IDBDatabase>((resolve, reject) => {
|
||||
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||
dbRequest.onerror = () => reject(dbRequest.error);
|
||||
dbRequest.onupgradeneeded = () => {
|
||||
const db = dbRequest.result;
|
||||
if (!db.objectStoreNames.contains(storeName)) {
|
||||
db.createObjectStore(storeName);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getState<T>(name: string): Promise<T | undefined> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const readTransaction = db.transaction([storeName], "readonly");
|
||||
const store = readTransaction.objectStore(storeName);
|
||||
const itemRequest = store.get(name);
|
||||
const result = await new Promise<T | undefined>((resolve) => {
|
||||
itemRequest.onsuccess = () => resolve(itemRequest.result);
|
||||
itemRequest.onerror = () => resolve(undefined);
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return undefined;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function storeState<T>(name: string, state: T): Promise<void> {
|
||||
const db = await openDb();
|
||||
try {
|
||||
const putTransaction = db.transaction([storeName], "readwrite");
|
||||
const putStore = putTransaction.objectStore(storeName);
|
||||
const putRequest = putStore.put(state, name);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
putRequest.onsuccess = () => resolve();
|
||||
putRequest.onerror = () => reject(putRequest.error);
|
||||
});
|
||||
putTransaction.commit();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
58
src/lib/chord-editor/save-chords.ts
Normal file
58
src/lib/chord-editor/save-chords.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { parser } from "./chords.grammar";
|
||||
import { parseCharaChords } from "./action-serializer";
|
||||
import { actionMetaPlugin } from "./action-meta-plugin";
|
||||
import { deviceChordField } from "./chord-sync-plugin";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
|
||||
export interface SaveChordsTask {
|
||||
remove: number[][];
|
||||
set: [number[], number[]][];
|
||||
}
|
||||
|
||||
export function createSaveTask(view: EditorView): SaveChordsTask {
|
||||
const tree = parser.parse(view.state.doc.toString());
|
||||
const { ids, codes } = view.state.field(actionMetaPlugin.field);
|
||||
const deviceChords = view.state.field(deviceChordField);
|
||||
const result = parseCharaChords(tree, ids, codes, deviceChords, (from, to) =>
|
||||
view.state.doc.sliceString(from, to),
|
||||
);
|
||||
|
||||
return {
|
||||
remove: result.removed.map((chord) => chord[0]),
|
||||
set: result.chords
|
||||
.filter(
|
||||
(chord) =>
|
||||
!chord.disabled &&
|
||||
(!chord.phrase ||
|
||||
chord.phrase?.originalValue !== chord.phrase?.value),
|
||||
)
|
||||
.map((chord) => [chord.input?.value ?? [], chord.phrase?.value ?? []]),
|
||||
};
|
||||
}
|
||||
|
||||
export function applySaveTask(
|
||||
backup: CharaChordFile["chords"],
|
||||
task: SaveChordsTask,
|
||||
): CharaChordFile["chords"] {
|
||||
const newBackup = [...backup];
|
||||
for (const input of task.remove) {
|
||||
const index = newBackup.findIndex((chord) => {
|
||||
return JSON.stringify(chord[0]) === JSON.stringify(input);
|
||||
});
|
||||
if (index !== -1) {
|
||||
newBackup.splice(index, 1);
|
||||
}
|
||||
}
|
||||
for (const [input, phrase] of task.set) {
|
||||
const index = newBackup.findIndex((chord) => {
|
||||
return JSON.stringify(chord[0]) === JSON.stringify(input);
|
||||
});
|
||||
if (index !== -1) {
|
||||
newBackup[index] = [input, phrase];
|
||||
} else {
|
||||
newBackup.push([input, phrase]);
|
||||
}
|
||||
}
|
||||
return newBackup;
|
||||
}
|
||||
35
src/lib/chord-editor/store-state-field.ts
Normal file
35
src/lib/chord-editor/store-state-field.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { StateEffect, StateField } from "@codemirror/state";
|
||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
||||
import { get, type Readable } from "svelte/store";
|
||||
|
||||
export function reactiveStateField<T>(store: Readable<T>) {
|
||||
const effect = StateEffect.define<T>();
|
||||
const field = StateField.define<T>({
|
||||
create() {
|
||||
return get(store);
|
||||
},
|
||||
update(value, transaction) {
|
||||
return (
|
||||
transaction.effects.findLast((it) => it.is(effect))?.value ?? value
|
||||
);
|
||||
},
|
||||
});
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
unsubscribe: () => void;
|
||||
|
||||
constructor(readonly view: EditorView) {
|
||||
this.unsubscribe = store.subscribe((value) => {
|
||||
setTimeout(() => {
|
||||
view.dispatch({ effects: effect.of(value) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unsubscribe();
|
||||
}
|
||||
},
|
||||
);
|
||||
return { field, plugin: [field, plugin] };
|
||||
}
|
||||
16
src/lib/chord-editor/test.txt
Normal file
16
src/lib/chord-editor/test.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
a|.=<LEFT_SHIFT>=>t=t
|
||||
;ims=<0x219><IMPULSE>
|
||||
-;<KSC_2C><LEFT_SHIFT>=><0x23e>_<0x23e>
|
||||
.;g=><0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||
'dg=><0x23e>'<0x23e>
|
||||
'gl=><0x23e>'ll<0x23e>
|
||||
'ar=><0x23e>'re<0x23e>
|
||||
'gs=><0x23e>'s<0x23e>
|
||||
'ev=><0x23e>'ve<0x23e>
|
||||
<SPACE>-;=><0x23e><0x223>-<0x223><KSC_00>
|
||||
<SPACE>;<LEFT_SHIFT>=><0x23e><0x223><0x23d><0x223><KSC_00>
|
||||
<SPACE>;g=><0x23e><0x223><SPACE><0x223><KSC_00>
|
||||
deg=><0x23e>ed<0x23e>
|
||||
;gr=><0x23e>er<0x23e>
|
||||
;es=><0x23e>es<0x23e>
|
||||
;est=><0x23e>est<0x23e>
|
||||
@@ -1,82 +1,120 @@
|
||||
<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 { osLayout } from "$lib/os-layout";
|
||||
import { tooltip } from "$lib/hover-popover";
|
||||
import { isVerbose } from "./verbose-action";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import ActionTooltip from "./action/ActionTooltip.svelte";
|
||||
|
||||
let {
|
||||
action,
|
||||
display,
|
||||
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
|
||||
ignoreIcon = false,
|
||||
inText = false,
|
||||
withPopover = true,
|
||||
}: {
|
||||
action: string | number | KeyInfo;
|
||||
display: "inline-keys" | "keys" | "verbose";
|
||||
ignoreIcon?: boolean;
|
||||
inText?: boolean;
|
||||
withPopover?: boolean;
|
||||
} = $props();
|
||||
|
||||
let info = $derived(
|
||||
let retrievedInfo = $derived(
|
||||
typeof action === "number"
|
||||
? ($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 icon = $derived(ignoreIcon ? undefined : info.icon);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
|
||||
let popover: HTMLElement | undefined = $state(undefined);
|
||||
let hasPopover = $derived(
|
||||
withPopover &&
|
||||
(!retrievedInfo || !info.id || info.title || info.description),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet popoverSnippet()}
|
||||
<div bind:this={popover} popover="hint">
|
||||
<{info.id ?? `0x${info.code.toString(16)}`}>
|
||||
{#if info.title}
|
||||
{info.title}
|
||||
{/if}
|
||||
{#if info.variant === "left"}
|
||||
(Left)
|
||||
{:else if info.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
</div>
|
||||
{#snippet popover()}
|
||||
<ActionTooltip valid={!!retrievedInfo} {info} />
|
||||
{/snippet}
|
||||
|
||||
{#if display === "keys"}
|
||||
{#snippet kbdText()}
|
||||
{dynamicMapping ??
|
||||
icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}
|
||||
{/snippet}
|
||||
{#snippet kbdSnippet(withPopover = true)}
|
||||
<kbd
|
||||
class:icon={!!info.icon}
|
||||
class:in-text={inText}
|
||||
class:icon={!!icon}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
{@attach tooltip(popover)}
|
||||
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 popoverSnippet()}
|
||||
{@render kbdText()}
|
||||
</kbd>
|
||||
{:else if display === "inline-keys"}
|
||||
{/snippet}
|
||||
{#snippet inlineKbdSnippet()}
|
||||
{#if !info.icon && dynamicMapping?.length === 1}
|
||||
<span
|
||||
{@attach tooltip(popover)}
|
||||
{@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}{@render popoverSnippet()}</span
|
||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if !info.icon && info.id?.length === 1}
|
||||
{:else if !icon && info.id?.length === 1}
|
||||
<span
|
||||
{@attach tooltip(popover)}
|
||||
{@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}{@render popoverSnippet()}</span
|
||||
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}
|
||||
{@attach tooltip(popover)}
|
||||
>
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
|
||||
class:icon={!!icon}
|
||||
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">
|
||||
@@ -84,20 +122,107 @@
|
||||
transition: color 250ms ease;
|
||||
padding-block: auto;
|
||||
height: 24px;
|
||||
|
||||
&.in-text {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-block: auto;
|
||||
padding-block: revert;
|
||||
}
|
||||
}
|
||||
|
||||
.warn:not(.error) {
|
||||
border-color: var(--md-sys-color-error);
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.error {
|
||||
opacity: 0.6;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
$variant-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-on-surface) 50%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.left,
|
||||
.right {
|
||||
background-color: transparent;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
outline: 2px dashed
|
||||
color-mix(in srgb, var(--bg-color), var(--md-sys-color-outline) 40%);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--border-radius);
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
$cutoff: 60%;
|
||||
|
||||
.left {
|
||||
border-left-width: 3px;
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--bg-color) $cutoff,
|
||||
transparent $cutoff
|
||||
);
|
||||
|
||||
&::before {
|
||||
clip-path: inset(0 0 0 $cutoff);
|
||||
}
|
||||
}
|
||||
.right {
|
||||
border-right-width: 3px;
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
var(--bg-color) $cutoff,
|
||||
transparent $cutoff
|
||||
);
|
||||
|
||||
&::before {
|
||||
clip-path: inset(0 $cutoff 0 0);
|
||||
}
|
||||
}
|
||||
|
||||
.inline-kbd {
|
||||
margin-inline-end: 2px;
|
||||
|
||||
&.in-text.icon {
|
||||
translate: 0 -4em;
|
||||
}
|
||||
}
|
||||
|
||||
:global(span) + .inline-kbd {
|
||||
margin-inline-start: 2px;
|
||||
}
|
||||
|
||||
.verbose {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-inline: 2px;
|
||||
min-width: 160px;
|
||||
height: 32px;
|
||||
|
||||
kbd {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.verbose-title {
|
||||
display: -webkit-box;
|
||||
opacity: 0.9;
|
||||
max-width: 15ch;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
let { value }: { value: number } = $props();
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<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());
|
||||
$serialPort?.send(0, [value.trim()]);
|
||||
value = "";
|
||||
io.scrollTo({ top: io.scrollHeight });
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
34
src/lib/components/action/ActionTooltip.svelte
Normal file
34
src/lib/components/action/ActionTooltip.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
|
||||
let { valid, info }: { valid: boolean; info: KeyInfo } = $props();
|
||||
</script>
|
||||
|
||||
{#if valid}
|
||||
{#if info.icon || info.display || !info.id}
|
||||
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||
{/if}
|
||||
{#if info.title}
|
||||
{info.title}
|
||||
{/if}
|
||||
{#if info.variant === "left"}
|
||||
(Left)
|
||||
{:else if info.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
{#if info.description}
|
||||
<br />
|
||||
<small>{info.description}</small>
|
||||
{/if}
|
||||
{#if info.breaking}
|
||||
<br /> <i>Prevents prepended autospaces</i>
|
||||
{/if}
|
||||
{#if info.separator || info.breaking}
|
||||
<br /> <i>Stops autocorrect</i>
|
||||
{/if}
|
||||
{:else}
|
||||
<b>Unknown Action</b><br />
|
||||
{#if info.code > 1023}
|
||||
This action cannot be translated and will be ingored.
|
||||
{/if}
|
||||
{/if}
|
||||
370
src/lib/components/layout/ActionList.svelte
Normal file
370
src/lib/components/layout/ActionList.svelte
Normal file
@@ -0,0 +1,370 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
KEYMAP_CATEGORIES,
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
type KeyInfo,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import { onMount } from "svelte";
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import { get } from "svelte/store";
|
||||
import type { KeymapCategory } from "$lib/meta/types/actions";
|
||||
import Action from "../Action.svelte";
|
||||
import { isVerbose } from "../verbose-action";
|
||||
import { actionToValue } from "$lib/chord-editor/chord-sync";
|
||||
|
||||
let {
|
||||
currentAction = undefined,
|
||||
nextAction = undefined,
|
||||
queryFilter = undefined,
|
||||
ignoreIcon,
|
||||
autofocus = false,
|
||||
onselect,
|
||||
onclose,
|
||||
}: {
|
||||
currentAction?: number;
|
||||
queryFilter?: string;
|
||||
nextAction?: number;
|
||||
autofocus?: boolean;
|
||||
ignoreIcon?: boolean;
|
||||
onselect?: (id: number) => void;
|
||||
onclose?: () => void;
|
||||
} = $props();
|
||||
|
||||
onMount(() => {
|
||||
search();
|
||||
if (autofocus) {
|
||||
searchBox.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||
|
||||
$effect(() => {
|
||||
createIndex($KEYMAP_CODES);
|
||||
});
|
||||
|
||||
let didClear = true;
|
||||
$effect(() => {
|
||||
if (queryFilter !== undefined || !didClear) {
|
||||
searchBox.value = queryFilter ?? "";
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||
for (const [, action] of codes) {
|
||||
await index?.addAsync(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const groups = new Map(
|
||||
$KEYMAP_CATEGORIES.map(
|
||||
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||
),
|
||||
);
|
||||
didClear = searchBox.value === "";
|
||||
const result =
|
||||
searchBox.value === ""
|
||||
? Array.from($KEYMAP_CODES.keys())
|
||||
: await index!.searchAsync(searchBox.value);
|
||||
for (const id of result) {
|
||||
const action = $KEYMAP_CODES.get(id as number);
|
||||
if (action?.category) {
|
||||
groups.get(action.category)?.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
function sortValue(action: KeyInfo): number {
|
||||
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
|
||||
}
|
||||
for (const actions of groups.values()) {
|
||||
actions.sort((a, b) => sortValue(b) - sortValue(a));
|
||||
}
|
||||
results = groups;
|
||||
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||
code = Number(searchBox.value);
|
||||
}
|
||||
|
||||
function select(id?: number) {
|
||||
if (id !== undefined) {
|
||||
onselect?.(id);
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardNavigation(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||
onselect?.(exact);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||
resultList.querySelector("li:not(.exact)");
|
||||
if (element instanceof HTMLLIElement) {
|
||||
element.querySelector("button")?.focus();
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.previousSibling ??
|
||||
resultList.querySelector("li:not(.exact)");
|
||||
if (element instanceof HTMLLIElement) {
|
||||
element.querySelector("button")?.focus();
|
||||
}
|
||||
} else {
|
||||
searchBox.focus();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
|
||||
let exact: number | undefined = $state(undefined);
|
||||
let code: number = $state(Number.NaN);
|
||||
|
||||
let searchBox: HTMLInputElement;
|
||||
let resultList: HTMLUListElement;
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="search-row">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="search"
|
||||
bind:this={searchBox}
|
||||
oninput={search}
|
||||
onkeypress={keyboardNavigation}
|
||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||
/>
|
||||
{#if onclose}
|
||||
<button onclick={() => select(0)} {@attach actionTooltip("", "shift+esc")}
|
||||
>{$LL.actionSearch.DELETE()}</button
|
||||
>
|
||||
<button
|
||||
{@attach actionTooltip($LL.modal.CLOSE(), "esc")}
|
||||
class="icon"
|
||||
onclick={onclose}>close</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if currentAction !== undefined}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||
<ActionListItem id={currentAction} />
|
||||
</aside>
|
||||
{#if nextAction}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
||||
<ActionListItem id={nextAction} />
|
||||
</aside>
|
||||
{/if}
|
||||
{/if}
|
||||
<ul bind:this={resultList}>
|
||||
{#if exact !== undefined}
|
||||
<li class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||
</li>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||
{:else}
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as [category, actions] (actions)}
|
||||
{#if actions.length > 0}
|
||||
<div class="category">
|
||||
<h3>{category.name}</h3>
|
||||
<div class="description">{category.description}</div>
|
||||
<ul>
|
||||
{#each actions as action (action.code)}
|
||||
<button
|
||||
class="action-item"
|
||||
draggable={!onclose}
|
||||
onclick={() => select(action.code)}
|
||||
ondragstart={onclose === undefined
|
||||
? (event) => {
|
||||
if (!event.dataTransfer) return;
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData(
|
||||
"text/plain",
|
||||
actionToValue(action.code),
|
||||
);
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
<Action {action} display="verbose" {ignoreIcon}></Action>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.action-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font: inherit;
|
||||
|
||||
&[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
aside {
|
||||
opacity: 0.4;
|
||||
|
||||
margin: 8px;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
> h3 {
|
||||
margin-inline-start: 16px;
|
||||
margin-block-start: -13px;
|
||||
margin-block-end: 0;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
padding-inline: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
opacity: 1;
|
||||
color: GrayText;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
transform-origin: top left;
|
||||
border-radius: 16px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
|
||||
width: calc(min(30cm, 90%));
|
||||
height: calc(min(100% - 128px, 90%));
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
transition: all 250ms ease;
|
||||
margin-block-end: 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
|
||||
background: none;
|
||||
padding-inline: 16px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
color: currentcolor;
|
||||
font-size: 16px;
|
||||
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline: 4px;
|
||||
height: 100%;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-gutter: both-edges stable;
|
||||
}
|
||||
|
||||
.category {
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
margin-block-start: -16px;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-block: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block-start: 8px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
> i {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
padding-inline: 6px;
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
background: Mark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,191 +12,24 @@
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -220,130 +42,4 @@
|
||||
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;
|
||||
}
|
||||
|
||||
li {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block-start: 8px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
> i {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
padding-inline: 6px;
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
background: Mark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { compileLayout } from "$lib/serialization/visual-layout";
|
||||
import type {
|
||||
VisualLayout,
|
||||
CompiledLayoutKey,
|
||||
} from "$lib/serialization/visual-layout";
|
||||
import { deviceLayout } from "$lib/serial/connection";
|
||||
import { dev } from "$app/environment";
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||
@@ -15,6 +10,10 @@
|
||||
import { fly } from "svelte/transition";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { activeLayer, activeProfile } from "$lib/serial/connection";
|
||||
import type {
|
||||
CompiledLayout,
|
||||
CompiledLayoutKey,
|
||||
} from "$lib/assets/layouts/layout.d.ts";
|
||||
|
||||
const { scale, margin, strokeWidth, fontSize, iconFontSize } =
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
@@ -29,8 +28,7 @@
|
||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
||||
}
|
||||
|
||||
let { visualLayout }: { visualLayout: VisualLayout } = $props();
|
||||
let layoutInfo = $state(compileLayout(visualLayout));
|
||||
let { layoutInfo }: { layoutInfo: CompiledLayout } = $props();
|
||||
|
||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
||||
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
|
||||
import { layout } from "$lib/undo-redo.js";
|
||||
import { osLayout } from "$lib/os-layout.js";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { action } from "$lib/title";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import { activeProfile, activeLayer } from "$lib/serial/connection";
|
||||
import { getContext } from "svelte";
|
||||
|
||||
@@ -28,7 +28,12 @@
|
||||
middle: [number, number];
|
||||
pos: [number, number];
|
||||
rotate: number;
|
||||
positions: [[number, number], [number, number], [number, number]];
|
||||
positions: [
|
||||
[number, number],
|
||||
[number, number],
|
||||
[number, number],
|
||||
[number, number],
|
||||
];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
@@ -67,9 +72,9 @@
|
||||
? "0 0 0"
|
||||
: `${direction[0]?.toPrecision(2)}px ${direction[1]?.toPrecision(2)}px 0`}
|
||||
style:rotate="{rotate}deg"
|
||||
use:action={{ title: tooltip }}
|
||||
{@attach actionTooltip(tooltip)}
|
||||
>
|
||||
{#if code !== 0}
|
||||
{#if code !== 0 && code != 1023}
|
||||
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
||||
{/if}
|
||||
{#if !isApplied}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import type { CompiledLayoutKey } from "$lib/serialization/visual-layout";
|
||||
import type { CompiledLayoutKey } from "$lib/assets/layouts/layout.d.ts";
|
||||
import { getContext } from "svelte";
|
||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||
import KeyText from "$lib/components/layout/KeyText.svelte";
|
||||
@@ -64,6 +64,7 @@
|
||||
[-1, 1],
|
||||
[-1, -1],
|
||||
[1, -1],
|
||||
[1, 1],
|
||||
]}
|
||||
/>
|
||||
{:else if key.shape === "quarter-circle"}
|
||||
@@ -103,6 +104,7 @@
|
||||
[-rotY, -rotX],
|
||||
[-rotX, -rotY],
|
||||
[rotX, rotY],
|
||||
[rotY, rotX],
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
<script lang="ts">
|
||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||
import { action } from "$lib/title";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import GenericLayout from "$lib/components/layout/GenericLayout.svelte";
|
||||
import { 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";
|
||||
import type { CompiledLayout } from "$lib/assets/layouts/layout.d.ts";
|
||||
|
||||
const layouts = {
|
||||
const layouts: Record<string, (() => Promise<CompiledLayout>) | undefined> = {
|
||||
ONE: () =>
|
||||
import("$lib/assets/layouts/one.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/one.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
TWO: () =>
|
||||
import("$lib/assets/layouts/one.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/one.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
LITE: () =>
|
||||
import("$lib/assets/layouts/lite.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/lite.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
X: () =>
|
||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/103-key.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
ZERO: () =>
|
||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/103-key.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
M4G: () =>
|
||||
import("$lib/assets/layouts/m4g.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/m4g.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
M4GR: () =>
|
||||
import("$lib/assets/layouts/m4gr.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/m4gr.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
T4G: () =>
|
||||
import("$lib/assets/layouts/t4g.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
import("$lib/assets/layouts/t4g.layout.yml").then(
|
||||
(it) => it.default as CompiledLayout,
|
||||
),
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if $serialPort}
|
||||
{#await layouts[$serialPort.device]() then visualLayout}
|
||||
{#await layouts[$serialPort.device]?.() then layoutInfo}
|
||||
<fieldset transition:fade>
|
||||
<div class="layers">
|
||||
{#each Array.from({ length: $serialPort.layerCount }, (_, i) => i) as layer}
|
||||
@@ -65,7 +65,7 @@
|
||||
</div>
|
||||
{#if $deviceMeta?.factoryDefaults?.layout}
|
||||
<button
|
||||
use:action={{ title: "Reset Layout" }}
|
||||
{@attach actionTooltip("Reset Layout")}
|
||||
transition:fly={{ x: -8 }}
|
||||
class="icon reset-layout"
|
||||
onclick={() =>
|
||||
@@ -75,7 +75,9 @@
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<GenericLayout {visualLayout} />
|
||||
{#if layoutInfo}
|
||||
<GenericLayout {layoutInfo} />
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
9
src/lib/components/verbose-action.ts
Normal file
9
src/lib/components/verbose-action.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
|
||||
export function isVerbose(info: KeyInfo) {
|
||||
return (
|
||||
info.id?.length !== 1 &&
|
||||
info.title &&
|
||||
(!info.id || /F\d{1,2}/.test(info.id) === false)
|
||||
);
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||
import type {
|
||||
Change,
|
||||
ChordChange,
|
||||
LayoutChange,
|
||||
SettingChange,
|
||||
} from "$lib/undo-redo";
|
||||
import { ChangeType, chords } from "$lib/undo-redo";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
|
||||
export let changes: Change[] = [
|
||||
{ type: ChangeType.Layout, layer: 0, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Layout, layer: 1, id: 1, action: 1 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{ type: ChangeType.Setting, id: 0, setting: 2 },
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: [1],
|
||||
actions: [55],
|
||||
phrase: [55, 63, 37, 36],
|
||||
},
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
actions: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
phrase: [55, 63, 37, 36],
|
||||
},
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
actions: [
|
||||
KEYMAP_IDS.get("y")!.code,
|
||||
KEYMAP_IDS.get("r")!.code,
|
||||
KEYMAP_IDS.get("t")!.code,
|
||||
],
|
||||
phrase: [],
|
||||
},
|
||||
];
|
||||
|
||||
$: existingChords = new Set($chords.map((it) => JSON.stringify(it.id)));
|
||||
|
||||
$: layoutChanges = Array.from(
|
||||
{ length: 3 },
|
||||
(_, i) =>
|
||||
changes.filter(
|
||||
(it) => it.type === ChangeType.Layout && it.layer === i,
|
||||
) as LayoutChange[],
|
||||
);
|
||||
$: settingChanges = changes.filter(
|
||||
(it) => it.type === ChangeType.Setting,
|
||||
) as SettingChange[];
|
||||
$: chordChanges = {
|
||||
added: changes.filter(
|
||||
(it) =>
|
||||
it.type === ChangeType.Chord &&
|
||||
it.phrase.length > 0 &&
|
||||
!existingChords.has(JSON.stringify(it.id)),
|
||||
) as ChordChange[],
|
||||
changed: changes.filter(
|
||||
(it) =>
|
||||
it.type === ChangeType.Chord &&
|
||||
it.phrase.length > 0 &&
|
||||
existingChords.has(JSON.stringify(it.id)),
|
||||
) as ChordChange[],
|
||||
deleted: changes.filter(
|
||||
(it) => it.type === ChangeType.Chord && it.phrase.length === 0,
|
||||
) as ChordChange[],
|
||||
};
|
||||
$: totalChordChanges = Object.values(chordChanges).reduce(
|
||||
(acc, curr) => acc + curr.length,
|
||||
0,
|
||||
);
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
<h1>{$LL.changes.TITLE()}</h1>
|
||||
<h2>
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>{$LL.changes.ALL_CHANGES()}</label
|
||||
>
|
||||
</h2>
|
||||
<ul>
|
||||
{#if layoutChanges.some((it) => it.length > 0)}
|
||||
<li>
|
||||
<h3>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
{$LL.changes.layout.TITLE(
|
||||
layoutChanges.reduce((acc, curr) => acc + curr.length, 0),
|
||||
)}
|
||||
</label>
|
||||
</h3>
|
||||
<ul>
|
||||
{#each layoutChanges as changes, i}
|
||||
{@const layer = i + 1}
|
||||
{#if changes.length > 0}
|
||||
<li>
|
||||
<h4>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
{$LL.changes.layout.LAYER({
|
||||
changes: changes.length,
|
||||
layer,
|
||||
})}
|
||||
</label>
|
||||
</h4>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
{#if settingChanges.length > 0}
|
||||
<li>
|
||||
<h3>
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>{$LL.changes.settings.TITLE(settingChanges.length)}</label
|
||||
>
|
||||
</h3>
|
||||
</li>
|
||||
{/if}
|
||||
{#if totalChordChanges > 0}
|
||||
<li>
|
||||
<h3>
|
||||
<label
|
||||
><input
|
||||
type="checkbox"
|
||||
class="checkbox"
|
||||
/>{$LL.changes.chords.TITLE(totalChordChanges)}</label
|
||||
>
|
||||
</h3>
|
||||
<ul>
|
||||
{#each Object.entries(chordChanges) as [category, changes]}
|
||||
{#if changes.length > 0}
|
||||
<li>
|
||||
<h4>
|
||||
<label
|
||||
><input type="checkbox" class="checkbox" />
|
||||
{#if category === "added"}
|
||||
{$LL.changes.chords.NEW_CHORDS(changes.length)}
|
||||
{:else if category === "changed"}
|
||||
{$LL.changes.chords.CHANGED_CHORDS(changes.length)}
|
||||
{:else if category === "deleted"}
|
||||
{$LL.changes.chords.DELETED_CHORDS(changes.length)}
|
||||
{/if}
|
||||
</label>
|
||||
</h4>
|
||||
<ul>
|
||||
{#each changes as change}
|
||||
<li>
|
||||
<label>
|
||||
<input type="checkbox" class="checkbox" />
|
||||
<ActionString display="keys" actions={change.actions} />
|
||||
<ActionString actions={change.phrase} />
|
||||
</label>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</Dialog>
|
||||
|
||||
<style lang="scss">
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding-inline-start: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-inline-start: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ export function tooltip(
|
||||
|
||||
node.addEventListener("mouseenter", show);
|
||||
node.addEventListener("focus", show);
|
||||
node.addEventListener("mouseout", hide);
|
||||
node.addEventListener("mouseleave", hide);
|
||||
node.addEventListener("blur", hide);
|
||||
|
||||
if (shortcut && node instanceof HTMLElement) {
|
||||
@@ -28,7 +28,7 @@ export function tooltip(
|
||||
return () => {
|
||||
node.removeEventListener("mouseenter", show);
|
||||
node.removeEventListener("focus", show);
|
||||
node.removeEventListener("mouseout", hide);
|
||||
node.removeEventListener("mouseleave", hide);
|
||||
node.removeEventListener("blur", hide);
|
||||
|
||||
if (shortcut && node instanceof HTMLElement) {
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { type ChordInfo, chords } from "$lib/undo-redo";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export const words = derived(
|
||||
[chords, osLayout],
|
||||
([chords, layout]) =>
|
||||
new Map<string, ChordInfo>(
|
||||
chords
|
||||
.map((chord) => ({
|
||||
chord,
|
||||
output: chord.phrase.map((action) =>
|
||||
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
|
||||
),
|
||||
}))
|
||||
.filter(({ output }) => output.every((it) => !!it))
|
||||
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
|
||||
),
|
||||
);
|
||||
|
||||
interface Score {
|
||||
lastTyped: number;
|
||||
score: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const scores = persistentWritable<Record<string, Score>>("scores", {});
|
||||
|
||||
export const learnConfigDefault = {
|
||||
maxScore: 3,
|
||||
minScore: -3,
|
||||
scoreBlend: 0.5,
|
||||
weakRate: 0.8,
|
||||
weakBoost: 0.5,
|
||||
maxWeak: 3,
|
||||
newRate: 0.3,
|
||||
initialNewRate: 0.9,
|
||||
initialCount: 10,
|
||||
};
|
||||
export const learnConfigStored = persistentWritable<
|
||||
Partial<typeof learnConfigDefault>
|
||||
>("learn-config", {});
|
||||
export const learnConfig = derived(learnConfigStored, (config) => ({
|
||||
...learnConfigDefault,
|
||||
...config,
|
||||
}));
|
||||
|
||||
let lastWord: string | undefined;
|
||||
|
||||
function shuffle<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j]!, array[i]!];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function randomLog2<T>(array: T[], max = array.length): T | undefined {
|
||||
return array[
|
||||
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
|
||||
];
|
||||
}
|
||||
|
||||
export const nextWord = derived(
|
||||
[words, scores, learnConfig],
|
||||
([words, scores, config]) => {
|
||||
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
|
||||
|
||||
values.sort(([, a], [, b]) => a.score - b.score);
|
||||
const weakCount =
|
||||
(values.findIndex(([, { score }]) => score > 0) + 1 ||
|
||||
values.length + 1) - 1;
|
||||
const weak = randomLog2(values, weakCount);
|
||||
if (weak && Math.random() / weakCount < config.weakRate) {
|
||||
lastWord = weak[0];
|
||||
return weak[0];
|
||||
}
|
||||
|
||||
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
|
||||
const recent = randomLog2(values);
|
||||
const newRate =
|
||||
values.length < config.initialCount
|
||||
? config.initialNewRate
|
||||
: config.newRate;
|
||||
if (
|
||||
recent &&
|
||||
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
|
||||
Math.random() > newRate)
|
||||
) {
|
||||
lastWord = recent[0];
|
||||
return recent[0];
|
||||
}
|
||||
|
||||
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
|
||||
const word = newWord || recent?.[0] || weak?.[0];
|
||||
lastWord = word;
|
||||
return word;
|
||||
},
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
|
||||
interface ChordStats {
|
||||
level: number;
|
||||
lastUprank: number;
|
||||
}
|
||||
|
||||
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
||||
"chord-stats",
|
||||
{},
|
||||
);
|
||||
@@ -17,7 +17,7 @@ export async function getMeta(
|
||||
try {
|
||||
if (!browser) return fetchMeta(device, version, fetch);
|
||||
|
||||
const dbRequest = indexedDB.open("version-meta", 5);
|
||||
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 ??
|
||||
|
||||
@@ -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;
|
||||
@@ -61,6 +63,39 @@ export interface RawVersionMeta {
|
||||
spi_flash: SPIFlashInfo | null;
|
||||
}
|
||||
|
||||
export interface E2eAddChord {
|
||||
input: string[][];
|
||||
output: string[];
|
||||
}
|
||||
|
||||
export interface E2eTestItem {
|
||||
keys?: string[];
|
||||
modifiers?: Record<string, boolean>;
|
||||
press?: string[];
|
||||
release?: string[];
|
||||
step?: number;
|
||||
skip?: number;
|
||||
idle?: boolean;
|
||||
clearChords?: boolean;
|
||||
addChords?: E2eAddChord[];
|
||||
settings: Record<string, Record<string, string | number>>;
|
||||
}
|
||||
|
||||
export interface E2eTest {
|
||||
matrix?: string[];
|
||||
test: E2eTestItem[];
|
||||
}
|
||||
|
||||
export interface E2eDemo {
|
||||
demo?: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
matrix?: string[];
|
||||
tests: E2eTest[];
|
||||
}
|
||||
|
||||
export interface VersionMeta {
|
||||
version: string;
|
||||
device: string;
|
||||
@@ -73,6 +108,7 @@ export interface VersionMeta {
|
||||
actions: KeymapCategory[];
|
||||
settings: SettingsMeta[];
|
||||
changelog: Changelog;
|
||||
recipes?: E2eDemo[];
|
||||
factoryDefaults?: {
|
||||
layout: CharaLayoutFile;
|
||||
settings: CharaSettingsFile;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { compressActions, decompressActions } from "../serialization/actions";
|
||||
import type { KeyInfo } from "./keymap-codes";
|
||||
|
||||
export interface Chord {
|
||||
actions: number[];
|
||||
@@ -56,6 +57,103 @@ export function deserializeActions(native: bigint): number[] {
|
||||
return actions;
|
||||
}
|
||||
|
||||
const compoundHashItems = 3;
|
||||
const maxChordInputItems = 12;
|
||||
const actionBits = 10;
|
||||
const actionMask = (1 << actionBits) - 1;
|
||||
|
||||
/**
|
||||
* Applies the compound value to a **valid** chord input
|
||||
*/
|
||||
export function applyCompound(actions: number[], compound: number): number[] {
|
||||
const result = [...actions];
|
||||
for (let i = 0; i < compoundHashItems; i++) {
|
||||
result[i] = (compound >>> (i * actionBits)) & actionMask;
|
||||
}
|
||||
result[compoundHashItems] = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the compound value from a chord input, if present
|
||||
*/
|
||||
export function splitCompound(
|
||||
actions: number[],
|
||||
): [inputs: number[], compound: number | undefined] {
|
||||
if (actions[compoundHashItems] != 0) {
|
||||
return [
|
||||
actions.slice(
|
||||
Math.max(
|
||||
0,
|
||||
actions.findIndex((it) => it !== 0),
|
||||
),
|
||||
),
|
||||
undefined,
|
||||
];
|
||||
}
|
||||
|
||||
let compound = 0;
|
||||
for (let i = 0; i < compoundHashItems; i++) {
|
||||
compound |= (actions[i] ?? 0) << (i * actionBits);
|
||||
}
|
||||
|
||||
return [
|
||||
actions.slice(
|
||||
actions.findIndex((it, i) => i > compoundHashItems && it !== 0),
|
||||
),
|
||||
compound === 0 ? undefined : compound,
|
||||
];
|
||||
}
|
||||
|
||||
export function willBeValidChordInput(
|
||||
inputCount: number,
|
||||
hasCompound: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
inputCount > 0 &&
|
||||
inputCount <= maxChordInputItems - (hasCompound ? compoundHashItems + 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
const ACTION_JOIN = 574;
|
||||
const ACTION_KSC_00 = 256;
|
||||
|
||||
export function hasConcatenator(
|
||||
actions: number[],
|
||||
ids: Map<number, KeyInfo>,
|
||||
): boolean {
|
||||
const lastAction = actions.at(-1);
|
||||
for (const action of actions) {
|
||||
if (!ids.get(action)?.printable) {
|
||||
if (actions.length == 0) {
|
||||
return false;
|
||||
}
|
||||
return lastAction == ACTION_JOIN;
|
||||
}
|
||||
}
|
||||
return lastAction != ACTION_KSC_00;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes a chord input from a list of actions and an optional compound value
|
||||
* to a valid chord input
|
||||
*/
|
||||
export function composeChordInput(
|
||||
actions: number[],
|
||||
compound?: number,
|
||||
): number[] {
|
||||
const result = [
|
||||
...Array.from(
|
||||
{
|
||||
length: Math.max(0, maxChordInputItems - actions.length),
|
||||
},
|
||||
() => 0,
|
||||
),
|
||||
...actions.slice(0, maxChordInputItems).sort((a, b) => a - b),
|
||||
];
|
||||
return compound !== undefined ? applyCompound(result, compound) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a chord input the same way as CCOS
|
||||
*/
|
||||
@@ -72,5 +170,6 @@ export function hashChord(actions: number[]) {
|
||||
if ((hash & 0xff) === 0xff) {
|
||||
hash ^= 0xff;
|
||||
}
|
||||
return hash & 0x3fff_ffff;
|
||||
hash &= 0x3fff_ffff;
|
||||
return hash === 0 ? 1 : hash;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,10 @@ export const syncStatus: Writable<
|
||||
"done" | "error" | "downloading" | "uploading"
|
||||
> = writable("done");
|
||||
|
||||
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
|
||||
export const deviceMeta = persistentWritable<VersionMeta | undefined>(
|
||||
"current-meta",
|
||||
undefined,
|
||||
);
|
||||
|
||||
export interface ProgressInfo {
|
||||
max: number;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LineBreakTransformer } from "$lib/serial/line-break-transformer";
|
||||
import { serialLog } from "$lib/serial/connection";
|
||||
import { serialLog, type SerialLogEntry } from "$lib/serial/connection";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
import {
|
||||
parseChordActions,
|
||||
@@ -64,6 +64,7 @@ const KEY_COUNTS = {
|
||||
TWO: 90,
|
||||
LITE: 67,
|
||||
X: 256,
|
||||
ENGINE: 256,
|
||||
M4G: 90,
|
||||
M4GR: 90,
|
||||
T4G: 7,
|
||||
@@ -146,7 +147,7 @@ export class CharaDevice {
|
||||
version!: string;
|
||||
company!: "CHARACHORDER" | "FORGE";
|
||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
|
||||
chipset!: "M0" | "S2" | "S3";
|
||||
chipset!: "M0" | "S2" | "S3" | "WASM";
|
||||
keyCount!: 90 | 67 | 256;
|
||||
layerCount = 3;
|
||||
profileCount = 1;
|
||||
@@ -156,8 +157,8 @@ export class CharaDevice {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly port: SerialPortLike,
|
||||
private readonly baudRate = 115200,
|
||||
readonly port: SerialPortLike,
|
||||
public baudRate = navigator.userAgent.includes("Mac") ? 38400 : 115200,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
@@ -178,13 +179,16 @@ export class CharaDevice {
|
||||
this.version = await this.send(1, ["VERSION"]).then(
|
||||
([version]) => version,
|
||||
);
|
||||
if (semverGte(this.version, "2.2.0-beta.4")) {
|
||||
this.profileCount = 3;
|
||||
}
|
||||
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.chipset !== "M0") {
|
||||
this.profileCount = 3;
|
||||
}
|
||||
if (semverGte(this.version, "2.2.0-beta.20") && this.chipset !== "M0") {
|
||||
this.layerCount = 4;
|
||||
}
|
||||
this.keyCount = KEY_COUNTS[this.device];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -338,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>;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -397,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">) {
|
||||
@@ -559,37 +564,48 @@ export class CharaDevice {
|
||||
|
||||
const writer = this.port.writable!.getWriter();
|
||||
try {
|
||||
await writer.write(new TextEncoder().encode(`RST OTA\r\n`));
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: "RST OTA",
|
||||
});
|
||||
return it;
|
||||
});
|
||||
const start = performance.now();
|
||||
writer.write(new TextEncoder().encode(`RST OTA\r\n`));
|
||||
|
||||
// Wait for the device to be ready
|
||||
const signal = await this.reader.read();
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
});
|
||||
return it;
|
||||
});
|
||||
const signalTime = performance.now();
|
||||
|
||||
const chunkSize = 128;
|
||||
const chunks: Promise<void>[] = [];
|
||||
for (let i = 0; i < file.byteLength; i += chunkSize) {
|
||||
const chunk = file.slice(i, i + chunkSize);
|
||||
await writer.write(new Uint8Array(chunk));
|
||||
progress(i + chunk.byteLength, file.byteLength);
|
||||
const size = Math.min(chunkSize, file.byteLength - i);
|
||||
chunks.push(
|
||||
writer
|
||||
.write(new Uint8Array(file, i, size))
|
||||
.then(() => progress(i + size, file.byteLength)),
|
||||
);
|
||||
}
|
||||
await Promise.all(chunks);
|
||||
|
||||
serialLog.update((it) => {
|
||||
it.push({
|
||||
type: "input",
|
||||
value: `...${file.byteLength} bytes`,
|
||||
});
|
||||
it.push(
|
||||
{
|
||||
type: "input",
|
||||
value: "RST OTA",
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
value: `+${(signalTime - start).toFixed(0)} ms`,
|
||||
},
|
||||
{
|
||||
type: "output",
|
||||
value: signal.value!.trim(),
|
||||
},
|
||||
{
|
||||
type: "system",
|
||||
value: `+${(performance.now() - signalTime).toFixed(0)} ms`,
|
||||
},
|
||||
{
|
||||
type: "input",
|
||||
value: `...${file.byteLength} bytes`,
|
||||
},
|
||||
);
|
||||
return it;
|
||||
});
|
||||
|
||||
@@ -616,9 +632,8 @@ export class CharaDevice {
|
||||
});
|
||||
} finally {
|
||||
writer.releaseLock();
|
||||
await this.suspend();
|
||||
}
|
||||
|
||||
await this.suspend();
|
||||
} finally {
|
||||
delete this.lock;
|
||||
resolveLock!(true);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
kbd {
|
||||
--bg-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-surface-variant) 50%,
|
||||
transparent
|
||||
);
|
||||
--border-radius: 4px;
|
||||
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block: 6px;
|
||||
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 4px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-color);
|
||||
padding: 4px;
|
||||
|
||||
height: 20px;
|
||||
color: currentcolor;
|
||||
font-weight: normal;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&.icon {
|
||||
|
||||
@@ -1,46 +1,43 @@
|
||||
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 },
|
||||
) => {
|
||||
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: { title, shortcut },
|
||||
});
|
||||
},
|
||||
onHidden() {
|
||||
if (component) {
|
||||
unmount(component);
|
||||
component = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
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);
|
||||
}
|
||||
if (shortcut && node instanceof HTMLElement) {
|
||||
hotkeys.set(shortcut, node);
|
||||
}
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
return () => {
|
||||
tooltip.destroy();
|
||||
if (shortcut && node instanceof HTMLElement) {
|
||||
hotkeys.delete(shortcut);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { derived } from "svelte/store";
|
||||
import { hashChord, type Chord } from "$lib/serial/chord";
|
||||
import {
|
||||
deviceChords,
|
||||
deviceLayout,
|
||||
deviceSettings,
|
||||
} from "$lib/serial/connection";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { deviceLayout, deviceSettings } from "$lib/serial/connection";
|
||||
|
||||
export enum ChangeType {
|
||||
Layout,
|
||||
Chord,
|
||||
Setting,
|
||||
}
|
||||
|
||||
@@ -22,14 +15,6 @@ export interface LayoutChange {
|
||||
profile?: number;
|
||||
}
|
||||
|
||||
export interface ChordChange {
|
||||
type: ChangeType.Chord;
|
||||
deleted?: true;
|
||||
id: number[];
|
||||
actions: number[];
|
||||
phrase: number[];
|
||||
}
|
||||
|
||||
export interface SettingChange {
|
||||
type: ChangeType.Setting;
|
||||
id: number;
|
||||
@@ -42,20 +27,18 @@ export interface ChangeInfo {
|
||||
isCommitted?: boolean;
|
||||
}
|
||||
|
||||
export type Change = LayoutChange | ChordChange | SettingChange;
|
||||
export type Change = LayoutChange | SettingChange;
|
||||
|
||||
export const changes = persistentWritable<Change[][]>("changes", []);
|
||||
|
||||
export interface Overlay {
|
||||
layout: Array<Array<Map<number, number> | undefined> | undefined>;
|
||||
chords: Map<string, Chord & { deleted: boolean }>;
|
||||
settings: Array<Map<number, number> | undefined>;
|
||||
}
|
||||
|
||||
export const overlay = derived(changes, (changes) => {
|
||||
const overlay: Overlay = {
|
||||
layout: [],
|
||||
chords: new Map(),
|
||||
settings: [],
|
||||
};
|
||||
|
||||
@@ -71,13 +54,6 @@ export const overlay = derived(changes, (changes) => {
|
||||
change.action,
|
||||
);
|
||||
break;
|
||||
case ChangeType.Chord:
|
||||
overlay.chords.set(JSON.stringify(change.id), {
|
||||
actions: change.actions,
|
||||
phrase: change.phrase,
|
||||
deleted: change.deleted ?? false,
|
||||
});
|
||||
break;
|
||||
case ChangeType.Setting:
|
||||
change.profile ??= 0;
|
||||
overlay.settings[change.profile] ??= new Map();
|
||||
@@ -113,74 +89,3 @@ export const layout = derived([overlay, deviceLayout], ([overlay, profiles]) =>
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export type ChordInfo = Chord &
|
||||
ChangeInfo & {
|
||||
phraseChanged: boolean;
|
||||
actionsChanged: boolean;
|
||||
sortBy: string;
|
||||
} & {
|
||||
id: number[];
|
||||
deleted: boolean;
|
||||
};
|
||||
export const chords = derived(
|
||||
[overlay, deviceChords, KEYMAP_CODES],
|
||||
([overlay, chords, codes]) => {
|
||||
const newChords = new Set(overlay.chords.keys());
|
||||
|
||||
const changedChords = chords.map<ChordInfo>((chord) => {
|
||||
const id = JSON.stringify(chord.actions);
|
||||
if (overlay.chords.has(id)) {
|
||||
newChords.delete(id);
|
||||
const changedChord = overlay.chords.get(id)!;
|
||||
return {
|
||||
id: chord.actions,
|
||||
// use the old phrase for stable editing
|
||||
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
|
||||
actions: changedChord.actions,
|
||||
phrase: changedChord.phrase,
|
||||
actionsChanged: id !== JSON.stringify(changedChord.actions),
|
||||
phraseChanged:
|
||||
JSON.stringify(chord.phrase) !==
|
||||
JSON.stringify(changedChord.phrase),
|
||||
isApplied: false,
|
||||
deleted: changedChord.deleted,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: chord.actions,
|
||||
sortBy: chord.phrase.map((it) => codes.get(it)?.id ?? it).join(),
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase,
|
||||
phraseChanged: false,
|
||||
actionsChanged: false,
|
||||
isApplied: true,
|
||||
deleted: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
for (const id of newChords) {
|
||||
const chord = overlay.chords.get(id)!;
|
||||
changedChords.push({
|
||||
sortBy: "",
|
||||
isApplied: false,
|
||||
actionsChanged: true,
|
||||
phraseChanged: false,
|
||||
deleted: chord.deleted,
|
||||
id: JSON.parse(id),
|
||||
phrase: chord.phrase,
|
||||
actions: chord.actions,
|
||||
});
|
||||
}
|
||||
|
||||
return changedChords.sort(({ sortBy: a }, { sortBy: b }) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const chordHashes = derived(
|
||||
chords,
|
||||
(chords) =>
|
||||
new Map(chords.map((chord) => [hashChord(chord.actions), chord] as const)),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
@@ -119,8 +131,6 @@
|
||||
<div class="layout">
|
||||
<Sidebar />
|
||||
|
||||
<!-- <PickChangesDialog /> -->
|
||||
|
||||
<PageTransition>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { preference } from "$lib/preferences";
|
||||
import { preference, userPreferences } from "$lib/preferences";
|
||||
import { initSerial } from "$lib/serial/connection";
|
||||
import {
|
||||
getPortName,
|
||||
@@ -9,13 +9,27 @@
|
||||
} from "$lib/serial/device";
|
||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||
import { onMount } from "svelte";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -28,64 +42,77 @@
|
||||
await showConnectionFailedDialog(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function closePopover() {
|
||||
element?.closest<HTMLElement>("[popover]")?.hidePopover();
|
||||
}
|
||||
|
||||
async function connectDevice(event: MouseEvent) {
|
||||
if (event.altKey) {
|
||||
goto("/terminal/");
|
||||
return;
|
||||
}
|
||||
const port = await navigator.serial.requestPort({
|
||||
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
||||
});
|
||||
if (!port) return;
|
||||
closePopover();
|
||||
refreshPorts();
|
||||
connect(port, true);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="device-list">
|
||||
<fieldset>
|
||||
<label
|
||||
><input type="checkbox" use:preference={"autoConnect"} />
|
||||
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
|
||||
</label>
|
||||
<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>
|
||||
<button
|
||||
onclick={async (event) => {
|
||||
const { fetchCCOS } = await import("$lib/ccos/ccos");
|
||||
const ccos = await fetchCCOS();
|
||||
if (ccos) {
|
||||
connect(ccos, !event.shiftKey);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="icon">history</span>
|
||||
CC0</button
|
||||
>
|
||||
{#each ports as port}
|
||||
<div class="device">
|
||||
<button
|
||||
onclick={(event) => {
|
||||
connect(port, !event.shiftKey);
|
||||
}}
|
||||
>
|
||||
<span class="icon">history</span>
|
||||
{getPortName(port)}</button
|
||||
>
|
||||
<button
|
||||
class="error"
|
||||
onclick={() => {
|
||||
port.forget();
|
||||
refreshPorts();
|
||||
}}><span class="icon">link_off</span></button
|
||||
>
|
||||
<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>
|
||||
{/each}
|
||||
{/if}
|
||||
<div class="pair">
|
||||
<button
|
||||
onclick={async (event) => {
|
||||
const port = await navigator.serial.requestPort({
|
||||
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
||||
});
|
||||
if (!port) return;
|
||||
refreshPorts();
|
||||
connect(port, true);
|
||||
}}
|
||||
class="primary"><span class="icon">add</span>Pair</button
|
||||
<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>
|
||||
<!--<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,7 +120,8 @@
|
||||
button,
|
||||
a {
|
||||
padding: 10px;
|
||||
height: 32px;
|
||||
padding-inline-end: 16px;
|
||||
height: 38px;
|
||||
font-size: 12px;
|
||||
|
||||
.icon {
|
||||
@@ -101,18 +129,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
margin-bottom: 8px;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,12 +172,57 @@
|
||||
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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { browser, version } from "$app/environment";
|
||||
import { action } from "$lib/title";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import LL, { setLocale } from "$i18n/i18n-svelte";
|
||||
import { theme } from "$lib/preferences.js";
|
||||
import type { Locales } from "$i18n/i18n-types";
|
||||
import { detectLocale, locales } from "$i18n/i18n-util";
|
||||
import { detectLocale } from "$i18n/i18n-util";
|
||||
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
||||
import { tick } from "svelte";
|
||||
import {
|
||||
@@ -15,6 +15,7 @@
|
||||
} from "$lib/serial/connection";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import ConnectPopup from "./ConnectPopup.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
let locale = $state(
|
||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
||||
@@ -49,20 +50,20 @@
|
||||
function disconnect(event: MouseEvent) {
|
||||
if (event.shiftKey) {
|
||||
sync();
|
||||
} else if (event.altKey) {
|
||||
goto("/terminal/");
|
||||
} else {
|
||||
$serialPort?.close();
|
||||
$serialPort = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
let languageSelect: HTMLSelectElement;
|
||||
</script>
|
||||
|
||||
<footer>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
use:action={{ title: "Branch" }}
|
||||
{@attach actionTooltip("Branch")}
|
||||
href={import.meta.env.VITE_HOMEPAGE_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"><span class="icon">commit</span> v{version}</a
|
||||
@@ -71,16 +72,21 @@
|
||||
<li>
|
||||
<a
|
||||
href="/ccos/{currentDevice ? `${currentDevice}/` : ''}"
|
||||
use:action={{ title: "Updates" }}
|
||||
{@attach actionTooltip("Updates")}
|
||||
>
|
||||
CCOS {$serialPort?.version ?? "Updates"}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sync-box">
|
||||
<div
|
||||
class="sync-box"
|
||||
class:primary={!$serialPort}
|
||||
class:attention={$syncStatus !== "done"}
|
||||
>
|
||||
{#if !$serialPort}
|
||||
<button
|
||||
class="warning"
|
||||
class="no-connection"
|
||||
id="connect-button"
|
||||
popovertarget="connect-popup"
|
||||
transition:slide={{ axis: "x" }}
|
||||
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||
@@ -89,12 +95,13 @@
|
||||
<ConnectPopup />
|
||||
</div>
|
||||
{:else}
|
||||
{#snippet disconnectTooltip()}
|
||||
Disconnect<br /><kbd class="icon">shift</kbd> Sync
|
||||
{/snippet}
|
||||
<button
|
||||
transition:slide={{ axis: "x" }}
|
||||
onclick={disconnect}
|
||||
use:action={{
|
||||
title: "Disconnect<br><kbd class='icon'>shift</kbd> Sync",
|
||||
}}
|
||||
{@attach actionTooltip(disconnectTooltip)}
|
||||
><b
|
||||
>{$serialPort.company}
|
||||
{$serialPort.device}
|
||||
@@ -103,7 +110,7 @@
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if $syncStatus !== "done"}
|
||||
{#if $syncStatus === "downloading"}
|
||||
<progress
|
||||
transition:fade
|
||||
max={$syncProgress?.max ?? 1}
|
||||
@@ -112,6 +119,25 @@
|
||||
{/if}
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href={import.meta.env.VITE_DISCORD_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<svg
|
||||
class="discord-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 126.64 96"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m81 0-3 7Q63 4 49 7l-4-7-26 8Q-4 45 1 80q14 10 32 16l6-11-10-5 2-2q33 13 64 0l3 2-11 5 7 11q17-5 32-16 4-40-19-72-12-5-26-8M42 65q-10-1-11-12 0-15 11-13c11 2 12 6 12 13q-1 11-12 12m42 0q-10-1-11-12 0-15 11-13c11 2 12 6 12 13q-1 11-12 12"
|
||||
/></svg
|
||||
>
|
||||
Discord</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
||||
><span class="icon">bug_report</span> Bugs</a
|
||||
@@ -124,7 +150,7 @@
|
||||
</li>
|
||||
<li class="hide-forced-colors">
|
||||
<input
|
||||
use:action={{ title: $LL.profile.theme.COLOR_SCHEME() }}
|
||||
{@attach actionTooltip($LL.profile.theme.COLOR_SCHEME())}
|
||||
type="color"
|
||||
bind:value={$theme.color}
|
||||
/>
|
||||
@@ -132,7 +158,7 @@
|
||||
<li class="hide-forced-colors">
|
||||
{#if $theme.mode === "light"}
|
||||
<button
|
||||
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
|
||||
{@attach actionTooltip($LL.profile.theme.DARK_MODE())}
|
||||
class="icon"
|
||||
onclick={switchTheme}
|
||||
>
|
||||
@@ -140,7 +166,7 @@
|
||||
</button>
|
||||
{:else if $theme.mode === "dark"}
|
||||
<button
|
||||
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
|
||||
{@attach actionTooltip($LL.profile.theme.LIGHT_MODE())}
|
||||
class="icon"
|
||||
onclick={switchTheme}
|
||||
>
|
||||
@@ -148,46 +174,63 @@
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
<!--<li>
|
||||
<div
|
||||
role="button"
|
||||
class="icon"
|
||||
use:action={{ title: $LL.profile.LANGUAGE() }}
|
||||
onclick={() => languageSelect.click()}
|
||||
>
|
||||
translate
|
||||
|
||||
<select bind:value={locale} bind:this={languageSelect}>
|
||||
{#each locales as code}
|
||||
<option value={code}>{code}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</li>-->
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
<style lang="scss">
|
||||
@keyframes attention {
|
||||
0%,
|
||||
100% {
|
||||
filter: brightness(0.5);
|
||||
}
|
||||
50% {
|
||||
filter: brightness(1);
|
||||
}
|
||||
}
|
||||
|
||||
$sync-border-radius: 16px;
|
||||
|
||||
.discord-icon {
|
||||
margin: 5px;
|
||||
inline-size: 14px;
|
||||
}
|
||||
|
||||
.sync-box {
|
||||
display: flex;
|
||||
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;
|
||||
right: 16px;
|
||||
bottom: 0;
|
||||
left: 16px;
|
||||
opacity: 0.3;
|
||||
z-index: -1;
|
||||
border-radius: 4px;
|
||||
width: calc(100% - 32px);
|
||||
height: 8px;
|
||||
inset: $inset;
|
||||
border-radius: #{$sync-border-radius - $inset};
|
||||
width: calc(100% - $inset * 2);
|
||||
height: calc(100% - $inset * 2);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -199,14 +242,6 @@
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.warning {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
input[type="color"] {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -241,7 +276,6 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
opacity: 0.4;
|
||||
padding: 8px;
|
||||
padding-inline-end: 16px;
|
||||
padding-block-start: 0;
|
||||
|
||||
@@ -13,14 +13,7 @@
|
||||
|
||||
let isNavigating = $state(false);
|
||||
|
||||
const routeOrder = [
|
||||
"/config",
|
||||
"/learn",
|
||||
"/docs",
|
||||
"/editor",
|
||||
"/chat",
|
||||
"/plugin",
|
||||
];
|
||||
const routeOrder = ["/config", "/docs", "/editor", "/chat", "/plugin"];
|
||||
|
||||
function routeIndex(route: string | undefined): number {
|
||||
return routeOrder.findIndex((it) => route?.startsWith(it));
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
<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" }]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
{
|
||||
href: "/editor/",
|
||||
icon: "playground_2",
|
||||
title: "Emulator",
|
||||
},
|
||||
{
|
||||
href: import.meta.env.VITE_LEARN_URL,
|
||||
icon: "school",
|
||||
@@ -32,14 +41,6 @@
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
||||
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
|
||||
{ href: "/learn", icon: "school", title: "Learn", wip: true },
|
||||
],
|
||||
/*[
|
||||
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
||||
],*/
|
||||
] satisfies {
|
||||
href: string;
|
||||
icon: string;
|
||||
@@ -47,7 +48,7 @@
|
||||
wip?: boolean;
|
||||
external?: boolean;
|
||||
primary?: boolean;
|
||||
}[][];
|
||||
}[][]);
|
||||
|
||||
let connectButton: HTMLButtonElement;
|
||||
</script>
|
||||
|
||||
1
src/routes/(app)/ccos/+layout.server.ts
Normal file
1
src/routes/(app)/ccos/+layout.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = false;
|
||||
@@ -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>
|
||||
@@ -70,14 +65,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
opacity: 0.5;
|
||||
&:before {
|
||||
padding-inline: 0.4ch;
|
||||
content: "•";
|
||||
}
|
||||
}
|
||||
|
||||
div.title:has(input:not(:checked)) ~ ul .pre-release {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
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();
|
||||
|
||||
@@ -31,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(
|
||||
@@ -205,18 +230,13 @@
|
||||
{#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={isTooOld ||
|
||||
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 isTooOld}
|
||||
<div class="error" transition:slide>
|
||||
@@ -241,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}
|
||||
@@ -344,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>
|
||||
@@ -424,27 +478,6 @@
|
||||
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;
|
||||
margin: 0;
|
||||
@@ -470,67 +503,6 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
button.update-button {
|
||||
position: relative;
|
||||
transition:
|
||||
border 200ms ease,
|
||||
color 200ms ease;
|
||||
|
||||
margin: 6px;
|
||||
margin-block: 16px;
|
||||
|
||||
outline: 2px dashed currentcolor;
|
||||
outline-offset: 4px;
|
||||
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
height: 42px;
|
||||
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: 8px;
|
||||
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: "";
|
||||
}
|
||||
}
|
||||
|
||||
.version {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat-rx";
|
||||
import { flip } from "svelte/animate";
|
||||
import { slide } from "svelte/transition";
|
||||
import Login from "./Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
await initMatrixClient();
|
||||
}
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let spaces = $derived($matrix?.topLevelSpaces$);
|
||||
|
||||
function spaceShort(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((it) => it[0])
|
||||
.join("");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLoggedIn}
|
||||
<div class="layout">
|
||||
<nav class="spaces">
|
||||
<a href="/chat/chats" class="icon chats">chat</a>
|
||||
<hr />
|
||||
{#if $spaces}
|
||||
<ul>
|
||||
{#each $spaces as space (space.roomId)}
|
||||
<li animate:flip transition:slide>
|
||||
<a class="space" href="/chat/space/{space.roomId}">
|
||||
{spaceShort(space.name)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="icon">add</button>
|
||||
</nav>
|
||||
</div>
|
||||
{:else}
|
||||
<Login />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chats {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.space {
|
||||
margin-bottom: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { matrixClient } from "$lib/chat/chat";
|
||||
|
||||
function passwordLogin() {
|
||||
// TODO
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $matrixClient}
|
||||
{#await $matrixClient.loginFlows() then flows}
|
||||
{#each flows.flows as flow}
|
||||
{#if flow.type === "m.login.sso"}
|
||||
<a
|
||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
||||
>
|
||||
{#each flow.identity_providers as idp}
|
||||
{#if idp.icon}
|
||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
||||
{:else}
|
||||
{idp.name}
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else if flow.type === "m.login.password"}
|
||||
<form onsubmit={passwordLogin}>
|
||||
<input name="username" type="text" placeholder="Username" />
|
||||
<input name="password" type="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -1,180 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { onDestroy, onMount, setContext } from "svelte";
|
||||
import type {
|
||||
IndexedDBStore,
|
||||
IndexedDBCryptoStore,
|
||||
LoginResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
|
||||
import { matrixClient, currentRoomId } from "$lib/chat/chat";
|
||||
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
|
||||
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
|
||||
|
||||
let loggedIn = $state(false);
|
||||
let ready = $state(false);
|
||||
|
||||
let store: IndexedDBStore;
|
||||
let cryptoStore: IndexedDBCryptoStore;
|
||||
|
||||
onMount(async () => {
|
||||
if (!browser) return;
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
|
||||
|
||||
$matrixClient = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
await handleLogin(await $matrixClient.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
await postLogin();
|
||||
});
|
||||
|
||||
async function passwordLogin(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const username = (form.elements.namedItem("username") as HTMLInputElement)
|
||||
.value;
|
||||
const password = (form.elements.namedItem("password") as HTMLInputElement)
|
||||
.value;
|
||||
|
||||
await handleLogin(
|
||||
await $matrixClient.loginWithPassword(username, password),
|
||||
);
|
||||
await postLogin();
|
||||
}
|
||||
|
||||
async function handleLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
async function postLogin() {
|
||||
loggedIn = $matrixClient.isLoggedIn();
|
||||
|
||||
if (loggedIn) {
|
||||
await store.startup();
|
||||
await cryptoStore.startup();
|
||||
await $matrixClient.startClient();
|
||||
$matrixClient.once("sync", function (state, prevState, res) {
|
||||
ready = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($matrixClient) {
|
||||
$matrixClient.stopClient();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $matrixClient && loggedIn}
|
||||
{#if ready}
|
||||
<div class="chat">
|
||||
<div class="rooms">
|
||||
<button
|
||||
onclick={() => {
|
||||
$matrixClient.logout(true);
|
||||
$matrixClient.clearStores();
|
||||
localStorage.removeItem("matrix-login");
|
||||
window.location.reload();
|
||||
}}>logout</button
|
||||
>
|
||||
<MatrixRooms rooms={$matrixClient.getRooms()} />
|
||||
</div>
|
||||
{#if $currentRoomId}
|
||||
{@const room = $matrixClient.getRoom($currentRoomId)}
|
||||
{#key room}
|
||||
{#if room}
|
||||
<div class="timeline">
|
||||
<MatrixTimeline timeline={room.getLiveTimeline()} />
|
||||
</div>
|
||||
<div class="members">
|
||||
<MatrixRoomMembers members={room.getJoinedMembers()} />
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if $matrixClient}
|
||||
{#await $matrixClient.loginFlows() then flows}
|
||||
{#each flows.flows as flow}
|
||||
{#if flow.type === "m.login.sso"}
|
||||
<a
|
||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
||||
>
|
||||
{#each flow.identity_providers as idp}
|
||||
{#if idp.icon}
|
||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
||||
{:else}
|
||||
{idp.name}
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else if flow.type === "m.login.password"}
|
||||
<!-- TODO: unambigous sso
|
||||
<form onsubmit={passwordLogin}>
|
||||
<input name="username" type="text" placeholder="Username" />
|
||||
<input name="password" type="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
-->
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.chat {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> *:not(:last-child) {
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.rooms {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.members {
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -3,23 +3,24 @@
|
||||
import {
|
||||
changes,
|
||||
ChangeType,
|
||||
chords,
|
||||
layout,
|
||||
overlay,
|
||||
settings,
|
||||
} from "$lib/undo-redo";
|
||||
import type { Change } from "$lib/undo-redo";
|
||||
import { fly } from "svelte/transition";
|
||||
import { action } from "$lib/title";
|
||||
import { actionTooltip } from "$lib/title";
|
||||
import {
|
||||
deviceChords,
|
||||
deviceLayout,
|
||||
deviceSettings,
|
||||
serialLog,
|
||||
serialPort,
|
||||
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,77 +41,13 @@
|
||||
}
|
||||
}
|
||||
let redoQueue: Change[][] = $state([]);
|
||||
let error = $state<Error | undefined>(undefined);
|
||||
let progressButton: HTMLButtonElement | undefined = $state();
|
||||
|
||||
async function save() {
|
||||
async function saveLayoutChanges(progress: () => void): Promise<boolean> {
|
||||
const port = $serialPort;
|
||||
if (!port) return false;
|
||||
try {
|
||||
const port = $serialPort;
|
||||
if (!port) return;
|
||||
$syncStatus = "uploading";
|
||||
|
||||
const layoutChanges = $overlay.layout.reduce(
|
||||
(acc, profile) =>
|
||||
acc + profile.reduce((acc, layer) => acc + layer.size, 0),
|
||||
0,
|
||||
);
|
||||
const settingChanges = $overlay.settings.reduce(
|
||||
(acc, profile) => acc + profile.size,
|
||||
0,
|
||||
);
|
||||
const chordChanges = $overlay.chords.size;
|
||||
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||
|
||||
let progressCurrent = 0;
|
||||
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent,
|
||||
});
|
||||
|
||||
console.log($overlay);
|
||||
|
||||
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 });
|
||||
}
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [profile, layout] of $overlay.layout.entries()) {
|
||||
if (layout === undefined) continue;
|
||||
for (const [layer, actions] of layout.entries()) {
|
||||
@@ -118,94 +55,155 @@
|
||||
for (const [id, action] of actions) {
|
||||
if (action === undefined) continue;
|
||||
await port.setLayoutKey(profile, layer + 1, id, action);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
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);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
progress();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
$deviceLayout = $layout.map((profile) =>
|
||||
profile.map((layer) => layer.map<number>(({ action }) => action)),
|
||||
);
|
||||
$deviceChords = $chords
|
||||
.filter(({ deleted }) => !deleted)
|
||||
.map(({ actions, phrase }) => ({ actions, phrase }));
|
||||
$deviceSettings = $settings.map((profile) =>
|
||||
profile.map(({ value }) => value),
|
||||
);
|
||||
$changes = [];
|
||||
changes.update((changes) =>
|
||||
changes
|
||||
.map((it) => it.filter((it) => it.type !== ChangeType.Setting))
|
||||
.filter((it) => it.length > 0),
|
||||
);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
await tick();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const port = $serialPort;
|
||||
if (!port) {
|
||||
document
|
||||
.getElementById("connect-popup")
|
||||
?.showPopover({ source: progressButton });
|
||||
return;
|
||||
}
|
||||
$syncStatus = "uploading";
|
||||
|
||||
const layoutChanges = $overlay.layout.reduce(
|
||||
(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 needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||
const progressMax = layoutChanges + settingChanges;
|
||||
|
||||
let progressCurrent = 0;
|
||||
|
||||
function updateProgress() {
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: Math.min(progressMax, progressCurrent++),
|
||||
});
|
||||
}
|
||||
updateProgress();
|
||||
|
||||
let layoutSuccess = await saveLayoutChanges(updateProgress);
|
||||
let settingsSuccess = await saveSettings(updateProgress);
|
||||
|
||||
if (needsCommit) {
|
||||
try {
|
||||
await port.commit();
|
||||
} catch (e) {
|
||||
console.error("Error during commit:", e);
|
||||
layoutSuccess = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutSuccess && settingsSuccess) {
|
||||
changes.set([]);
|
||||
} else {
|
||||
throw new Error("Some changes could not be saved.");
|
||||
}
|
||||
} catch (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";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-inline: 8px;
|
||||
outline: 2px dashed var(--md-sys-color-primary);
|
||||
outline-offset: 2px;
|
||||
border: 2px solid var(--md-sys-color-primary);
|
||||
border-radius: 18px;
|
||||
padding-inline-start: 8px;
|
||||
padding-inline-end: 12px;
|
||||
padding-block: 2px;
|
||||
height: fit-content;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-weight: bold;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +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>
|
||||
@@ -13,8 +78,11 @@
|
||||
</div>
|
||||
|
||||
<div class="profiles">
|
||||
{#if $serialPort}
|
||||
{#if $serialPort.profileCount > 1}
|
||||
{#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
|
||||
@@ -25,20 +93,20 @@
|
||||
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
</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
|
||||
@@ -49,10 +117,32 @@
|
||||
<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 auto 1fr;
|
||||
|
||||
@@ -1,472 +1,280 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { action } from "$lib/title";
|
||||
import { onDestroy, onMount, setContext, tick } from "svelte";
|
||||
import { changes, ChangeType, chords } from "$lib/undo-redo";
|
||||
import type { ChordChange, ChordInfo } from "$lib/undo-redo";
|
||||
import { derived, writable } from "svelte/store";
|
||||
import ChordEdit from "./ChordEdit.svelte";
|
||||
import { crossfade, fly } from "svelte/transition";
|
||||
import ChordActionEdit from "./ChordActionEdit.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import randomTips from "$lib/assets/random-tips/en.json";
|
||||
import { deviceMeta } from "$lib/serial/connection";
|
||||
import { restoreFromFile } from "$lib/backup/backup";
|
||||
import { EditorView } from "codemirror";
|
||||
import "$lib/chord-editor/chords.grammar";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import ActionList from "$lib/components/layout/ActionList.svelte";
|
||||
import {
|
||||
createConfig,
|
||||
loadPersistentState,
|
||||
} from "$lib/chord-editor/persistent-state-plugin";
|
||||
import { parsedChordsField } from "$lib/chord-editor/parsed-chords-plugin";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { deviceChords } from "$lib/serial/connection";
|
||||
import { editorSyncChords } from "$lib/chord-editor/chord-sync-plugin";
|
||||
|
||||
const resultSize = 38;
|
||||
let results: HTMLElement;
|
||||
const pageSize = writable(0);
|
||||
let resizeObserver: ResizeObserver;
|
||||
let queryFilter: string | undefined = $state(undefined);
|
||||
|
||||
let abortIndexing: (() => void) | undefined;
|
||||
let progress = $state(0);
|
||||
const rawCode = persistentWritable("chord-editor-raw-code", false);
|
||||
const showEdits = persistentWritable("chord-editor-show-edits", true);
|
||||
const denseSpacing = persistentWritable("chord-editor-spacing", false);
|
||||
|
||||
onMount(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
pageSize.set(Math.floor(results.clientHeight / resultSize));
|
||||
});
|
||||
pageSize.set(Math.floor(results.clientHeight / resultSize));
|
||||
resizeObserver.observe(results);
|
||||
});
|
||||
let editor: HTMLDivElement | undefined = $state(undefined);
|
||||
let view: EditorView | undefined = $state(undefined);
|
||||
|
||||
onDestroy(() => {
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
let index = new FlexSearch.Index();
|
||||
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
|
||||
$effect(() => {
|
||||
abortIndexing?.();
|
||||
progress = 0;
|
||||
buildIndex($chords, $osLayout, $KEYMAP_CODES).then(searchIndex.set);
|
||||
});
|
||||
|
||||
function encodeChord(
|
||||
chord: ChordInfo,
|
||||
osLayout: Map<string, string>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
onlyPhrase: boolean = false,
|
||||
) {
|
||||
const plainPhrase: string[] = [""];
|
||||
const tags = new Set<string>();
|
||||
const extraActions = new Set<string>();
|
||||
const extraCodes = new Set<string>();
|
||||
|
||||
for (const actionCode of chord.phrase ?? []) {
|
||||
const action = codes.get(actionCode);
|
||||
if (!action) {
|
||||
extraCodes.add(`0x${actionCode.toString(16)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const osCode = action.keyCode && osLayout.get(action.keyCode);
|
||||
const token = osCode?.length === 1 ? osCode : action.display || action.id;
|
||||
if (!token) {
|
||||
extraCodes.add(`0x${action.code.toString(16)}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(token === "SPACE" || /^\s$/.test(token)) &&
|
||||
plainPhrase.at(-1) !== ""
|
||||
) {
|
||||
plainPhrase.push("");
|
||||
} else if (token.length === 1) {
|
||||
plainPhrase[plainPhrase.length - 1] =
|
||||
plainPhrase[plainPhrase.length - 1] + token;
|
||||
} else {
|
||||
extraActions.add(token);
|
||||
}
|
||||
}
|
||||
|
||||
if (chord.phrase?.[0] === 298) {
|
||||
tags.add("suffix");
|
||||
}
|
||||
if (
|
||||
["ARROW_LT", "ARROW_RT", "ARROW_UP", "ARROW_DN"].some((it) =>
|
||||
extraActions.has(it),
|
||||
)
|
||||
) {
|
||||
tags.add("cursor warp");
|
||||
}
|
||||
if (
|
||||
["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) => extraActions.has(it))
|
||||
) {
|
||||
tags.add("macro");
|
||||
}
|
||||
if (chord.actions[0] !== 0) {
|
||||
tags.add("compound");
|
||||
}
|
||||
|
||||
const input = chord.actions
|
||||
.slice(chord.actions.lastIndexOf(0) + 1)
|
||||
.map((it) => {
|
||||
const info = codes.get(it);
|
||||
if (!info) return `0x${it.toString(16)}`;
|
||||
const osCode = info.keyCode && osLayout.get(info.keyCode);
|
||||
const result = osCode?.length === 1 ? osCode : info.id;
|
||||
return result ?? `0x${it.toString(16)}`;
|
||||
});
|
||||
|
||||
if (onlyPhrase) {
|
||||
return plainPhrase.join(" ");
|
||||
}
|
||||
|
||||
return [
|
||||
...plainPhrase,
|
||||
`+${input.join("+")}`,
|
||||
...tags,
|
||||
...extraActions,
|
||||
...extraCodes,
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
async function buildIndex(
|
||||
chords: ChordInfo[],
|
||||
osLayout: Map<string, string>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
): Promise<FlexSearch.Index> {
|
||||
if (chords.length === 0 || !browser) return index;
|
||||
|
||||
index = new FlexSearch.Index({
|
||||
tokenize: "full",
|
||||
encode(phrase: string) {
|
||||
return phrase.split(/\s+/).flatMap((it) => {
|
||||
if (/^[A-Z_]+$/.test(it)) {
|
||||
return it;
|
||||
}
|
||||
if (it.startsWith("+")) {
|
||||
return it
|
||||
.slice(1)
|
||||
.split("+")
|
||||
.map((it) => `+${it}`);
|
||||
}
|
||||
return it.toLowerCase();
|
||||
});
|
||||
if (!editor) return;
|
||||
const viewPromise = loadPersistentState({
|
||||
rawCode: $rawCode,
|
||||
storeName: "chord-editor-state-storage",
|
||||
autocomplete(query) {
|
||||
queryFilter = query;
|
||||
},
|
||||
});
|
||||
|
||||
let abort = false;
|
||||
abortIndexing = () => {
|
||||
abort = true;
|
||||
};
|
||||
|
||||
const batchSize = 200;
|
||||
const batches = Math.ceil(chords.length / batchSize);
|
||||
|
||||
for (let b = 0; b < batches; b++) {
|
||||
if (abort) return index;
|
||||
|
||||
const start = b * batchSize;
|
||||
const end = Math.min((b + 1) * batchSize, chords.length);
|
||||
const batch = chords.slice(start, end);
|
||||
|
||||
const promises = batch.map((chord, i) => {
|
||||
const chordIndex = start + i;
|
||||
progress = chordIndex + 1;
|
||||
|
||||
if ("phrase" in chord) {
|
||||
const encodedChord = encodeChord(chord, osLayout, codes);
|
||||
return index.addAsync(chordIndex, encodedChord);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
|
||||
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[])
|
||||
: undefined,
|
||||
}).then(
|
||||
(state) =>
|
||||
new EditorView({
|
||||
parent: editor,
|
||||
state,
|
||||
}),
|
||||
);
|
||||
page = 0;
|
||||
}
|
||||
viewPromise.then((it) => (view = it));
|
||||
return () => viewPromise.then((it) => it.destroy());
|
||||
});
|
||||
|
||||
// Re-run search when chords change to fix stale indices
|
||||
$effect(() => {
|
||||
if (currentSearchQuery && $searchIndex) {
|
||||
search($searchIndex, { target: { value: currentSearchQuery } } as any);
|
||||
console.log("Syncing chords to editor");
|
||||
if (view) {
|
||||
editorSyncChords(
|
||||
view,
|
||||
$deviceChords.map((chord) => [chord.actions, chord.phrase] as const),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function insertChord(actions: number[]) {
|
||||
const id = JSON.stringify(actions);
|
||||
if ($chords.some((it) => JSON.stringify(it.actions) === id)) {
|
||||
alert($LL.configure.chords.DUPLICATE());
|
||||
return;
|
||||
}
|
||||
changes.update((changes) => {
|
||||
changes.push([
|
||||
{
|
||||
type: ChangeType.Chord,
|
||||
id: actions,
|
||||
actions,
|
||||
phrase: [],
|
||||
},
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadVocabulary() {
|
||||
const vocabulary = new Set(
|
||||
$chords.map((it) =>
|
||||
"phrase" in it
|
||||
? encodeChord(it, $osLayout, $KEYMAP_CODES, true).trim()
|
||||
: "",
|
||||
function regenerate() {
|
||||
if (!view) return;
|
||||
view.setState(
|
||||
EditorState.create(
|
||||
createConfig({
|
||||
rawCode: $rawCode,
|
||||
storeName: "chord-editor-state-storage",
|
||||
autocomplete(query) {
|
||||
queryFilter = query;
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
vocabulary.delete("");
|
||||
const blob = new Blob([Array.from(vocabulary).join("|")], {
|
||||
type: "text/plain",
|
||||
editorSyncChords(
|
||||
view,
|
||||
$deviceChords.map((chord) => [chord.actions, chord.phrase] as const),
|
||||
);
|
||||
}
|
||||
|
||||
function downloadBackup() {
|
||||
if (!view) return;
|
||||
const backup: CharaChordFile = {
|
||||
charaVersion: 1,
|
||||
type: "chords",
|
||||
chords: view.state
|
||||
.field(parsedChordsField)
|
||||
.chords.map((chord) => [
|
||||
chord.input?.value ?? [],
|
||||
chord.phrase?.value ?? [],
|
||||
]),
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(backup)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "vocabulary.txt";
|
||||
a.download = "chord-backup.json";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function clearChords() {
|
||||
changes.update((changes) => {
|
||||
changes.push(
|
||||
$chords.map<ChordChange>((it) => ({
|
||||
type: ChangeType.Chord,
|
||||
id: it.id,
|
||||
actions: it.actions,
|
||||
phrase: it.phrase,
|
||||
deleted: true,
|
||||
})),
|
||||
);
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
|
||||
const items = derived(
|
||||
[searchFilter, chords],
|
||||
([filter, chords]) =>
|
||||
filter?.map((it) => [chords[it], it] as const) ??
|
||||
chords.map((it, i) => [it, i] as const),
|
||||
);
|
||||
const lastPage = derived(
|
||||
[items, pageSize],
|
||||
([items, pageSize]) => Math.ceil((items.length + 1) / pageSize) - 1,
|
||||
);
|
||||
|
||||
setContext("cursor-crossfade", crossfade({}));
|
||||
|
||||
let page = $state(0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Chord Manager - CharaChorder Device Manager</title>
|
||||
<meta name="description" content="Manage your chords" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="search-container">
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress)}
|
||||
value={currentSearchQuery}
|
||||
oninput={(event) => $searchIndex && search($searchIndex, event)}
|
||||
class:loading={progress !== $chords.length}
|
||||
/>
|
||||
<div class="paginator">
|
||||
{#if $lastPage !== -1}
|
||||
{page + 1} / {$lastPage + 1}
|
||||
{:else}
|
||||
- / -
|
||||
{/if}
|
||||
<div class="vertical">
|
||||
<div style:display="flex">
|
||||
<label><input type="checkbox" bind:checked={$rawCode} />Edit as code</label>
|
||||
<!--<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>-->
|
||||
<label
|
||||
><input type="checkbox" bind:checked={$denseSpacing} />Dense Spacing</label
|
||||
>
|
||||
<button onclick={regenerate}>Reset</button>
|
||||
<!--<button onclick={largeFile}>Create Huge File</button>-->
|
||||
<button onclick={downloadBackup}>Download Backup</button>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div
|
||||
class="editor"
|
||||
class:hide-edits={!$showEdits}
|
||||
class:raw={$rawCode}
|
||||
class:dense-spacing={$denseSpacing}
|
||||
bind:this={editor}
|
||||
></div>
|
||||
<ActionList {queryFilter} ignoreIcon={$rawCode} />
|
||||
</div>
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => (page = Math.max(page - 1, 0))}
|
||||
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
|
||||
>
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => (page = Math.min(page + 1, $lastPage))}
|
||||
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
|
||||
>
|
||||
</div>
|
||||
|
||||
<section bind:this={results}>
|
||||
<!-- fixes some unresponsiveness -->
|
||||
{#await tick() then}
|
||||
<div class="results">
|
||||
<table transition:fly={{ y: 48, easing: expoOut }}>
|
||||
{#if $lastPage !== -1}
|
||||
<tbody>
|
||||
{#if page === 0}
|
||||
<tr
|
||||
><th class="new-chord"
|
||||
><ChordActionEdit
|
||||
onsubmit={(action) => insertChord(action)}
|
||||
/></th
|
||||
><td></td><td></td></tr
|
||||
>
|
||||
{/if}
|
||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord]}
|
||||
{#if chord}
|
||||
<ChordEdit {chord} onduplicate={() => (page = 0)} />
|
||||
{/if}
|
||||
{/each}</tbody
|
||||
>
|
||||
{:else}
|
||||
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
|
||||
{/if}
|
||||
</table>
|
||||
</div>
|
||||
<div class="sidebar">
|
||||
<textarea
|
||||
placeholder={$LL.configure.chords.TRY_TYPING() +
|
||||
"\n\nDid you know? " +
|
||||
randomTips[Math.floor(randomTips.length * Math.random())]}
|
||||
></textarea>
|
||||
<button onclick={clearChords}
|
||||
><span class="icon">delete_sweep</span>
|
||||
Clear Chords</button
|
||||
>
|
||||
<div>
|
||||
{#each Object.entries($deviceMeta?.factoryDefaults?.chords ?? {}) as [title, library]}
|
||||
<button onclick={() => restoreFromFile(library)}
|
||||
><span class="icon">library_add</span>{title}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<button onclick={downloadVocabulary}
|
||||
><span class="icon">download</span>
|
||||
{$LL.configure.chords.VOCABULARY()}</button
|
||||
>
|
||||
</div>
|
||||
{/await}
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.search-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.paginator {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-width: 8ch;
|
||||
}
|
||||
|
||||
.new-chord :global(.add) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
> button {
|
||||
padding-inline-start: 0;
|
||||
.split {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
width: calc(min(100%, 1400px));
|
||||
min-height: 0;
|
||||
|
||||
> :global(*) {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
flex: 1;
|
||||
transition: outline-color 250ms ease;
|
||||
margin: 2px;
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -1px;
|
||||
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);
|
||||
}
|
||||
.editor :global(.cm-deletedChunk) {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
transition: all 250ms ease;
|
||||
margin-block-start: 16px;
|
||||
border: 0 solid var(--md-sys-color-surface-variant);
|
||||
border-bottom-width: 1px;
|
||||
|
||||
background: none;
|
||||
padding-inline: 16px;
|
||||
padding-block: 8px;
|
||||
width: 512px;
|
||||
color: inherit;
|
||||
|
||||
.editor {
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
border-style: dashed;
|
||||
border-color: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.8;
|
||||
:global(.cm-tooltip) {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
:global(ul) {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
:global(li[role="option"][aria-selected="true"]) {
|
||||
border-radius: 4px;
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
:global(completion-section) {
|
||||
margin-block: 8px;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:not(.raw) :global(.cm-line) {
|
||||
vertical-align: middle;
|
||||
columns: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.dense-spacing :global(.cm-line) {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
:global(.cm-line) {
|
||||
padding-block: 8px;
|
||||
width: 100%;
|
||||
text-wrap: wrap;
|
||||
text-wrap-style: stable;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
> :global(*) {
|
||||
break-before: avoid;
|
||||
break-after: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.cm-panels) {
|
||||
border-top: none;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
:global(.chord-ignored) {
|
||||
opacity: 0.5;
|
||||
background-image: none;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
:global(.chord-child) {
|
||||
background-image: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:global(.chord-invalid) {
|
||||
color: var(--md-sys-color-error);
|
||||
text-decoration-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
:global(.change-button) {
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(.cm-deletedLineGutter) {
|
||||
background-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
:global(.cm-changedLineGutter) {
|
||||
background-color: var(--md-sys-color-success);
|
||||
}
|
||||
|
||||
:global(.cm-changedText) {
|
||||
background: linear-gradient(
|
||||
var(--md-sys-color-primary),
|
||||
var(--md-sys-color-primary)
|
||||
)
|
||||
bottom / 100% 1px no-repeat;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.raw :global(.cm-gutters) {
|
||||
border-color: var(--md-sys-color-surface-variant);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
:global(.cm-editor) {
|
||||
outline: none;
|
||||
border-style: solid;
|
||||
border-color: var(--md-sys-color-primary);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.loading {
|
||||
opacity: 0.4;
|
||||
:global(.cm-changedLine) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-primary) 5%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
position: relative;
|
||||
:global(.cm-activeLine),
|
||||
:global(.cm-line:hover) {
|
||||
--auto-space-show: 1;
|
||||
}
|
||||
|
||||
border-radius: 16px;
|
||||
padding-inline: 8px;
|
||||
:global(.cm-activeLine) {
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
|
||||
height: 100%;
|
||||
&:not(.cm-changedLine) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.results {
|
||||
min-width: min(90vw, 16.5cm);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
transition: all 1s ease;
|
||||
height: fit-content;
|
||||
overflow-y: hidden;
|
||||
:global(::selection),
|
||||
:global(.cm-selectionBackground) {
|
||||
background-color: var(--md-sys-color-surface-variant) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user