// The design editor: a preset selector with a "Custom (edited)" entry, a // Revert button, the script in a CodeMirror view with MATLAB (Octave) // highlighting, and a status line. The editor does not compile anything // itself: it hands the source to the host (debounced while typing, at once // on Ctrl/Cmd-Enter and on preset selection), and the host reports the // outcome back through setStatus. import { Compartment, EditorState } from "@codemirror/state"; import { EditorView, drawSelection, highlightActiveLine, highlightActiveLineGutter, keymap, lineNumbers } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { HighlightStyle, StreamLanguage, bracketMatching, syntaxHighlighting } from "@codemirror/language"; import { octave } from "@codemirror/legacy-modes/mode/octave"; import { tags } from "@lezer/highlight"; import { DEFAULT_PRESET, PRESETS, presetById } from "../presets.ts"; export interface EditorCallbacks { /** Called (debounced 400 ms after typing, or at once on Ctrl/Cmd-Enter and on preset selection) with the current source. The host compiles it and reports back through setStatus. */ onSource: (source: string, presetId: string | null) => void; } export type StatusKind = "ok" | "error" | "info"; const CUSTOM = "custom"; const DEBOUNCE_MS = 400; const highlight = HighlightStyle.define([ { tag: tags.comment, color: "#7d8893", fontStyle: "italic" }, { tag: tags.keyword, color: "#8ab4f8" }, { tag: tags.string, color: "#e3b26f" }, { tag: tags.number, color: "#b5cea8" }, { tag: tags.operator, color: "#c8cdd3" }, { tag: tags.variableName, color: "#dde1e6" }, { tag: tags.atom, color: "#c586c0" }, { tag: tags.bracket, color: "#c8cdd3" }, ]); const theme = EditorView.theme( { "&": { backgroundColor: "#0f1113", color: "#dde1e6", border: "1px solid #2b2f35", borderRadius: "4px", fontSize: "12px", height: "360px" }, "&.cm-focused": { outline: "1px solid #3b8ce6" }, ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", lineHeight: "1.4", overflow: "auto" }, ".cm-content": { caretColor: "#f0b429" }, ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#f0b429" }, "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "#2b4a70 !important" }, ".cm-activeLine": { backgroundColor: "#191c20" }, ".cm-gutters": { backgroundColor: "#15171a", color: "#5b636c", border: "none", borderRight: "1px solid #2b2f35" }, ".cm-activeLineGutter": { backgroundColor: "#191c20", color: "#9aa0a8" }, ".cm-matchingBracket": { backgroundColor: "#2b4a70", outline: "none" }, "&.cm-locked": { opacity: "0.65" }, }, { dark: true }, ); export class DesignEditor { private readonly callbacks: EditorCallbacks; private readonly select = document.createElement("select"); private readonly revert = document.createElement("button"); private readonly status = document.createElement("div"); private readonly view: EditorView; private readonly editable = new Compartment(); private readonly readOnly = new Compartment(); private timer: number | null = null; /** Set while the text is replaced programmatically, so the change is not treated as typing. */ private replacing = false; /** The source last handed to the host, so a debounce that changed nothing is skipped. */ private lastSent: string | null = null; /** * Builds the editor inside `container`. The default preset's text is loaded * but onSource is not fired; the host calls selectPreset or setSource to * start (and compiles on its own if it used setSource). */ constructor(container: HTMLElement, callbacks: EditorCallbacks) { this.callbacks = callbacks; container.classList.add("design-editor"); const head = document.createElement("div"); head.className = "editor-head"; const label = document.createElement("label"); label.textContent = "Preset"; for (const p of PRESETS) { const o = document.createElement("option"); o.value = p.id; o.textContent = p.label; this.select.appendChild(o); } const custom = document.createElement("option"); custom.value = CUSTOM; custom.textContent = "Custom (edited)"; this.select.appendChild(custom); this.select.title = "Load a predefined design script"; this.select.addEventListener("change", () => { const id = this.select.value; if (id === CUSTOM) { // Nothing to load: keep the text as it is and stay on the custom entry. return; } this.selectPreset(id); }); this.revert.type = "button"; this.revert.textContent = "Revert"; this.revert.title = "Restore the selected preset's script (the default preset when the script is custom)"; this.revert.addEventListener("click", () => { const id = presetById(this.select.value) ? this.select.value : DEFAULT_PRESET; this.selectPreset(id); }); head.append(label, this.select, this.revert); const wrap = document.createElement("div"); wrap.className = "editor-script"; const initial = presetById(DEFAULT_PRESET) ?? PRESETS[0]; this.view = new EditorView({ parent: wrap, state: EditorState.create({ doc: initial?.source ?? "", extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), drawSelection(), history(), bracketMatching(), StreamLanguage.define(octave), syntaxHighlighting(highlight), theme, this.editable.of(EditorView.editable.of(true)), this.readOnly.of(EditorState.readOnly.of(false)), keymap.of([ { key: "Mod-Enter", run: () => { this.fireNow(); return true; } }, indentWithTab, ...defaultKeymap, ...historyKeymap, ]), EditorView.updateListener.of((u) => { if (!u.docChanged || this.replacing) return; this.syncSelect(); this.schedule(); }), ], }), }); this.select.value = initial?.id ?? CUSTOM; this.status.className = "editor-status"; this.setStatus("", "info"); container.append(head, wrap, this.status); } /** Loads the preset's text, selects it, and fires onSource at once. Unknown ids are ignored. */ selectPreset(id: string): void { const p = presetById(id); if (!p) return; this.replaceText(p.source); this.select.value = p.id; this.fireNow(); } /** Replaces the text without firing onSource. The select shows the matching preset if the text is one, otherwise "Custom (edited)". */ setSource(source: string): void { this.cancel(); this.replaceText(source); this.syncSelect(); } getSource(): string { return this.view.state.doc.toString(); } /** The id of the selected preset, or null when the text is custom. */ presetId(): string | null { return this.select.value === CUSTOM ? null : this.select.value; } /** Writes the status line under the editor; the kind selects the class (errors are tinted red by the host's stylesheet). */ setStatus(text: string, kind: StatusKind): void { this.status.textContent = text; this.status.className = `editor-status editor-status-${kind}`; this.status.hidden = text === ""; } /** Makes the text read-only (and the preset controls inert) while a simulation runs. */ setLocked(locked: boolean): void { if (locked) this.cancel(); this.view.dispatch({ effects: [ this.editable.reconfigure(EditorView.editable.of(!locked)), this.readOnly.reconfigure(EditorState.readOnly.of(locked)), ], }); this.view.dom.classList.toggle("cm-locked", locked); this.select.disabled = locked; this.revert.disabled = locked; } /** Focuses the text. */ focus(): void { this.view.focus(); } private replaceText(source: string): void { if (source === this.getSource()) return; this.replacing = true; try { this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: source } }); } finally { this.replacing = false; } } /** Points the select at the preset whose text matches, preferring the one already selected, else at the custom entry. */ private syncSelect(): void { const src = this.getSource(); const current = presetById(this.select.value); if (current && current.source === src) return; const match = PRESETS.find((p) => p.source === src); this.select.value = match ? match.id : CUSTOM; } private schedule(): void { this.cancel(); this.timer = window.setTimeout(() => { this.timer = null; if (this.getSource() !== this.lastSent) this.fireNow(); }, DEBOUNCE_MS); } private cancel(): void { if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; } } private fireNow(): void { this.cancel(); const src = this.getSource(); this.lastSent = src; this.callbacks.onSource(src, this.presetId()); } }