hyperbook 0.94.0 → 0.95.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -179,11 +179,14 @@
179
179
 
180
180
  .directive-openscad .parameters-body {
181
181
  flex: 1;
182
+ min-height: 0;
182
183
  overflow-y: auto;
183
184
  padding: 12px;
184
- display: flex;
185
- flex-direction: column;
186
- gap: 10px;
185
+ box-sizing: border-box;
186
+ }
187
+
188
+ .directive-openscad .parameters-body > * + * {
189
+ margin-top: 10px;
187
190
  }
188
191
 
189
192
  .directive-openscad .editor-container {
@@ -286,7 +289,13 @@
286
289
  width: 100%;
287
290
  border: 1px solid var(--color-spacer);
288
291
  flex: 1;
289
- margin: 0; /* override code-input.min.css default margin: 8px */
292
+ margin: 0;
293
+ min-height: 0;
294
+ overflow: hidden;
295
+ }
296
+
297
+ .directive-openscad .editor .cm-editor {
298
+ height: 100%;
290
299
  }
291
300
 
292
301
  /* The parameters textarea is always hidden — the form replaces it visually */
@@ -343,6 +352,52 @@
343
352
  text-align: center;
344
353
  }
345
354
 
355
+ .directive-openscad .param-group {
356
+ border: 1px solid var(--color-spacer);
357
+ border-radius: 6px;
358
+ overflow: hidden;
359
+ }
360
+
361
+ .directive-openscad .param-group-summary {
362
+ padding: 6px 12px;
363
+ font-weight: 600;
364
+ cursor: pointer;
365
+ background: var(--color-background, var(--color--background, #fff));
366
+ user-select: none;
367
+ list-style: none;
368
+ display: flex;
369
+ align-items: center;
370
+ gap: 6px;
371
+ }
372
+
373
+ .directive-openscad .param-group-summary::-webkit-details-marker {
374
+ display: none;
375
+ }
376
+
377
+ .directive-openscad .param-group-summary::before {
378
+ content: "▶";
379
+ font-size: 0.65em;
380
+ transition: transform 0.15s ease;
381
+ flex-shrink: 0;
382
+ }
383
+
384
+ .directive-openscad .param-group[open] .param-group-summary::before {
385
+ transform: rotate(90deg);
386
+ }
387
+
388
+ .directive-openscad .param-group-summary:hover {
389
+ background: var(--color-spacer);
390
+ }
391
+
392
+ .directive-openscad .param-group-body {
393
+ padding: 8px 12px;
394
+ border-top: 1px solid var(--color-spacer);
395
+ }
396
+
397
+ .directive-openscad .param-group-body > * + * {
398
+ margin-top: 10px;
399
+ }
400
+
346
401
  .directive-openscad:fullscreen {
347
402
  width: 100vw;
348
403
  height: 100dvh !important;
@@ -204,6 +204,261 @@ const serializeInvocationResults = (result) => {
204
204
  };
205
205
  };
206
206
 
207
+ const getExtractedParameters = (result) => {
208
+ if (result?.error || result?.exitCode !== 0) {
209
+ return [];
210
+ }
211
+ const output = result?.outputs?.[0]?.[1];
212
+ if (!output) return [];
213
+ try {
214
+ const bytes = output instanceof Uint8Array ? output : new Uint8Array(output);
215
+ const json = new TextDecoder().decode(bytes);
216
+ const paramSet = JSON.parse(json);
217
+ if (!Array.isArray(paramSet?.parameters)) return [];
218
+ return paramSet.parameters.filter((p) => !p.name?.startsWith("$"));
219
+ } catch (_) {
220
+ return [];
221
+ }
222
+ };
223
+
224
+ const escapeHtml = (value) =>
225
+ String(value)
226
+ .replace(/&/g, "&")
227
+ .replace(/</g, "&lt;")
228
+ .replace(/>/g, "&gt;")
229
+ .replace(/"/g, "&quot;")
230
+ .replace(/'/g, "&#39;");
231
+
232
+ const toFiniteNumber = (value, fallback = 0) => {
233
+ const num = Number(value);
234
+ return Number.isFinite(num) ? num : fallback;
235
+ };
236
+
237
+ const buildParamFormUi = (codeParams, currentOverrides = {}, id = "model") => {
238
+ if (!Array.isArray(codeParams) || codeParams.length === 0) {
239
+ return { hasParams: false, html: "", values: {} };
240
+ }
241
+
242
+ const values = {};
243
+
244
+ const appendNumberAttrs = (min, max, step) => {
245
+ let attrs = "";
246
+ if (step != null) attrs += ` step="${escapeHtml(String(step))}"`;
247
+ else attrs += ' step="any"';
248
+ if (min != null) attrs += ` min="${escapeHtml(String(min))}"`;
249
+ if (max != null) attrs += ` max="${escapeHtml(String(max))}"`;
250
+ return attrs;
251
+ };
252
+
253
+ const buildParamRow = (param, index) => {
254
+ const name = param?.name;
255
+ if (!name) return null;
256
+ const caption = param?.caption || name;
257
+ const type = param?.type;
258
+ const initial = param?.initial;
259
+ const min = param?.min;
260
+ const max = param?.max;
261
+ const step = param?.step;
262
+ const options = Array.isArray(param?.options) ? param.options : [];
263
+ const current = Object.prototype.hasOwnProperty.call(currentOverrides, name)
264
+ ? currentOverrides[name]
265
+ : initial;
266
+
267
+ const inputId = `openscad-param-${id}-${index}`;
268
+ let controlHtml = "";
269
+
270
+ if (type === "boolean") {
271
+ const value = Boolean(current);
272
+ values[name] = value;
273
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="checkbox" data-param-name="${escapeHtml(name)}" data-param-kind="boolean"${value ? " checked" : ""}>`;
274
+ } else if (options.length > 0) {
275
+ let selectedIndex = options.findIndex((opt) => String(opt?.value) === String(current));
276
+ if (selectedIndex < 0) selectedIndex = 0;
277
+ const selectedValue = options[selectedIndex]?.value ?? null;
278
+ values[name] = selectedValue;
279
+ const optionHtml = options.map((opt, optIndex) => {
280
+ const optLabel = opt?.name || String(opt?.value);
281
+ const optValueJson = JSON.stringify(opt?.value ?? null);
282
+ const selected = optIndex === selectedIndex ? " selected" : "";
283
+ return `<option value="${escapeHtml(String(optIndex))}" data-param-option-value="${escapeHtml(optValueJson)}"${selected}>${escapeHtml(optLabel)}</option>`;
284
+ }).join("");
285
+ controlHtml = `<select id="${escapeHtml(inputId)}" data-param-name="${escapeHtml(name)}" data-param-kind="option">${optionHtml}</select>`;
286
+ } else if (type === "number" && Array.isArray(initial)) {
287
+ const source = Array.isArray(current) ? current : initial;
288
+ const vector = source.map((entry) => toFiniteNumber(entry));
289
+ values[name] = vector;
290
+ const vectorInputs = vector.map((entry, vectorIndex) =>
291
+ `<input id="${escapeHtml(`${inputId}-${vectorIndex}`)}" type="number" value="${escapeHtml(String(entry))}" data-param-name="${escapeHtml(name)}" data-param-kind="vector" data-vector-index="${vectorIndex}"${appendNumberAttrs(min, max, step)}>`
292
+ ).join("");
293
+ controlHtml = `<span class="param-vector">${vectorInputs}</span>`;
294
+ } else if (type === "number") {
295
+ const value = toFiniteNumber(current, toFiniteNumber(initial));
296
+ values[name] = value;
297
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="number" value="${escapeHtml(String(value))}" data-param-name="${escapeHtml(name)}" data-param-kind="number"${appendNumberAttrs(min, max, step)}>`;
298
+ } else {
299
+ const value = current == null ? "" : String(current);
300
+ values[name] = value;
301
+ controlHtml = `<input id="${escapeHtml(inputId)}" type="text" value="${escapeHtml(value)}" data-param-name="${escapeHtml(name)}" data-param-kind="string">`;
302
+ }
303
+
304
+ return `<div class="param-row"><label for="${escapeHtml(inputId)}">${escapeHtml(caption)}</label>${controlHtml}</div>`;
305
+ };
306
+
307
+ // Separate global/ungrouped params from named groups.
308
+ // Parameters with group=null/undefined/"Global" are always shown outside any accordion.
309
+ const globalRows = [];
310
+ const groupMap = new Map(); // preserves insertion order
311
+
312
+ codeParams.forEach((param, index) => {
313
+ const row = buildParamRow(param, index);
314
+ if (!row) return;
315
+ const group = param?.group;
316
+ if (!group || group === "Global") {
317
+ globalRows.push(row);
318
+ } else {
319
+ if (!groupMap.has(group)) groupMap.set(group, []);
320
+ groupMap.get(group).push(row);
321
+ }
322
+ });
323
+
324
+ const parts = [...globalRows];
325
+
326
+ for (const [groupName, groupRows] of groupMap) {
327
+ parts.push(
328
+ `<details class="param-group" open><summary class="param-group-summary">${escapeHtml(groupName)}</summary><div class="param-group-body">${groupRows.join("")}</div></details>`
329
+ );
330
+ }
331
+
332
+ const totalRows = globalRows.length + [...groupMap.values()].reduce((sum, rows) => sum + rows.length, 0);
333
+
334
+ return {
335
+ hasParams: totalRows > 0,
336
+ html: parts.join(""),
337
+ values,
338
+ };
339
+ };
340
+
341
+ const DEFAULT_FACE_COLOR_WORKER = [0xf9 / 255, 0xd7 / 255, 0x2c / 255, 1];
342
+
343
+ // Compute flat (per-triangle) normals for a non-indexed positions array.
344
+ // Every 9 consecutive floats represent one triangle (3 vertices × xyz).
345
+ const computeFlatNormals = (positions) => {
346
+ const normals = new Float32Array(positions.length);
347
+ for (let i = 0; i < positions.length; i += 9) {
348
+ const ax = positions[i], ay = positions[i + 1], az = positions[i + 2];
349
+ const bx = positions[i + 3], by = positions[i + 4], bz = positions[i + 5];
350
+ const cx = positions[i + 6], cy = positions[i + 7], cz = positions[i + 8];
351
+ const ex = bx - ax, ey = by - ay, ez = bz - az;
352
+ const fx = cx - ax, fy = cy - ay, fz = cz - az;
353
+ const nx = ey * fz - ez * fy;
354
+ const ny = ez * fx - ex * fz;
355
+ const nz = ex * fy - ey * fx;
356
+ const len = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
357
+ const nnx = nx / len, nny = ny / len, nnz = nz / len;
358
+ normals[i] = nnx; normals[i + 1] = nny; normals[i + 2] = nnz;
359
+ normals[i + 3] = nnx; normals[i + 4] = nny; normals[i + 5] = nnz;
360
+ normals[i + 6] = nnx; normals[i + 7] = nny; normals[i + 8] = nnz;
361
+ }
362
+ return normals;
363
+ };
364
+
365
+ // Parse an OFF file into flat Float32Array colour buckets so the main thread can
366
+ // construct Three.js geometries without any text-parsing work.
367
+ // Returns { colorBuckets: [{color, positions, normals}], transfer } or null on failure.
368
+ const parseOffToColorBuckets = (offData) => {
369
+ try {
370
+ const text = new TextDecoder().decode(
371
+ offData instanceof Uint8Array ? offData : new Uint8Array(offData),
372
+ );
373
+ const lines = text
374
+ .split("\n")
375
+ .map((l) => l.trim())
376
+ .filter((l) => l.length > 0 && !l.startsWith("#"));
377
+ if (lines.length === 0) return null;
378
+
379
+ let countsLine = "";
380
+ let currentLine = 0;
381
+ if (/^OFF(\s|$)/.test(lines[0])) {
382
+ countsLine = lines[0].substring(3).trim();
383
+ currentLine = 1;
384
+ }
385
+ // Handle standard two-line OFF header: "OFF\nV F E"
386
+ if (!countsLine && currentLine < lines.length) {
387
+ countsLine = lines[currentLine];
388
+ currentLine++;
389
+ }
390
+ if (!countsLine) return null;
391
+
392
+ const countParts = countsLine.split(/\s+/).map(Number);
393
+ const vertexCount = Number.isFinite(countParts[0]) ? Math.floor(countParts[0]) : NaN;
394
+ const faceCount = Number.isFinite(countParts[1]) ? Math.floor(countParts[1]) : NaN;
395
+ if (!Number.isFinite(vertexCount) || !Number.isFinite(faceCount) || vertexCount <= 0 || faceCount <= 0) return null;
396
+ if (currentLine + vertexCount + faceCount > lines.length) return null;
397
+
398
+ const vertices = new Float32Array(vertexCount * 3);
399
+ for (let i = 0; i < vertexCount; i++) {
400
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
401
+ vertices[i * 3] = parts[0] || 0;
402
+ vertices[i * 3 + 1] = parts[1] || 0;
403
+ vertices[i * 3 + 2] = parts[2] || 0;
404
+ }
405
+ currentLine += vertexCount;
406
+
407
+ const colorMap = new Map();
408
+ const buckets = [];
409
+
410
+ for (let i = 0; i < faceCount; i++) {
411
+ const parts = lines[currentLine + i].split(/\s+/).map(Number);
412
+ const numVerts = Number.isFinite(parts[0]) ? Math.floor(parts[0]) : 0;
413
+ const faceVertices = parts.slice(1, numVerts + 1).map(Math.floor);
414
+ if (faceVertices.length < 3) continue;
415
+
416
+ let color = DEFAULT_FACE_COLOR_WORKER;
417
+ if (parts.length >= numVerts + 4) {
418
+ const raw = parts.slice(numVerts + 1, numVerts + 5).filter(Number.isFinite);
419
+ if (raw.length >= 3) {
420
+ const r = raw[0], g = raw[1], b = raw[2];
421
+ const a = raw.length >= 4 ? raw[3] : (Math.max(r, g, b) > 1 ? 255 : 1);
422
+ const div = Math.max(r, g, b, a) > 1 ? 255 : 1;
423
+ color = [r / div, g / div, b / div, a / div];
424
+ }
425
+ }
426
+
427
+ const colorKey = color.join(",");
428
+ let bucket = colorMap.get(colorKey);
429
+ if (!bucket) {
430
+ bucket = { color, positionsList: [] };
431
+ colorMap.set(colorKey, bucket);
432
+ buckets.push(bucket);
433
+ }
434
+
435
+ // Fan triangulation of potentially non-triangular faces
436
+ for (let j = 1; j < faceVertices.length - 1; j++) {
437
+ const i1 = faceVertices[0], i2 = faceVertices[j], i3 = faceVertices[j + 1];
438
+ bucket.positionsList.push(
439
+ vertices[i1 * 3], vertices[i1 * 3 + 1], vertices[i1 * 3 + 2],
440
+ vertices[i2 * 3], vertices[i2 * 3 + 1], vertices[i2 * 3 + 2],
441
+ vertices[i3 * 3], vertices[i3 * 3 + 1], vertices[i3 * 3 + 2],
442
+ );
443
+ }
444
+ }
445
+
446
+ if (buckets.length === 0) return null;
447
+
448
+ const transfer = [];
449
+ const colorBuckets = buckets.map((bucket) => {
450
+ const positions = new Float32Array(bucket.positionsList);
451
+ const normals = computeFlatNormals(positions);
452
+ transfer.push(positions.buffer, normals.buffer);
453
+ return { color: bucket.color, positions, normals };
454
+ });
455
+
456
+ return { colorBuckets, transfer };
457
+ } catch (_) {
458
+ return null;
459
+ }
460
+ };
461
+
207
462
  const runOpenScadInvocation = async ({
208
463
  code,
209
464
  sourcePath,
@@ -284,6 +539,40 @@ self.addEventListener("message", async (event) => {
284
539
  self.postMessage({ requestId, ok: true, result: serialized.result }, serialized.transfer);
285
540
  return;
286
541
  }
542
+ if (type === "buildParamForm") {
543
+ const sourcePath = "/tmp/params_model.scad";
544
+ const outPath = "/tmp/params_out.json";
545
+ result = await runOpenScadInvocation({
546
+ code: `$preview=true;\n${payload?.code || ""}`,
547
+ sourcePath,
548
+ outputPaths: [outPath],
549
+ libraryNames: payload?.libraryNames || [],
550
+ args: [
551
+ sourcePath,
552
+ "-o",
553
+ outPath,
554
+ "--export-format=param",
555
+ ],
556
+ });
557
+
558
+ const codeParams = getExtractedParameters(result);
559
+ const ui = buildParamFormUi(
560
+ codeParams,
561
+ payload?.currentOverrides || {},
562
+ payload?.id || "model",
563
+ );
564
+
565
+ self.postMessage({
566
+ requestId,
567
+ ok: true,
568
+ result: {
569
+ hasParams: ui.hasParams,
570
+ html: ui.html,
571
+ values: ui.values,
572
+ },
573
+ });
574
+ return;
575
+ }
287
576
  if (type === "render") {
288
577
  const format = payload?.format || "stl";
289
578
  const sourcePath = "/tmp/model.scad";
@@ -304,6 +593,27 @@ self.addEventListener("message", async (event) => {
304
593
  ...OPENSCAD_FEATURE_ARGS,
305
594
  ],
306
595
  });
596
+ // For preview OFF renders, parse geometry here in the worker so the main thread
597
+ // never has to do heavy text parsing or Float32Array building.
598
+ if (format === "off" && payload?.isPreview && result.exitCode === 0 && !result.error) {
599
+ const offOutput = result.outputs?.[0]?.[1];
600
+ if (offOutput) {
601
+ const parsed = parseOffToColorBuckets(offOutput);
602
+ if (parsed) {
603
+ self.postMessage({
604
+ requestId,
605
+ ok: true,
606
+ result: {
607
+ exitCode: result.exitCode,
608
+ mergedOutputs: result.mergedOutputs,
609
+ elapsedMillis: result.elapsedMillis,
610
+ parsedGeometry: parsed.colorBuckets,
611
+ },
612
+ }, parsed.transfer);
613
+ return;
614
+ }
615
+ }
616
+ }
307
617
  const serialized = serializeInvocationResults(result);
308
618
  self.postMessage({ requestId, ok: true, result: serialized.result }, serialized.transfer);
309
619
  return;
@@ -7,14 +7,6 @@
7
7
  * @see hyperbook.store
8
8
  */
9
9
  hyperbook.p5 = (function () {
10
- window.codeInput?.registerTemplate(
11
- "p5-highlighted",
12
- codeInput.templates.prism(window.Prism, [
13
- new codeInput.plugins.AutoCloseBrackets(),
14
- new codeInput.plugins.Indent(true, 2),
15
- ]),
16
- );
17
-
18
10
  const wrapSketch = (sketchCode) => {
19
11
  if (sketchCode !== "" && !sketchCode?.includes("setup")) {
20
12
  return `
@@ -128,7 +120,7 @@ hyperbook.p5 = (function () {
128
120
  const container = elem.querySelector(".container");
129
121
  const editorContainer = elem.querySelector(".editor-container");
130
122
  const splitter = elem.querySelector(".splitter");
131
- const editor = elem.getElementsByClassName("editor")[0];
123
+ const editorDiv = elem.getElementsByClassName("editor")[0];
132
124
  /** @type {HTMLButtonElement} */
133
125
  const update = elem.getElementsByClassName("update")[0];
134
126
  const frame = elem.getElementsByTagName("iframe")[0];
@@ -139,6 +131,20 @@ hyperbook.p5 = (function () {
139
131
  const downloadEl = elem.getElementsByClassName("download")[0];
140
132
  const fullscreenEl = elem.getElementsByClassName("fullscreen")[0];
141
133
 
134
+ // Initialize CodeMirror editor (only in editor mode)
135
+ let cm = null;
136
+ if (editorDiv) {
137
+ const initialSource = editorDiv.textContent;
138
+ editorDiv.textContent = "";
139
+ cm = HyperbookCM.create(editorDiv, {
140
+ lang: editorDiv.dataset.lang,
141
+ value: initialSource,
142
+ onChange: (code) => {
143
+ if (id) hyperbook.store.db.p5.put({ id, sketch: code });
144
+ },
145
+ });
146
+ }
147
+
142
148
  setupSplitter(elem, container, editorContainer, splitter);
143
149
 
144
150
  fullscreenEl?.addEventListener("click", async () => {
@@ -159,7 +165,7 @@ hyperbook.p5 = (function () {
159
165
 
160
166
  copyEl?.addEventListener("click", async () => {
161
167
  try {
162
- await navigator.clipboard.writeText(editor.value);
168
+ await navigator.clipboard.writeText(cm?.getValue() ?? "");
163
169
  } catch (error) {
164
170
  console.error(error.message);
165
171
  }
@@ -172,35 +178,30 @@ hyperbook.p5 = (function () {
172
178
 
173
179
  downloadEl?.addEventListener("click", () => {
174
180
  const a = document.createElement("a");
175
- const blob = new Blob([editor.value], { type: "text/plain" });
181
+ const blob = new Blob([cm?.getValue() ?? ""], { type: "text/plain" });
176
182
  a.href = URL.createObjectURL(blob);
177
183
  a.download = `sketch-${id}.js`;
178
184
  a.click();
179
185
  });
180
186
 
181
- editor?.addEventListener("code-input_load", async () => {
182
- if (id) {
183
- const result = await hyperbook.store.db.p5.get(id);
187
+ if (id && cm) {
188
+ hyperbook.store.db.p5.get(id).then((result) => {
184
189
  if (result) {
185
- editor.value = result.sketch;
190
+ cm.setValue(result.sketch);
186
191
  const code = result.sketch;
187
192
  frame.srcdoc = template
188
193
  .replace("###SLOT###", wrapSketch(code))
189
194
  .replaceAll("###ORIGIN###", window.location.origin)
190
195
  .replace(/\u00A0/g, " ");
191
196
  }
192
-
193
- editor.addEventListener("input", () => {
194
- hyperbook.store.db.p5.put({ id, sketch: editor.value });
195
- });
196
- }
197
-
198
- update?.addEventListener("click", () => {
199
- const code = editor.value;
200
- frame.srcdoc = template
201
- .replace("###SLOT###", wrapSketch(code))
202
- .replaceAll("###ORIGIN###", window.location.origin);
203
197
  });
198
+ }
199
+
200
+ update?.addEventListener("click", () => {
201
+ const code = cm?.getValue() ?? "";
202
+ frame.srcdoc = template
203
+ .replace("###SLOT###", wrapSketch(code))
204
+ .replaceAll("###ORIGIN###", window.location.origin);
204
205
  });
205
206
  }
206
207
 
@@ -11,6 +11,18 @@ code-input {
11
11
  margin: 0;
12
12
  }
13
13
 
14
+ .directive-p5 .editor {
15
+ width: 100%;
16
+ border: 1px solid var(--color-spacer);
17
+ flex: 1;
18
+ min-height: 0;
19
+ overflow: hidden;
20
+ }
21
+
22
+ .directive-p5 .editor .cm-editor {
23
+ height: 100%;
24
+ }
25
+
14
26
  .directive-p5 .container {
15
27
  width: 100%;
16
28
  min-height: 120px;
@@ -70,12 +82,6 @@ code-input {
70
82
  opacity: 0.75;
71
83
  }
72
84
 
73
- .directive-p5 .editor {
74
- width: 100%;
75
- border: 1px solid var(--color-spacer);
76
- flex: 1;
77
- }
78
-
79
85
  .directive-p5 .buttons {
80
86
  display: flex;
81
87
  border: 1px solid var(--color-spacer);
@@ -8,14 +8,6 @@
8
8
  * @see hyperbook.i18n
9
9
  */
10
10
  hyperbook.python = (function () {
11
- window.codeInput?.registerTemplate(
12
- "pyide-highlighted",
13
- codeInput.templates.prism(window.Prism, [
14
- new codeInput.plugins.AutoCloseBrackets(),
15
- new codeInput.plugins.Indent(true, 2),
16
- ])
17
- );
18
-
19
11
  const PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js";
20
12
 
21
13
  const loadPyodideScript = () => {
@@ -1899,7 +1891,7 @@ if _pg:
1899
1891
  const test = elem.getElementsByClassName("test")[0];
1900
1892
  const stop = elem.getElementsByClassName("stop")[0];
1901
1893
  const editor = elem.getElementsByClassName("editor")[0];
1902
- const editorTextarea = editor?.querySelector("textarea");
1894
+ const editorCm = editor?._cm;
1903
1895
  const state = getExecutionState(elem.id);
1904
1896
  const hasRuntime = runtimes.has(elem.id);
1905
1897
  const hasInterrupt = interruptBuffers.has(elem.id);
@@ -1917,11 +1909,8 @@ if _pg:
1917
1909
  elem.classList.toggle("locked-by-other", lockedByOther);
1918
1910
 
1919
1911
  if (state.running || lockedByOther) {
1920
- editor?.setAttribute("disabled", "");
1921
1912
  editor?.classList.add("running");
1922
- if (editorTextarea) {
1923
- editorTextarea.readOnly = true;
1924
- }
1913
+ editorCm?.setReadOnly(true);
1925
1914
  if (state.running && state.type === "run") {
1926
1915
  run.textContent = hyperbook.i18n.get("pyide-running");
1927
1916
  run.disabled = true;
@@ -1969,11 +1958,8 @@ if _pg:
1969
1958
  stop.classList.toggle("stopping", state.stopping);
1970
1959
  }
1971
1960
  } else {
1972
- editor?.removeAttribute("disabled");
1973
1961
  editor?.classList.remove("running");
1974
- if (editorTextarea) {
1975
- editorTextarea.readOnly = false;
1976
- }
1962
+ editorCm?.setReadOnly(false);
1977
1963
  run.classList.remove("stopping");
1978
1964
  run.classList.remove("running");
1979
1965
  run.textContent = hyperbook.i18n.get("pyide-run");
@@ -2162,7 +2148,7 @@ if _pg:
2162
2148
  if (elem.getAttribute("data-pyide-initialized") === "true") continue;
2163
2149
  elem.setAttribute("data-pyide-initialized", "true");
2164
2150
 
2165
- const editor = elem.getElementsByClassName("editor")[0];
2151
+ const editorDiv = elem.getElementsByClassName("editor")[0];
2166
2152
  const container = elem.getElementsByClassName("container")[0];
2167
2153
  const editorContainer = elem.getElementsByClassName("editor-container")[0];
2168
2154
  const splitter = elem.getElementsByClassName("splitter")[0];
@@ -2202,11 +2188,20 @@ if _pg:
2202
2188
  };
2203
2189
  let pyideState = { id };
2204
2190
 
2205
- const getEditorValue = () => {
2206
- const textarea = editor?.querySelector("textarea");
2207
- if (textarea) return textarea.value;
2208
- return typeof editor?.textContent === "string" ? editor.textContent : "";
2209
- };
2191
+ // Initialize CodeMirror
2192
+ const initialSource = editorDiv ? editorDiv.textContent : "";
2193
+ if (editorDiv) editorDiv.textContent = "";
2194
+ const cm = editorDiv ? HyperbookCM.create(editorDiv, {
2195
+ lang: editorDiv.dataset.lang || "python",
2196
+ value: initialSource,
2197
+ onChange: (code) => {
2198
+ void persistPyideState({ script: code });
2199
+ },
2200
+ }) : null;
2201
+ // Store CM on the element so updateRunning() can toggle readOnly
2202
+ if (editorDiv && cm) editorDiv._cm = cm;
2203
+
2204
+ const getEditorValue = () => cm?.getValue() ?? "";
2210
2205
 
2211
2206
  pyideState = { ...pyideState, script: getEditorValue() };
2212
2207
 
@@ -2359,7 +2354,7 @@ if _pg:
2359
2354
  if (result) {
2360
2355
  pyideState = { ...pyideState, ...result };
2361
2356
  if (typeof result.script === "string") {
2362
- editor.value = result.script;
2357
+ cm?.setValue(result.script);
2363
2358
  }
2364
2359
  if (
2365
2360
  Number.isFinite(result.splitHorizontal) &&
@@ -2386,10 +2381,7 @@ if _pg:
2386
2381
  }
2387
2382
  };
2388
2383
 
2389
- editor.addEventListener("code-input_load", restoreEditorState);
2390
- if (editor.querySelector("textarea")) {
2391
- void restoreEditorState();
2392
- }
2384
+ void restoreEditorState();
2393
2385
 
2394
2386
  window.addEventListener("resize", () => {
2395
2387
  applyCanvasOutputLayout();
@@ -2397,10 +2389,6 @@ if _pg:
2397
2389
  });
2398
2390
  applyCanvasOutputLayout();
2399
2391
 
2400
- editor.addEventListener("input", () => {
2401
- void persistPyideState({ script: getEditorValue() });
2402
- });
2403
-
2404
2392
  test?.addEventListener("click", async () => {
2405
2393
  showOutput();
2406
2394
  const state = getExecutionState(id);
@@ -12,6 +12,18 @@
12
12
  margin: 0;
13
13
  }
14
14
 
15
+ .directive-pyide .editor {
16
+ width: 100%;
17
+ border: 1px solid var(--color-spacer);
18
+ flex: 1;
19
+ min-height: 0;
20
+ overflow: hidden;
21
+ }
22
+
23
+ .directive-pyide .editor .cm-editor {
24
+ height: 100%;
25
+ }
26
+
15
27
  .directive-pyide .container {
16
28
  width: 100%;
17
29
  overflow: hidden;
@@ -135,12 +147,6 @@
135
147
  opacity: 0.75;
136
148
  }
137
149
 
138
- .directive-pyide .editor {
139
- width: 100%;
140
- border: 1px solid var(--color-spacer);
141
- flex: 1;
142
- }
143
-
144
150
  .directive-pyide .editor.running {
145
151
  pointer-events: none;
146
152
  opacity: 0.7;