hyperbook 0.90.0 → 0.91.1

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.
@@ -16,154 +16,1057 @@ hyperbook.python = (function () {
16
16
  ])
17
17
  );
18
18
 
19
- const pyodideWorker = new Worker(
20
- `${HYPERBOOK_ASSETS}directive-pyide/webworker.js`
21
- );
19
+ const PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js";
20
+
21
+ const loadPyodideScript = () => {
22
+ if (window.loadPyodide) {
23
+ return Promise.resolve();
24
+ }
25
+
26
+ return new Promise((resolve, reject) => {
27
+ const script = document.createElement("script");
28
+ script.src = PYODIDE_CDN;
29
+ script.onload = () => resolve();
30
+ script.onerror = () => reject(new Error("Failed to load Pyodide"));
31
+ document.head.appendChild(script);
32
+ });
33
+ };
34
+
35
+ const pyodideReadyPromise = (async () => {
36
+ await loadPyodideScript();
37
+ return window.loadPyodide;
38
+ })();
22
39
 
23
- let callback = null;
24
40
  /**
25
- * @type Uint8Array
41
+ * @type {Map<string, any>}
26
42
  */
27
- let interruptBuffer;
43
+ const runtimes = new Map();
28
44
  /**
29
- * @type Int32Array
45
+ * @type {Map<string, Set<string>>}
30
46
  */
