mirror of
https://github.com/Horizontal42/summarum.git
synced 2026-11-10 15:42:01 +03:00
Merge a5a6412c69 into bede889ced
This commit is contained in:
commit
769cd355e6
1 changed files with 84 additions and 73 deletions
157
src/ui/editor.ts
157
src/ui/editor.ts
|
|
@ -1,7 +1,7 @@
|
|||
// CodeMirror editor wired to the engine: evaluates on change, highlights
|
||||
// engine tokens, renders the results overlay, autocompletes phrases.
|
||||
import { EditorView, ViewPlugin, ViewUpdate, Decoration, DecorationSet, keymap, drawSelection } from "@codemirror/view";
|
||||
import { EditorState, StateEffect, StateField, RangeSetBuilder } from "@codemirror/state";
|
||||
import { EditorState, StateEffect, StateField, RangeSetBuilder, Extension } from "@codemirror/state";
|
||||
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
||||
import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
|
||||
import { SumEngine, LineResult } from "../engine";
|
||||
|
|
@ -158,6 +158,7 @@ export class SumEditor {
|
|||
view: EditorView;
|
||||
private resultsEl: HTMLElement;
|
||||
private results: LineResult[] = [];
|
||||
private engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
||||
|
||||
constructor(
|
||||
parent: HTMLElement,
|
||||
|
|
@ -169,87 +170,97 @@ export class SumEditor {
|
|||
) {
|
||||
this.resultsEl = resultsEl;
|
||||
|
||||
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
|
||||
const varPattern = /^\s*([\p{L}_][\p{L}\d_]*)\s*=/u;
|
||||
const completionSource = (ctx: CompletionContext): CompletionResult | null => {
|
||||
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||
if (word.to - word.from < 2 && !ctx.explicit) return null;
|
||||
// the engine's phrase list is fixed after boot — map it once
|
||||
engineOptions ??= this.engine.completions().map((c) => ({
|
||||
label: c.label,
|
||||
type: c.type === "function" ? "function" : c.type === "keyword" ? "keyword" : "constant",
|
||||
detail: c.detail,
|
||||
}));
|
||||
const options = [...engineOptions];
|
||||
// document variables
|
||||
const iter = ctx.state.doc.iterLines();
|
||||
for (let next = iter.next(); !next.done; next = iter.next()) {
|
||||
const line = next.value;
|
||||
if (line.includes("=")) {
|
||||
const m = varPattern.exec(line);
|
||||
if (m) {
|
||||
options.push({ label: m[1], type: "variable" });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
||||
};
|
||||
|
||||
this.view = new EditorView({
|
||||
parent,
|
||||
state: EditorState.create({
|
||||
doc: initialText,
|
||||
extensions: [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
autocompletion({ override: [completionSource], activateOnTyping: true, defaultKeymap: false }),
|
||||
keymap.of([{ key: "Tab", run: acceptCompletion }]),
|
||||
resultsField,
|
||||
highlightField,
|
||||
varHighlightField,
|
||||
varHighlightPlugin,
|
||||
EditorView.lineWrapping,
|
||||
drawSelection({ cursorBlinkRate: 1000 }),
|
||||
// CM injects its base styles at runtime after app.css, so layout
|
||||
// and cursor styling must live in a theme to take precedence
|
||||
EditorView.theme({
|
||||
".cm-content": {
|
||||
// --results-width lives on #editor-wrap; the divider drags it
|
||||
paddingRight: "var(--results-width, 42%)",
|
||||
paddingLeft: "16px",
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeft: "2px solid var(--caret)",
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of((u: ViewUpdate) => {
|
||||
if (u.docChanged) {
|
||||
const text = u.state.doc.toString();
|
||||
this.evaluate(text);
|
||||
this.cb.onChange(text);
|
||||
}
|
||||
if (u.geometryChanged || u.viewportChanged || u.docChanged) {
|
||||
this.renderResults();
|
||||
}
|
||||
if (u.selectionSet || u.docChanged) {
|
||||
const sel = u.state.selection.main;
|
||||
if (sel.empty) {
|
||||
this.cb.onSelection(null);
|
||||
} else {
|
||||
const from = u.state.doc.lineAt(sel.from).number - 1;
|
||||
const to = u.state.doc.lineAt(sel.to).number - 1;
|
||||
this.cb.onSelection([from, to]);
|
||||
}
|
||||
}
|
||||
}),
|
||||
],
|
||||
extensions: this.buildExtensions(),
|
||||
}),
|
||||
});
|
||||
|
||||
this.evaluate(initialText);
|
||||
requestAnimationFrame(() => this.renderResults());
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private buildExtensions(): Extension[] {
|
||||
return [
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
autocompletion({ override: [this.completionSource.bind(this)], activateOnTyping: true, defaultKeymap: false }),
|
||||
keymap.of([{ key: "Tab", run: acceptCompletion }]),
|
||||
resultsField,
|
||||
highlightField,
|
||||
varHighlightField,
|
||||
varHighlightPlugin,
|
||||
EditorView.lineWrapping,
|
||||
drawSelection({ cursorBlinkRate: 1000 }),
|
||||
// CM injects its base styles at runtime after app.css, so layout
|
||||
// and cursor styling must live in a theme to take precedence
|
||||
EditorView.theme({
|
||||
".cm-content": {
|
||||
// --results-width lives on #editor-wrap; the divider drags it
|
||||
paddingRight: "var(--results-width, 42%)",
|
||||
paddingLeft: "16px",
|
||||
overflowWrap: "anywhere",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeft: "2px solid var(--caret)",
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of((u: ViewUpdate) => this.onUpdate(u)),
|
||||
];
|
||||
}
|
||||
|
||||
private completionSource(ctx: CompletionContext): CompletionResult | null {
|
||||
const varPattern = /^\s*([\p{L}_][\p{L}\d_]*)\s*=/u;
|
||||
const word = ctx.matchBefore(/[\p{L}_]+$/u);
|
||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||
if (word.to - word.from < 2 && !ctx.explicit) return null;
|
||||
// the engine's phrase list is fixed after boot — map it once
|
||||
this.engineOptions ??= this.engine.completions().map((c) => ({
|
||||
label: c.label,
|
||||
type: c.type === "function" ? "function" : c.type === "keyword" ? "keyword" : "constant",
|
||||
detail: c.detail,
|
||||
}));
|
||||
const options = [...this.engineOptions];
|
||||
// document variables
|
||||
const iter = ctx.state.doc.iterLines();
|
||||
for (let next = iter.next(); !next.done; next = iter.next()) {
|
||||
const line = next.value;
|
||||
if (line.includes("=")) {
|
||||
const m = varPattern.exec(line);
|
||||
if (m) {
|
||||
options.push({ label: m[1], type: "variable" });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { from: word.from, options, validFor: /^[\p{L}_]*$/u };
|
||||
}
|
||||
|
||||
private onUpdate(u: ViewUpdate) {
|
||||
if (u.docChanged) {
|
||||
const text = u.state.doc.toString();
|
||||
this.evaluate(text);
|
||||
this.cb.onChange(text);
|
||||
}
|
||||
if (u.geometryChanged || u.viewportChanged || u.docChanged) {
|
||||
this.renderResults();
|
||||
}
|
||||
if (u.selectionSet || u.docChanged) {
|
||||
const sel = u.state.selection.main;
|
||||
if (sel.empty) {
|
||||
this.cb.onSelection(null);
|
||||
} else {
|
||||
const from = u.state.doc.lineAt(sel.from).number - 1;
|
||||
const to = u.state.doc.lineAt(sel.to).number - 1;
|
||||
this.cb.onSelection([from, to]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
this.view.scrollDOM.addEventListener("scroll", () => this.renderResults());
|
||||
|
||||
// copying whole lines also copies their results: "5+5 = 10";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue