// The design editor: a preset selector, the script in a CodeMirror view with // MATLAB (Octave) syntax highlighting, and a status line. The script is // compiled on every edit (debounced); the owner is told about each new // design or error and supplies the summary shown under the script. import { EditorState, Compartment } from "@codemirror/state"; import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from "@codemirror/view"; import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; import { StreamLanguage, syntaxHighlighting, HighlightStyle, bracketMatching } from "@codemirror/language"; import { octave } from "@codemirror/legacy-modes/mode/octave"; import { tags } from "@lezer/highlight"; import { type Design, DesignError, compileDesign } from "../design.ts"; import { DEFAULT_PRESET, PRESETS } from "../presets.ts"; import { HELPER_NAMES } from "../mhelpers.ts"; const CUSTOM = "custom"; const DEBOUNCE_MS = 400; /** Colors of the page's dark theme (see style.css). */ const highlight = HighlightStyle.define([ { tag: tags.comment, color: "#7f8a96", fontStyle: "italic" }, { tag: tags.keyword, color: "#8fb8f0" }, { tag: tags.string, color: "#e6b06b" }, { tag: tags.number, color: "#b5cea8" }, { tag: tags.operator, color: "#c9ced4" }, { tag: tags.variableName, color: "#dfe2e6" }, { tag: tags.atom, color: "#c586c0" }, { tag: tags.bracket, color: "#c9ced4" }, ]); const theme = EditorView.theme( { "&": { backgroundColor: "#0f1113", color: "#dfe2e6", border: "1px solid #2c3036", borderRadius: "4px", fontSize: "12px", height: "340px" }, "&.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: "#1a1d21" }, ".cm-gutters": { backgroundColor: "#15171a", color: "#5c646d", border: "none", borderRight: "1px solid #2c3036" }, ".cm-activeLineGutter": { backgroundColor: "#1a1d21", color: "#9aa0a8" }, ".cm-matchingBracket": { backgroundColor: "#2b4a70", outline: "none" }, "&.cm-editor.cm-disabled": { opacity: "0.6" }, }, { dark: true }, ); export class DesignEditor { /** Called with the compiled design, or null and the message when the script fails. */ onDesign: (design: Design | null, error: string | null) => void = () => {}; readonly select = document.createElement("select"); readonly view: EditorView; private editable = new Compartment(); private status = document.createElement("div"); private summary = document.createElement("div"); private timer: number | null = null; private lastSource = ""; /** Set while the text is being replaced programmatically, so the change is not debounced as an edit. */ private replacing = false; design: Design | null = null; error: string | null = null; constructor(root: HTMLElement) { 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.value = DEFAULT_PRESET.id; this.select.addEventListener("change", () => { const p = PRESETS.find((q) => q.id === this.select.value); if (!p) return; this.setText(p.source); this.compileNow(); }); const btnReset = document.createElement("button"); btnReset.textContent = "Revert"; btnReset.title = "Restore the selected preset's script"; btnReset.addEventListener("click", () => { const p = PRESETS.find((q) => q.id === this.select.value) ?? DEFAULT_PRESET; this.setText(p.source); this.select.value = p.id; this.compileNow(); }); head.append(label, this.select, btnReset); const wrap = document.createElement("div"); wrap.className = "script"; this.view = new EditorView({ parent: wrap, state: EditorState.create({ doc: DEFAULT_PRESET.source, extensions: [ lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), drawSelection(), history(), bracketMatching(), StreamLanguage.define(octave), syntaxHighlighting(highlight), theme, this.editable.of(EditorView.editable.of(true)), keymap.of([ { key: "Mod-Enter", run: () => { this.compileNow(); return true; } }, indentWithTab, ...defaultKeymap, ...historyKeymap, ]), EditorView.updateListener.of((u) => { if (!u.docChanged || this.replacing) return; const src = this.source; if (this.select.value !== CUSTOM && src !== PRESETS.find((q) => q.id === this.select.value)?.source) this.select.value = CUSTOM; if (this.timer !== null) window.clearTimeout(this.timer); this.timer = window.setTimeout(() => this.compileNow(), DEBOUNCE_MS); }), ], }), }); this.status.className = "editor-status sub"; this.summary.className = "editor-summary"; const hint = document.createElement("p"); hint.className = "sub"; hint.textContent = `MATLAB syntax (run by numbl). The script fills in the struct design: material, bounds, inside, thickness, profile, mount, and loss, in SI units; it is applied as you type (or with Ctrl+Enter). The thickness, profile, and inside fields are numbers or handles @(x, y, r, theta) applied to whole grid matrices, so use .*, ./, .^ in them. Helpers: ${HELPER_NAMES.join(", ")}.`; root.append(head, wrap, this.status, this.summary, hint); } /** Replaces the whole text without triggering the debounced compile. */ private setText(source: string): void { this.replacing = true; this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: source } }); this.replacing = false; } /** Compiles the current text at once (no debounce) and notifies the owner if anything changed. */ compileNow(): void { if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; } const src = this.source; if (src === this.lastSource) return; this.lastSource = src; try { this.design = compileDesign(src); this.error = null; } catch (e) { this.design = null; this.error = e instanceof DesignError ? e.message : String(e); } this.setStatus(this.error, ""); this.onDesign(this.design, this.error); } /** Replaces the script (from a preset id or a source string) and compiles it. */ setScript(source: string, presetId: string | null = null): void { this.setText(source); this.select.value = presetId && PRESETS.some((p) => p.id === presetId) ? presetId : CUSTOM; this.compileNow(); } get source(): string { return this.view.state.doc.toString(); } /** Shows an error (red) or a message, and the summary HTML below it. */ setStatus(error: string | null, summaryHtml: string): void { this.status.textContent = error ?? ""; this.status.className = error ? "editor-status err" : "editor-status sub"; this.summary.innerHTML = summaryHtml; } setEnabled(enabled: boolean): void { this.view.dispatch({ effects: this.editable.reconfigure(EditorView.editable.of(enabled)) }); this.view.dom.classList.toggle("cm-disabled", !enabled); this.select.disabled = !enabled; } }