mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-18 07:52:50 +00:00
feat: cv2
This commit is contained in:
@@ -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<typeof linter>[1]) {
|
||||
const finalConfig: Parameters<typeof linter>[1] = {
|
||||
@@ -113,186 +110,26 @@ export function actionLinter(config?: Parameters<typeof linter>[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:
|
||||
"<PRESS_NEXT><LEFT_ALT><KP_PLUS>" +
|
||||
action
|
||||
.codePointAt(0)!
|
||||
.toString(16)
|
||||
.split("")
|
||||
.map((c) =>
|
||||
/^\d$/.test(c)
|
||||
? `<KP_${c}>`
|
||||
: c.toLowerCase(),
|
||||
)
|
||||
.join("") +
|
||||
"<RELEASE_NEXT><LEFT_ALT>",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
] 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);
|
||||
}
|
||||
|
||||
@@ -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<string, ChordMeta>();
|
||||
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<string, KeyInfo>,
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
39
src/lib/chord-editor/changes-panel.ts
Normal file
39
src/lib/chord-editor/changes-panel.ts
Normal file
@@ -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);
|
||||
}
|
||||
@@ -77,6 +77,7 @@ function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
|
||||
|
||||
export interface PhraseMeta extends ActionStringMeta<number[]> {
|
||||
hasConcatenator: boolean;
|
||||
originalValue?: number[];
|
||||
}
|
||||
|
||||
export interface CompoundMeta extends ActionStringMeta<number> {
|
||||
|
||||
@@ -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<EditorState> {
|
||||
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<void>();
|
||||
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) {}
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style:display="flex">
|
||||
<label><input type="checkbox" bind:checked={$rawCode} />Edit as code</label>
|
||||
<!--<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>-->
|
||||
<label
|
||||
><input type="checkbox" bind:checked={$denseSpacing} />Dense Spacing</label
|
||||
>
|
||||
<button onclick={regenerate}>Regenerate from current chords</button>
|
||||
<button onclick={downloadBackup}>Download Backup</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
onchange={loadBackup}
|
||||
style="margin-left: 1rem"
|
||||
/>
|
||||
</div>
|
||||
<div class="vertical">
|
||||
<div style:display="flex">
|
||||
<label><input type="checkbox" bind:checked={$rawCode} />Edit as code</label>
|
||||
<!--<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>-->
|
||||
<label
|
||||
><input type="checkbox" bind:checked={$denseSpacing} />Dense Spacing</label
|
||||
>
|
||||
<button onclick={regenerate}>Regenerate from current chords</button>
|
||||
<!--<button onclick={largeFile}>Create Huge File</button>-->
|
||||
<button onclick={downloadBackup}>Download Backup</button>
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
onchange={loadBackup}
|
||||
style="margin-left: 1rem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div
|
||||
class="editor"
|
||||
class:hide-edits={!$showEdits}
|
||||
class:raw={$rawCode}
|
||||
class:dense-spacing={$denseSpacing}
|
||||
bind:this={editor}
|
||||
></div>
|
||||
<ActionList {queryFilter} ignoreIcon={$rawCode} />
|
||||
<div class="split">
|
||||
<div
|
||||
class="editor"
|
||||
class:hide-edits={!$showEdits}
|
||||
class:raw={$rawCode}
|
||||
class:dense-spacing={$denseSpacing}
|
||||
bind:this={editor}
|
||||
></div>
|
||||
<ActionList {queryFilter} ignoreIcon={$rawCode} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
width: calc(min(100%, 1400px));
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
|
||||
> :global(*) {
|
||||
flex: 1;
|
||||
|
||||
Reference in New Issue
Block a user