1// The design editor: a preset selector, the script text, and a status line.
2// The script is compiled on every edit (debounced); the owner is told about
3// each new design or error and supplies the summary shown under the script.
5import { type Design, DesignError, compileDesign } from "../design.ts";
6import { DEFAULT_PRESET, PRESETS } from "../presets.ts";
8const CUSTOM = "custom";
9const DEBOUNCE_MS = 400;
11export class DesignEditor {
12 /** Called with the compiled design, or null and the message when the script fails. */
13 onDesign: (design: Design | null, error: string | null) => void = () => {};
15 readonly select = document.createElement("select");
16 readonly textarea = document.createElement("textarea");
17 private status = document.createElement("div");
18 private summary = document.createElement("div");
19 private timer: number | null = null;
20 private lastSource = "";
21 design: Design | null = null;
22 error: string | null = null;
24 constructor(root: HTMLElement) {
25 const head = document.createElement("div");
26 head.className = "editor-head";
27 const label = document.createElement("label");
28 label.textContent = "Preset";
29 for (const p of PRESETS) {
30 const o = document.createElement("option");
31 o.value = p.id; o.textContent = p.label;
32 this.select.appendChild(o);
33 }
34 const custom = document.createElement("option");
35 custom.value = CUSTOM; custom.textContent = "Custom (edited)";
36 this.select.appendChild(custom);
37 this.select.value = DEFAULT_PRESET.id;
38 this.select.addEventListener("change", () => {
39 const p = PRESETS.find((q) => q.id === this.select.value);
40 if (!p) return;
41 this.textarea.value = p.source;
42 this.compileNow();
43 });
44 const btnReset = document.createElement("button");
45 btnReset.textContent = "Revert";
46 btnReset.title = "Restore the selected preset's script";
47 btnReset.addEventListener("click", () => {
48 const p = PRESETS.find((q) => q.id === this.select.value) ?? DEFAULT_PRESET;
49 this.textarea.value = p.source;
50 this.select.value = p.id;
51 this.compileNow();
52 });
53 head.append(label, this.select, btnReset);
55 this.textarea.className = "script";
56 this.textarea.spellcheck = false;
57 this.textarea.wrap = "off";
58 this.textarea.value = DEFAULT_PRESET.source;
59 this.textarea.addEventListener("input", () => {
60 if (this.select.value !== CUSTOM && this.textarea.value !== PRESETS.find((q) => q.id === this.select.value)?.source) this.select.value = CUSTOM;
61 if (this.timer !== null) window.clearTimeout(this.timer);
62 this.timer = window.setTimeout(() => this.compileNow(), DEBOUNCE_MS);
63 });
64 this.textarea.addEventListener("keydown", (e) => {
65 if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
66 e.preventDefault();
67 this.compileNow();
68 } else if (e.key === "Tab") {
69 // Insert two spaces rather than leaving the field.
70 e.preventDefault();
71 const t = this.textarea;
72 const s = t.selectionStart, end = t.selectionEnd;
73 t.value = t.value.slice(0, s) + " " + t.value.slice(end);
74 t.selectionStart = t.selectionEnd = s + 2;
75 t.dispatchEvent(new Event("input"));
76 }
77 });
78 this.status.className = "editor-status sub";
79 this.summary.className = "editor-summary";
80 const hint = document.createElement("p");
81 hint.className = "sub";
82 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).";
83 root.append(head, this.textarea, this.status, this.summary, hint);
84 }
86 /** Compiles the current text at once (no debounce) and notifies the owner if anything changed. */
87 compileNow(): void {
88 if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; }
89 const src = this.textarea.value;
90 if (src === this.lastSource) return;
91 this.lastSource = src;
92 try {
93 this.design = compileDesign(src);
94 this.error = null;
95 } catch (e) {
96 this.design = null;
97 this.error = e instanceof DesignError ? e.message : String(e);
98 }
99 this.setStatus(this.error, "");
100 this.onDesign(this.design, this.error);
101 }
103 /** Replaces the script (from a preset id or a source string) and compiles it. */
104 setScript(source: string, presetId: string | null = null): void {
105 this.textarea.value = source;
106 this.select.value = presetId && PRESETS.some((p) => p.id === presetId) ? presetId : CUSTOM;
107 this.compileNow();
108 }
110 get source(): string {
111 return this.textarea.value;
112 }
114 /** Shows an error (red) or a message, and the summary HTML below it. */
115 setStatus(error: string | null, summaryHtml: string): void {
116 this.status.textContent = error ?? "";
117 this.status.className = error ? "editor-status err" : "editor-status sub";
118 this.summary.innerHTML = summaryHtml;
119 }
121 setEnabled(enabled: boolean): void {
122 this.textarea.disabled = !enabled;
123 this.select.disabled = !enabled;
124 }
125}