31
- let stdinBuffer;
32
- if (window.crossOriginIsolated) {
33
- interruptBuffer = new Uint8Array(new SharedArrayBuffer(1));
34
- pyodideWorker.postMessage({
35
- type: "setInterruptBuffer",
36
- payload: { interruptBuffer },
37
- });
38
- } else {
39
- interruptBuffer = new ArrayBuffer(1);
40
- pyodideWorker.postMessage({
41
- type: "setInterruptBuffer",
42
- payload: { interruptBuffer },
43
- });
44
- }
45
-
46
- const asyncRun = (id, type) => {
47
- if (callback) return;
48
-
49
- interruptBuffer[0] = 0;
50
- return (script, context) => {
51
- return new Promise((onSuccess) => {
52
- callback = onSuccess;
53
- updateRunning(id, type);
54
- pyodideWorker.postMessage({
55
- type: "run",
56
- payload: {
57
- ...context,
58
- python: script,
59
- },
60
- id,
61
- });
47
+ const installedMicropipPackages = new Map();
48
+
49
+ /**
50
+ * @type {Map<string, { running: boolean, stopping: boolean, stopRequested: boolean, type: "run" | "test" | null }>}
51
+ */
52
+ const executionStates = new Map();
53
+ /**
54
+ * @type {Map<string, Int32Array>}
55
+ */
56
+ const interruptBuffers = new Map();
57
+
58
+ const getExecutionState = (id) => {
59
+ if (!executionStates.has(id)) {
60
+ executionStates.set(id, {
61
+ running: false,
62
+ stopping: false,
63
+ stopRequested: false,
64
+ type: null,
62
65
  });
66
+ }
67
+ return executionStates.get(id);
68
+ };
69
+
70
+ const getRuntime = async (id) => {
71
+ if (runtimes.has(id)) {
72
+ return runtimes.get(id);
73
+ }
74
+ const loadPyodide = await pyodideReadyPromise;
75
+ const pyodide = await loadPyodide();
76
+ if (typeof pyodide.registerJsModule === "function") {
77
+ pyodide.registerJsModule("pytamaro_js_ffi", createPytamaroJsFFI());
78
+ }
79
+ if (
80
+ typeof SharedArrayBuffer !== "undefined" &&
81
+ window.crossOriginIsolated &&
82
+ typeof pyodide.setInterruptBuffer === "function"
83
+ ) {
84
+ const interruptBuffer = new Int32Array(new SharedArrayBuffer(4));
85
+ pyodide.setInterruptBuffer(interruptBuffer);
86
+ interruptBuffers.set(id, interruptBuffer);
87
+ }
88
+ runtimes.set(id, pyodide);
89
+ return pyodide;
90
+ };
91
+
92
+ const PYTAMARO_URI_BEGIN = "@@@PYTAMARO_DATA_URI_BEGIN@@@";
93
+ const PYTAMARO_URI_END = "@@@PYTAMARO_DATA_URI_END@@@";
94
+ /**
95
+ * @type {Map<string, string>}
96
+ */
97
+ const pytamaroStdoutCarry = new Map();
98
+
99
+ const getOutput = (id) => {
100
+ return document.getElementById(id)?.getElementsByClassName("output")[0];
101
+ };
102
+
103
+ /**
104
+ * Renders a message that may contain pytamaro data URI image markers into
105
+ * the given container, creating <img> elements for each embedded image.
106
+ */
107
+ const renderOutputSegments = (container, message) => {
108
+ let remaining = String(message);
109
+ while (remaining.length > 0) {
110
+ const beginIdx = remaining.indexOf(PYTAMARO_URI_BEGIN);
111
+ if (beginIdx === -1) {
112
+ container.appendChild(document.createTextNode(remaining));
113
+ break;
114
+ }
115
+ if (beginIdx > 0) {
116
+ container.appendChild(document.createTextNode(remaining.slice(0, beginIdx)));
117
+ }
118
+ const afterBegin = remaining.slice(beginIdx + PYTAMARO_URI_BEGIN.length);
119
+ const endIdx = afterBegin.indexOf(PYTAMARO_URI_END);
120
+ if (endIdx === -1) {
121
+ container.appendChild(document.createTextNode(remaining.slice(beginIdx)));
122
+ break;
123
+ }
124
+ const dataUri = afterBegin.slice(0, endIdx);
125
+ const img = document.createElement("img");
126
+ img.src = dataUri;
127
+ img.style.maxWidth = "100%";
128
+ img.style.display = "block";
129
+ container.appendChild(img);
130
+ remaining = afterBegin.slice(endIdx + PYTAMARO_URI_END.length);
131
+ }
132
+ };
133
+
134
+ const getTrailingPrefixLength = (text, marker) => {
135
+ const max = Math.min(text.length, marker.length - 1);
136
+ for (let len = max; len > 0; len -= 1) {
137
+ if (text.endsWith(marker.slice(0, len))) {
138
+ return len;
139
+ }
140
+ }
141
+ return 0;
142
+ };
143
+
144
+ const appendText = (output, text) => {
145
+ if (!text) return;
146
+ output.appendChild(document.createTextNode(text));
147
+ };
148
+
149
+ const appendOutputLine = (id, message) => {
150
+ const output = getOutput(id);
151
+ if (!output) return;
152
+ const msg = String(message ?? "");
153
+ const carry = pytamaroStdoutCarry.get(id) || "";
154
+ let combined = carry + msg;
155
+ pytamaroStdoutCarry.delete(id);
156
+
157
+ // Fast path for regular stdout chunks.
158
+ if (!combined.includes(PYTAMARO_URI_BEGIN) && carry.length === 0) {
159
+ const partialBeginLength = getTrailingPrefixLength(combined, PYTAMARO_URI_BEGIN);
160
+ if (partialBeginLength > 0) {
161
+ const visible = combined.slice(0, combined.length - partialBeginLength);
162
+ appendText(output, visible);
163
+ pytamaroStdoutCarry.set(id, combined.slice(combined.length - partialBeginLength));
164
+ } else {
165
+ appendText(output, combined);
166
+ }
167
+ return;
168
+ }
169
+
170
+ while (combined.length > 0) {
171
+ const beginIdx = combined.indexOf(PYTAMARO_URI_BEGIN);
172
+ if (beginIdx === -1) {
173
+ const partialBeginLength = getTrailingPrefixLength(combined, PYTAMARO_URI_BEGIN);
174
+ const visible = combined.slice(0, combined.length - partialBeginLength);
175
+ appendText(output, visible);
176
+ if (partialBeginLength > 0) {
177
+ pytamaroStdoutCarry.set(id, combined.slice(combined.length - partialBeginLength));
178
+ }
179
+ break;
180
+ }
181
+
182
+ appendText(output, combined.slice(0, beginIdx));
183
+ const afterBegin = combined.slice(beginIdx + PYTAMARO_URI_BEGIN.length);
184
+ const endIdx = afterBegin.indexOf(PYTAMARO_URI_END);
185
+ if (endIdx === -1) {
186
+ // Keep incomplete marker and continue when the next stdout chunk arrives.
187
+ pytamaroStdoutCarry.set(id, combined.slice(beginIdx));
188
+ break;
189
+ }
190
+
191
+ const dataUri = afterBegin.slice(0, endIdx);
192
+ const img = document.createElement("img");
193
+ img.src = dataUri;
194
+ img.style.maxWidth = "100%";
195
+ img.style.display = "block";
196
+ output.appendChild(img);
197
+ combined = afterBegin.slice(endIdx + PYTAMARO_URI_END.length);
198
+ }
199
+ };
200
+
201
+ const appendOutputErrorLine = (id, message) => {
202
+ const output = getOutput(id);
203
+ if (!output) return;
204
+ const line = document.createElement("span");
205
+ line.classList.add("error-line");
206
+ line.textContent = String(message);
207
+ output.appendChild(line);
208
+ };
209
+
210
+ const appendOutput = (output, message, isError = false) => {
211
+ if (!output || message === undefined || message === null) return;
212
+ if (isError) {
213
+ const line = document.createElement("span");
214
+ line.classList.add("error-line");
215
+ line.textContent = String(message);
216
+ output.appendChild(line);
217
+ return;
218
+ }
219
+ const msg = String(message);
220
+ if (msg.includes(PYTAMARO_URI_BEGIN)) {
221
+ renderOutputSegments(output, msg);
222
+ return;
223
+ }
224
+ output.appendChild(document.createTextNode(msg));
225
+ };
226
+
227
+ const clearPytamaroStdoutCarry = (id) => {
228
+ pytamaroStdoutCarry.delete(id);
229
+ };
230
+
231
+ const updateFullscreenButtonState = (elem, button) => {
232
+ if (!elem || !button) return;
233
+ const isFullscreen = document.fullscreenElement === elem;
234
+ const label = hyperbook.i18n.get("ide-fullscreen-enter");
235
+ button.textContent = "⛶";
236
+ button.title = label;
237
+ button.setAttribute("aria-label", label);
238
+ button.classList.toggle("active", isFullscreen);
239
+ };
240
+
241
+ const toggleFullscreen = async (elem) => {
242
+ if (!elem) return;
243
+ if (document.fullscreenElement === elem) {
244
+ await document.exitFullscreen();
245
+ return;
246
+ }
247
+ await elem.requestFullscreen();
248
+ };
249
+
250
+ const syncFullscreenButtons = () => {
251
+ const elems = document.getElementsByClassName("directive-pyide");
252
+ for (const elem of elems) {
253
+ const fullscreen = elem.getElementsByClassName("fullscreen")[0];
254
+ updateFullscreenButtonState(elem, fullscreen);
255
+ }
256
+ };
257
+
258
+ const releaseKeyboardCapture = (id) => {
259
+ const elem = document.getElementById(id);
260
+ if (!elem) return;
261
+ const canvas = elem.getElementsByClassName("canvas")[0];
262
+ canvas?.blur?.();
263
+ };
264
+
265
+ const resetCanvas = (canvas) => {
266
+ if (!canvas) return;
267
+ const context = canvas.getContext("2d");
268
+ context?.clearRect(0, 0, canvas.width, canvas.height);
269
+ };
270
+
271
+ const createPytamaroJsFFI = () => {
272
+ const floatBuffer = new ArrayBuffer(4);
273
+ const floatView = new DataView(floatBuffer);
274
+
275
+ const unProxy = (obj) => {
276
+ if (typeof obj === "object" && obj !== null && typeof obj.toJs === "function") {
277
+ try {
278
+ return obj.toJs({ pyproxies: [], dict_converter: Object.fromEntries });
279
+ } catch (e) {
280
+ console.error("Error converting PyProxy:", e);
281
+ return obj;
282
+ }
283
+ }
284
+ return obj;
285
+ };
286
+
287
+ const uint32ToFloat = (u32) => {
288
+ floatView.setUint32(0, u32 >>> 0, false);
289
+ return floatView.getFloat32(0, false);
290
+ };
291
+
292
+ const decodePoint = (value, width, height) => {
293
+ const packed = typeof value === "bigint" ? value : BigInt(value || 0);
294
+ const x = uint32ToFloat(Number((packed >> 32n) & 0xffffffffn));
295
+ const y = uint32ToFloat(Number(packed & 0xffffffffn));
296
+ return { x: x * width * 0.5, y: -y * height * 0.5 };
297
+ };
298
+
299
+ const colorToCss = (value) => {
300
+ const argb =
301
+ typeof value === "bigint"
302
+ ? Number(value & 0xffffffffn)
303
+ : Number(value >>> 0);
304
+ const a = ((argb >> 24) & 0xff) / 255;
305
+ const r = (argb >> 16) & 0xff;
306
+ const g = (argb >> 8) & 0xff;
307
+ const b = argb & 0xff;
308
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
309
+ };
310
+
311
+ const rotatePoint = (point, pivot, angleRad) => {
312
+ const dx = point.x - pivot.x;
313
+ const dy = point.y - pivot.y;
314
+ const cos = Math.cos(angleRad);
315
+ const sin = Math.sin(angleRad);
316
+ return {
317
+ x: pivot.x + dx * cos - dy * sin,
318
+ y: pivot.y + dx * sin + dy * cos,
319
+ };
320
+ };
321
+
322
+ const buildGraphic = (specs) => {
323
+ const stack = [];
324
+ const measureCanvas = document.createElement("canvas");
325
+ const measureCtx = measureCanvas.getContext("2d");
326
+
327
+ for (const spec of specs || []) {
328
+ if (!spec || typeof spec !== "object") continue;
329
+ const type = spec.t;
330
+ if (
331
+ type === "Empty" ||
332
+ type === "Rectangle" ||
333
+ type === "Ellipse" ||
334
+ type === "CircularSector" ||
335
+ type === "Triangle" ||
336
+ type === "Text"
337
+ ) {
338
+ let width = 0;
339
+ let height = 0;
340
+ let pin = { x: 0, y: 0 };
341
+ let draw = () => {};
342
+
343
+ if (type === "Rectangle") {
344
+ width = Math.max(0, Number(spec.width) || 0);
345
+ height = Math.max(0, Number(spec.height) || 0);
346
+ const fill = colorToCss(spec.color);
347
+ draw = (ctx) => {
348
+ ctx.fillStyle = fill;
349
+ ctx.fillRect(-width / 2, -height / 2, width, height);
350
+ };
351
+ } else if (type === "Ellipse") {
352
+ width = Math.max(0, Number(spec.width) || 0);
353
+ height = Math.max(0, Number(spec.height) || 0);
354
+ const fill = colorToCss(spec.color);
355
+ draw = (ctx) => {
356
+ ctx.beginPath();
357
+ ctx.ellipse(0, 0, width / 2, height / 2, 0, 0, 2 * Math.PI);
358
+ ctx.fillStyle = fill;
359
+ ctx.fill();
360
+ };
361
+ } else if (type === "CircularSector") {
362
+ const radius = Math.max(0, Number(spec.radius) || 0);
363
+ const angle = Number(spec.angle) || 0;
364
+ width = radius * 2;
365
+ height = radius * 2;
366
+ const fill = colorToCss(spec.color);
367
+ draw = (ctx) => {
368
+ ctx.beginPath();
369
+ ctx.moveTo(0, 0);
370
+ ctx.arc(0, 0, radius, 0, (-angle * Math.PI) / 180, true);
371
+ ctx.closePath();
372
+ ctx.fillStyle = fill;
373
+ ctx.fill();
374
+ };
375
+ } else if (type === "Triangle") {
376
+ const side1 = Math.max(0, Number(spec.side1) || 0);
377
+ const side2 = Math.max(0, Number(spec.side2) || 0);
378
+ const angle = (Number(spec.angle) || 0) * (Math.PI / 180);
379
+ const p1 = { x: 0, y: 0 };
380
+ const p2 = { x: side1, y: 0 };
381
+ const p3 = { x: side2 * Math.cos(angle), y: -side2 * Math.sin(angle) };
382
+ const centroid = {
383
+ x: (p1.x + p2.x + p3.x) / 3,
384
+ y: (p1.y + p2.y + p3.y) / 3,
385
+ };
386
+ const points = [p1, p2, p3].map((p) => ({
387
+ x: p.x - centroid.x,
388
+ y: p.y - centroid.y,
389
+ }));
390
+ const xs = points.map((p) => p.x);
391
+ const ys = points.map((p) => p.y);
392
+ width = Math.max(...xs) - Math.min(...xs);
393
+ height = Math.max(...ys) - Math.min(...ys);
394
+ const fill = colorToCss(spec.color);
395
+ draw = (ctx) => {
396
+ ctx.beginPath();
397
+ ctx.moveTo(points[0].x, points[0].y);
398
+ ctx.lineTo(points[1].x, points[1].y);
399
+ ctx.lineTo(points[2].x, points[2].y);
400
+ ctx.closePath();
401
+ ctx.fillStyle = fill;
402
+ ctx.fill();
403
+ };
404
+ } else if (type === "Text") {
405
+ const text = String(spec.text || "");
406
+ const fontName = String(spec.font_name || "sans-serif");
407
+ const textSize = Math.max(1, Number(spec.text_size) || 1);
408
+ const fill = colorToCss(spec.color);
409
+ measureCtx.font = `${textSize}px ${fontName}`;
410
+ const metrics = measureCtx.measureText(text);
411
+ width = Math.max(0, metrics.width || 0);
412
+ const ascent = metrics.actualBoundingBoxAscent || textSize * 0.8;
413
+ const descent = metrics.actualBoundingBoxDescent || textSize * 0.2;
414
+ height = ascent + descent;
415
+ pin = { x: -width / 2, y: (ascent - descent) / 2 };
416
+ draw = (ctx) => {
417
+ ctx.fillStyle = fill;
418
+ ctx.font = `${textSize}px ${fontName}`;
419
+ ctx.textAlign = "left";
420
+ ctx.textBaseline = "alphabetic";
421
+ const centerY = (descent - ascent) / 2;
422
+ ctx.fillText(text, -width / 2, -centerY);
423
+ };
424
+ }
425
+
426
+ stack.push({ width, height, pin, draw });
427
+ continue;
428
+ }
429
+
430
+ if (type === "Pin") {
431
+ const child = stack.pop();
432
+ if (!child) continue;
433
+ const pin = decodePoint(spec.pin, child.width, child.height);
434
+ stack.push({ ...child, pin });
435
+ continue;
436
+ }
437
+
438
+ if (type === "Rotate") {
439
+ const child = stack.pop();
440
+ if (!child) continue;
441
+ const angleDeg = Number(spec.angle) || 0;
442
+ const angleRad = (-angleDeg * Math.PI) / 180;
443
+ const corners = [
444
+ { x: -child.width / 2, y: -child.height / 2 },
445
+ { x: child.width / 2, y: -child.height / 2 },
446
+ { x: child.width / 2, y: child.height / 2 },
447
+ { x: -child.width / 2, y: child.height / 2 },
448
+ ].map((p) => rotatePoint(p, child.pin, angleRad));
449
+ const minX = Math.min(...corners.map((p) => p.x));
450
+ const maxX = Math.max(...corners.map((p) => p.x));
451
+ const minY = Math.min(...corners.map((p) => p.y));
452
+ const maxY = Math.max(...corners.map((p) => p.y));
453
+ const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
454
+ const offset = { x: -center.x, y: -center.y };
455
+ const pin = { x: child.pin.x + offset.x, y: child.pin.y + offset.y };
456
+
457
+ stack.push({
458
+ width: maxX - minX,
459
+ height: maxY - minY,
460
+ pin,
461
+ draw: (ctx) => {
462
+ ctx.save();
463
+ ctx.translate(offset.x, offset.y);
464
+ ctx.translate(child.pin.x, child.pin.y);
465
+ ctx.rotate(angleRad);
466
+ ctx.translate(-child.pin.x, -child.pin.y);
467
+ child.draw(ctx);
468
+ ctx.restore();
469
+ },
470
+ });
471
+ continue;
472
+ }
473
+
474
+ if (type === "Compose") {
475
+ const bg = stack.pop();
476
+ const fg = stack.pop();
477
+ if (!fg || !bg) continue;
478
+ const fgPin = spec.fg_pin
479
+ ? decodePoint(spec.fg_pin, fg.width, fg.height)
480
+ : fg.pin;
481
+ const bgPin = spec.bg_pin
482
+ ? decodePoint(spec.bg_pin, bg.width, bg.height)
483
+ : bg.pin;
484
+
485
+ const bgCenter = { x: 0, y: 0 };
486
+ const fgCenter = { x: bgPin.x - fgPin.x, y: bgPin.y - fgPin.y };
487
+ const minX = Math.min(
488
+ fgCenter.x - fg.width / 2,
489
+ bgCenter.x - bg.width / 2,
490
+ );
491
+ const maxX = Math.max(
492
+ fgCenter.x + fg.width / 2,
493
+ bgCenter.x + bg.width / 2,
494
+ );
495
+ const minY = Math.min(
496
+ fgCenter.y - fg.height / 2,
497
+ bgCenter.y - bg.height / 2,
498
+ );
499
+ const maxY = Math.max(
500
+ fgCenter.y + fg.height / 2,
501
+ bgCenter.y + bg.height / 2,
502
+ );
503
+
504
+ const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
505
+ const fgOffset = {
506
+ x: fgCenter.x - center.x,
507
+ y: fgCenter.y - center.y,
508
+ };
509
+ const bgOffset = {
510
+ x: bgCenter.x - center.x,
511
+ y: bgCenter.y - center.y,
512
+ };
513
+ const pin = spec.pin
514
+ ? decodePoint(spec.pin, maxX - minX, maxY - minY)
515
+ : { x: bgPin.x - center.x, y: bgPin.y - center.y };
516
+
517
+ stack.push({
518
+ width: maxX - minX,
519
+ height: maxY - minY,
520
+ pin,
521
+ draw: (ctx) => {
522
+ ctx.save();
523
+ ctx.translate(bgOffset.x, bgOffset.y);
524
+ bg.draw(ctx);
525
+ ctx.restore();
526
+ ctx.save();
527
+ ctx.translate(fgOffset.x, fgOffset.y);
528
+ fg.draw(ctx);
529
+ ctx.restore();
530
+ },
531
+ });
532
+ }
533
+ }
534
+
535
+ return (
536
+ stack[stack.length - 1] || {
537
+ width: 0,
538
+ height: 0,
539
+ pin: { x: 0, y: 0 },
540
+ draw: () => {},
541
+ }
542
+ );
543
+ };
544
+
545
+ return {
546
+ js_graphic_size: (specs) => {
547
+ try {
548
+ const unproxiedSpecs = unProxy(specs);
549
+ const graphic = buildGraphic(unproxiedSpecs);
550
+ return { width: graphic.width, height: graphic.height };
551
+ } catch (e) {
552
+ console.error("js_graphic_size error:", e);
553
+ throw e;
554
+ }
555
+ },
556
+ js_render_graphic: (specs, scalingFactor, debug) => {
557
+ try {
558
+ const unproxiedSpecs = unProxy(specs);
559
+ const graphic = buildGraphic(unproxiedSpecs);
560
+ const width = Math.max(1, Math.ceil(graphic.width));
561
+ const height = Math.max(1, Math.ceil(graphic.height));
562
+ const scale = Math.max(1, Number(scalingFactor) || 1);
563
+ const canvas = document.createElement("canvas");
564
+ canvas.width = Math.max(1, Math.ceil(width * scale));
565
+ canvas.height = Math.max(1, Math.ceil(height * scale));
566
+ const ctx = canvas.getContext("2d");
567
+ ctx.scale(scale, scale);
568
+ ctx.translate(width / 2, height / 2);
569
+ graphic.draw(ctx);
570
+
571
+ if (debug) {
572
+ ctx.strokeStyle = "red";
573
+ ctx.lineWidth = 1 / scale;
574
+ ctx.strokeRect(-width / 2, -height / 2, width, height);
575
+ ctx.strokeStyle = "rgba(255, 255, 0, 0.8)";
576
+ ctx.beginPath();
577
+ ctx.moveTo(graphic.pin.x - 8, graphic.pin.y);
578
+ ctx.lineTo(graphic.pin.x + 8, graphic.pin.y);
579
+ ctx.moveTo(graphic.pin.x, graphic.pin.y - 8);
580
+ ctx.lineTo(graphic.pin.x, graphic.pin.y + 8);
581
+ ctx.stroke();
582
+ }
583
+
584
+ return canvas.toDataURL("image/png");
585
+ } catch (e) {
586
+ console.error("js_render_graphic error:", e);
587
+ throw e;
588
+ }
589
+ },
590
+ js_save: (filename, content) => {
591
+ const link = document.createElement("a");
592
+ link.href = String(content || "");
593
+ link.download = String(filename || "graphic.png");
594
+ link.click();
595
+ },
63
596
  };
64
597
  };
65
598
 
66
- function interruptExecution() {
67
- // 2 stands for SIGINT.
68
- interruptBuffer[0] = 2;
69
- }
599
+ const ensureMicropipPackages = async (id, pyodide, packages = []) => {
600
+ if (packages.length === 0) return;
601
+ if (!installedMicropipPackages.has(id)) {
602
+ installedMicropipPackages.set(id, new Set());
603
+ }
604
+ const installed = installedMicropipPackages.get(id);
605
+ const toInstall = packages.filter((pkg) => !installed.has(pkg));
606
+ if (toInstall.length === 0) return;
70
607
 
71
- function reload() {
72
- window.location.reload();
73
- }
608
+ await pyodide.loadPackage("micropip");
609
+ const micropip = pyodide.pyimport("micropip");
610
+ try {
611
+ for (const pkg of toInstall) {
612
+ await micropip.install(pkg);
613
+ installed.add(pkg);
614
+ }
615
+ } finally {
616
+ micropip?.destroy?.();
617
+ }
618
+ };
74
619
 
75
- const updateRunning = (id, type) => {
620
+ const executeScript = async (id, script, context = {}, packages = []) => {
621
+ const filename = "<exec>";
622
+ try {
623
+ const pyodide = await getRuntime(id);
624
+ const { canvas, ...globalsContext } = context;
625
+ const decoder = new TextDecoder("utf-8");
626
+
627
+ if (canvas) {
628
+ try {
629
+ resetCanvas(canvas);
630
+ pyodide.canvas.setCanvas2D(canvas);
631
+ } catch (error) {
632
+ appendOutputErrorLine(id, `Canvas setup failed: ${error.message}`);
633
+ }
634
+ }
635
+
636
+ pyodide.setStdin({
637
+ stdin: () => {
638
+ const value = window.prompt(hyperbook.i18n.get("pyide-input-prompt"));
639
+ if (value === null) {
640
+ return "";
641
+ }
642
+ return value;
643
+ },
644
+ });
645
+ pyodide.setStdout({
646
+ write: (msg) => {
647
+ const text = typeof msg === "string" ? msg : decoder.decode(msg);
648
+ appendOutputLine(id, text);
649
+ return msg?.length ?? text.length;
650
+ },
651
+ });
652
+ pyodide.setStderr({
653
+ write: (msg) => {
654
+ const text = typeof msg === "string" ? msg : decoder.decode(msg);
655
+ appendOutputErrorLine(id, text);
656
+ return msg?.length ?? text.length;
657
+ },
658
+ });
659
+
660
+ await ensureMicropipPackages(id, pyodide, packages);
661
+ await pyodide.loadPackagesFromImports(script);
662
+ const dict = pyodide.globals.get("dict");
663
+ const globals = dict();
664
+ try {
665
+ for (const [key, value] of Object.entries(globalsContext)) {
666
+ globals.set(key, value);
667
+ }
668
+ const results = await pyodide.runPythonAsync(script, {
669
+ globals,
670
+ locals: globals,
671
+ filename,
672
+ });
673
+ return { results };
674
+ } finally {
675
+ globals.destroy();
676
+ dict.destroy();
677
+ if (canvas) {
678
+ try {
679
+ await pyodide.runPythonAsync(
680
+ `import sys as _sys
681
+ _pg = _sys.modules.get('pygame')
682
+ if _pg:
683
+ try:
684
+ _pg.quit()
685
+ except Exception:
686
+ pass`,
687
+ { filename: "<cleanup>" },
688
+ );
689
+ } catch (e) {
690
+ console.warn("pygame cleanup failed:", e);
691
+ }
692
+ }
693
+ }
694
+ } catch (error) {
695
+ let message = error.message;
696
+ if (message.startsWith("Traceback")) {
697
+ const lines = message?.split("\n") || [];
698
+ const i = lines.findIndex((line) => line.includes(filename));
699
+ message = lines[0] + "\n" + lines.slice(i).join("\n");
700
+ }
701
+ return { error: message };
702
+ }
703
+ };
704
+
705
+ const requestStop = (id) => {
706
+ const state = getExecutionState(id);
707
+ const hasRuntime = runtimes.has(id);
708
+ if ((!state.running && !hasRuntime) || state.stopRequested) return;
709
+ state.stopRequested = true;
710
+ state.stopping = true;
711
+ const interruptBuffer = interruptBuffers.get(id);
712
+ if (interruptBuffer) {
713
+ interruptBuffer[0] = 2;
714
+ appendOutputLine(id, "Stop requested. Interrupting execution...");
715
+ } else {
716
+ appendOutputLine(id, hyperbook.i18n.get("pyide-stop-reloading"));
717
+ }
718
+ releaseKeyboardCapture(id);
719
+ updateRunning();
720
+ if (!interruptBuffer) {
721
+ window.setTimeout(() => {
722
+ window.location.reload();
723
+ }, 50);
724
+ }
725
+ };
726
+
727
+ const handleStopClick = (event) => {
728
+ const elem = event.currentTarget.closest(".directive-pyide");
729
+ if (!elem?.id) return;
730
+ requestStop(elem.id);
731
+ };
732
+
733
+ const getRunningInstanceId = () => {
734
+ const elems = document.getElementsByClassName("directive-pyide");
735
+ for (const elem of elems) {
736
+ if (getExecutionState(elem.id).running) {
737
+ return elem.id;
738
+ }
739
+ }
740
+ return null;
741
+ };
742
+
743
+ const updateRunning = () => {
744
+ const runningInstanceId = getRunningInstanceId();
76
745
  const elems = document.getElementsByClassName("directive-pyide");
77
746
  for (let elem of elems) {
78
747
  const run = elem.getElementsByClassName("run")[0];
79
748
  const test = elem.getElementsByClassName("test")[0];
80
- if (callback) {
81
- if (elem.id === id && type === "run") {
82
- if (window.crossOriginIsolated) {
83
- run.textContent = hyperbook.i18n.get("pyide-running-click-to-stop");
84
- run.addEventListener("click", interruptExecution);
85
- } else {
86
- run.textContent = hyperbook.i18n.get("pyide-running-refresh-to-stop");
749
+ const stop = elem.getElementsByClassName("stop")[0];
750
+ const editor = elem.getElementsByClassName("editor")[0];
751
+ const editorTextarea = editor?.querySelector("textarea");
752
+ const state = getExecutionState(elem.id);
753
+ const hasRuntime = runtimes.has(elem.id);
754
+ const hasInterrupt = interruptBuffers.has(elem.id);
755
+ const lockedByOther =
756
+ runningInstanceId !== null &&
757
+ runningInstanceId !== elem.id &&
758
+ !state.running;
87
759
 
88
- run.addEventListener("click", reload);
89
- }
90
- } else if (test && elem.id === id && type === "test") {
91
- if (window.crossOriginIsolated) {
92
- test.textContent = hyperbook.i18n.get("pyide-testing-click-to-stop");
93
- test.addEventListener("click", interruptExecution);
94
- } else {
95
- test.textContent = hyperbook.i18n.get("pyide-testing-refresh-to-stop");
96
- test.addEventListener("click", reload);
760
+ stop?.removeEventListener("click", handleStopClick);
761
+ run.classList.remove("stopping");
762
+ run.classList.remove("locked");
763
+ test?.classList.remove("stopping");
764
+ test?.classList.remove("locked");
765
+ stop?.classList.remove("stopping");
766
+ elem.classList.toggle("locked-by-other", lockedByOther);
767
+
768
+ if (state.running || lockedByOther) {
769
+ editor?.setAttribute("disabled", "");
770
+ editor?.classList.add("running");
771
+ if (editorTextarea) {
772
+ editorTextarea.readOnly = true;
773
+ }
774
+ if (state.running && state.type === "run") {
775
+ run.textContent = hyperbook.i18n.get("pyide-running");
776
+ run.disabled = true;
777
+ run.classList.add("running");
778
+ if (test) {
779
+ test.classList.add("running");
780
+ test.disabled = true;
97
781
  }
782
+ } else if (state.running && state.type === "test" && test) {
783
+ test.textContent = hyperbook.i18n.get("pyide-testing");
784
+ test.disabled = true;
785
+ test.classList.add("running");
786
+ run.classList.add("running");
787
+ run.disabled = true;
98
788
  } else {
789
+ const lockLabel = lockedByOther
790
+ ? "pyide-locked-other-instance-running"
791
+ : "pyide-run";
792
+ run.textContent = hyperbook.i18n.get(lockLabel);
99
793
  run.classList.add("running");
794
+ run.classList.toggle("locked", lockedByOther);
100
795
  run.disabled = true;
101
796
  if (test) {
797
+ test.textContent = hyperbook.i18n.get(
798
+ lockedByOther ? "pyide-locked-other-instance-running" : "pyide-test",
799
+ );
800
+ test.classList.toggle("locked", lockedByOther);
102
801
  test.classList.add("running");
103
802
  test.disabled = true;
104
803
  }
105
804
  }
805
+
806
+ if (stop) {
807
+ const stopLabel = hasInterrupt ? "pyide-stop" : "pyide-stop-refresh";
808
+ if (state.running) {
809
+ stop.textContent = state.stopping
810
+ ? hyperbook.i18n.get("pyide-stopping")
811
+ : hyperbook.i18n.get(stopLabel);
812
+ stop.disabled = false;
813
+ stop.addEventListener("click", handleStopClick);
814
+ } else {
815
+ stop.textContent = hyperbook.i18n.get(stopLabel);
816
+ stop.disabled = true;
817
+ }
818
+ stop.classList.toggle("stopping", state.stopping);
819
+ }
106
820
  } else {
821
+ editor?.removeAttribute("disabled");
822
+ editor?.classList.remove("running");
823
+ if (editorTextarea) {
824
+ editorTextarea.readOnly = false;
825
+ }
826
+ run.classList.remove("stopping");
107
827
  run.classList.remove("running");
108
828
  run.textContent = hyperbook.i18n.get("pyide-run");
109
829
  run.disabled = false;
110
- run.removeEventListener("click", interruptExecution);
111
- run.removeEventListener("click", reload);
112
830
  if (test) {
831
+ test.classList.remove("stopping");
113
832
  test.classList.remove("running");
114
833
  test.textContent = hyperbook.i18n.get("pyide-test");
115
834
  test.disabled = false;
116
- test.removeEventListener("click", interruptExecution);
117
- test.removeEventListener("click", reload);
835
+ }
836
+ if (stop) {
837
+ stop.classList.remove("stopping");
838
+ stop.classList.remove("running");
839
+ stop.textContent = hyperbook.i18n.get(
840
+ hasInterrupt ? "pyide-stop" : "pyide-stop-refresh",
841
+ );
842
+ stop.disabled = true;
118
843
  }
119
844
  }
120
845
  }
121
846
  };
122
847
 
123
- pyodideWorker.onmessage = (event) => {
124
- const { id, type, payload } = event.data;
125
- switch (type) {
126
- case "stdout": {
127
- const output = document
128
- .getElementById(id)
129
- .getElementsByClassName("output")[0];
130
- output.appendChild(document.createTextNode(payload + "\n"));
131
- break;
132
- }
133
- case "error": {
134
- const onSuccess = callback;
135
- onSuccess({ error: payload });
136
- break;
848
+ const setupSplitter = (
849
+ elem,
850
+ container,
851
+ editorContainer,
852
+ splitter,
853
+ onSplitChanged,
854
+ ) => {
855
+ if (!container || !editorContainer || !splitter) return;
856
+
857
+ const minPanelSize = 120;
858
+
859
+ const getIsHorizontal = () =>
860
+ getComputedStyle(elem).flexDirection.startsWith("row");
861
+
862
+ const applySplitSize = (rawSize, isHorizontal) => {
863
+ const total = isHorizontal ? elem.clientWidth : elem.clientHeight;
864
+ const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight;
865
+ const maxSize = Math.max(
866
+ minPanelSize,
867
+ total - splitterSize - minPanelSize
868
+ );
869
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
870
+ container.style.flex = `0 0 ${clamped}px`;
871
+ return clamped;
872
+ };
873
+
874
+ const applyStoredSplitSize = () => {
875
+ const isHorizontal = getIsHorizontal();
876
+ elem.classList.toggle("split-horizontal", isHorizontal);
877
+ elem.classList.toggle("split-vertical", !isHorizontal);
878
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
879
+ const rawStored = Number(elem.dataset[key]);
880
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
881
+ container.style.flex = "";
882
+ return;
137
883
  }
138
- case "success": {
139
- const onSuccess = callback;
140
- onSuccess({ results: payload });
141
- break;
884
+ applySplitSize(rawStored, isHorizontal);
885
+ };
886
+
887
+ applyStoredSplitSize();
888
+
889
+ splitter.addEventListener("pointerdown", (event) => {
890
+ event.preventDefault();
891
+ splitter.setPointerCapture(event.pointerId);
892
+
893
+ const isHorizontal = getIsHorizontal();
894
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
895
+ const startPointer = isHorizontal ? event.clientX : event.clientY;
896
+ const startSize = isHorizontal
897
+ ? container.getBoundingClientRect().width
898
+ : container.getBoundingClientRect().height;
899
+
900
+ elem.classList.add("resizing");
901
+
902
+ const onPointerMove = (moveEvent) => {
903
+ const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
904
+ const delta = pointer - startPointer;
905
+ const size = applySplitSize(startSize + delta, isHorizontal);
906
+ elem.dataset[key] = String(Math.round(size));
907
+ };
908
+
909
+ const onPointerUp = () => {
910
+ elem.classList.remove("resizing");
911
+ splitter.removeEventListener("pointermove", onPointerMove);
912
+ splitter.removeEventListener("pointerup", onPointerUp);
913
+ splitter.removeEventListener("pointercancel", onPointerUp);
914
+ const splitHorizontal = Number(elem.dataset.splitHorizontal);
915
+ const splitVertical = Number(elem.dataset.splitVertical);
916
+ onSplitChanged?.({
917
+ ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0
918
+ ? { splitHorizontal: Math.round(splitHorizontal) }
919
+ : {}),
920
+ ...(Number.isFinite(splitVertical) && splitVertical > 0
921
+ ? { splitVertical: Math.round(splitVertical) }
922
+ : {}),
923
+ });
924
+ };
925
+
926
+ splitter.addEventListener("pointermove", onPointerMove);
927
+ splitter.addEventListener("pointerup", onPointerUp);
928
+ splitter.addEventListener("pointercancel", onPointerUp);
929
+ });
930
+
931
+ window.addEventListener("resize", applyStoredSplitSize);
932
+ return applyStoredSplitSize;
933
+ };
934
+
935
+ const setupCanvasOutputSplitter = (
936
+ elem,
937
+ container,
938
+ canvasWrapper,
939
+ output,
940
+ splitter,
941
+ onSplitChanged,
942
+ ) => {
943
+ if (!elem || !container || !canvasWrapper || !output || !splitter) return;
944
+
945
+ const minPanelSize = 80;
946
+
947
+ const getAvailableHeight = () => {
948
+ const tabs = container.querySelector(".buttons");
949
+ const tabsHeight = tabs && tabs.offsetParent !== null ? tabs.offsetHeight : 0;
950
+ return container.clientHeight - tabsHeight - splitter.offsetHeight;
951
+ };
952
+
953
+ const applySplitSize = (rawSize) => {
954
+ const total = getAvailableHeight();
955
+ const maxSize = Math.max(minPanelSize, total - minPanelSize);
956
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
957
+ canvasWrapper.style.flex = `0 0 ${clamped}px`;
958
+ output.style.flex = "1 1 0";
959
+ return clamped;
960
+ };
961
+
962
+ const applyStoredSplitSize = () => {
963
+ const rawStored = Number(elem.dataset.splitCanvasOutput);
964
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
965
+ canvasWrapper.style.flex = "1 1 0";
966
+ output.style.flex = "1 1 0";
967
+ return;
142
968
  }
143
- }
969
+ applySplitSize(rawStored);
970
+ };
971
+
972
+ splitter.addEventListener("pointerdown", (event) => {
973
+ event.preventDefault();
974
+ splitter.setPointerCapture(event.pointerId);
975
+
976
+ const startPointer = event.clientY;
977
+ const startSize = canvasWrapper.getBoundingClientRect().height;
978
+
979
+ elem.classList.add("resizing");
980
+
981
+ const onPointerMove = (moveEvent) => {
982
+ const delta = moveEvent.clientY - startPointer;
983
+ const size = applySplitSize(startSize + delta);
984
+ elem.dataset.splitCanvasOutput = String(Math.round(size));
985
+ };
986
+
987
+ const onPointerUp = () => {
988
+ elem.classList.remove("resizing");
989
+ splitter.removeEventListener("pointermove", onPointerMove);
990
+ splitter.removeEventListener("pointerup", onPointerUp);
991
+ splitter.removeEventListener("pointercancel", onPointerUp);
992
+ const splitCanvasOutput = Number(elem.dataset.splitCanvasOutput);
993
+ if (Number.isFinite(splitCanvasOutput) && splitCanvasOutput > 0) {
994
+ onSplitChanged?.({ splitCanvasOutput: Math.round(splitCanvasOutput) });
995
+ }
996
+ };
997
+
998
+ splitter.addEventListener("pointermove", onPointerMove);
999
+ splitter.addEventListener("pointerup", onPointerUp);
1000
+ splitter.addEventListener("pointercancel", onPointerUp);
1001
+ });
1002
+
1003
+ window.addEventListener("resize", applyStoredSplitSize);
1004
+ return applyStoredSplitSize;
144
1005
  };
145
1006
 
146
1007
  const init = (root) => {
147
1008
  const elems = root.getElementsByClassName("directive-pyide");
148
1009
 
149
1010
  for (let elem of elems) {
1011
+ if (elem.getAttribute("data-pyide-initialized") === "true") continue;
1012
+ elem.setAttribute("data-pyide-initialized", "true");
1013
+
150
1014
  const editor = elem.getElementsByClassName("editor")[0];
1015
+ const container = elem.getElementsByClassName("container")[0];
1016
+ const editorContainer = elem.getElementsByClassName("editor-container")[0];
1017
+ const splitter = elem.getElementsByClassName("splitter")[0];
151
1018
  const run = elem.getElementsByClassName("run")[0];
152
1019
  const test = elem.getElementsByClassName("test")[0];
1020
+ const stop = elem.getElementsByClassName("stop")[0];
153
1021
  const output = elem.getElementsByClassName("output")[0];
154
- const input = elem.getElementsByClassName("input")[0];
1022
+ const canvas = elem.getElementsByClassName("canvas")[0];
1023
+ const canvasWrapper = elem.getElementsByClassName("canvas-wrapper")[0] || canvas;
1024
+ const canvasOutputSplitter = elem.getElementsByClassName("canvas-output-splitter")[0];
1025
+ const canvasHeader = elem.getElementsByClassName("canvas-header")[0];
1026
+ const outputHeader = elem.getElementsByClassName("output-header")[0];
155
1027
  const outputBtn = elem.getElementsByClassName("output-btn")[0];
156
- const inputBtn = elem.getElementsByClassName("input-btn")[0];
1028
+ const canvasBtn = elem.getElementsByClassName("canvas-btn")[0];
1029
+ const canvasTabs = outputBtn?.closest(".buttons");
157
1030
 
158
1031
  const copyEl = elem.getElementsByClassName("copy")[0];
159
1032
  const resetEl = elem.getElementsByClassName("reset")[0];
160
1033
  const downloadEl = elem.getElementsByClassName("download")[0];
1034
+ const fullscreenEl = elem.getElementsByClassName("fullscreen")[0];
161
1035
 
162
1036
  const id = elem.id;
1037
+ const hasCanvas = elem.getAttribute("data-canvas") === "true";
1038
+ const additionalPackages = Array.from(
1039
+ new Set(
1040
+ (elem.getAttribute("data-packages") || "")
1041
+ .split(",")
1042
+ .map((pkg) => pkg.trim())
1043
+ .filter((pkg) => pkg.length > 0),
1044
+ ),
1045
+ );
1046
+ const hasPytamaroPackage = additionalPackages.some(
1047
+ (pkg) => pkg.toLowerCase() === "pytamaro",
1048
+ );
1049
+ const scriptLooksLikePytamaro = (script) => {
1050
+ return /\bfrom\s+pytamaro\s+import\b|\bimport\s+pytamaro\b/.test(script);
1051
+ };
1052
+ let pyideState = { id };
1053
+
1054
+ const getEditorValue = () => {
1055
+ const textarea = editor?.querySelector("textarea");
1056
+ if (textarea) return textarea.value;
1057
+ return typeof editor?.textContent === "string" ? editor.textContent : "";
1058
+ };
1059
+
1060
+ pyideState = { ...pyideState, script: getEditorValue() };
1061
+
1062
+ const persistPyideState = (updates = {}) => {
1063
+ pyideState = { ...pyideState, ...updates, id };
1064
+ return hyperbook.store.db.pyide.put(pyideState);
1065
+ };
163
1066
 
164
1067
  copyEl?.addEventListener("click", async () => {
165
1068
  try {
166
- await navigator.clipboard.writeText(editor.value);
1069
+ await navigator.clipboard.writeText(getEditorValue());
167
1070
  } catch (error) {
168
1071
  console.error(error.message);
169
1072
  }
@@ -176,102 +1079,278 @@ hyperbook.python = (function () {
176
1079
 
177
1080
  downloadEl?.addEventListener("click", () => {
178
1081
  const a = document.createElement("a");
179
- const blob = new Blob([editor.value], { type: "text/plain" });
1082
+ const blob = new Blob([getEditorValue()], { type: "text/plain" });
180
1083
  a.href = URL.createObjectURL(blob);
181
1084
  a.download = `script-${id}.py`;
182
1085
  a.click();
183
1086
  });
1087
+
1088
+ fullscreenEl?.addEventListener("click", async () => {
1089
+ try {
1090
+ await toggleFullscreen(elem);
1091
+ } catch (error) {
1092
+ console.error(error.message);
1093
+ }
1094
+ });
1095
+ updateFullscreenButtonState(elem, fullscreenEl);
184
1096
  let tests = [];
185
1097
  try {
186
1098
  tests = JSON.parse(atob(elem.getAttribute("data-tests")));
187
1099
  } catch (e) {}
188
1100
 
189
- function showInput() {
1101
+ const isWideCanvasMode = () =>
1102
+ hasCanvas && window.matchMedia("(min-width: 1024px)").matches;
1103
+
1104
+ let activeCanvasView = "output";
1105
+
1106
+ const showOutputTab = () => {
1107
+ activeCanvasView = "output";
1108
+ outputBtn.classList.add("active");
1109
+ if (canvasBtn) canvasBtn.classList.remove("active");
1110
+ canvasHeader?.classList.add("hidden");
1111
+ outputHeader?.classList.add("hidden");
1112
+ output.classList.remove("hidden");
1113
+ if (canvasWrapper) canvasWrapper.classList.add("hidden");
1114
+ canvasOutputSplitter?.classList.add("hidden");
1115
+ };
1116
+ const showCanvasTab = () => {
1117
+ activeCanvasView = "canvas";
190
1118
  outputBtn.classList.remove("active");
191
- inputBtn.classList.add("active");
1119
+ if (canvasBtn) canvasBtn.classList.add("active");
1120
+ canvasHeader?.classList.add("hidden");
1121
+ outputHeader?.classList.add("hidden");
192
1122
  output.classList.add("hidden");
193
- input.classList.remove("hidden");
194
- }
1123
+ if (canvasWrapper) canvasWrapper.classList.remove("hidden");
1124
+ canvasOutputSplitter?.classList.add("hidden");
1125
+ };
1126
+
1127
+ const applyStoredCanvasOutputSplit = setupCanvasOutputSplitter(
1128
+ elem,
1129
+ container,
1130
+ canvasWrapper,
1131
+ output,
1132
+ canvasOutputSplitter,
1133
+ (splitState) => {
1134
+ void persistPyideState(splitState);
1135
+ },
1136
+ );
1137
+
1138
+ const applyCanvasOutputLayout = () => {
1139
+ if (!hasCanvas || !canvasWrapper || !canvasOutputSplitter) return;
1140
+ if (isWideCanvasMode()) {
1141
+ elem.classList.add("canvas-split-mode");
1142
+ canvasTabs?.classList.add("hidden");
1143
+ output.classList.remove("hidden");
1144
+ canvasWrapper.classList.remove("hidden");
1145
+ canvasOutputSplitter.classList.remove("hidden");
1146
+ canvasHeader?.classList.remove("hidden");
1147
+ outputHeader?.classList.remove("hidden");
1148
+ outputBtn.classList.add("active");
1149
+ outputBtn.disabled = true;
1150
+ if (canvasBtn) {
1151
+ canvasBtn.classList.add("active");
1152
+ canvasBtn.disabled = true;
1153
+ }
1154
+ applyStoredCanvasOutputSplit?.();
1155
+ return;
1156
+ }
1157
+
1158
+ elem.classList.remove("canvas-split-mode");
1159
+ canvasTabs?.classList.remove("hidden");
1160
+ output.style.flex = "";
1161
+ canvasWrapper.style.flex = "";
1162
+ outputBtn.disabled = false;
1163
+ if (canvasBtn) {
1164
+ canvasBtn.disabled = false;
1165
+ }
1166
+ if (activeCanvasView === "canvas") {
1167
+ showCanvasTab();
1168
+ } else {
1169
+ showOutputTab();
1170
+ }
1171
+ };
1172
+
195
1173
  function showOutput() {
196
- outputBtn.classList.add("active");
197
- inputBtn.classList.remove("active");
198
- output.classList.remove("hidden");
199
- input.classList.add("hidden");
1174
+ if (isWideCanvasMode()) {
1175
+ applyCanvasOutputLayout();
1176
+ return;
1177
+ }
1178
+ showOutputTab();
1179
+ }
1180
+ function showCanvas() {
1181
+ if (isWideCanvasMode()) {
1182
+ applyCanvasOutputLayout();
1183
+ return;
1184
+ }
1185
+ showCanvasTab();
200
1186
  }
201
1187
 
202
1188
  outputBtn?.addEventListener("click", showOutput);
203
- inputBtn?.addEventListener("click", showInput);
1189
+ canvasBtn?.addEventListener("click", showCanvas);
1190
+ const applyStoredSplitSize = setupSplitter(
1191
+ elem,
1192
+ container,
1193
+ editorContainer,
1194
+ splitter,
1195
+ (splitState) => {
1196
+ void persistPyideState(splitState);
1197
+ },
1198
+ );
1199
+
1200
+ let editorStateRestored = false;
1201
+ const restoreEditorState = async () => {
1202
+ if (editorStateRestored) return;
1203
+ editorStateRestored = true;
204
1204
 
205
- editor.addEventListener("code-input_load", async () => {
206
1205
  const result = await hyperbook.store.db.pyide.get(id);
207
1206
  if (result) {
208
- editor.value = result.script;
1207
+ pyideState = { ...pyideState, ...result };
1208
+ if (typeof result.script === "string") {
1209
+ editor.value = result.script;
1210
+ }
1211
+ if (
1212
+ Number.isFinite(result.splitHorizontal) &&
1213
+ result.splitHorizontal > 0
1214
+ ) {
1215
+ elem.dataset.splitHorizontal = String(Math.round(result.splitHorizontal));
1216
+ }
1217
+ if (
1218
+ Number.isFinite(result.splitVertical) &&
1219
+ result.splitVertical > 0
1220
+ ) {
1221
+ elem.dataset.splitVertical = String(Math.round(result.splitVertical));
1222
+ }
1223
+ if (
1224
+ Number.isFinite(result.splitCanvasOutput) &&
1225
+ result.splitCanvasOutput > 0
1226
+ ) {
1227
+ elem.dataset.splitCanvasOutput = String(
1228
+ Math.round(result.splitCanvasOutput),
1229
+ );
1230
+ }
1231
+ applyStoredSplitSize?.();
1232
+ applyCanvasOutputLayout();
209
1233
  }
210
- });
1234
+ };
1235
+
1236
+ editor.addEventListener("code-input_load", restoreEditorState);
1237
+ if (editor.querySelector("textarea")) {
1238
+ void restoreEditorState();
1239
+ }
1240
+
1241
+ window.addEventListener("resize", applyCanvasOutputLayout);
1242
+ applyCanvasOutputLayout();
211
1243
 
212
1244
  editor.addEventListener("input", () => {
213
- hyperbook.store.db.pyide.put({ id, script: editor.value });
1245
+ void persistPyideState({ script: getEditorValue() });
214
1246
  });
215
1247
 
216
1248
  test?.addEventListener("click", async () => {
217
1249
  showOutput();
218
- if (callback) return;
1250
+ const state = getExecutionState(id);
1251
+ if (state.running || getRunningInstanceId() !== null) return;
1252
+ state.running = true;
1253
+ state.type = "test";
1254
+ state.stopRequested = false;
1255
+ state.stopping = false;
1256
+ const interruptBuffer = interruptBuffers.get(id);
1257
+ if (interruptBuffer) interruptBuffer[0] = 0;
1258
+ updateRunning();
219
1259
 
220
1260
  output.innerHTML = "";
1261
+ clearPytamaroStdoutCarry(id);
1262
+
1263
+ const script = getEditorValue();
1264
+ try {
1265
+ for (let test of tests) {
1266
+ if (state.stopRequested) {
1267
+ appendOutputLine(id, "Stopped pending test execution.");
1268
+ break;
1269
+ }
1270
+
1271
+ const testCode = test.code.replace("#SCRIPT#", script);
221
1272
 
222
- const script = editor.value;
223
- for (let test of tests) {
224
- const testCode = test.code.replace("#SCRIPT#", script);
225
-
226
- const heading = document.createElement("div");
227
- heading.innerHTML = `== Test ${test.name} ==`;
228
- heading.classList.add("test-heading");
229
- output.appendChild(heading);
230
-
231
- await asyncRun(id, "test")(testCode, {})
232
- .then(({ results, error }) => {
233
- if (results) {
234
- output.textContent += results;
235
- } else if (error) {
236
- output.textContent += error;
237
- }
238
- callback = null;
239
- updateRunning(id, "test");
240
- })
241
- .catch((e) => {
242
- output.textContent = `Error: ${e}`;
243
- console.log(e);
244
- callback = null;
245
- updateRunning(id, "test");
246
- });
1273
+ const heading = document.createElement("div");
1274
+ heading.innerHTML = `== Test ${test.name} ==`;
1275
+ heading.classList.add("test-heading");
1276
+ output.appendChild(heading);
1277
+
1278
+ const { results, error } = await executeScript(
1279
+ id,
1280
+ testCode,
1281
+ {},
1282
+ additionalPackages,
1283
+ );
1284
+ if (results) {
1285
+ appendOutput(output, results);
1286
+ } else if (error) {
1287
+ appendOutput(output, error, true);
1288
+ }
1289
+ }
1290
+ } catch (e) {
1291
+ output.innerHTML = "";
1292
+ appendOutput(output, `Error: ${e}`, true);
1293
+ console.log(e);
1294
+ } finally {
1295
+ clearPytamaroStdoutCarry(id);
1296
+ state.running = false;
1297
+ state.stopping = false;
1298
+ state.type = null;
1299
+ releaseKeyboardCapture(id);
1300
+ updateRunning();
247
1301
  }
248
1302
  });
249
1303
 
250
1304
  run?.addEventListener("click", async () => {
251
- showOutput();
252
- if (callback) return;
1305
+ const script = getEditorValue();
1306
+ const useOutputForPytamaro = hasPytamaroPackage || scriptLooksLikePytamaro(script);
1307
+ if (hasCanvas && !useOutputForPytamaro) {
1308
+ showCanvas();
1309
+ } else {
1310
+ showOutput();
1311
+ }
1312
+ const state = getExecutionState(id);
1313
+ if (state.running || getRunningInstanceId() !== null) return;
1314
+ state.running = true;
1315
+ state.type = "run";
1316
+ state.stopRequested = false;
1317
+ state.stopping = false;
1318
+ const interruptBuffer = interruptBuffers.get(id);
1319
+ if (interruptBuffer) interruptBuffer[0] = 0;
1320
+ updateRunning();
253
1321
 
254
- const script = editor.value;
255
1322
  output.innerHTML = "";
256
- asyncRun(id, "run")(script, {
257
- inputs: input.value.split("\n"),
258
- })
259
- .then(({ results, error }) => {
1323
+ clearPytamaroStdoutCarry(id);
1324
+ try {
1325
+ const { results, error } = await executeScript(id, script, {
1326
+ ...(hasCanvas && canvas && !useOutputForPytamaro ? { canvas } : {}),
1327
+ }, additionalPackages);
1328
+ if (!state.stopRequested) {
260
1329
  if (results) {
261
- output.textContent += results;
1330
+ appendOutput(output, results);
262
1331
  } else if (error) {
263
- output.textContent += error;
1332
+ showOutput();
1333
+ appendOutput(output, error, true);
264
1334
  }
265
- callback = null;
266
- updateRunning(id, "run");
267
- })
268
- .catch((e) => {
269
- output.textContent = `Error: ${e}`;
270
- console.log(e);
271
- callback = null;
272
- updateRunning(id, "run");
273
- });
1335
+ } else {
1336
+ appendOutputLine(id, "Execution stopped.");
1337
+ }
1338
+ } catch (e) {
1339
+ showOutput();
1340
+ output.innerHTML = "";
1341
+ appendOutput(output, `Error: ${e}`, true);
1342
+ console.log(e);
1343
+ } finally {
1344
+ clearPytamaroStdoutCarry(id);
1345
+ state.running = false;
1346
+ state.stopping = false;
1347
+ state.type = null;
1348
+ releaseKeyboardCapture(id);
1349
+ updateRunning();
1350
+ }
274
1351
  });
1352
+
1353
+ stop?.addEventListener("click", handleStopClick);
275
1354
  }
276
1355
  };
277
1356
 
@@ -292,6 +1371,7 @@ hyperbook.python = (function () {
292
1371
  document.addEventListener("DOMContentLoaded", () => {
293
1372
  init(document);
294
1373
  });
1374
+ document.addEventListener("fullscreenchange", syncFullscreenButtons);
295
1375
 
296
1376
  return { init };
297
1377
  })();