// The design editor: a preset selector, the script text, 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 { type Design, DesignError, compileDesign } from "../design.ts"; import { DEFAULT_PRESET, PRESETS } from "../presets.ts"; const CUSTOM = "custom"; const DEBOUNCE_MS = 400; 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 textarea = document.createElement("textarea"); private status = document.createElement("div"); private summary = document.createElement("div"); private timer: number | null = null; private lastSource = ""; 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.textarea.value = 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.textarea.value = p.source; this.select.value = p.id; this.compileNow(); }); head.append(label, this.select, btnReset); this.textarea.className = "script"; this.textarea.spellcheck = false; this.textarea.wrap = "off"; this.textarea.value = DEFAULT_PRESET.source; this.textarea.addEventListener("input", () => { if (this.select.value !== CUSTOM && this.textarea.value !== 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.textarea.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { e.preventDefault(); this.compileNow(); } else if (e.key === "Tab") { // Insert two spaces rather than leaving the field. e.preventDefault(); const t = this.textarea; const s = t.selectionStart, end = t.selectionEnd; t.value = t.value.slice(0, s) + " " + t.value.slice(end); t.selectionStart = t.selectionEnd = s + 2; t.dispatchEvent(new Event("input")); } }); this.status.className = "editor-status sub"; this.summary.className = "editor-summary"; const hint = document.createElement("p"); hint.className = "sub"; hint.textContent = "The script returns the design: material, bounds, outline, thickness, profile, mount, and losses, in SI units. It is applied as you type (or with Ctrl+Enter). Helpers: inch, mm, circle, ellipse, rectangle, clamp, lerp, smoothstep, bump, dome, bow, noise2d; every field function receives (x, y, r, theta)."; root.append(head, this.textarea, this.status, this.summary, hint); } /** 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.textarea.value; 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.textarea.value = source; this.select.value = presetId && PRESETS.some((p) => p.id === presetId) ? presetId : CUSTOM; this.compileNow(); } get source(): string { return this.textarea.value; } /** 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.textarea.disabled = !enabled; this.select.disabled = !enabled; } }