diff --git a/src/lib/chord-editor/action-linter.ts b/src/lib/chord-editor/action-linter.ts index 03105146..0dfd3eef 100644 --- a/src/lib/chord-editor/action-linter.ts +++ b/src/lib/chord-editor/action-linter.ts @@ -1,25 +1,33 @@ -import { - KEYMAP_CODES, - KEYMAP_IDS, - type KeyInfo, -} from "$lib/serial/keymap-codes"; +import { type KeyInfo } from "$lib/serial/keymap-codes"; import { syntaxTree } from "@codemirror/language"; import { linter, type Diagnostic } from "@codemirror/lint"; -import { derived, get } from "svelte/store"; -import { parseCharaChords } from "./action-serializer"; -import { deviceChords } from "$lib/serial/connection"; +import { parsedChordsField } from "./parsed-chords-plugin"; +import { actionMetaPlugin } from "./action-meta-plugin"; -export const actionLinterDependencies = derived( - [KEYMAP_IDS, KEYMAP_CODES, deviceChords], - (it) => it, -); - -export const actionLinter = linter( - (view) => { +export function actionLinter(config?: Parameters[1]) { + const finalConfig: Parameters[1] = { + ...config, + needsRefresh(update) { + console.log( + "test", + update.startState.field(actionMetaPlugin.field) !== + update.state.field(actionMetaPlugin.field), + update.startState.field(parsedChordsField) !== + update.state.field(parsedChordsField), + ); + return ( + update.startState.field(actionMetaPlugin.field) !== + update.state.field(actionMetaPlugin.field) || + update.startState.field(parsedChordsField) !== + update.state.field(parsedChordsField) + ); + }, + }; + return linter((view) => { + console.log("lint"); const diagnostics: Diagnostic[] = []; - const [ids, codes, deviceChords] = get(actionLinterDependencies); - - const { meta, compoundInputs } = parseCharaChords(view.state, ids); + const { ids, codes } = view.state.field(actionMetaPlugin.field); + const { meta, compoundInputs } = view.state.field(parsedChordsField); syntaxTree(view.state) .cursor() @@ -150,7 +158,7 @@ export const actionLinter = linter( if (m.emptyPhrase) { diagnostics.push({ from: m.from, - to: m.to, + to: m.from, severity: "warning", message: `Chord phrase is empty`, }); @@ -158,7 +166,7 @@ export const actionLinter = linter( if (m.overriddenBy) { diagnostics.push({ from: m.from, - to: m.to, + to: m.from, severity: "warning", message: `Chord overridden by previous chord`, }); @@ -166,7 +174,7 @@ export const actionLinter = linter( if (m.orphan) { diagnostics.push({ from: m.from, - to: m.to, + to: m.from, severity: "warning", message: `Orphan compound chord`, }); @@ -183,7 +191,7 @@ export const actionLinter = linter( if ((m.overrides?.length ?? 0) > 0) { diagnostics.push({ from: m.from, - to: m.to, + to: m.from, severity: "info", message: `Chord overrides other chords`, }); @@ -191,6 +199,5 @@ export const actionLinter = linter( } return diagnostics; - }, - { delay: 100 }, -); + }, finalConfig); +} diff --git a/src/lib/chord-editor/action-meta-plugin.ts b/src/lib/chord-editor/action-meta-plugin.ts new file mode 100644 index 00000000..446ec2c4 --- /dev/null +++ b/src/lib/chord-editor/action-meta-plugin.ts @@ -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); diff --git a/src/lib/chord-editor/action-plugin.ts b/src/lib/chord-editor/action-plugin.ts index dbb3811d..c08abdd8 100644 --- a/src/lib/chord-editor/action-plugin.ts +++ b/src/lib/chord-editor/action-plugin.ts @@ -12,28 +12,28 @@ import type { Range } from "@codemirror/state"; export class ActionWidget extends WidgetType { component?: {}; - element?: HTMLElement; constructor(readonly id: string | number) { super(); this.id = id; } - override eq(other: ActionWidget) { + /*override eq(other: ActionWidget) { return this.id == other.id; - } + }*/ toDOM() { - if (!this.element) { - this.element = document.createElement("span"); - this.element.style.paddingInline = "2px"; - - this.component = mount(Action, { - target: this.element, - props: { action: this.id, display: "keys", inText: true }, - }); + if (this.component) { + unmount(this.component); } - return this.element; + const element = document.createElement("span"); + element.style.paddingInline = "2px"; + + this.component = mount(Action, { + target: element, + props: { action: this.id, display: "keys", inText: true }, + }); + return element; } override ignoreEvent() { diff --git a/src/lib/chord-editor/action-serializer.ts b/src/lib/chord-editor/action-serializer.ts index 3be836de..3d1823dc 100644 --- a/src/lib/chord-editor/action-serializer.ts +++ b/src/lib/chord-editor/action-serializer.ts @@ -10,6 +10,7 @@ import type { Update } from "@codemirror/collab"; import { get } from "svelte/store"; import { composeChordInput, + hasConcatenator, hashChord, splitCompound, willBeValidChordInput, @@ -33,6 +34,7 @@ export function actionToValue(action: number | KeyInfo) { export interface ParseMeta { from: number; to: number; + hasConcatenator: boolean; invalidActions?: true; invalidInput?: true; emptyPhrase?: true; @@ -51,11 +53,14 @@ export interface ParseResult { export function parseCharaChords( data: EditorState, ids: Map, + codes: Map, ): ParseResult { + console.time("parseCharaChords"); const chords: CharaChordFile["chords"] = []; const metas: ParseMeta[] = []; const keys = new Map(); const compoundInputs = new Map(); + const orphanCompounds = new Set(); let currentChord: CharaChordFile["chords"][number] | undefined = undefined; let compound: number | undefined = undefined; @@ -64,15 +69,16 @@ export function parseCharaChords( let invalidInput = false; let chordFrom = 0; - function makeChordInput(node: SyntaxNodeRef): number[] { + const makeChordInput = (node: SyntaxNodeRef): number[] => { invalidInput ||= !willBeValidChordInput(currentActions.length, !!compound); const input = composeChordInput(currentActions, compound); compound = hashChord(input); if (!compoundInputs.has(compound)) { compoundInputs.set(compound, data.doc.sliceString(chordFrom, node.from)); + orphanCompounds.add(compound); } return input; - } + }; syntaxTree(data) .cursor() @@ -125,7 +131,11 @@ export function parseCharaChords( currentChord[1] = currentActions; const index = chords.length; chords.push(currentChord); - const meta: ParseMeta = { from: node.from, to: node.to }; + const meta: ParseMeta = { + from: node.from, + to: node.to, + hasConcatenator: hasConcatenator(currentChord[1], codes), + }; if (invalidActions) { meta.invalidActions = true; } @@ -161,18 +171,25 @@ export function parseCharaChords( makeChordInput(node); } else if (node.name === "PhraseDelim") { const input = makeChordInput(node); - currentChord = [composeChordInput(input, compound), []]; + orphanCompounds.delete(hashChord(input)); + currentChord = [input, []]; } }, ); for (let i = 0; i < metas.length; i++) { const [, compound] = splitCompound(chords[i]![0]); - if (compound !== undefined && !compoundInputs.has(compound)) { + if ( + compound !== undefined && + (!compoundInputs.has(compound) || orphanCompounds.has(compound)) + ) { metas[i]!.orphan = true; } } + console.timeEnd("parseCharaChords"); + + console.log(chords.length); return { result: chords, meta: metas, compoundInputs }; } diff --git a/src/lib/chord-editor/chord-sync-plugin.ts b/src/lib/chord-editor/chord-sync-plugin.ts new file mode 100644 index 00000000..acf2abaf --- /dev/null +++ b/src/lib/chord-editor/chord-sync-plugin.ts @@ -0,0 +1,25 @@ +import type { CharaChordFile } from "$lib/share/chara-file"; +import { StateEffect, StateField } from "@codemirror/state"; + +export const chordSyncEffect = StateEffect.define(); + +export const deviceChordField = StateField.define({ + create() { + return []; + }, + update(value, transaction) { + return ( + transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ?? + value + ); + }, + compare(a, b) { + return JSON.stringify(a) === JSON.stringify(b); + }, + toJSON(value) { + return value; + }, + fromJSON(value) { + return value; + }, +}); diff --git a/src/lib/chord-editor/parsed-chords-plugin.ts b/src/lib/chord-editor/parsed-chords-plugin.ts new file mode 100644 index 00000000..6eaa46ce --- /dev/null +++ b/src/lib/chord-editor/parsed-chords-plugin.ts @@ -0,0 +1,102 @@ +import { + ChangeDesc, + StateEffect, + StateField, + type Extension, +} from "@codemirror/state"; +import { parseCharaChords, type ParseResult } from "./action-serializer"; +import { type KeyInfo } from "$lib/serial/keymap-codes"; +import { actionMetaPlugin } from "./action-meta-plugin"; +import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"; +import type { Tree } from "@lezer/common"; +import { syntaxParserRunning, syntaxTree } from "@codemirror/language"; +import { debounceTime, Subject } from "rxjs"; +import { forceLinting } from "@codemirror/lint"; + +function mapParseResult(value: ParseResult, change: ChangeDesc): ParseResult { + if (change.empty) return value; + if ( + value.meta.every( + (it) => + change.mapPos(it.to) === it.to && change.mapPos(it.from) === it.from, + ) + ) + return value; + return { + result: value.result, + meta: value.meta.map((it) => ({ + ...it, + from: change.mapPos(it.from), + to: change.mapPos(it.to), + })), + compoundInputs: value.compoundInputs, + }; +} + +export const parsedChordsEffect = StateEffect.define({ + map: mapParseResult, +}); + +export const parsedChordsField = StateField.define({ + create() { + return { compoundInputs: new Map(), meta: [], result: [] }; + }, + update(value, transaction) { + return ( + transaction.effects.findLast((it) => it.is(parsedChordsEffect))?.value ?? + mapParseResult(value, transaction.changes) + ); + }, +}); + +export function parsedChordsPlugin(debounce = 200): Extension { + const plugin = ViewPlugin.fromClass( + class { + tree: Tree; + ids: Map; + codes: Map; + + needsUpdate = new Subject(); + subscription = this.needsUpdate + .pipe(debounceTime(debounce)) + .subscribe(() => { + if (syntaxParserRunning(this.view)) { + this.needsUpdate.next(); + return; + } + requestIdleCallback(() => { + this.view.dispatch({ + effects: parsedChordsEffect.of( + parseCharaChords(this.view.state, this.ids, this.codes), + ), + }); + forceLinting(this.view); + }); + }); + + constructor(readonly view: EditorView) { + this.tree = syntaxTree(view.state); + this.ids = view.state.field(actionMetaPlugin.field).ids; + this.codes = view.state.field(actionMetaPlugin.field).codes; + this.needsUpdate.next(); + } + + update(update: ViewUpdate) { + const tree = syntaxTree(update.state); + const ids = update.state.field(actionMetaPlugin.field).ids; + const codes = update.state.field(actionMetaPlugin.field).codes; + if (tree !== this.tree || ids !== this.ids || codes !== this.codes) { + this.tree = tree; + this.ids = ids; + this.codes = codes; + this.needsUpdate.next(); + } + } + + destroy() { + this.subscription.unsubscribe(); + } + }, + ); + return [parsedChordsField, plugin]; +} diff --git a/src/lib/chord-editor/persistent-state-plugin.ts b/src/lib/chord-editor/persistent-state-plugin.ts new file mode 100644 index 00000000..b340f0de --- /dev/null +++ b/src/lib/chord-editor/persistent-state-plugin.ts @@ -0,0 +1,122 @@ +import { + EditorView, + highlightActiveLine, + keymap, + lineNumbers, + ViewPlugin, + ViewUpdate, +} from "@codemirror/view"; +import { + history, + historyField, + historyKeymap, + standardKeymap, +} from "@codemirror/commands"; +import { debounceTime, 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 { parsedChordsPlugin } from "./parsed-chords-plugin"; + +const serializedFields = { + history: historyField, + deviceChords: deviceChordField, +}; + +export interface EditorConfig { + rawCode?: boolean; + storeName: string; + autocomplete(query: string | undefined): void; +} + +export function loadPersistentState(params: EditorConfig): EditorState { + const stored = localStorage.getItem(params.storeName); + const config = { + extensions: [ + actionMetaPlugin.plugin, + deviceChordField, + parsedChordsPlugin(), + 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]), + ], + } satisfies EditorStateConfig; + + if (stored) { + try { + const parsed = JSON.parse(stored); + return EditorState.fromJSON(parsed, 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(); + subscription = this.updateSubject + .pipe(debounceTime(500)) + .subscribe(() => { + localStorage.setItem( + storeName, + JSON.stringify(this.view.state.toJSON(serializedFields)), + ); + }); + + constructor(readonly view: EditorView) {} + + update(update: ViewUpdate) { + if (update.state !== update.startState) { + this.updateSubject.next(); + } + } + + destroy() { + this.subscription.unsubscribe(); + } + }, + ); +} diff --git a/src/lib/chord-editor/store-state-field.ts b/src/lib/chord-editor/store-state-field.ts new file mode 100644 index 00000000..42ff8098 --- /dev/null +++ b/src/lib/chord-editor/store-state-field.ts @@ -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(store: Readable) { + const effect = StateEffect.define(); + const field = StateField.define({ + 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] }; +} diff --git a/src/lib/serial/chord.ts b/src/lib/serial/chord.ts index 4f80421f..8b18030f 100644 --- a/src/lib/serial/chord.ts +++ b/src/lib/serial/chord.ts @@ -1,4 +1,5 @@ import { compressActions, decompressActions } from "../serialization/actions"; +import type { KeyInfo } from "./keymap-codes"; export interface Chord { actions: number[]; @@ -114,6 +115,25 @@ export function willBeValidChordInput( ); } +const ACTION_JOIN = 574; +const ACTION_KSC_00 = 256; + +export function hasConcatenator( + actions: number[], + ids: Map, +): 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 diff --git a/src/routes/(app)/config/cv2/+page.svelte b/src/routes/(app)/config/cv2/+page.svelte index a16b3476..224061ba 100644 --- a/src/routes/(app)/config/cv2/+page.svelte +++ b/src/routes/(app)/config/cv2/+page.svelte @@ -2,26 +2,13 @@ import { chords } from "$lib/undo-redo"; import { EditorView } from "codemirror"; import { actionToValue } from "$lib/chord-editor/action-serializer"; - import { actionPlugin } from "$lib/chord-editor/action-plugin"; - import { delimPlugin } from "$lib/chord-editor/chord-delim-plugin"; - import { highlightActiveLine, keymap } from "@codemirror/view"; - import { history, standardKeymap } from "@codemirror/commands"; import "$lib/chord-editor/chords.grammar"; - import { - chordHighlightStyle, - chordLanguageSupport, - } from "$lib/chord-editor/chords-grammar-plugin"; - import { syntaxHighlighting } from "@codemirror/language"; import { persistentWritable } from "$lib/storage"; import ActionList from "$lib/components/layout/ActionList.svelte"; - import { actionAutocompletePlugin } from "$lib/chord-editor/autocomplete"; - import { - actionLinter, - actionLinterDependencies, - } from "$lib/chord-editor/action-linter"; - import { forceLinting } from "@codemirror/lint"; - import { untrack } from "svelte"; import { splitCompound } from "$lib/serial/chord"; + import { 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"; let queryFilter: string | undefined = $state(undefined); @@ -29,8 +16,26 @@ const showEdits = persistentWritable("chord-editor-show-edits", true); const denseSpacing = persistentWritable("chord-editor-spacing", false); - let originalDoc = $derived( - $chords + let editor: HTMLDivElement | undefined = $state(undefined); + let view: EditorView; + + $effect(() => { + if (!editor) return; + view = new EditorView({ + parent: editor, + state: loadPersistentState({ + rawCode: $rawCode, + storeName: "chord-editor-state-storage", + autocomplete(query) { + queryFilter = query; + }, + }), + }); + return () => view.destroy(); + }); + + function regenerate() { + const doc = $chords .map((chord) => { const [actions, compound] = splitCompound(chord.actions); return ( @@ -42,63 +47,78 @@ chord.phrase.map((it) => actionToValue(it)).join("") ); }) - .join("\n"), - ); - let editor: HTMLDivElement | undefined = $state(undefined); - let view: EditorView; - - $effect(() => { - if (!editor) return; - view = new EditorView({ - parent: editor, - doc: originalDoc, - extensions: [ - ...($rawCode ? [] : [delimPlugin, actionPlugin]), - chordLanguageSupport(), - actionLinter, - // lineNumbers(), - actionAutocompletePlugin((query) => { - queryFilter = query; - }), - history(), - syntaxHighlighting(chordHighlightStyle), - highlightActiveLine(), - // drawSelection(), - 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": { - width: "100%", - }, - ".cm-cursor": { - borderColor: "var(--md-sys-color-on-surface)", - }, - }), - keymap.of(standardKeymap), - ], + .join("\n"); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: doc }, }); - return () => view.destroy(); - }); + } - $effect(() => { - $actionLinterDependencies; - untrack(() => view && forceLinting(view)); - }); + function loadBackup(event: Event) { + const input = event.target as HTMLInputElement; + if (!input.files || input.files.length === 0) return; + const file = input.files[0]; + const reader = new FileReader(); + reader.onload = (e) => { + try { + const content = e.target?.result as string; + const backup: CharaChordFile = JSON.parse(content); + const doc = backup.chords + .map((chord) => { + const [actions, compound] = splitCompound(chord[0]); + return ( + (compound + ? "<0x" + compound.toString(16).padStart(8, "0") + ">" + : "") + + actions.map((it) => actionToValue(it)).join("") + + "=>" + + chord[1].map((it) => actionToValue(it)).join("") + ); + }) + .join("\n"); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: doc }, + }); + } catch (err) { + alert("Failed to load backup: " + err); + } + }; + reader.readAsText(file); + } + + function downloadBackup() { + const backup: CharaChordFile = { + charaVersion: 1, + type: "chords", + chords: view.state.field(parsedChordsField).result, + }; + console.log(JSON.stringify(backup)); + 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 = "chord-backup.json"; + a.click(); + URL.revokeObjectURL(url); + } - - - +
+ + + + + + +
:global(:last-child) { - width: min(600px, 30vw); + > :global(*) { + flex: 1; } } @@ -127,7 +148,6 @@ } .editor { - width: min(600px, 30vw); height: 100%; font-size: 16px;