summarum/src/ui/editor.ts

340 lines
12 KiB
TypeScript

// 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 { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { autocompletion, CompletionContext, CompletionResult, acceptCompletion } from "@codemirror/autocomplete";
import { SumEngine, LineResult } from "../engine";
import { buildSparkline } from "./sparkline";
export interface EditorCallbacks {
onChange(text: string): void;
onCopy(text: string): void;
onResults(results: LineResult[]): void;
/** 0-based inclusive line range of the selection, null when collapsed */
onSelection(range: [number, number] | null): void;
}
const setResults = StateEffect.define<LineResult[]>();
const resultsField = StateField.define<LineResult[]>({
create: () => [],
update(value, tr) {
for (const e of tr.effects) if (e.is(setResults)) value = e.value;
return value;
},
});
function buildDecorations(state: EditorState): DecorationSet {
const results = state.field(resultsField);
const builder = new RangeSetBuilder<Decoration>();
const doc = state.doc;
for (let i = 0; i < Math.min(results.length, doc.lines); i++) {
const line = doc.line(i + 1);
const r = results[i];
if (r.kind === "header") {
builder.add(line.from, line.from, Decoration.line({ class: "tok-header" }));
continue;
}
const marks: { from: number; to: number; cls: string }[] = [];
for (const tk of r.tokens) {
let cls: string | null = null;
switch (tk.t) {
case "num": case "const": case "scale": cls = "tok-number"; break;
case "unit": case "currency": case "repr": case "date": case "datelit": cls = "tok-unit"; break;
case "op": case "conv": case "assign": case "pctop": case "percent": case "bang": cls = "tok-op"; break;
case "func": case "agg": cls = "tok-var"; break;
default: cls = null;
}
if (cls && tk.end > tk.start && line.from + tk.end <= line.to) {
marks.push({ from: line.from + tk.start, to: line.from + tk.end, cls });
}
}
if (r.commentStart !== null) {
marks.push({ from: line.from + r.commentStart, to: line.to, cls: "tok-comment" });
}
marks.sort((a, b) => a.from - b.from);
let last = line.from;
for (const m of marks) {
if (m.from < last) continue; // overlapping (comment vs tokens) — first wins
builder.add(m.from, m.to, Decoration.mark({ class: m.cls }));
last = m.to;
}
}
return builder.finish();
}
const highlightField = StateField.define<DecorationSet>({
create: buildDecorations,
update(value, tr) {
const hasResults = tr.effects.some((e) => e.is(setResults));
if (hasResults) return buildDecorations(tr.state);
if (tr.docChanged) return value.map(tr.changes);
return value;
},
provide: (f) => EditorView.decorations.from(f),
});
/** Highlights every occurrence of the variable under the cursor. */
function buildVarHighlight(state: EditorState): DecorationSet {
const builder = new RangeSetBuilder<Decoration>();
const sel = state.selection.main;
if (!sel.empty) return builder.finish();
const text = state.doc.toString();
// expand a word around the cursor
const isWordCh = (ch: string) => /[\p{L}\d_]/u.test(ch);
let a = sel.head;
let b = sel.head;
while (a > 0 && isWordCh(text[a - 1])) a--;
while (b < text.length && isWordCh(text[b])) b++;
if (a === b) return builder.finish();
const word = text.slice(a, b);
if (!/^[\p{L}_][\p{L}\d_]*$/u.test(word)) return builder.finish();
// only highlight if the word is actually assigned somewhere in the document
const esc = word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
if (!new RegExp(`^\\s*${esc}\\s*=`, "mu").test(text)) return builder.finish();
const re = new RegExp(`(?<![\\p{L}\\d_])${esc}(?![\\p{L}\\d_])`, "gu");
for (const m of text.matchAll(re)) {
builder.add(m.index, m.index + word.length, Decoration.mark({ class: "tok-var-active" }));
}
return builder.finish();
}
const setVarHighlight = StateEffect.define<DecorationSet>();
const varHighlightField = StateField.define<DecorationSet>({
create: () => Decoration.none,
update(value, tr) {
for (const e of tr.effects) {
if (e.is(setVarHighlight)) return e.value;
}
if (tr.docChanged) return value.map(tr.changes);
return value;
},
provide: (f) => EditorView.decorations.from(f),
});
const varHighlightPlugin = ViewPlugin.fromClass(
class {
timeout: ReturnType<typeof setTimeout> | null = null;
constructor(view: EditorView) {
this.schedule(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet) {
this.schedule(update.view);
}
}
schedule(view: EditorView) {
if (this.timeout) clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
const decos = buildVarHighlight(view.state);
view.dispatch({ effects: setVarHighlight.of(decos) });
}, 150);
}
destroy() {
if (this.timeout) clearTimeout(this.timeout);
}
}
);
export class SumEditor {
view: EditorView;
private resultsEl: HTMLElement;
private results: LineResult[] = [];
constructor(
parent: HTMLElement,
resultsEl: HTMLElement,
private engine: SumEngine,
private cb: EditorCallbacks,
private evaluateDoc: (text: string) => LineResult[],
initialText: string,
) {
this.resultsEl = resultsEl;
let engineOptions: { label: string; type: string; detail?: string }[] | null = null;
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 text = ctx.state.doc.toString();
for (const m of text.matchAll(/^\s*([\p{L}_][\p{L}\d_]*)\s*=/gmu)) {
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]);
}
}
}),
],
}),
});
this.evaluate(initialText);
requestAnimationFrame(() => this.renderResults());
this.view.scrollDOM.addEventListener("scroll", () => this.renderResults());
// copying whole lines also copies their results: "5+5 = 10";
// a partial single-line selection copies as usual
this.view.dom.addEventListener("copy", (e) => {
const state = this.view.state;
const sel = state.selection.main;
const doc = state.doc;
const lineFrom = doc.lineAt(sel.from);
const lineTo = doc.lineAt(sel.to);
const multiLine = lineFrom.number !== lineTo.number;
const coversLine = sel.from <= lineFrom.from && sel.to >= lineTo.to && !sel.empty;
if (!sel.empty && !multiLine && !coversLine) return;
const lines: string[] = [];
for (let n = lineFrom.number; n <= lineTo.number; n++) {
const line = doc.line(n);
const res = this.results[n - 1]?.text;
lines.push(res ? `${line.text} = ${res}` : line.text);
}
e.clipboardData?.setData("text/plain", lines.join("\n"));
e.preventDefault();
});
this.resultsEl.addEventListener("click", (e) => {
const el = (e.target as HTMLElement).closest(".result-line") as HTMLElement | null;
if (el?.dataset.value) this.cb.onCopy(el.dataset.value);
});
}
setText(text: string): void {
this.view.dispatch({
changes: { from: 0, to: this.view.state.doc.length, insert: text },
});
}
getText(): string {
return this.view.state.doc.toString();
}
/** whole sheet with results appended: for Ctrl+Shift+C export */
getSheetWithResults(): string {
const doc = this.view.state.doc;
const lines: string[] = [];
for (let n = 1; n <= doc.lines; n++) {
const res = this.results[n - 1]?.text;
const text = doc.line(n).text;
lines.push(res ? `${text} = ${res}` : text);
}
return lines.join("\n");
}
/** re-evaluate with current engine state (settings/rates changed) */
refresh(): void {
this.evaluate(this.getText());
this.renderResults();
}
focus(): void {
this.view.focus();
}
/** put the caret on line n (1-based) and scroll it into view */
goToLine(n: number): void {
const doc = this.view.state.doc;
const line = doc.line(Math.min(Math.max(n, 1), doc.lines));
this.view.dispatch({
selection: { anchor: line.from, head: line.to },
scrollIntoView: true,
});
this.view.focus();
}
private evaluate(text: string): void {
this.results = this.evaluateDoc(text);
this.view.dispatch({ effects: setResults.of(this.results) });
this.cb.onResults(this.results);
}
private renderResults(): void {
const frag = document.createDocumentFragment();
const doc = this.view.state.doc;
const overlayTop = this.resultsEl.getBoundingClientRect().top;
const docTop = this.view.documentTop;
for (let i = 0; i < Math.min(this.results.length, doc.lines); i++) {
const r = this.results[i];
const isChart = r.value?.kind === "chart";
const isError = !!r.error;
if (!r.text && !isChart && !isError) continue;
const line = doc.line(i + 1);
const block = this.view.lineBlockAt(line.from);
const top = docTop + block.top - overlayTop;
if (top < -40 || top > this.resultsEl.clientHeight + 40) continue;
if (isChart && r.value?.kind === "chart") {
frag.appendChild(buildSparkline(r.value.points.map((p) => p.toNumber()), top));
} else {
const el = document.createElement("div");
el.className = "result-line" + (isError ? " result-error" : "");
el.textContent = isError ? "#ref?" : r.text;
if (!isError) el.dataset.value = r.text!;
if (isError) el.title = r.error!;
el.style.top = `${top}px`;
frag.appendChild(el);
}
}
this.resultsEl.replaceChildren(frag);
}
}