mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-22 09:52:50 +00:00
feat: cv2
This commit is contained in:
@@ -8,26 +8,113 @@ export function actionLinter(config?: Parameters<typeof linter>[1]) {
|
||||
const finalConfig: Parameters<typeof linter>[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)
|
||||
update.state.field(parsedChordsField)
|
||||
);
|
||||
},
|
||||
};
|
||||
return linter((view) => {
|
||||
console.log("lint");
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const { ids, codes } = view.state.field(actionMetaPlugin.field);
|
||||
const { meta, compoundInputs } = view.state.field(parsedChordsField);
|
||||
const parsed = view.state.field(parsedChordsField);
|
||||
|
||||
for (const chord of parsed.chords) {
|
||||
if (chord.disabled) {
|
||||
diagnostics.push({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
severity: "info",
|
||||
markClass: "chord-ignored",
|
||||
message: `Chord disabled`,
|
||||
});
|
||||
}
|
||||
if (chord.compounds) {
|
||||
for (const compound of chord.compounds) {
|
||||
if (compound.actions.length === 0 && compound.parent) {
|
||||
const replacement = view.state.doc.sliceString(
|
||||
compound.parent.range[0],
|
||||
compound.parent.input!.range[1],
|
||||
);
|
||||
diagnostics.push({
|
||||
from: compound.range[0],
|
||||
to: compound.range[1],
|
||||
severity: "warning",
|
||||
message: `Compound literal can be replaced with "${replacement}"`,
|
||||
actions: [
|
||||
{
|
||||
name: "Replace",
|
||||
apply(view, from, to) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert: replacement + "|",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
const lastCompound = chord.compounds.at(-1);
|
||||
if (lastCompound) {
|
||||
const from = chord.range[0];
|
||||
const to = lastCompound.range[1];
|
||||
if (lastCompound.parent) {
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "info",
|
||||
markClass: "chord-child",
|
||||
message: `Child of ${view.state.doc.sliceString(lastCompound.parent.range[0], lastCompound.parent.range[1])}`,
|
||||
actions: [
|
||||
{
|
||||
name: "Select Parent",
|
||||
apply(view) {
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: lastCompound.parent!.range[0],
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "warning",
|
||||
message: `Orphan compound`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chord.children) {
|
||||
diagnostics.push({
|
||||
from: chord.range[0],
|
||||
to: chord.range[1],
|
||||
severity: "info",
|
||||
markClass: "chord-parent",
|
||||
message: `Parent of ${chord.children.length} compound(s)`,
|
||||
actions: chord.children.map((child) => ({
|
||||
name: `Go to ${view.state.doc.sliceString(child.range[0], child.range[1])}`,
|
||||
apply(view) {
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: child.range[0],
|
||||
},
|
||||
scrollIntoView: true,
|
||||
});
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
|
||||
syntaxTree(view.state)
|
||||
.cursor()
|
||||
@@ -196,6 +283,14 @@ export function actionLinter(config?: Parameters<typeof linter>[1]) {
|
||||
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;
|
||||
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
} from "@codemirror/view";
|
||||
import { mount, unmount } from "svelte";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Range } from "@codemirror/state";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
import { iterActions } from "./parse-meta";
|
||||
|
||||
export class ActionWidget extends WidgetType {
|
||||
component?: {};
|
||||
@@ -50,27 +51,24 @@ export class ActionWidget extends WidgetType {
|
||||
function actionWidgets(view: EditorView) {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (node.name !== "ExplicitAction") return;
|
||||
const value =
|
||||
node.node.getChild("ActionId") ?? node.node.getChild("HexNumber");
|
||||
if (!value) return;
|
||||
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||
for (const chord of view.state.field(parsedChordsField).chords) {
|
||||
if (chord.range[1] < from || chord.range[0] > to) continue;
|
||||
iterActions(chord, (action) => {
|
||||
if (
|
||||
view.state.selection.ranges.some(
|
||||
(r) => r.from <= action.range[1] && r.to > action.range[0],
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const id = view.state.doc.sliceString(value.from, value.to);
|
||||
if (value.name === "HexNumber" && id.length === 10) return;
|
||||
let deco = Decoration.replace({
|
||||
widget: new ActionWidget(
|
||||
value.name === "ActionId" ? id : Number.parseInt(id, 16),
|
||||
),
|
||||
});
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
},
|
||||
});
|
||||
if (action.info && action.explicit) {
|
||||
const deco = Decoration.replace({
|
||||
widget: new ActionWidget(action.code),
|
||||
});
|
||||
widgets.push(deco.range(action.range[0], action.range[1]));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
@@ -87,7 +85,9 @@ export const actionPlugin = ViewPlugin.fromClass(
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||
update.selectionSet ||
|
||||
update.startState.field(parsedChordsField) !=
|
||||
update.state.field(parsedChordsField)
|
||||
)
|
||||
this.decorations = actionWidgets(update.view);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import {
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
type KeyInfo,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { StateEffect, ChangeDesc, type EditorState } from "@codemirror/state";
|
||||
import type { Update } from "@codemirror/collab";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
composeChordInput,
|
||||
hasConcatenator,
|
||||
hashChord,
|
||||
splitCompound,
|
||||
willBeValidChordInput,
|
||||
} from "$lib/serial/chord";
|
||||
import type { SyntaxNodeRef } from "@lezer/common";
|
||||
import type {
|
||||
ActionMeta,
|
||||
ChordMeta,
|
||||
MetaRange,
|
||||
ParseResult,
|
||||
} from "./parse-meta";
|
||||
|
||||
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||
@@ -31,153 +30,214 @@ export function actionToValue(action: number | KeyInfo) {
|
||||
return `<${info.id}>`;
|
||||
}
|
||||
|
||||
export interface ParseMeta {
|
||||
from: number;
|
||||
to: number;
|
||||
hasConcatenator: boolean;
|
||||
invalidActions?: true;
|
||||
invalidInput?: true;
|
||||
emptyPhrase?: true;
|
||||
orphan?: true;
|
||||
disabled?: true;
|
||||
overrides?: number[];
|
||||
overriddenBy?: number;
|
||||
export function parseChordMeta(
|
||||
data: EditorState,
|
||||
ids: Map<string, KeyInfo>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
): ChordMeta[] {
|
||||
console.time("parseChordTree");
|
||||
const result: ChordMeta[] = [];
|
||||
|
||||
let current: ChordMeta = { range: [0, 0], valid: false };
|
||||
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);
|
||||
current.compounds ??= [];
|
||||
current.compounds.push({
|
||||
range: [node.from, node.to],
|
||||
value: hashChord(
|
||||
composeChordInput(
|
||||
actions.map(({ code }) => code),
|
||||
lastCompound?.value,
|
||||
),
|
||||
),
|
||||
actions,
|
||||
valid:
|
||||
willBeValidChordInput(
|
||||
actions.length,
|
||||
lastCompound !== undefined,
|
||||
) && actions.every(({ valid }) => valid),
|
||||
});
|
||||
} else if (node.name === "ChordInput") {
|
||||
const lastCompound = current.compounds?.at(-1);
|
||||
current.input = {
|
||||
range: [node.from, node.to],
|
||||
value: composeChordInput(
|
||||
actions.map(({ code }) => code),
|
||||
lastCompound?.value,
|
||||
),
|
||||
valid:
|
||||
willBeValidChordInput(
|
||||
actions.length,
|
||||
lastCompound !== undefined,
|
||||
) && actions.every(({ valid }) => valid),
|
||||
actions,
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
console.timeEnd("parseChordTree");
|
||||
return result;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
result: CharaChordFile["chords"];
|
||||
meta: ParseMeta[];
|
||||
compoundInputs: Map<number, string>;
|
||||
function resolveChordOverrides(chords: ChordMeta[]) {
|
||||
console.time("resolveOverrides");
|
||||
const seen = new Map<string, ChordMeta>();
|
||||
for (const info of chords) {
|
||||
if (!info.input || info.disabled) continue;
|
||||
const key = JSON.stringify(info.input.value);
|
||||
const override = seen.get(key);
|
||||
if (override) {
|
||||
override.overrides ??= [];
|
||||
override.overrides.push(info);
|
||||
info.overriddenBy = override;
|
||||
info.disabled = true;
|
||||
} else {
|
||||
seen.set(key, info);
|
||||
}
|
||||
}
|
||||
console.timeEnd("resolveOverrides");
|
||||
}
|
||||
|
||||
function resolveChordAliases(chords: ChordMeta[]) {
|
||||
console.time("resolveAliases");
|
||||
const aliases = new Map<string, ChordMeta[]>();
|
||||
for (const info of chords) {
|
||||
if (!info.phrase) continue;
|
||||
const key = JSON.stringify(info.phrase.value);
|
||||
const list = aliases.get(key) ?? [];
|
||||
list.push(info);
|
||||
aliases.set(key, list);
|
||||
}
|
||||
for (const aliasList of aliases.values()) {
|
||||
if (aliasList.length > 1) {
|
||||
for (const info of aliasList) {
|
||||
info.aliases = aliasList.filter((i) => i !== info);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.timeEnd("resolveAliases");
|
||||
}
|
||||
|
||||
function resolveCompoundParents(chords: ChordMeta[]) {
|
||||
console.time("resolveCompoundParents");
|
||||
const compounds = new Map<number, ChordMeta>();
|
||||
for (const chord of chords) {
|
||||
if (chord.input && !chord.disabled) {
|
||||
compounds.set(hashChord(chord.input.value), chord);
|
||||
}
|
||||
}
|
||||
for (const chord of chords) {
|
||||
if (chord.compounds) {
|
||||
for (const compound of chord.compounds) {
|
||||
const parent = compounds.get(compound.value);
|
||||
if (parent) {
|
||||
compound.parent = parent;
|
||||
}
|
||||
}
|
||||
const lastCompound = chord.compounds?.at(-1);
|
||||
if (lastCompound && lastCompound.parent) {
|
||||
lastCompound.parent.children ??= [];
|
||||
lastCompound.parent.children.push(chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.timeEnd("resolveCompoundParents");
|
||||
}
|
||||
|
||||
export function parseCharaChords(
|
||||
data: EditorState,
|
||||
ids: Map<string, KeyInfo>,
|
||||
codes: Map<number, KeyInfo>,
|
||||
deviceChords: CharaChordFile["chords"],
|
||||
): ParseResult {
|
||||
console.time("parseCharaChords");
|
||||
const chords: CharaChordFile["chords"] = [];
|
||||
const metas: ParseMeta[] = [];
|
||||
const keys = new Map<string, number>();
|
||||
const compoundInputs = new Map<number, string>();
|
||||
const orphanCompounds = new Set<number>();
|
||||
console.time("parseTotal");
|
||||
|
||||
let currentChord: CharaChordFile["chords"][number] | undefined = undefined;
|
||||
let compound: number | undefined = undefined;
|
||||
let currentActions: number[] = [];
|
||||
let invalidActions = false;
|
||||
let invalidInput = false;
|
||||
let chordFrom = 0;
|
||||
const chords = parseChordMeta(data, ids, codes);
|
||||
resolveChordOverrides(chords);
|
||||
resolveChordAliases(chords);
|
||||
resolveCompoundParents(chords);
|
||||
|
||||
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()
|
||||
.iterate(
|
||||
(node) => {
|
||||
if (node.name === "Chord") {
|
||||
currentChord = undefined;
|
||||
compound = undefined;
|
||||
invalidActions = false;
|
||||
invalidInput = false;
|
||||
chordFrom = node.from;
|
||||
} else if (node.name === "ActionString") {
|
||||
currentActions = [];
|
||||
} else if (node.name === "HexNumber") {
|
||||
const hexString = data.doc.sliceString(node.from, node.to);
|
||||
const code = Number.parseInt(hexString, 16);
|
||||
if (hexString.length === 10) {
|
||||
if (compound !== undefined) {
|
||||
invalidInput = true;
|
||||
}
|
||||
compound = code;
|
||||
} else {
|
||||
if (Number.isNaN(code) || code < 0 || code > 1023) {
|
||||
invalidActions = true;
|
||||
}
|
||||
currentActions.push(code);
|
||||
}
|
||||
} else if (
|
||||
node.name === "ActionId" ||
|
||||
node.name === "SingleLetter" ||
|
||||
node.name === "EscapedChar"
|
||||
) {
|
||||
const id = data.doc.sliceString(node.from, node.to);
|
||||
const code = ids.get(id)?.code;
|
||||
if (code === undefined) {
|
||||
invalidActions = true;
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(id);
|
||||
for (let byte of bytes) {
|
||||
currentActions.push(-byte);
|
||||
}
|
||||
} else {
|
||||
currentActions.push(code);
|
||||
}
|
||||
}
|
||||
},
|
||||
(node) => {
|
||||
if (node.name === "Chord" && currentChord !== undefined) {
|
||||
if (currentChord !== undefined) {
|
||||
currentChord[1] = currentActions;
|
||||
const index = chords.length;
|
||||
chords.push(currentChord);
|
||||
const meta: ParseMeta = {
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
hasConcatenator: hasConcatenator(currentChord[1], codes),
|
||||
};
|
||||
if (invalidActions) {
|
||||
meta.invalidActions = true;
|
||||
}
|
||||
if (invalidInput) {
|
||||
meta.invalidInput = true;
|
||||
}
|
||||
metas.push(meta);
|
||||
if (currentChord[1].length === 0) {
|
||||
meta.emptyPhrase = true;
|
||||
}
|
||||
const key = JSON.stringify(currentChord[0]);
|
||||
if (!meta.invalidInput) {
|
||||
if (keys.has(key)) {
|
||||
const targetIndex = keys.get(key)!;
|
||||
const targetMeta = metas[targetIndex]!;
|
||||
if (!targetMeta.overrides) targetMeta.overrides = [];
|
||||
targetMeta.overrides.push(index);
|
||||
meta.overriddenBy = targetIndex;
|
||||
} else {
|
||||
keys.set(key, index);
|
||||
}
|
||||
}
|
||||
if (
|
||||
meta.emptyPhrase ||
|
||||
meta.invalidInput ||
|
||||
meta.invalidActions ||
|
||||
meta.overriddenBy !== undefined
|
||||
) {
|
||||
meta.disabled = true;
|
||||
}
|
||||
}
|
||||
} else if (node.name === "CompoundDelim") {
|
||||
makeChordInput(node);
|
||||
} else if (node.name === "PhraseDelim") {
|
||||
const input = makeChordInput(node);
|
||||
orphanCompounds.delete(hashChord(input));
|
||||
currentChord = [input, []];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
/*for (let i = 0; i < metas.length; i++) {
|
||||
const [, compound] = splitCompound(chords[i]![0]);
|
||||
if (
|
||||
compound !== undefined &&
|
||||
@@ -187,124 +247,27 @@ export function parseCharaChords(
|
||||
}
|
||||
}
|
||||
|
||||
console.timeEnd("parseCharaChords");
|
||||
|
||||
console.log(chords.length);
|
||||
return { result: chords, meta: metas, compoundInputs };
|
||||
}
|
||||
|
||||
class ChordRecord {
|
||||
private chords = new Map<string, Set<string>>();
|
||||
|
||||
constructor(chords: CharaChordFile["chords"]) {
|
||||
for (let chord of chords) {
|
||||
const key = JSON.stringify(chord[0]);
|
||||
if (!this.chords.has(key)) {
|
||||
this.chords.set(key, new Set());
|
||||
}
|
||||
this.chords.get(key)!.add(JSON.stringify(chord));
|
||||
}
|
||||
}
|
||||
|
||||
static createDiff(
|
||||
previous: CharaChordFile["chords"],
|
||||
updated: CharaChordFile["chords"],
|
||||
) {
|
||||
const deleted = new ChordRecord(previous);
|
||||
const added = new ChordRecord(updated);
|
||||
const dupA = deleted.duplicates(added);
|
||||
const dupB = added.duplicates(deleted);
|
||||
for (let chord of dupA) {
|
||||
deleted.remove(chord);
|
||||
added.remove(chord);
|
||||
}
|
||||
for (let chord of dupB) {
|
||||
deleted.remove(chord);
|
||||
added.remove(chord);
|
||||
}
|
||||
return { deleted, added };
|
||||
}
|
||||
|
||||
duplicates(
|
||||
other: ChordRecord,
|
||||
): IteratorObject<CharaChordFile["chords"][number]> {
|
||||
const duplicates = new Set<string>();
|
||||
for (let [key, chordSet] of this.chords) {
|
||||
for (let chord of chordSet) {
|
||||
if (other.hasInternal(key, chord)) {
|
||||
duplicates.add(chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
return duplicates
|
||||
.values()
|
||||
.map((it) => JSON.parse(it) as CharaChordFile["chords"][number]);
|
||||
}
|
||||
|
||||
private hasInternal(key: string, chord: string): boolean {
|
||||
return this.chords.get(key)?.has(chord) ?? false;
|
||||
}
|
||||
|
||||
has(chord: CharaChordFile["chords"][number]): boolean {
|
||||
return this.hasInternal(JSON.stringify(chord[0]), JSON.stringify(chord));
|
||||
}
|
||||
|
||||
remove(chord: CharaChordFile["chords"][number]) {
|
||||
const key = JSON.stringify(chord[0]);
|
||||
const set = this.chords.get(key);
|
||||
if (set) {
|
||||
set.delete(JSON.stringify(chord));
|
||||
if (set.size === 0) {
|
||||
this.chords.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncChords(
|
||||
previous: CharaChordFile["chords"],
|
||||
updated: CharaChordFile["chords"],
|
||||
state: EditorState,
|
||||
) {
|
||||
const deviceDiff = ChordRecord.createDiff(previous, updated);
|
||||
const current = parseCharaChords(state, get(KEYMAP_IDS));
|
||||
// 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
|
||||
}
|
||||
|
||||
export function rebaseUpdates(
|
||||
updates: readonly Update[],
|
||||
over: readonly { changes: ChangeDesc; clientID: string }[],
|
||||
) {
|
||||
if (!over.length || !updates.length) return updates;
|
||||
let changes: ChangeDesc | null = null,
|
||||
skip = 0;
|
||||
for (let update of over) {
|
||||
let other = skip < updates.length ? updates[skip] : null;
|
||||
if (other && other.clientID == update.clientID) {
|
||||
if (changes) changes = changes.mapDesc(other.changes, true);
|
||||
skip++;
|
||||
const removed: CharaChordFile["chords"] = [];
|
||||
for (let deviceChord of deviceChords) {
|
||||
const key = JSON.stringify(deviceChord[0]);
|
||||
if (!keys.has(key)) {
|
||||
removed.push(deviceChord);
|
||||
} else {
|
||||
changes = changes ? changes.composeDesc(update.changes) : update.changes;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
if (skip) updates = updates.slice(skip);
|
||||
return !changes
|
||||
? updates
|
||||
: updates.map((update) => {
|
||||
let updateChanges = update.changes.map(changes!);
|
||||
changes = changes!.mapDesc(update.changes, true);
|
||||
return {
|
||||
changes: updateChanges,
|
||||
effects:
|
||||
update.effects && StateEffect.mapEffects(update.effects, changes!),
|
||||
clientID: update.clientID,
|
||||
};
|
||||
});
|
||||
console.timeEnd("parseTotal");
|
||||
|
||||
console.log(chords);
|
||||
return { chords, removed: [] };
|
||||
}
|
||||
|
||||
23
src/lib/chord-editor/changes-decorations-plugin.ts
Normal file
23
src/lib/chord-editor/changes-decorations-plugin.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
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<DecorationSet>({
|
||||
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),
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
type PluginValue,
|
||||
} from "@codemirror/view";
|
||||
|
||||
export const changesPlugin = ViewPlugin.fromClass(
|
||||
class implements PluginValue {
|
||||
constructor(readonly view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {}
|
||||
},
|
||||
{
|
||||
eventHandlers: {},
|
||||
},
|
||||
);
|
||||
@@ -8,6 +8,13 @@ export const deviceChordField = StateField.define<CharaChordFile["chords"]>({
|
||||
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
|
||||
|
||||
@@ -3,24 +3,41 @@
|
||||
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
|
||||
EscapedSingleAction { Escape EscapedLetter }
|
||||
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||
ActionString { Action* }
|
||||
ChordInput { (ActionString CompoundDelim)* ActionString }
|
||||
|
||||
ActionString { Action+ }
|
||||
|
||||
CompoundLiteral { CompoundDelim HexNumber CompoundDelim }
|
||||
CompoundInput { ActionString CompoundDelim }
|
||||
|
||||
ChordInput { CompoundLiteral? CompoundInput* ActionString }
|
||||
ChordPhrase { ActionString }
|
||||
|
||||
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||
|
||||
@skip {
|
||||
Space
|
||||
}
|
||||
|
||||
@tokens {
|
||||
@precedence {HexNumber}
|
||||
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||
@precedence {EscapedLetter}
|
||||
@precedence { HexNumber, ActionId }
|
||||
@precedence { Space, Escape }
|
||||
@precedence { Space, SingleLetter }
|
||||
@precedence { Escape, SingleLetter }
|
||||
@precedence { CompoundDelim, SingleLetter }
|
||||
@precedence { ActionId, Space }
|
||||
@precedence { EscapedLetter, Space }
|
||||
|
||||
Space {" "}
|
||||
ExplicitDelimStart {"<"}
|
||||
ExplicitDelimEnd {">"}
|
||||
CompoundDelim {"|"}
|
||||
PhraseDelim {"=>"}
|
||||
Escape { "\\" }
|
||||
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||
SingleLetter { ![\\] }
|
||||
EscapedLetter { ![] }
|
||||
ChordDelim { ($[\n] | @eof) }
|
||||
ActionId { ![\n>]+ }
|
||||
SingleLetter { ![\n<] }
|
||||
EscapedLetter { ![\n] }
|
||||
ChordDelim { ("\n" | @eof) }
|
||||
}
|
||||
|
||||
@detectDelim
|
||||
|
||||
171
src/lib/chord-editor/parse-meta.ts
Normal file
171
src/lib/chord-editor/parse-meta.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import type { ChangeDesc } from "@codemirror/state";
|
||||
|
||||
export type MetaRange = [from: number, to: number];
|
||||
|
||||
function mapMetaRange(range: MetaRange, change: ChangeDesc): MetaRange {
|
||||
const newFrom = change.mapPos(range[0]);
|
||||
const newTo = change.mapPos(range[1]);
|
||||
if (newFrom === range[0] && newTo === range[1]) {
|
||||
return range;
|
||||
}
|
||||
return [newFrom, newTo];
|
||||
}
|
||||
|
||||
export interface ActionMeta {
|
||||
code: number;
|
||||
info?: KeyInfo;
|
||||
explicit?: boolean;
|
||||
range: MetaRange;
|
||||
valid: boolean;
|
||||
}
|
||||
|
||||
function mapActionMeta(action: ActionMeta, change: ChangeDesc): ActionMeta {
|
||||
const newRange = mapMetaRange(action.range, change);
|
||||
if (newRange === action.range) {
|
||||
return action;
|
||||
}
|
||||
return {
|
||||
...action,
|
||||
range: newRange,
|
||||
};
|
||||
}
|
||||
|
||||
function mapArray<T>(
|
||||
array: T[],
|
||||
change: ChangeDesc,
|
||||
mapFn: (action: T, change: ChangeDesc) => T,
|
||||
): T[] {
|
||||
let changed = false;
|
||||
const newArray = array.map((value) => {
|
||||
const newValue = mapFn(value, change);
|
||||
if (newValue !== value) {
|
||||
changed = true;
|
||||
return newValue;
|
||||
}
|
||||
return value;
|
||||
});
|
||||
if (changed) {
|
||||
return newArray;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
export interface ActionStringMeta<T> {
|
||||
range: MetaRange;
|
||||
value: T;
|
||||
valid: boolean;
|
||||
actions: ActionMeta[];
|
||||
}
|
||||
|
||||
function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
|
||||
actionString: T,
|
||||
change: ChangeDesc,
|
||||
) {
|
||||
const newRange = mapMetaRange(actionString.range, change);
|
||||
const newActions = mapArray(actionString.actions, change, mapActionMeta);
|
||||
if (newRange === actionString.range && newActions === actionString.actions) {
|
||||
return actionString;
|
||||
}
|
||||
return {
|
||||
...actionString,
|
||||
range: newRange,
|
||||
actions: newActions,
|
||||
};
|
||||
}
|
||||
|
||||
export interface PhraseMeta extends ActionStringMeta<number[]> {
|
||||
hasConcatenator: boolean;
|
||||
}
|
||||
|
||||
export interface CompoundMeta extends ActionStringMeta<number> {
|
||||
parent?: ChordMeta;
|
||||
}
|
||||
|
||||
export interface InputMeta extends ActionStringMeta<number[]> {}
|
||||
|
||||
export interface ChordMeta {
|
||||
range: MetaRange;
|
||||
valid: boolean;
|
||||
disabled?: boolean;
|
||||
compounds?: CompoundMeta[];
|
||||
input?: InputMeta;
|
||||
phrase?: PhraseMeta;
|
||||
children?: ChordMeta[];
|
||||
overrides?: ChordMeta[];
|
||||
aliases?: ChordMeta[];
|
||||
overriddenBy?: ChordMeta;
|
||||
}
|
||||
|
||||
export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta {
|
||||
const newRange = mapMetaRange(chord.range, change);
|
||||
const newCompounds = chord.compounds
|
||||
? mapArray(chord.compounds, change, mapActionStringMeta)
|
||||
: undefined;
|
||||
const newInput = chord.input
|
||||
? mapActionStringMeta(chord.input, change)
|
||||
: undefined;
|
||||
const newPhrase = chord.phrase
|
||||
? mapActionStringMeta(chord.phrase, change)
|
||||
: undefined;
|
||||
if (
|
||||
newRange === chord.range &&
|
||||
newCompounds === chord.compounds &&
|
||||
newInput === chord.input &&
|
||||
newPhrase === chord.phrase
|
||||
) {
|
||||
return chord;
|
||||
}
|
||||
|
||||
const newChord: ChordMeta = {
|
||||
...chord,
|
||||
range: newRange,
|
||||
};
|
||||
if (newCompounds) newChord.compounds = newCompounds;
|
||||
if (newInput) newChord.input = newInput;
|
||||
if (newPhrase) newChord.phrase = newPhrase;
|
||||
return newChord;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
chords: ChordMeta[];
|
||||
removed: CharaChordFile["chords"];
|
||||
}
|
||||
|
||||
export function mapParseResult(
|
||||
result: ParseResult,
|
||||
change: ChangeDesc,
|
||||
): ParseResult {
|
||||
const newChords = mapArray(result.chords, change, mapChordMeta);
|
||||
if (newChords === result.chords) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...result,
|
||||
chords: newChords,
|
||||
};
|
||||
}
|
||||
|
||||
export function iterActions(
|
||||
chord: ChordMeta,
|
||||
callback: (action: ActionMeta) => void,
|
||||
) {
|
||||
if (chord.input) {
|
||||
for (const action of chord.input.actions) {
|
||||
callback(action);
|
||||
}
|
||||
}
|
||||
if (chord.compounds) {
|
||||
for (const compound of chord.compounds) {
|
||||
for (const action of compound.actions) {
|
||||
callback(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (chord.phrase) {
|
||||
for (const action of chord.phrase.actions) {
|
||||
callback(action);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +1,30 @@
|
||||
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 { StateField } from "@codemirror/state";
|
||||
import { parseCharaChords } from "./action-serializer";
|
||||
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<ParseResult>({
|
||||
map: mapParseResult,
|
||||
});
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { deviceChordField } from "./chord-sync-plugin";
|
||||
import { mapParseResult, type ParseResult } from "./parse-meta";
|
||||
|
||||
export const parsedChordsField = StateField.define<ParseResult>({
|
||||
create() {
|
||||
return { compoundInputs: new Map(), meta: [], result: [] };
|
||||
return {
|
||||
chords: [],
|
||||
removed: [],
|
||||
};
|
||||
},
|
||||
update(value, transaction) {
|
||||
return (
|
||||
transaction.effects.findLast((it) => it.is(parsedChordsEffect))?.value ??
|
||||
mapParseResult(value, transaction.changes)
|
||||
);
|
||||
const tree = syntaxTree(transaction.state);
|
||||
const ids = transaction.state.field(actionMetaPlugin.field).ids;
|
||||
const codes = transaction.state.field(actionMetaPlugin.field).codes;
|
||||
const deviceChords = transaction.state.field(deviceChordField);
|
||||
if (
|
||||
tree !== syntaxTree(transaction.startState) ||
|
||||
ids !== transaction.startState.field(actionMetaPlugin.field).ids ||
|
||||
codes !== transaction.startState.field(actionMetaPlugin.field).codes ||
|
||||
deviceChords !== transaction.startState.field(deviceChordField)
|
||||
) {
|
||||
return parseCharaChords(transaction.state, ids, codes, deviceChords);
|
||||
}
|
||||
return mapParseResult(value, transaction.changes);
|
||||
},
|
||||
});
|
||||
|
||||
export function parsedChordsPlugin(debounce = 200): Extension {
|
||||
const plugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
tree: Tree;
|
||||
ids: Map<string, KeyInfo>;
|
||||
codes: Map<number, KeyInfo>;
|
||||
|
||||
needsUpdate = new Subject<void>();
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ 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";
|
||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
||||
|
||||
const serializedFields = {
|
||||
history: historyField,
|
||||
@@ -45,7 +45,7 @@ export function loadPersistentState(params: EditorConfig): EditorState {
|
||||
extensions: [
|
||||
actionMetaPlugin.plugin,
|
||||
deviceChordField,
|
||||
parsedChordsPlugin(),
|
||||
parsedChordsField,
|
||||
lintGutter(),
|
||||
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
|
||||
chordLanguageSupport(),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
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";
|
||||
|
||||
let queryFilter: string | undefined = $state(undefined);
|
||||
|
||||
@@ -39,9 +40,7 @@
|
||||
.map((chord) => {
|
||||
const [actions, compound] = splitCompound(chord.actions);
|
||||
return (
|
||||
(compound
|
||||
? "<0x" + compound.toString(16).padStart(8, "0") + ">"
|
||||
: "") +
|
||||
(compound ? "|0x" + compound.toString(16) + "|" : "") +
|
||||
actions.map((it) => actionToValue(it)).join("") +
|
||||
"=>" +
|
||||
chord.phrase.map((it) => actionToValue(it)).join("")
|
||||
@@ -50,6 +49,9 @@
|
||||
.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),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -202,6 +204,11 @@
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
:global(.chord-child) {
|
||||
background-image: none;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:global(.chord-invalid) {
|
||||
color: var(--md-sys-color-error);
|
||||
text-decoration-color: var(--md-sys-color-error);
|
||||
|
||||
Reference in New Issue
Block a user