MATLAB syntax highlighting in the script editor; default exaggeration 1
The script editor is a CodeMirror 6 view with the Octave stream mode,
line numbers, bracket matching, history, and Ctrl+Enter to apply, styled
to match the page. The default vertical exaggeration of the 3D view is
now 1.
6 changed files+211−39
package-lock.jsonmodified+110−0View file
@@ -8,6 +8,12 @@
88 "name": "steelsim41",
99 "version": "0.1.0",
1010 "dependencies": {
11+ "@codemirror/commands": "^6.11.0",
12+ "@codemirror/language": "^6.12.4",
13+ "@codemirror/legacy-modes": "^6.5.4",
14+ "@codemirror/state": "^6.7.4",
15+ "@codemirror/view": "^6.43.11",
16+ "@lezer/highlight": "^1.2.3",
1117 "numbl": "^0.4.21"
1218 },
1319 "devDependencies": {
@@ -27,6 +33,62 @@
2733 "node": ">=6.9.0"
2834 }
2935 },
36+ "node_modules/@codemirror/commands": {
37+ "version": "6.11.0",
38+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz",
39+ "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==",
40+ "license": "MIT",
41+ "dependencies": {
42+ "@codemirror/language": "^6.0.0",
43+ "@codemirror/state": "^6.7.0",
44+ "@codemirror/view": "^6.27.0",
45+ "@lezer/common": "^1.1.0"
46+ }
47+ },
48+ "node_modules/@codemirror/language": {
49+ "version": "6.12.4",
50+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
51+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
52+ "license": "MIT",
53+ "dependencies": {
54+ "@codemirror/state": "^6.0.0",
55+ "@codemirror/view": "^6.23.0",
56+ "@lezer/common": "^1.5.0",
57+ "@lezer/highlight": "^1.0.0",
58+ "@lezer/lr": "^1.0.0",
59+ "style-mod": "^4.0.0"
60+ }
61+ },
62+ "node_modules/@codemirror/legacy-modes": {
63+ "version": "6.5.4",
64+ "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
65+ "integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
66+ "license": "MIT",
67+ "dependencies": {
68+ "@codemirror/language": "^6.0.0"
69+ }
70+ },
71+ "node_modules/@codemirror/state": {
72+ "version": "6.7.4",
73+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.4.tgz",
74+ "integrity": "sha512-QhQIVRY+xHZDxwOSFrJ1eUMapJBUID3IdeAjf7dHO7zBUzSkyooHiodnalz5MG3iHzwixKMlAAyn7244y537EA==",
75+ "license": "MIT",
76+ "dependencies": {
77+ "@marijn/find-cluster-break": "^1.0.0"
78+ }
79+ },
80+ "node_modules/@codemirror/view": {
81+ "version": "6.43.11",
82+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.11.tgz",
83+ "integrity": "sha512-2+esucbQX6wB2JYi1eDvdCPFTA31BN8oSy6xCmk3G6CloV11yOvEjYk+gH7kLrP0MuHG94E8WDhjs5oMiu3+Wg==",
84+ "license": "MIT",
85+ "dependencies": {
86+ "@codemirror/state": "^6.7.0",
87+ "crelt": "^1.0.6",
88+ "style-mod": "^4.1.0",
89+ "w3c-keyname": "^2.2.4"
90+ }
91+ },
3092 "node_modules/@esbuild/aix-ppc64": {
3193 "version": "0.25.12",
3294 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -469,6 +531,36 @@
469531 "node": ">=18"
470532 }
471533 },
534+ "node_modules/@lezer/common": {
535+ "version": "1.5.2",
536+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
537+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
538+ "license": "MIT"
539+ },
540+ "node_modules/@lezer/highlight": {
541+ "version": "1.2.3",
542+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
543+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
544+ "license": "MIT",
545+ "dependencies": {
546+ "@lezer/common": "^1.3.0"
547+ }
548+ },
549+ "node_modules/@lezer/lr": {
550+ "version": "1.4.10",
551+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
552+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
553+ "license": "MIT",
554+ "dependencies": {
555+ "@lezer/common": "^1.0.0"
556+ }
557+ },
558+ "node_modules/@marijn/find-cluster-break": {
559+ "version": "1.0.4",
560+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
561+ "integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
562+ "license": "MIT"
563+ },
472564 "node_modules/@napi-rs/lzma-linux-x64-gnu": {
473565 "version": "1.5.1",
474566 "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
@@ -1041,6 +1133,12 @@
10411133 "url": "https://github.com/sponsors/wooorm"
10421134 }
10431135 },
1136+ "node_modules/crelt": {
1137+ "version": "1.0.7",
1138+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
1139+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
1140+ "license": "MIT"
1141+ },
10441142 "node_modules/csstype": {
10451143 "version": "3.2.3",
10461144 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2710,6 +2808,12 @@
27102808 "url": "https://github.com/sponsors/wooorm"
27112809 }
27122810 },
2811+ "node_modules/style-mod": {
2812+ "version": "4.1.3",
2813+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
2814+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
2815+ "license": "MIT"
2816+ },
27132817 "node_modules/style-to-js": {
27142818 "version": "1.1.21",
27152819 "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
@@ -2982,6 +3086,12 @@
29823086 }
29833087 }
29843088 },
3089+ "node_modules/w3c-keyname": {
3090+ "version": "2.2.8",
3091+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
3092+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
3093+ "license": "MIT"
3094+ },
29853095 "node_modules/zwitch": {
29863096 "version": "2.0.4",
29873097 "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
package.jsonmodified+6−0View file
@@ -21,6 +21,12 @@
2121 "vite": "^6.0.0"
2222 },
2323 "dependencies": {
24+ "@codemirror/commands": "^6.11.0",
25+ "@codemirror/language": "^6.12.4",
26+ "@codemirror/legacy-modes": "^6.5.4",
27+ "@codemirror/state": "^6.7.4",
28+ "@codemirror/view": "^6.43.11",
29+ "@lezer/highlight": "^1.2.3",
2430 "numbl": "^0.4.21"
2531 }
2632 }
src/main.tsmodified+1−1View file
@@ -173,7 +173,7 @@ function buildViewToolbar(): void {
173173 o.value = v; o.textContent = t;
174174 colorSel.appendChild(o);
175175 }
176- exagInput = numInput(4, 0.5);
176+ exagInput = numInput(1, 0.5);
177177 rangeInput = numInput(0, 0.5);
178178 shapeCheck = document.createElement("input");
179179 shapeCheck.type = "checkbox";
src/render/renderer.tsmodified+1−1View file
@@ -18,7 +18,7 @@ export interface Marker {
1818 export class Renderer {
1919 mode: ViewMode = "2d";
2020 /** Vertical exaggeration of the 3D view. */
21- exaggeration = 4;
21+ exaggeration = 1;
2222 /** Color range |w| [m]; 0 means automatic (from the current maximum). */
2323 colorRange = 0;
2424 /** Show the total shape (initial shape plus deviation) rather than the deviation alone, in both views. */
src/style.cssmodified+2−4View file
@@ -60,10 +60,8 @@ button.on { background: var(--accent2); border-color: var(--accent2); color: whi
6060 .editor-head { display: flex; align-items: center; gap: 6px; margin: 4px 0 6px; }
6161 .editor-head label { color: var(--muted); flex: none; }
6262 .editor-head select { flex: 1; }
63-textarea.script {
64- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.4;
65- min-height: 280px; height: 340px; resize: vertical; white-space: pre; overflow: auto; tab-size: 2;
66-}
63+.script { resize: vertical; overflow: hidden; min-height: 200px; height: 340px; }
64+.script .cm-editor { height: 100%; }
6765 .editor-status { margin: 6px 0 4px; min-height: 1.2em; white-space: pre-wrap; }
6866 .editor-summary { margin-bottom: 6px; }
6967 .editor-summary .warn { margin-top: 4px; line-height: 1.35; }
src/ui/editor.tsmodified+91−33View file
@@ -1,7 +1,14 @@
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.
1+// The design editor: a preset selector, the script in a CodeMirror view with
2+// MATLAB (Octave) syntax highlighting, and a status line. The script is
3+// compiled on every edit (debounced); the owner is told about each new
4+// design or error and supplies the summary shown under the script.
45
6+import { EditorState, Compartment } from "@codemirror/state";
7+import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection } from "@codemirror/view";
8+import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
9+import { StreamLanguage, syntaxHighlighting, HighlightStyle, bracketMatching } from "@codemirror/language";
10+import { octave } from "@codemirror/legacy-modes/mode/octave";
11+import { tags } from "@lezer/highlight";
512 import { type Design, DesignError, compileDesign } from "../design.ts";
613 import { DEFAULT_PRESET, PRESETS } from "../presets.ts";
714 import { HELPER_NAMES } from "../mhelpers.ts";
@@ -9,16 +16,48 @@ import { HELPER_NAMES } from "../mhelpers.ts";
916 const CUSTOM = "custom";
1017 const DEBOUNCE_MS = 400;
1118
19+/** Colors of the page's dark theme (see style.css). */
20+const highlight = HighlightStyle.define([
21+ { tag: tags.comment, color: "#7f8a96", fontStyle: "italic" },
22+ { tag: tags.keyword, color: "#8fb8f0" },
23+ { tag: tags.string, color: "#e6b06b" },
24+ { tag: tags.number, color: "#b5cea8" },
25+ { tag: tags.operator, color: "#c9ced4" },
26+ { tag: tags.variableName, color: "#dfe2e6" },
27+ { tag: tags.atom, color: "#c586c0" },
28+ { tag: tags.bracket, color: "#c9ced4" },
29+]);
30+
31+const theme = EditorView.theme(
32+ {
33+ "&": { backgroundColor: "#0f1113", color: "#dfe2e6", border: "1px solid #2c3036", borderRadius: "4px", fontSize: "12px", height: "340px" },
34+ "&.cm-focused": { outline: "1px solid #3b8ce6" },
35+ ".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", lineHeight: "1.4", overflow: "auto" },
36+ ".cm-content": { caretColor: "#f0b429" },
37+ ".cm-cursor, .cm-dropCursor": { borderLeftColor: "#f0b429" },
38+ "&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "#2b4a70 !important" },
39+ ".cm-activeLine": { backgroundColor: "#1a1d21" },
40+ ".cm-gutters": { backgroundColor: "#15171a", color: "#5c646d", border: "none", borderRight: "1px solid #2c3036" },
41+ ".cm-activeLineGutter": { backgroundColor: "#1a1d21", color: "#9aa0a8" },
42+ ".cm-matchingBracket": { backgroundColor: "#2b4a70", outline: "none" },
43+ "&.cm-editor.cm-disabled": { opacity: "0.6" },
44+ },
45+ { dark: true },
46+);
47+
1248 export class DesignEditor {
1349 /** Called with the compiled design, or null and the message when the script fails. */
1450 onDesign: (design: Design | null, error: string | null) => void = () => {};
1551
1652 readonly select = document.createElement("select");
17- readonly textarea = document.createElement("textarea");
53+ readonly view: EditorView;
54+ private editable = new Compartment();
1855 private status = document.createElement("div");
1956 private summary = document.createElement("div");
2057 private timer: number | null = null;
2158 private lastSource = "";
59+ /** Set while the text is being replaced programmatically, so the change is not debounced as an edit. */
60+ private replacing = false;
2261 design: Design | null = null;
2362 error: string | null = null;
2463
@@ -39,7 +78,7 @@ export class DesignEditor {
3978 this.select.addEventListener("change", () => {
4079 const p = PRESETS.find((q) => q.id === this.select.value);
4180 if (!p) return;
42- this.textarea.value = p.source;
81+ this.setText(p.source);
4382 this.compileNow();
4483 });
4584 const btnReset = document.createElement("button");
@@ -47,47 +86,65 @@ export class DesignEditor {
4786 btnReset.title = "Restore the selected preset's script";
4887 btnReset.addEventListener("click", () => {
4988 const p = PRESETS.find((q) => q.id === this.select.value) ?? DEFAULT_PRESET;
50- this.textarea.value = p.source;
89+ this.setText(p.source);
5190 this.select.value = p.id;
5291 this.compileNow();
5392 });
5493 head.append(label, this.select, btnReset);
5594
56- this.textarea.className = "script";
57- this.textarea.spellcheck = false;
58- this.textarea.wrap = "off";
59- this.textarea.value = DEFAULT_PRESET.source;
60- this.textarea.addEventListener("input", () => {
61- if (this.select.value !== CUSTOM && this.textarea.value !== PRESETS.find((q) => q.id === this.select.value)?.source) this.select.value = CUSTOM;
62- if (this.timer !== null) window.clearTimeout(this.timer);
63- this.timer = window.setTimeout(() => this.compileNow(), DEBOUNCE_MS);
64- });
65- this.textarea.addEventListener("keydown", (e) => {
66- if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
67- e.preventDefault();
68- this.compileNow();
69- } else if (e.key === "Tab") {
70- // Insert two spaces rather than leaving the field.
71- e.preventDefault();
72- const t = this.textarea;
73- const s = t.selectionStart, end = t.selectionEnd;
74- t.value = t.value.slice(0, s) + " " + t.value.slice(end);
75- t.selectionStart = t.selectionEnd = s + 2;
76- t.dispatchEvent(new Event("input"));
77- }
95+ const wrap = document.createElement("div");
96+ wrap.className = "script";
97+ this.view = new EditorView({
98+ parent: wrap,
99+ state: EditorState.create({
100+ doc: DEFAULT_PRESET.source,
101+ extensions: [
102+ lineNumbers(),
103+ highlightActiveLineGutter(),
104+ highlightActiveLine(),
105+ drawSelection(),
106+ history(),
107+ bracketMatching(),
108+ StreamLanguage.define(octave),
109+ syntaxHighlighting(highlight),
110+ theme,
111+ this.editable.of(EditorView.editable.of(true)),
112+ keymap.of([
113+ { key: "Mod-Enter", run: () => { this.compileNow(); return true; } },
114+ indentWithTab,
115+ ...defaultKeymap,
116+ ...historyKeymap,
117+ ]),
118+ EditorView.updateListener.of((u) => {
119+ if (!u.docChanged || this.replacing) return;
120+ const src = this.source;
121+ if (this.select.value !== CUSTOM && src !== PRESETS.find((q) => q.id === this.select.value)?.source) this.select.value = CUSTOM;
122+ if (this.timer !== null) window.clearTimeout(this.timer);
123+ this.timer = window.setTimeout(() => this.compileNow(), DEBOUNCE_MS);
124+ }),
125+ ],
126+ }),
78127 });
128+
79129 this.status.className = "editor-status sub";
80130 this.summary.className = "editor-summary";
81131 const hint = document.createElement("p");
82132 hint.className = "sub";
83133 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(", ")}.`;
84- root.append(head, this.textarea, this.status, this.summary, hint);
134+ root.append(head, wrap, this.status, this.summary, hint);
135+ }
136+
137+ /** Replaces the whole text without triggering the debounced compile. */
138+ private setText(source: string): void {
139+ this.replacing = true;
140+ this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: source } });
141+ this.replacing = false;
85142 }
86143
87144 /** Compiles the current text at once (no debounce) and notifies the owner if anything changed. */
88145 compileNow(): void {
89146 if (this.timer !== null) { window.clearTimeout(this.timer); this.timer = null; }
90- const src = this.textarea.value;
147+ const src = this.source;
91148 if (src === this.lastSource) return;
92149 this.lastSource = src;
93150 try {
@@ -103,13 +160,13 @@ export class DesignEditor {
103160
104161 /** Replaces the script (from a preset id or a source string) and compiles it. */
105162 setScript(source: string, presetId: string | null = null): void {
106- this.textarea.value = source;
163+ this.setText(source);
107164 this.select.value = presetId && PRESETS.some((p) => p.id === presetId) ? presetId : CUSTOM;
108165 this.compileNow();
109166 }
110167
111168 get source(): string {
112- return this.textarea.value;
169+ return this.view.state.doc.toString();
113170 }
114171
115172 /** Shows an error (red) or a message, and the summary HTML below it. */
@@ -120,7 +177,8 @@ export class DesignEditor {
120177 }
121178
122179 setEnabled(enabled: boolean): void {
123- this.textarea.disabled = !enabled;
180+ this.view.dispatch({ effects: this.editable.reconfigure(EditorView.editable.of(enabled)) });
181+ this.view.dom.classList.toggle("cm-disabled", !enabled);
124182 this.select.disabled = !enabled;
125183 }
126184 }