From 662a5f8e9de06a01322cd2643f5c3725c48827db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thea=20Sch=C3=B6bl?= Date: Fri, 16 Jan 2026 18:13:51 +0100 Subject: [PATCH] feat: cv2 --- icons.config.js | 1 + package.json | 2 + pnpm-lock.yaml | 11 +- src/lib/backup/backup.ts | 16 +- src/lib/chord-editor/ChangesPanel.svelte | 149 ++++ src/lib/chord-editor/action-linter.ts | 21 + src/lib/chord-editor/action-serializer.ts | 313 ++++----- .../changes-decorations-plugin.ts | 23 - src/lib/chord-editor/changes-panel.svelte.ts | 44 ++ src/lib/chord-editor/changes-panel.ts | 39 -- src/lib/chord-editor/chord-sync-plugin.ts | 34 +- src/lib/chord-editor/chord-sync.spec.ts | 135 ++++ src/lib/chord-editor/chord-sync.ts | 130 ++++ src/lib/chord-editor/parse-meta.ts | 4 + src/lib/chord-editor/parsed-chords-plugin.ts | 12 +- .../chord-editor/persistent-state-plugin.ts | 88 ++- src/lib/chord-editor/save-chords.ts | 58 ++ src/lib/components/layout/ActionList.svelte | 2 +- src/lib/learn/chords.ts | 101 --- src/lib/learn/stats.ts | 11 - src/lib/undo-redo.ts | 115 +-- src/routes/(app)/PageTransition.svelte | 9 +- src/routes/(app)/Sidebar.svelte | 7 +- src/routes/(app)/config/EditActions.svelte | 125 +--- src/routes/(app)/config/chords/+page.svelte | 644 ++++++----------- .../config/chords/ChordActionEdit.svelte | 240 ------- .../(app)/config/chords/ChordEdit.svelte | 172 ----- .../config/chords/ChordPhraseEdit.svelte | 376 ---------- .../(app)/config/chords/action-selector.ts | 56 -- .../(app)/config/chords/input-converter.ts | 16 - .../will-my-compound-break/+page.svelte | 7 +- src/routes/(app)/config/cv2/+page.svelte | 356 ---------- src/routes/(app)/learn/+page.svelte | 24 - src/routes/(app)/learn/chords/+page.svelte | 232 ------- src/routes/(app)/learn/layout/+page.svelte | 124 ---- src/routes/(app)/learn/sentence/+page.svelte | 652 ------------------ .../(app)/learn/sentence/configuration.ts | 32 - src/routes/(app)/learn/sentence/constants.ts | 8 - src/routes/(app)/learn/sentence/types.ts | 0 .../learn/sentence/word-selector.spec.ts | 69 -- .../(app)/learn/sentence/word-selector.ts | 25 - vitest.config.ts | 6 +- 42 files changed, 1042 insertions(+), 3447 deletions(-) create mode 100644 src/lib/chord-editor/ChangesPanel.svelte delete mode 100644 src/lib/chord-editor/changes-decorations-plugin.ts create mode 100644 src/lib/chord-editor/changes-panel.svelte.ts delete mode 100644 src/lib/chord-editor/changes-panel.ts create mode 100644 src/lib/chord-editor/chord-sync.spec.ts create mode 100644 src/lib/chord-editor/chord-sync.ts create mode 100644 src/lib/chord-editor/save-chords.ts delete mode 100644 src/lib/learn/chords.ts delete mode 100644 src/lib/learn/stats.ts delete mode 100644 src/routes/(app)/config/chords/ChordActionEdit.svelte delete mode 100644 src/routes/(app)/config/chords/ChordEdit.svelte delete mode 100644 src/routes/(app)/config/chords/ChordPhraseEdit.svelte delete mode 100644 src/routes/(app)/config/chords/action-selector.ts delete mode 100644 src/routes/(app)/config/chords/input-converter.ts delete mode 100644 src/routes/(app)/config/cv2/+page.svelte delete mode 100644 src/routes/(app)/learn/+page.svelte delete mode 100644 src/routes/(app)/learn/chords/+page.svelte delete mode 100644 src/routes/(app)/learn/layout/+page.svelte delete mode 100644 src/routes/(app)/learn/sentence/+page.svelte delete mode 100644 src/routes/(app)/learn/sentence/configuration.ts delete mode 100644 src/routes/(app)/learn/sentence/constants.ts delete mode 100644 src/routes/(app)/learn/sentence/types.ts delete mode 100644 src/routes/(app)/learn/sentence/word-selector.spec.ts delete mode 100644 src/routes/(app)/learn/sentence/word-selector.ts diff --git a/icons.config.js b/icons.config.js index 2ae7ef79..49deb215 100644 --- a/icons.config.js +++ b/icons.config.js @@ -6,6 +6,7 @@ const config = { icons: [ "rocket_launch", "deployed_code_update", + "difference", "adjust", "add", "piano", diff --git a/package.json b/package.json index 6cda0bce..ba83c111 100644 --- a/package.json +++ b/package.json @@ -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", @@ -41,6 +42,7 @@ "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f58e936b..3a0290e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@codemirror/merge': specifier: ^6.11.2 version: 6.11.2 + '@codemirror/search': + specifier: ^6.6.0 + version: 6.6.0 '@codemirror/state': specifier: ^6.5.3 version: 6.5.3 @@ -759,8 +762,8 @@ packages: '@codemirror/merge@6.11.2': resolution: {integrity: sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==} - '@codemirror/search@6.5.6': - resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==} + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} '@codemirror/state@6.5.3': resolution: {integrity: sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==} @@ -5270,7 +5273,7 @@ snapshots: '@lezer/highlight': 1.2.3 style-mod: 4.1.2 - '@codemirror/search@6.5.6': + '@codemirror/search@6.6.0': dependencies: '@codemirror/state': 6.5.3 '@codemirror/view': 6.39.9 @@ -6259,7 +6262,7 @@ snapshots: '@codemirror/commands': 6.10.1 '@codemirror/language': 6.12.1 '@codemirror/lint': 6.9.2 - '@codemirror/search': 6.5.6 + '@codemirror/search': 6.6.0 '@codemirror/state': 6.5.3 '@codemirror/view': 6.39.9 diff --git a/src/lib/backup/backup.ts b/src/lib/backup/backup.ts index 2bfb9012..0c34e0f6 100644 --- a/src/lib/backup/backup.ts +++ b/src/lib/backup/backup.ts @@ -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]), }; } @@ -168,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]))) { diff --git a/src/lib/chord-editor/ChangesPanel.svelte b/src/lib/chord-editor/ChangesPanel.svelte new file mode 100644 index 00000000..cc995537 --- /dev/null +++ b/src/lib/chord-editor/ChangesPanel.svelte @@ -0,0 +1,149 @@ + + +
+ {#if added + changed + removed !== 0 || $syncStatus === "uploading" || $syncStatus === "error"} +
+ + save + {$LL.saveActions.SAVE()} + +
+ {/if} + +
+ {#if added} + +{added} + {/if} + {#if changed} + ~{changed} + {/if} + {#if removed} + -{removed} + {/if} +
+ + {#if parsed.aliases.size > 0} +
+ content_copy + {parsed.aliases.size} +
+ {/if} +
+ + diff --git a/src/lib/chord-editor/action-linter.ts b/src/lib/chord-editor/action-linter.ts index 13e6f730..3930eb4b 100644 --- a/src/lib/chord-editor/action-linter.ts +++ b/src/lib/chord-editor/action-linter.ts @@ -128,6 +128,27 @@ export function actionLinter(config?: Parameters[1]) { 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; diff --git a/src/lib/chord-editor/action-serializer.ts b/src/lib/chord-editor/action-serializer.ts index de22321b..7e4b746a 100644 --- a/src/lib/chord-editor/action-serializer.ts +++ b/src/lib/chord-editor/action-serializer.ts @@ -1,8 +1,5 @@ -import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes"; +import type { KeyInfo } from "$lib/serial/keymap-codes"; import type { CharaChordFile } from "$lib/share/chara-file"; -import { syntaxTree } from "@codemirror/language"; -import type { EditorState } from "@codemirror/state"; -import { get } from "svelte/store"; import { composeChordInput, hasConcatenator, @@ -15,25 +12,13 @@ import type { MetaRange, ParseResult, } from "./parse-meta"; +import type { Tree } from "@lezer/common"; -export function canUseIdAsString(info: KeyInfo): boolean { - return !!info.id && /^[^>\n]+$/.test(info.id); -} - -export function actionToValue(action: number | KeyInfo) { - const info = - typeof action === "number" ? get(KEYMAP_CODES).get(action) : action; - if (info && info.id?.length === 1) - return /^[<>|\\\s]$/.test(info.id) ? `\\${info.id}` : info.id; - if (!info || !canUseIdAsString(info)) - return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`; - return `<${info.id}>`; -} - -export function parseChordMeta( - data: EditorState, +function parseChordMeta( + tree: Tree, ids: Map, codes: Map, + sliceString: (from: number, to: number) => string, ): ChordMeta[] { console.time("parseChordTree"); const result: ChordMeta[] = []; @@ -42,124 +27,118 @@ export function parseChordMeta( let actions: ActionMeta[] = []; let actionRange: MetaRange | undefined = undefined; - syntaxTree(data) - .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 = data.doc.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 = data.doc.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); + 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: [node.from, node.to], - value: hashChord( - composeChordInput( - actions.map(({ code }) => code), - lastCompound?.value, - ), - ), - actions, - valid: - willBeValidChordInput( - actions.length, - lastCompound !== undefined, - ) && actions.every(({ valid }) => valid), + range: [parentNode.from, parentNode.to], + value: code, + actions: [], + valid: true, // TODO: validate compound literal }); - } else if (node.name === "ChordInput") { - const lastCompound = current.compounds?.at(-1); - current.input = { - range: [node.from, node.to], - value: composeChordInput( + } 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, ), - valid: - willBeValidChordInput( - actions.length, - lastCompound !== undefined, - ) && actions.every(({ valid }) => valid), - actions, - }; - } - }, - ); + ), + 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[]) { +function resolveChordOverrides(chords: ChordMeta[]): Map { console.time("resolveOverrides"); const seen = new Map(); for (const info of chords) { @@ -176,9 +155,10 @@ function resolveChordOverrides(chords: ChordMeta[]) { } } console.timeEnd("resolveOverrides"); + return seen; } -function resolveChordAliases(chords: ChordMeta[]) { +function resolveChordAliases(chords: ChordMeta[]): Map { console.time("resolveAliases"); const aliases = new Map(); for (const info of chords) { @@ -188,17 +168,20 @@ function resolveChordAliases(chords: ChordMeta[]) { list.push(info); aliases.set(key, list); } - for (const aliasList of aliases.values()) { - if (aliasList.length > 1) { - for (const info of aliasList) { - info.aliases = aliasList.filter((i) => i !== info); + 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[]) { +function resolveCompoundParents(chords: ChordMeta[]): Map { console.time("resolveCompoundParents"); const compounds = new Map(); for (const chord of chords) { @@ -222,31 +205,32 @@ function resolveCompoundParents(chords: ChordMeta[]) { } } console.timeEnd("resolveCompoundParents"); + return compounds; } export function resolveChanges( chords: ChordMeta[], + inputs: Map, deviceChords: CharaChordFile["chords"], -): CharaChordFile["chords"] { +): [CharaChordFile["chords"], Map] { console.time("resolveChanges"); const removed: CharaChordFile["chords"] = []; - const info = new Map(); + const exact = new Map(); for (const chord of chords) { if (chord.input && chord.phrase && !chord.disabled) { - info.set( + exact.set( JSON.stringify([chord.input.value, chord.phrase?.value ?? []]), chord, ); - info.set(JSON.stringify(chord.input.value), chord); } } for (const deviceChord of deviceChords) { - const exact = info.get(JSON.stringify(deviceChord)); - if (exact) { - exact.phrase!.originalValue = exact.phrase!.value; + const exactMatch = exact.get(JSON.stringify(deviceChord)); + if (exactMatch) { + exactMatch.phrase!.originalValue = exactMatch.phrase!.value; continue; } - const byInput = info.get(JSON.stringify(deviceChord[0])); + const byInput = inputs.get(JSON.stringify(deviceChord[0])); if (byInput) { byInput.phrase!.originalValue = deviceChord[1]; continue; @@ -255,54 +239,25 @@ export function resolveChanges( } console.timeEnd("resolveChanges"); - return removed; + return [removed, exact]; } export function parseCharaChords( - data: EditorState, + tree: Tree, ids: Map, codes: Map, deviceChords: CharaChordFile["chords"], + sliceString: (from: number, to: number) => string, ): ParseResult { console.time("parseTotal"); - const chords = parseChordMeta(data, ids, codes); - resolveChordOverrides(chords); - resolveChordAliases(chords); - resolveCompoundParents(chords); - const removed = resolveChanges(chords, deviceChords); - - /*for (let i = 0; i < metas.length; i++) { - const [, compound] = splitCompound(chords[i]![0]); - if ( - compound !== undefined && - (!compoundInputs.has(compound) || orphanCompounds.has(compound)) - ) { - metas[i]!.orphan = true; - } - } - - const removed: CharaChordFile["chords"] = []; - for (let deviceChord of deviceChords) { - const key = JSON.stringify(deviceChord[0]); - if (!keys.has(key)) { - removed.push(deviceChord); - } else { - const index = keys.get(key)!; - const meta = metas[index]!; - if ( - JSON.stringify(deviceChord[1]) !== - JSON.stringify(chords[keys.get(key)!]![1]) - ) { - meta.originalPhrase = deviceChord[1]; - } else { - meta.unchanged = true; - } - } - }*/ + 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"); - console.log(chords); - return { chords, removed }; + return { chords, removed, aliases, compounds, inputs, exact }; } diff --git a/src/lib/chord-editor/changes-decorations-plugin.ts b/src/lib/chord-editor/changes-decorations-plugin.ts deleted file mode 100644 index e5e7ac74..00000000 --- a/src/lib/chord-editor/changes-decorations-plugin.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { EditorView, Decoration, type DecorationSet } from "@codemirror/view"; -import { StateField } from "@codemirror/state"; -import { parsedChordsEffect } from "./parsed-chords-plugin"; - -const changedMark = Decoration.mark({ class: "cm-changed" }); - -const chordMetaMark = StateField.define({ - create() { - return Decoration.none; - }, - update(decorations, tr) { - const newChords = tr.effects.findLast((e) => e.is(parsedChordsEffect)); - if (!newChords) { - return decorations.map(tr.changes); - } - return newChords.value.meta.map(meta => { - if (meta.originalPhrase) { - return underlineMark.range(meta.from, meta.to); -} -}); - }, - provide: (f) => EditorView.decorations.from(f), -}); diff --git a/src/lib/chord-editor/changes-panel.svelte.ts b/src/lib/chord-editor/changes-panel.svelte.ts new file mode 100644 index 00000000..9c34ed25 --- /dev/null +++ b/src/lib/chord-editor/changes-panel.svelte.ts @@ -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); +} diff --git a/src/lib/chord-editor/changes-panel.ts b/src/lib/chord-editor/changes-panel.ts deleted file mode 100644 index cf97f14c..00000000 --- a/src/lib/chord-editor/changes-panel.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { EditorState } from "@codemirror/state"; -import { EditorView, showPanel, type Panel } from "@codemirror/view"; -import { parsedChordsField } from "./parsed-chords-plugin"; - -function getChanges(state: EditorState): string { - const parsed = state.field(parsedChordsField); - const added = parsed.chords.reduce( - (acc, chord) => - acc + (chord.phrase && chord.phrase.originalValue === undefined ? 1 : 0), - 0, - ); - const changed = parsed.chords.reduce( - (acc, chord) => - acc + - (chord.phrase && - chord.phrase.originalValue && - chord.phrase.originalValue !== chord.phrase.value - ? 1 - : 0), - 0, - ); - const removed = parsed.removed.length; - return `+${added} ~${changed} -${removed} (${parsed.chords.length} total)`; -} - -function wordCountPanel(view: EditorView): Panel { - let dom = document.createElement("div"); - dom.textContent = getChanges(view.state); - return { - dom, - update(update) { - dom.textContent = getChanges(update.state); - }, - }; -} - -export function changesPanel() { - return showPanel.of(wordCountPanel); -} diff --git a/src/lib/chord-editor/chord-sync-plugin.ts b/src/lib/chord-editor/chord-sync-plugin.ts index d0a110dc..7406d79e 100644 --- a/src/lib/chord-editor/chord-sync-plugin.ts +++ b/src/lib/chord-editor/chord-sync-plugin.ts @@ -1,28 +1,40 @@ 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"; -export const chordSyncEffect = StateEffect.define(); +const chordSyncEffect = StateEffect.define(); + +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({ create() { return []; }, update(value, transaction) { - // save initial device chords - // compare new device chords with initial device chords - // take changed/new/removed chords - // compare current editor chords with initial device chords - // compare two change sets - // apply removals if the chord didn't change on either end - // apply return ( transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ?? value ); }, - compare(a, b) { - return JSON.stringify(a) === JSON.stringify(b); - }, toJSON(value) { return value; }, diff --git a/src/lib/chord-editor/chord-sync.spec.ts b/src/lib/chord-editor/chord-sync.spec.ts new file mode 100644 index 00000000..f739655d --- /dev/null +++ b/src/lib/chord-editor/chord-sync.spec.ts @@ -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( + asciiInfo.map((info) => [info.code, info]), +); +const asciiIds = new Map( + 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", + ], + }); + }); +}); diff --git a/src/lib/chord-editor/chord-sync.ts b/src/lib/chord-editor/chord-sync.ts new file mode 100644 index 00000000..8446dfa8 --- /dev/null +++ b/src/lib/chord-editor/chord-sync.ts @@ -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, + codes: Map, + 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(); + 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(); + 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); +} diff --git a/src/lib/chord-editor/parse-meta.ts b/src/lib/chord-editor/parse-meta.ts index ac21336d..3f503113 100644 --- a/src/lib/chord-editor/parse-meta.ts +++ b/src/lib/chord-editor/parse-meta.ts @@ -132,6 +132,10 @@ export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta { export interface ParseResult { chords: ChordMeta[]; removed: CharaChordFile["chords"]; + aliases: Map; + compounds: Map; + inputs: Map; + exact: Map; } export function mapParseResult( diff --git a/src/lib/chord-editor/parsed-chords-plugin.ts b/src/lib/chord-editor/parsed-chords-plugin.ts index 2a3160f6..9e900443 100644 --- a/src/lib/chord-editor/parsed-chords-plugin.ts +++ b/src/lib/chord-editor/parsed-chords-plugin.ts @@ -10,6 +10,10 @@ export const parsedChordsField = StateField.define({ return { chords: [], removed: [], + aliases: new Map(), + compounds: new Map(), + inputs: new Map(), + exact: new Map(), }; }, update(value, transaction) { @@ -23,7 +27,13 @@ export const parsedChordsField = StateField.define({ codes !== transaction.startState.field(actionMetaPlugin.field).codes || deviceChords !== transaction.startState.field(deviceChordField) ) { - return parseCharaChords(transaction.state, ids, codes, deviceChords); + return parseCharaChords( + syntaxTree(transaction.state), + ids, + codes, + deviceChords, + (from, to) => transaction.state.doc.sliceString(from, to), + ); } return mapParseResult(value, transaction.changes); }, diff --git a/src/lib/chord-editor/persistent-state-plugin.ts b/src/lib/chord-editor/persistent-state-plugin.ts index 15db9849..1af0660a 100644 --- a/src/lib/chord-editor/persistent-state-plugin.ts +++ b/src/lib/chord-editor/persistent-state-plugin.ts @@ -27,11 +27,8 @@ 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"; -import { - parseCompressed, - stringifyCompressed, -} from "$lib/serial/serialization"; +import { changesPanel } from "./changes-panel.svelte"; +import { searchKeymap } from "@codemirror/search"; const serializedFields = { history: historyField, @@ -44,11 +41,8 @@ export interface EditorConfig { autocomplete(query: string | undefined): void; } -export async function loadPersistentState( - params: EditorConfig, -): Promise { - const stored = localStorage.getItem(params.storeName); - const config = { +export function createConfig(params: EditorConfig) { + return { extensions: [ actionMetaPlugin.plugin, deviceChordField, @@ -86,14 +80,20 @@ export async function loadPersistentState( borderColor: "var(--md-sys-color-on-surface)", }, }), - keymap.of([...standardKeymap, ...historyKeymap]), + keymap.of([...standardKeymap, ...historyKeymap, ...searchKeymap]), ], } satisfies EditorStateConfig; +} + +export async function loadPersistentState( + params: EditorConfig, +): Promise { + const stored = await getState(params.storeName); + const config = createConfig(params); if (stored) { try { - const parsed = await parseCompressed(new Blob([stored])); - return EditorState.fromJSON(parsed, config, serializedFields); + return EditorState.fromJSON(stored, config, serializedFields); } catch (e) { console.error("Failed to parse persistent state:", e); } @@ -109,13 +109,10 @@ export function persistentStatePlugin(storeName: string) { .pipe( debounceTime(500), mergeMap(() => - stringifyCompressed(this.view.state.toJSON(serializedFields)), + storeState(storeName, this.view.state.toJSON(serializedFields)), ), - mergeMap((blob) => blob.text()), ) - .subscribe((value) => { - localStorage.setItem(storeName, value); - }); + .subscribe(() => {}); constructor(readonly view: EditorView) {} @@ -131,3 +128,58 @@ export function persistentStatePlugin(storeName: string) { }, ); } + +const dbName = "chord-state"; +const dbVersion = 1; +const storeName = "state"; + +async function openDb(): Promise { + const dbRequest = indexedDB.open(dbName, dbVersion); + return new Promise((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(name: string): Promise { + 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((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(name: string, state: T): Promise { + 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((resolve, reject) => { + putRequest.onsuccess = () => resolve(); + putRequest.onerror = () => reject(putRequest.error); + }); + putTransaction.commit(); + } catch (e) { + console.error(e); + } finally { + db.close(); + } +} diff --git a/src/lib/chord-editor/save-chords.ts b/src/lib/chord-editor/save-chords.ts new file mode 100644 index 00000000..efc82a56 --- /dev/null +++ b/src/lib/chord-editor/save-chords.ts @@ -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; +} diff --git a/src/lib/components/layout/ActionList.svelte b/src/lib/components/layout/ActionList.svelte index ad0149d7..03905e5c 100644 --- a/src/lib/components/layout/ActionList.svelte +++ b/src/lib/components/layout/ActionList.svelte @@ -14,7 +14,7 @@ import type { KeymapCategory } from "$lib/meta/types/actions"; import Action from "../Action.svelte"; import { isVerbose } from "../verbose-action"; - import { actionToValue } from "$lib/chord-editor/action-serializer"; + import { actionToValue } from "$lib/chord-editor/chord-sync"; let { currentAction = undefined, diff --git a/src/lib/learn/chords.ts b/src/lib/learn/chords.ts deleted file mode 100644 index d191eb92..00000000 --- a/src/lib/learn/chords.ts +++ /dev/null @@ -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, KEYMAP_CODES], - ([chords, layout, KEYMAP_CODES]) => - new Map( - 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>("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 ->("learn-config", {}); -export const learnConfig = derived(learnConfigStored, (config) => ({ - ...learnConfigDefault, - ...config, -})); - -let lastWord: string | undefined; - -function shuffle(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(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; - }, -); diff --git a/src/lib/learn/stats.ts b/src/lib/learn/stats.ts deleted file mode 100644 index 05df68a2..00000000 --- a/src/lib/learn/stats.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { persistentWritable } from "$lib/storage"; - -interface ChordStats { - level: number; - lastUprank: number; -} - -export const chordStats = persistentWritable>( - "chord-stats", - {}, -); diff --git a/src/lib/undo-redo.ts b/src/lib/undo-redo.ts index cfbf743a..e8958246 100644 --- a/src/lib/undo-redo.ts +++ b/src/lib/undo-redo.ts @@ -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("changes", []); export interface Overlay { layout: Array | undefined> | undefined>; - chords: Map; settings: Array | 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,90 +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((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 duplicateChords = derived(chords, (chords) => { - const duplicates = new Set(); - const seen = new Set(); - - for (const chord of chords) { - const key = JSON.stringify(chord.actions); - if (seen.has(key)) { - duplicates.add(key); - } else { - seen.add(key); - } - } - - return duplicates; -}); - -export const chordHashes = derived( - chords, - (chords) => - new Map(chords.map((chord) => [hashChord(chord.actions), chord] as const)), -); diff --git a/src/routes/(app)/PageTransition.svelte b/src/routes/(app)/PageTransition.svelte index 840a94d6..477a341c 100644 --- a/src/routes/(app)/PageTransition.svelte +++ b/src/routes/(app)/PageTransition.svelte @@ -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)); diff --git a/src/routes/(app)/Sidebar.svelte b/src/routes/(app)/Sidebar.svelte index bf3cc62b..d62a4e35 100644 --- a/src/routes/(app)/Sidebar.svelte +++ b/src/routes/(app)/Sidebar.svelte @@ -39,7 +39,12 @@ [ { 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: "https://adventure.charachorder.io/", + icon: "school", + title: "Learn", + external: true, + }, ], /*[ { href: "/plugin", icon: "code", title: "Plugin", wip: true }, diff --git a/src/routes/(app)/config/EditActions.svelte b/src/routes/(app)/config/EditActions.svelte index dc3efeba..58d31785 100644 --- a/src/routes/(app)/config/EditActions.svelte +++ b/src/routes/(app)/config/EditActions.svelte @@ -6,18 +6,15 @@ layout, overlay, settings, - duplicateChords, } from "$lib/undo-redo"; - import type { Change, ChordChange } from "$lib/undo-redo"; + import type { Change } from "$lib/undo-redo"; import { fly } from "svelte/transition"; import { actionTooltip } from "$lib/title"; import { - deviceChords, deviceLayout, deviceSettings, serialLog, serialPort, - sync, syncProgress, syncStatus, } from "$lib/serial/connection"; @@ -106,115 +103,7 @@ return true; } - async function safeDeleteChord(actions: number[]): Promise { - const port = $serialPort; - if (!port) return false; - try { - await port.deleteChord({ actions }); - return true; - } catch (e) { - console.error(e); - try { - if ((await port.getChordPhrase(actions)) === undefined) { - return true; - } - } catch (e) { - console.error(e); - } - } - return false; - } - - async function saveChords(progress: () => void): Promise { - const port = $serialPort; - if (!port) return false; - let ok = true; - - const empty = new Set(); - for (const [id, chord] of $overlay.chords) { - if (chord.actions.length === 0 || chord.phrase.length === 0) { - empty.add(id); - } - } - changes.update((changes) => { - changes.push([ - ...empty.keys().map( - (id) => - ({ - type: ChangeType.Chord, - id: JSON.parse(id), - deleted: true, - actions: [], - phrase: [], - }) satisfies ChordChange, - ), - ]); - return changes; - }); - await tick(); - - const deleted = new Set(); - const changed = new Map(); - for (const [id, chord] of $overlay.chords) { - if (!chord.deleted) continue; - if (await safeDeleteChord(JSON.parse(id))) { - deleted.add(id); - } else { - ok = false; - } - progress(); - } - deviceChords.update((chords) => - chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions))), - ); - deleted.clear(); - await tick(); - - for (const [id, chord] of $overlay.chords) { - if (chord.deleted) continue; - if ($duplicateChords.has(JSON.stringify(chord.actions))) { - ok = false; - } else { - let skip = false; - if (id !== JSON.stringify(chord.actions)) { - if (await safeDeleteChord(JSON.parse(id))) { - deleted.add(id); - } else { - skip = true; - ok = false; - } - } - if (!skip) { - try { - await port.setChord({ - actions: chord.actions, - phrase: chord.phrase, - }); - deleted.add(JSON.stringify(chord.actions)); - changed.set(JSON.stringify(chord.actions), chord.phrase); - } catch (e) { - console.error(e); - ok = false; - } - } else { - ok = false; - } - } - progress(); - } - deviceChords.update((chords) => { - chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions))); - for (const [id, phrase] of changed) { - chords.push({ actions: JSON.parse(id), phrase }); - } - return chords; - }); - await tick(); - return ok; - } - async function save() { - let needsSync = false; try { const port = $serialPort; if (!port) { @@ -235,10 +124,8 @@ (acc, profile) => acc + (profile?.size ?? 0), 0, ); - const chordChanges = $overlay.chords.size; - needsSync = chordChanges > 0; const needsCommit = settingChanges > 0 || layoutChanges > 0; - const progressMax = layoutChanges + settingChanges + chordChanges; + const progressMax = layoutChanges + settingChanges; let progressCurrent = 0; @@ -261,11 +148,9 @@ layoutSuccess = false; } } - let chordsSuccess = await saveChords(updateProgress); - if (layoutSuccess && settingsSuccess && chordsSuccess) { + if (layoutSuccess && settingsSuccess) { changes.set([]); - needsSync = true; } else { throw new Error("Some changes could not be saved."); } @@ -280,10 +165,6 @@ } finally { $syncStatus = "done"; } - - if (needsSync) { - await sync(); - } } let progressPopover: HTMLElement | undefined = $state(); diff --git a/src/routes/(app)/config/chords/+page.svelte b/src/routes/(app)/config/chords/+page.svelte index 5f65f8d1..fffaee8f 100644 --- a/src/routes/(app)/config/chords/+page.svelte +++ b/src/routes/(app)/config/chords/+page.svelte @@ -1,468 +1,278 @@ - - Chord Manager - CharaChorder Device Manager - - - -
- $searchIndex && search($searchIndex, event)} - class:loading={progress !== $chords.length} - /> -
- {#if $lastPage !== -1} - {page + 1} / {$lastPage + 1} - {:else} - - / - - {/if} +
+
+ + + + + + +
+ +
+
+
- -
-
- - {#await tick() then} -
- - {#if $lastPage !== -1} - - {#if page === 0} - - {/if} - {#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord]} - {#if chord} - (page = 0)} /> - {/if} - {/each} - {:else} - - {/if} -
insertChord(action)} - />
{$LL.configure.chords.search.NO_RESULTS()}
-
- - {/await} -
- diff --git a/src/routes/(app)/config/chords/ChordActionEdit.svelte b/src/routes/(app)/config/chords/ChordActionEdit.svelte deleted file mode 100644 index f1363de5..00000000 --- a/src/routes/(app)/config/chords/ChordActionEdit.svelte +++ /dev/null @@ -1,240 +0,0 @@ - - - - - diff --git a/src/routes/(app)/config/chords/ChordEdit.svelte b/src/routes/(app)/config/chords/ChordEdit.svelte deleted file mode 100644 index 032d2fa3..00000000 --- a/src/routes/(app)/config/chords/ChordEdit.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - - - - {}} /> - - - - - -
- {#if !chord.deleted} - - {:else} - - {/if} - - -
- -
- - - - diff --git a/src/routes/(app)/config/chords/ChordPhraseEdit.svelte b/src/routes/(app)/config/chords/ChordPhraseEdit.svelte deleted file mode 100644 index 8200962c..00000000 --- a/src/routes/(app)/config/chords/ChordPhraseEdit.svelte +++ /dev/null @@ -1,376 +0,0 @@ - - - - -
{ - box?.focus(); - }} -> - {#if supportsAutospace} - { - const autospace = hasAutospace; - if ((event.target as HTMLInputElement).checked) { - if (chord.phrase[0] === JOIN_ACTION) { - deleteAction(0, 1); - await tick(); - moveCursor(cursorPosition - 1, true); - } - } else { - if (chord.phrase[0] !== JOIN_ACTION) { - insertAction(0, JOIN_ACTION); - moveCursor(cursorPosition + 1, true); - } - } - await tick(); - resolveAutospace(autospace); - }} - /> - {/if} -
(hasFocus = true)} - onfocusout={(event) => { - if (event.relatedTarget !== button) hasFocus = false; - }} - > - {#if hasFocus} -
- -
- {:else} -
- - {/if} - {#each chord.phrase as action, i} - {#if isHidden(action, i, chord.phrase)} - - {:else} - - {/if} - {/each} -
- {#if supportsAutospace} - - resolveAutospace((event.target as HTMLInputElement).checked)} - /> - {/if} - -
- - diff --git a/src/routes/(app)/config/chords/action-selector.ts b/src/routes/(app)/config/chords/action-selector.ts deleted file mode 100644 index 51b8c41d..00000000 --- a/src/routes/(app)/config/chords/action-selector.ts +++ /dev/null @@ -1,56 +0,0 @@ -import ActionSelector from "$lib/components/layout/ActionSelector.svelte"; -import { mount, unmount, tick } from "svelte"; - -export function selectAction( - event: MouseEvent | KeyboardEvent, - select: (action: number) => void, - dismissed?: () => void, -) { - const component = mount(ActionSelector, { - target: document.body, - props: { - onclose: () => closed(), - onselect: (action: number) => { - select(action); - closed(); - }, - }, - }); - const dialog = document.querySelector("dialog > div") as HTMLDivElement; - const backdrop = document.querySelector("dialog") as HTMLDialogElement; - const dialogRect = dialog.getBoundingClientRect(); - const groupRect = (event.target as HTMLElement).getBoundingClientRect(); - - const scale = 0.5; - const dialogScale = `${ - 1 - scale * (1 - groupRect.width / dialogRect.width) - } ${1 - scale * (1 - groupRect.height / dialogRect.height)}`; - const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${ - scale * (groupRect.y - dialogRect.y) - }px`; - - const duration = 150; - const options = { duration, easing: "ease" }; - const dialogAnimation = dialog.animate( - [ - { scale: dialogScale, translate: dialogTranslate }, - { translate: "0 0", scale: "1" }, - ], - options, - ); - const backdropAnimation = backdrop.animate( - [{ opacity: 0 }, { opacity: 1 }], - options, - ); - - async function closed() { - dialogAnimation.reverse(); - backdropAnimation.reverse(); - - await dialogAnimation.finished; - - unmount(component); - await tick(); - dismissed?.(); - } -} diff --git a/src/routes/(app)/config/chords/input-converter.ts b/src/routes/(app)/config/chords/input-converter.ts deleted file mode 100644 index 5619fae4..00000000 --- a/src/routes/(app)/config/chords/input-converter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { KEYMAP_IDS, KEYMAP_KEYCODES } from "$lib/serial/keymap-codes"; -import { get } from "svelte/store"; - -export function inputToAction( - event: KeyboardEvent, - useKeycodes?: boolean, -): number | undefined { - if (useKeycodes) { - return get(KEYMAP_KEYCODES).get(event.code); - } else { - return ( - get(KEYMAP_IDS).get(event.key)?.code ?? - get(KEYMAP_KEYCODES).get(event.code) - ); - } -} diff --git a/src/routes/(app)/config/chords/will-my-compound-break/+page.svelte b/src/routes/(app)/config/chords/will-my-compound-break/+page.svelte index a7e5b4a7..6ad0145f 100644 --- a/src/routes/(app)/config/chords/will-my-compound-break/+page.svelte +++ b/src/routes/(app)/config/chords/will-my-compound-break/+page.svelte @@ -1,7 +1,5 @@ @@ -35,7 +34,7 @@ >, your library might have been corrupted.

{#each broken as chord} - {}} /> + {/each} {:else}

No problematic chords found

diff --git a/src/routes/(app)/config/cv2/+page.svelte b/src/routes/(app)/config/cv2/+page.svelte deleted file mode 100644 index aa167895..00000000 --- a/src/routes/(app)/config/cv2/+page.svelte +++ /dev/null @@ -1,356 +0,0 @@ - - -
-
- - - - - - - -
- -
-
- -
-
- - diff --git a/src/routes/(app)/learn/+page.svelte b/src/routes/(app)/learn/+page.svelte deleted file mode 100644 index 0ed68878..00000000 --- a/src/routes/(app)/learn/+page.svelte +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/src/routes/(app)/learn/chords/+page.svelte b/src/routes/(app)/learn/chords/+page.svelte deleted file mode 100644 index bb009fe2..00000000 --- a/src/routes/(app)/learn/chords/+page.svelte +++ /dev/null @@ -1,232 +0,0 @@ - - -

WIP

- - - -{#key $nextWord} -

- {$nextWord} - {#if $scores[$nextWord!] === undefined} - new - {:else if ($scores[$nextWord!]?.score ?? 0) < 0} - weak - {/if} -

- -
- - - -
-{/key} - -{#key $nextWord} -
- {}} /> -
-{/key} - - -
- - - - - - {#each Object.entries($scores) - .sort(([, a], [, b]) => a.score - b.score) - .splice(0, 10) as [word, score]} - - - - - {/each} - -
Weak
{word}{score.score.toFixed(2)}
- - - - - - - {#each Object.entries($scores) - .sort(([, a], [, b]) => b.score - a.score) - .splice(0, 10) as [word, score]} - - - - - {/each} - -
Strong
{word}{score.score.toFixed(2)}
- - - - - - - {#each Object.entries($scores) - .sort(([, a], [, b]) => b.lastTyped - a.lastTyped) - .splice(0, 10) as [word, _score]} - - - - {/each} - -
Rehearse
{word}
-
- -
- Settings - - - - {#each Object.entries(learnConfigDefault) as [key, value]} - - - - - - {/each} - -
{key} - ($learnConfigStored[key as keyof typeof $learnConfig] = ( - event.target as HTMLInputElement - ).value as any)} - /> - - -
-
- - diff --git a/src/routes/(app)/learn/layout/+page.svelte b/src/routes/(app)/learn/layout/+page.svelte deleted file mode 100644 index a495383d..00000000 --- a/src/routes/(app)/learn/layout/+page.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - -
-
- -
- - -
- - diff --git a/src/routes/(app)/learn/sentence/+page.svelte b/src/routes/(app)/learn/sentence/+page.svelte deleted file mode 100644 index 629ed3ba..00000000 --- a/src/routes/(app)/learn/sentence/+page.svelte +++ /dev/null @@ -1,652 +0,0 @@ - - -
-

Sentence Trainer

- - -
- {#each masteryThresholds as [, , title], i} - - {/each} - - {#each masteryThresholds as _, i} -
- {/each} -
-
-
- {#each sentenceWords as _, i} - {#if i !== sentenceWords.length - 1} - {@const word = sentenceWords.slice(i, i + 2).join(" ")} - {@const mastery = wordMastery.get(word) ?? 0} -
- {/if} - {/each} - {#each sentenceWords as word, i} - {@const mastery = wordMastery.get(word)} -
- {word} -
- {/each} - {#each sentenceWords as _, i} - {#if i < sentenceWords.length - 2} - {@const word = sentenceWords.slice(i, i + 3).join(" ")} - {@const mastery = wordMastery.get(word) ?? 0} -
- {/if} - {/each} -
- {#if level === masteryThresholds.length} - {@const maxDigits = 4} - {@const indices = Array.from({ length: maxDigits }, (_, i) => i)} - {@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")} -
-
- {#each indices as i} - {@const char = wpmString[i]} - {#key char} -
- {char} -
- {/key} - {/each} -
- WPM -
-
-
- {#key wpm} -
- {Math.floor(wpm)} -
- {/key} -
WPM
-
-
- {/if} - -
-
- {#key recorder} -
- - - - -
- {/key} -
-
- {#if devTools} -
Dev Tools
- - - - - - - - - - - - - - - - - -
Total{Math.round(totalMs)}ms -
Char{Math.round(msPerChar)}ms -
Word{Math.round(msPerWord)}ms -
- - - {#each masteryThresholds as _, i} - - - - - - - {/each} - -
L{i + 1}
- - - {#each wordStats.entries() as [word, stats]} - {@const mastery = wordMastery.get(word) ?? 0} - - - - {#each stats as stat} - - {/each} - - {/each} - -
{word}{Math.round(mastery * 100)}% - {stat}
- {/if} -
- - diff --git a/src/routes/(app)/learn/sentence/configuration.ts b/src/routes/(app)/learn/sentence/configuration.ts deleted file mode 100644 index 2d932e90..00000000 --- a/src/routes/(app)/learn/sentence/configuration.ts +++ /dev/null @@ -1,32 +0,0 @@ -export interface PageParam { - key: string; - default: T; - parse?: (value: string) => T; -} - -export const SENTENCE_TRAINER_PAGE_PARAMS: { - sentence: PageParam; - wpm: PageParam; - showDevTools: PageParam; - textAreaDebounceInMillis: PageParam; -} = { - sentence: { - key: "sentence", - default: "This text has been typed at the speed of thought", - }, - wpm: { - key: "wpm", - default: 250, - parse: (value) => Number(value), - }, - showDevTools: { - key: "dev", - default: false, - parse: (value) => value === "true", - }, - textAreaDebounceInMillis: { - key: "debounceMillis", - default: 5000, - parse: (value) => Number(value), - }, -}; diff --git a/src/routes/(app)/learn/sentence/constants.ts b/src/routes/(app)/learn/sentence/constants.ts deleted file mode 100644 index faf41187..00000000 --- a/src/routes/(app)/learn/sentence/constants.ts +++ /dev/null @@ -1,8 +0,0 @@ -// Domain constants -export const AVG_WORD_LENGTH = 5; -export const SECONDS_IN_MINUTE = 60; -export const MILLIS_IN_SECOND = 1000; - -// Error messages. -export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE = - "The sentence is too short to make N-Grams, please enter longer sentence"; diff --git a/src/routes/(app)/learn/sentence/types.ts b/src/routes/(app)/learn/sentence/types.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/routes/(app)/learn/sentence/word-selector.spec.ts b/src/routes/(app)/learn/sentence/word-selector.spec.ts deleted file mode 100644 index 2e46a9f0..00000000 --- a/src/routes/(app)/learn/sentence/word-selector.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it, beforeEach, expect, vi } from "vitest"; -import { pickNextWord } from "./word-selector"; -import { untrack } from "svelte"; -import { SvelteMap } from "svelte/reactivity"; -import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants"; - -// Mock untrack so it simply executes the callback, allowing us to spy on its usage. -vi.mock("svelte", () => ({ - untrack: vi.fn((fn: any) => fn()), -})); - -describe("pickNextWord", () => { - let words: string[]; - let wordMastery: SvelteMap; - let currentWord: string; - - beforeEach(() => { - vi.clearAllMocks(); - - // Set up sample words and mastery values. - words = ["alpha", "beta", "gamma"]; - wordMastery = new SvelteMap(); - // For this test, assume none of the words are mastered. - words.forEach((word) => wordMastery.set(word, 0)); - currentWord = "alpha"; - }); - - it("should return a word different from current", () => { - // Force Math.random() to return a predictable value. - vi.spyOn(Math, "random").mockReturnValueOnce(0.3); - - const nextWord = pickNextWord(words, wordMastery, currentWord); - - // Since currentWord ("alpha") should be skipped, we expect next word. - expect(nextWord).toBe("beta"); - }); - - it("should randomly skip words", () => { - // Force Math.random() to return a predictable value. - vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3); - - const nextWord = pickNextWord(words, wordMastery, currentWord); - - // Since currentWord ("alpha") should be skipped as current - // and "beta" should be randomly skipped we expect "gamma". - expect(nextWord).toBe("gamma"); - }); - - it("should return current word if all other words were randomly skipped", () => { - // Force Math.random() to return a predictable value. - vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6); - - const nextWord = pickNextWord(words, wordMastery, currentWord); - - // Since all other words have been randomly skipped, we expect - // current word to be returned. - expect(nextWord).toBe("alpha"); - }); - - it("current word should be passed untracked", () => { - pickNextWord(words, wordMastery, currentWord); - expect(untrack).toHaveBeenCalledTimes(0); - }); - - it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => { - const result = pickNextWord([], wordMastery, currentWord); - expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE); - }); -}); diff --git a/src/routes/(app)/learn/sentence/word-selector.ts b/src/routes/(app)/learn/sentence/word-selector.ts deleted file mode 100644 index 21603af7..00000000 --- a/src/routes/(app)/learn/sentence/word-selector.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants"; -import { SvelteMap } from "svelte/reactivity"; - -export function pickNextWord( - words: string[], - wordMastery: SvelteMap, - untrackedCurrentWord: string, -) { - const unmasteredWords = words - .map((it) => [it, wordMastery.get(it) ?? 0] as const) - .filter(([, it]) => it !== 1); - unmasteredWords.sort(([, a], [, b]) => a - b); - let nextWord = - unmasteredWords[0]?.[0] ?? - words[0] ?? - TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE; - // This is important to break infinite loop created by - // reading and writing `currentWord` inside $effect rune - for (const [word] of unmasteredWords) { - if (word === untrackedCurrentWord || Math.random() > 0.5) continue; - nextWord = word; - break; - } - return nextWord; -} diff --git a/vitest.config.ts b/vitest.config.ts index 6a77c909..35f27e69 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from "vitest/config"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { sveltekit } from "@sveltejs/kit/vite"; +import { lezerGrammarPlugin } from "./vite-plugin-lezer"; +import { layoutPlugin } from "./vite-plugin-layout"; export default defineConfig({ - plugins: [svelte({ hot: !process.env.VITEST })], + plugins: [layoutPlugin(), sveltekit(), lezerGrammarPlugin()], test: { globals: true, environment: "jsdom",