diff --git a/src/lib/chord-editor/action-linter.ts b/src/lib/chord-editor/action-linter.ts index d05f0215..13e6f730 100644 --- a/src/lib/chord-editor/action-linter.ts +++ b/src/lib/chord-editor/action-linter.ts @@ -1,8 +1,5 @@ -import { type KeyInfo } from "$lib/serial/keymap-codes"; -import { syntaxTree } from "@codemirror/language"; import { linter, type Diagnostic } from "@codemirror/lint"; import { parsedChordsField } from "./parsed-chords-plugin"; -import { actionMetaPlugin } from "./action-meta-plugin"; export function actionLinter(config?: Parameters[1]) { const finalConfig: Parameters[1] = { @@ -113,186 +110,26 @@ export function actionLinter(config?: Parameters[1]) { })), }); } - } - return diagnostics; - - syntaxTree(view.state) - .cursor() - .iterate((node) => { - let action: KeyInfo | undefined = undefined; - switch (node.name) { - case "SingleLetter": { - action = ids.get(view.state.doc.sliceString(node.from, node.to)); - break; - } - case "ActionId": { - action = ids.get(view.state.doc.sliceString(node.from, node.to)); - break; - } - case "HexNumber": { - const hexString = view.state.doc.sliceString(node.from, node.to); - const code = Number.parseInt(hexString, 16); - if (hexString.length === 10) { - if (compoundInputs.has(code)) { - diagnostics.push({ - from: node.from, - to: node.to, - severity: "info", - message: "Compound hash literal can be expanded", - actions: [ - { - name: "Expand", - apply(view, from, to) { - view.dispatch({ - changes: { - from: from - 1, - to: to + 1, - insert: compoundInputs.get(code)! + "|", - }, - }); - }, - }, - ], - }); - } - return; - } - - if (!(code >= 0 && code <= 1023)) { - diagnostics.push({ - from: node.from, - to: node.to, - severity: "error", - message: "Hex code invalid (out of range)", - actions: [ - { - name: "Remove", - apply(view, from, to) { - view.dispatch({ changes: { from, to } }); - }, - }, - ], - }); - return; - } - - action = codes.get(code); - break; - } - default: - return; - } - if (!action) { - const action = view.state.doc.sliceString(node.from, node.to); + if (chord.phrase) { + if (!chord.phrase.originalValue) { diagnostics.push({ - from: node.from, - to: node.to, - severity: node.name === "HexNumber" ? "warning" : "error", - message: `Unknown action: ${action}`, - actions: [ - ...(node.name === "SingleLetter" - ? ([ - { - name: "Generate Windows Hex Numpad Code", - apply(view, from, to) { - view.dispatch({ - changes: { - from, - to, - insert: - "" + - action - .codePointAt(0)! - .toString(16) - .split("") - .map((c) => - /^\d$/.test(c) - ? `` - : c.toLowerCase(), - ) - .join("") + - "", - }, - }); - }, - }, - ] satisfies Diagnostic["actions"]) - : []), - ], + 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`, }); } - }); - - for (const m of meta) { - if (m.invalidActions) { - diagnostics.push({ - from: m.from, - to: m.to, - severity: "error", - markClass: "chord-invalid", - message: `Chord contains invalid actions`, - }); - } - if (m.invalidInput) { - diagnostics.push({ - from: m.from, - to: m.to, - severity: "error", - markClass: "chord-invalid", - message: `Chord input is invalid`, - }); - } - if (m.emptyPhrase) { - diagnostics.push({ - from: m.from, - to: m.from, - severity: "warning", - message: `Chord phrase is empty`, - }); - } - if (m.overriddenBy) { - diagnostics.push({ - from: m.from, - to: m.from, - severity: "warning", - message: `Chord overridden by previous chord`, - }); - } - if (m.orphan) { - diagnostics.push({ - from: m.from, - to: m.from, - severity: "warning", - message: `Orphan compound chord`, - }); - } - if (m.disabled) { - diagnostics.push({ - from: m.from, - to: m.to, - severity: "info", - markClass: "chord-ignored", - message: `Chord disabled`, - }); - } - if ((m.overrides?.length ?? 0) > 0) { - diagnostics.push({ - from: m.from, - to: m.from, - severity: "info", - message: `Chord overrides other chords`, - }); - } - if (m.originalPhrase) { - diagnostics.push({ - from: m.from, - to: m.to, - severity: "info", - message: `Chord phrase changed from "${m.originalPhrase}"`, - }); } } - return diagnostics; }, finalConfig); } diff --git a/src/lib/chord-editor/action-serializer.ts b/src/lib/chord-editor/action-serializer.ts index e3c3be34..de22321b 100644 --- a/src/lib/chord-editor/action-serializer.ts +++ b/src/lib/chord-editor/action-serializer.ts @@ -17,7 +17,7 @@ import type { } from "./parse-meta"; export function canUseIdAsString(info: KeyInfo): boolean { - return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id); + return !!info.id && /^[^>\n]+$/.test(info.id); } export function actionToValue(action: number | KeyInfo) { @@ -224,6 +224,40 @@ function resolveCompoundParents(chords: ChordMeta[]) { console.timeEnd("resolveCompoundParents"); } +export function resolveChanges( + chords: ChordMeta[], + deviceChords: CharaChordFile["chords"], +): CharaChordFile["chords"] { + console.time("resolveChanges"); + const removed: CharaChordFile["chords"] = []; + const info = new Map(); + for (const chord of chords) { + if (chord.input && chord.phrase && !chord.disabled) { + info.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; + continue; + } + const byInput = info.get(JSON.stringify(deviceChord[0])); + if (byInput) { + byInput.phrase!.originalValue = deviceChord[1]; + continue; + } + removed.push(deviceChord); + } + + console.timeEnd("resolveChanges"); + return removed; +} + export function parseCharaChords( data: EditorState, ids: Map, @@ -236,6 +270,7 @@ export function parseCharaChords( 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]); @@ -269,5 +304,5 @@ export function parseCharaChords( console.timeEnd("parseTotal"); console.log(chords); - return { chords, removed: [] }; + return { chords, removed }; } diff --git a/src/lib/chord-editor/changes-panel.ts b/src/lib/chord-editor/changes-panel.ts new file mode 100644 index 00000000..cf97f14c --- /dev/null +++ b/src/lib/chord-editor/changes-panel.ts @@ -0,0 +1,39 @@ +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/parse-meta.ts b/src/lib/chord-editor/parse-meta.ts index aec61120..ac21336d 100644 --- a/src/lib/chord-editor/parse-meta.ts +++ b/src/lib/chord-editor/parse-meta.ts @@ -77,6 +77,7 @@ function mapActionStringMeta>( export interface PhraseMeta extends ActionStringMeta { hasConcatenator: boolean; + originalValue?: number[]; } export interface CompoundMeta extends ActionStringMeta { diff --git a/src/lib/chord-editor/persistent-state-plugin.ts b/src/lib/chord-editor/persistent-state-plugin.ts index 413da2e3..15db9849 100644 --- a/src/lib/chord-editor/persistent-state-plugin.ts +++ b/src/lib/chord-editor/persistent-state-plugin.ts @@ -12,7 +12,7 @@ import { historyKeymap, standardKeymap, } from "@codemirror/commands"; -import { debounceTime, Subject } from "rxjs"; +import { debounceTime, mergeMap, Subject } from "rxjs"; import { EditorState, type EditorStateConfig } from "@codemirror/state"; import { lintGutter } from "@codemirror/lint"; import { @@ -27,6 +27,11 @@ 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"; const serializedFields = { history: historyField, @@ -39,13 +44,16 @@ export interface EditorConfig { autocomplete(query: string | undefined): void; } -export function loadPersistentState(params: EditorConfig): EditorState { +export async function loadPersistentState( + params: EditorConfig, +): Promise { const stored = localStorage.getItem(params.storeName); const config = { extensions: [ actionMetaPlugin.plugin, deviceChordField, parsedChordsField, + changesPanel(), lintGutter(), params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin], chordLanguageSupport(), @@ -84,7 +92,7 @@ export function loadPersistentState(params: EditorConfig): EditorState { if (stored) { try { - const parsed = JSON.parse(stored); + const parsed = await parseCompressed(new Blob([stored])); return EditorState.fromJSON(parsed, config, serializedFields); } catch (e) { console.error("Failed to parse persistent state:", e); @@ -98,12 +106,15 @@ export function persistentStatePlugin(storeName: string) { class { updateSubject = new Subject(); subscription = this.updateSubject - .pipe(debounceTime(500)) - .subscribe(() => { - localStorage.setItem( - storeName, - JSON.stringify(this.view.state.toJSON(serializedFields)), - ); + .pipe( + debounceTime(500), + mergeMap(() => + stringifyCompressed(this.view.state.toJSON(serializedFields)), + ), + mergeMap((blob) => blob.text()), + ) + .subscribe((value) => { + localStorage.setItem(storeName, value); }); constructor(readonly view: EditorView) {} diff --git a/src/routes/(app)/config/cv2/+page.svelte b/src/routes/(app)/config/cv2/+page.svelte index f8bc4631..aa167895 100644 --- a/src/routes/(app)/config/cv2/+page.svelte +++ b/src/routes/(app)/config/cv2/+page.svelte @@ -5,11 +5,16 @@ import "$lib/chord-editor/chords.grammar"; import { persistentWritable } from "$lib/storage"; import ActionList from "$lib/components/layout/ActionList.svelte"; - import { splitCompound } from "$lib/serial/chord"; + import { + composeChordInput, + hashChord, + 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"; import { chordSyncEffect } from "$lib/chord-editor/chord-sync-plugin"; + import { KEYMAP_IDS, type KeyInfo } from "$lib/serial/keymap-codes"; let queryFilter: string | undefined = $state(undefined); @@ -22,17 +27,21 @@ $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(); + const viewPromise = loadPersistentState({ + rawCode: $rawCode, + storeName: "chord-editor-state-storage", + autocomplete(query) { + queryFilter = query; + }, + }).then( + (state) => + new EditorView({ + parent: editor, + state, + }), + ); + viewPromise.then((it) => (view = it)); + return () => viewPromise.then((it) => it.destroy()); }); function regenerate() { @@ -55,6 +64,63 @@ }); } + function largeFile() { + const chordCount = 100000; + const maxPhraseLength = 100; + const maxInputLength = 8; + const compoundChance = 0.05; + + const actions = [...$KEYMAP_IDS.values()]; + function randomAction(): KeyInfo { + return actions[Math.floor(actions.length * Math.random())]!; + } + + const backup: [KeyInfo[][], KeyInfo[]][] = Array.from( + { length: chordCount }, + () => + [ + [ + Array.from( + { length: Math.floor(Math.random() * maxInputLength) + 1 }, + randomAction, + ), + ], + Array.from( + { + length: Math.floor(Math.log(Math.random() * maxPhraseLength)) + 1, + }, + randomAction, + ), + ] as const, + ); + for (const chord of backup) { + if (Math.random() < compoundChance) { + chord[0] = [ + ...backup[Math.floor(backup.length * Math.random())]![0], + ...chord[0], + ]; + } + } + + const doc = backup + .map(([inputs, phrase]) => { + return ( + inputs + .map((input) => input.map((it) => actionToValue(it)).join("")) + .join("|") + + "=>" + + phrase.map((it) => actionToValue(it)).join("") + ); + }) + .join("\n"); + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: doc }, + effects: chordSyncEffect.of( + $chords.map((chord) => [chord.actions, chord.phrase] as const), + ), + }); + } + function loadBackup(event: Event) { const input = event.target as HTMLInputElement; if (!input.files || input.files.length === 0) return; @@ -106,39 +172,50 @@ } -
- - - - - - -
+
+
+ + + + + + + +
-
-
- +
+
+ +