From 02e94cf2393bb6aea96a768d56eb019b542600a9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 21:45:01 +0000
Subject: [PATCH 01/14] Initial plan
From 7277c5f43147472d68d5f5fa60d0da82884caebc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 21:53:00 +0000
Subject: [PATCH 02/14] Add canvas support to pyide directive
Agent-Logs-Url: https://github.com/openpatch/hyperbook/sessions/b7522a63-204c-4183-808f-603d85fd942e
---
.../markdown/assets/directive-pyide/client.js | 30 ++++++++-
.../markdown/assets/directive-pyide/style.css | 9 +++
.../assets/directive-pyide/webworker.js | 14 +++-
packages/markdown/locales/de.json | 1 +
packages/markdown/locales/en.json | 1 +
packages/markdown/src/remarkDirectivePyide.ts | 31 +++++++++
.../remarkDirectivePyide.test.ts.snap | 37 ++++++++++
.../tests/remarkDirectivePyide.test.ts | 67 +++++++++++++++++++
8 files changed, 186 insertions(+), 4 deletions(-)
create mode 100644 packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap
create mode 100644 packages/markdown/tests/remarkDirectivePyide.test.ts
diff --git a/packages/markdown/assets/directive-pyide/client.js b/packages/markdown/assets/directive-pyide/client.js
index 46ae7f485..bece761cc 100644
--- a/packages/markdown/assets/directive-pyide/client.js
+++ b/packages/markdown/assets/directive-pyide/client.js
@@ -151,8 +151,10 @@ hyperbook.python = (function () {
const run = elem.getElementsByClassName("run")[0];
const test = elem.getElementsByClassName("test")[0];
const output = elem.getElementsByClassName("output")[0];
+ const canvas = elem.getElementsByClassName("canvas")[0];
const input = elem.getElementsByClassName("input")[0];
const outputBtn = elem.getElementsByClassName("output-btn")[0];
+ const canvasBtn = elem.getElementsByClassName("canvas-btn")[0];
const inputBtn = elem.getElementsByClassName("input-btn")[0];
const copyEl = elem.getElementsByClassName("copy")[0];
@@ -160,6 +162,15 @@ hyperbook.python = (function () {
const downloadEl = elem.getElementsByClassName("download")[0];
const id = elem.id;
+ const hasCanvas = elem.getAttribute("data-canvas") === "true";
+
+ if (hasCanvas && canvas) {
+ const offscreenCanvas = canvas.transferControlToOffscreen();
+ pyodideWorker.postMessage(
+ { type: "setCanvas", id, payload: { canvas: offscreenCanvas } },
+ [offscreenCanvas]
+ );
+ }
copyEl?.addEventListener("click", async () => {
try {
@@ -188,18 +199,31 @@ hyperbook.python = (function () {
function showInput() {
outputBtn.classList.remove("active");
+ if (canvasBtn) canvasBtn.classList.remove("active");
inputBtn.classList.add("active");
output.classList.add("hidden");
+ if (canvas) canvas.classList.add("hidden");
input.classList.remove("hidden");
}
function showOutput() {
outputBtn.classList.add("active");
+ if (canvasBtn) canvasBtn.classList.remove("active");
inputBtn.classList.remove("active");
output.classList.remove("hidden");
+ if (canvas) canvas.classList.add("hidden");
+ input.classList.add("hidden");
+ }
+ function showCanvas() {
+ outputBtn.classList.remove("active");
+ if (canvasBtn) canvasBtn.classList.add("active");
+ inputBtn.classList.remove("active");
+ output.classList.add("hidden");
+ if (canvas) canvas.classList.remove("hidden");
input.classList.add("hidden");
}
outputBtn?.addEventListener("click", showOutput);
+ canvasBtn?.addEventListener("click", showCanvas);
inputBtn?.addEventListener("click", showInput);
editor.addEventListener("code-input_load", async () => {
@@ -248,7 +272,11 @@ hyperbook.python = (function () {
});
run?.addEventListener("click", async () => {
- showOutput();
+ if (hasCanvas) {
+ showCanvas();
+ } else {
+ showOutput();
+ }
if (callback) return;
const script = editor.value;
diff --git a/packages/markdown/assets/directive-pyide/style.css b/packages/markdown/assets/directive-pyide/style.css
index 29e846681..5fefde7e6 100644
--- a/packages/markdown/assets/directive-pyide/style.css
+++ b/packages/markdown/assets/directive-pyide/style.css
@@ -35,6 +35,15 @@
font-family: hyperbook-monospace, monospace;
}
+.directive-pyide .canvas {
+ width: 100%;
+ height: 100%;
+ border: 1px solid var(--color-spacer);
+ border-radius: 8px;
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+}
+
.directive-pyide .hidden {
display: none;
}
diff --git a/packages/markdown/assets/directive-pyide/webworker.js b/packages/markdown/assets/directive-pyide/webworker.js
index 5629b34f6..6e52f948a 100644
--- a/packages/markdown/assets/directive-pyide/webworker.js
+++ b/packages/markdown/assets/directive-pyide/webworker.js
@@ -1,6 +1,3 @@
-// Setup your project to serve `py-worker.js`. You should also serve
-// `pyodide.js`, and all its associated `.asm.js`, `.json`,
-// and `.wasm` files as well:
importScripts("https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js");
class StdinHandler {
@@ -22,8 +19,15 @@ async function loadPyodideAndPackages() {
}
let pyodideReadyPromise = loadPyodideAndPackages();
+const canvases = {};
+
self.onmessage = async ({ data: { id, type, payload } }) => {
switch (type) {
+ case "setCanvas": {
+ const { canvas } = payload;
+ canvases[id] = canvas;
+ break;
+ }
case "run": {
// make sure loading is done
await pyodideReadyPromise;
@@ -34,6 +38,10 @@ self.onmessage = async ({ data: { id, type, payload } }) => {
self[key] = context[key];
}
+ if (canvases[id]) {
+ self.pyodide.canvas.setCanvas2D(canvases[id]);
+ }
+
self.pyodide.setStdin(new StdinHandler(inputs));
self.pyodide.setStdout({
diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json
index c07e52b96..47084102e 100644
--- a/packages/markdown/locales/de.json
+++ b/packages/markdown/locales/de.json
@@ -34,6 +34,7 @@
"pyide-test-running-click-to-stop": "Test läuft (Klicken zum Stoppen) ...",
"pyide-test-running-refresh-to-stop": "Test läuft (Aktualisieren zum Stoppen) ...",
"pyide-output": "Ausgabe",
+ "pyide-canvas": "Canvas",
"pyide-input": "Eingabe",
"pyide-reset": "Zurücksetzen",
"pyide-copy": "Kopieren",
diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json
index 7f7b47248..b281cbb7c 100644
--- a/packages/markdown/locales/en.json
+++ b/packages/markdown/locales/en.json
@@ -34,6 +34,7 @@
"pyide-test-running-click-to-stop": "Test running (Click to stop) ...",
"pyide-test-running-refresh-to-stop": "Test running (Refresh to stop) ...",
"pyide-output": "Output",
+ "pyide-canvas": "Canvas",
"pyide-input": "Input",
"pyide-reset": "Reset",
"pyide-copy": "Copy",
diff --git a/packages/markdown/src/remarkDirectivePyide.ts b/packages/markdown/src/remarkDirectivePyide.ts
index 94c63e778..d7642281b 100644
--- a/packages/markdown/src/remarkDirectivePyide.ts
+++ b/packages/markdown/src/remarkDirectivePyide.ts
@@ -35,6 +35,7 @@ export default (ctx: HyperbookContext) => () => {
const data = node.data || (node.data = {});
const { src = "", id = hash(node) } = node.attributes || {};
+ const hasCanvas = "canvas" in (node.attributes || {});
expectContainerDirective(node, file, name);
registerDirective(file, name, ["client.js"], ["style.css"], []);
@@ -81,6 +82,7 @@ export default (ctx: HyperbookContext) => () => {
class: "directive-pyide",
id: id,
"data-tests": Buffer.from(JSON.stringify(tests)).toString("base64"),
+ ...(hasCanvas ? { "data-canvas": "true" } : {}),
};
data.hChildren = [
{
@@ -110,6 +112,23 @@ export default (ctx: HyperbookContext) => () => {
},
],
},
+ ...(hasCanvas
+ ? [
+ {
+ type: "element",
+ tagName: "button",
+ properties: {
+ class: "canvas-btn",
+ },
+ children: [
+ {
+ type: "text",
+ value: i18n.get("pyide-canvas"),
+ },
+ ],
+ } as ElementContent,
+ ]
+ : []),
{
type: "element",
tagName: "button",
@@ -133,6 +152,18 @@ export default (ctx: HyperbookContext) => () => {
},
children: [],
},
+ ...(hasCanvas
+ ? [
+ {
+ type: "element",
+ tagName: "canvas",
+ properties: {
+ class: "canvas hidden",
+ },
+ children: [],
+ } as ElementContent,
+ ]
+ : []),
{
type: "element",
tagName: "code-input",
diff --git a/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap b/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap
new file mode 100644
index 000000000..7e907a32c
--- /dev/null
+++ b/packages/markdown/tests/__snapshots__/remarkDirectivePyide.test.ts.snap
@@ -0,0 +1,37 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`remarkDirectivePyide > should transform basic pyide 1`] = `
+"
+
+
+
+
+
+
+
+
+
print("Hello World")
+
+
+
+"
+`;
+
+exports[`remarkDirectivePyide > should transform pyide with canvas attribute 1`] = `
+"
+
+
+
+
+
+
+
+
+
+
import pygame
+pygame.init()
+
+
+
+"
+`;
diff --git a/packages/markdown/tests/remarkDirectivePyide.test.ts b/packages/markdown/tests/remarkDirectivePyide.test.ts
new file mode 100644
index 000000000..067cf5c90
--- /dev/null
+++ b/packages/markdown/tests/remarkDirectivePyide.test.ts
@@ -0,0 +1,67 @@
+import { HyperbookContext } from "@hyperbook/types";
+import { describe, expect, it } from "vitest";
+import rehypeStringify from "rehype-stringify";
+import remarkToRehype from "remark-rehype";
+import rehypeFormat from "rehype-format";
+import { unified, PluggableList } from "unified";
+import remarkDirective from "remark-directive";
+import remarkDirectiveRehype from "remark-directive-rehype";
+import remarkDirectivePyide from "../src/remarkDirectivePyide";
+import { ctx } from "./mock";
+import remarkParse from "../src/remarkParse";
+
+export const toHtml = (md: string, ctx: HyperbookContext) => {
+ const remarkPlugins: PluggableList = [
+ remarkDirective,
+ remarkDirectiveRehype,
+ remarkDirectivePyide(ctx),
+ ];
+
+ return unified()
+ .use(remarkParse)
+ .use(remarkPlugins)
+ .use(remarkToRehype)
+ .use(rehypeFormat)
+ .use(rehypeStringify, {
+ allowDangerousCharacters: true,
+ allowDangerousHtml: true,
+ })
+ .processSync(md);
+};
+
+describe("remarkDirectivePyide", () => {
+ it("should transform basic pyide", async () => {
+ expect(
+ toHtml(
+ `:::pyide
+
+\`\`\`python
+print("Hello World")
+\`\`\`
+
+:::
+
+`,
+ ctx,
+ ).value,
+ ).toMatchSnapshot();
+ });
+
+ it("should transform pyide with canvas attribute", async () => {
+ expect(
+ toHtml(
+ `:::pyide{canvas}
+
+\`\`\`python
+import pygame
+pygame.init()
+\`\`\`
+
+:::
+
+`,
+ ctx,
+ ).value,
+ ).toMatchSnapshot();
+ });
+});
From 4adbc66be7124e6a0e00a0dfe83910d71a03476e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Fri, 8 May 2026 21:56:56 +0000
Subject: [PATCH 03/14] Address code review: add error handling and explicit
canvas min-height
Agent-Logs-Url: https://github.com/openpatch/hyperbook/sessions/b7522a63-204c-4183-808f-603d85fd942e
---
packages/markdown/assets/directive-pyide/client.js | 14 +++++++++-----
packages/markdown/assets/directive-pyide/style.css | 1 +
.../markdown/assets/directive-pyide/webworker.js | 6 +++++-
3 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/packages/markdown/assets/directive-pyide/client.js b/packages/markdown/assets/directive-pyide/client.js
index bece761cc..79988ca54 100644
--- a/packages/markdown/assets/directive-pyide/client.js
+++ b/packages/markdown/assets/directive-pyide/client.js
@@ -165,11 +165,15 @@ hyperbook.python = (function () {
const hasCanvas = elem.getAttribute("data-canvas") === "true";
if (hasCanvas && canvas) {
- const offscreenCanvas = canvas.transferControlToOffscreen();
- pyodideWorker.postMessage(
- { type: "setCanvas", id, payload: { canvas: offscreenCanvas } },
- [offscreenCanvas]
- );
+ try {
+ const offscreenCanvas = canvas.transferControlToOffscreen();
+ pyodideWorker.postMessage(
+ { type: "setCanvas", id, payload: { canvas: offscreenCanvas } },
+ [offscreenCanvas]
+ );
+ } catch (error) {
+ console.error("Canvas transfer failed:", error.message);
+ }
}
copyEl?.addEventListener("click", async () => {
diff --git a/packages/markdown/assets/directive-pyide/style.css b/packages/markdown/assets/directive-pyide/style.css
index 5fefde7e6..98561bf31 100644
--- a/packages/markdown/assets/directive-pyide/style.css
+++ b/packages/markdown/assets/directive-pyide/style.css
@@ -38,6 +38,7 @@
.directive-pyide .canvas {
width: 100%;
height: 100%;
+ min-height: 300px;
border: 1px solid var(--color-spacer);
border-radius: 8px;
border-top-left-radius: 0;
diff --git a/packages/markdown/assets/directive-pyide/webworker.js b/packages/markdown/assets/directive-pyide/webworker.js
index 6e52f948a..08b1cdfa1 100644
--- a/packages/markdown/assets/directive-pyide/webworker.js
+++ b/packages/markdown/assets/directive-pyide/webworker.js
@@ -39,7 +39,11 @@ self.onmessage = async ({ data: { id, type, payload } }) => {
}
if (canvases[id]) {
- self.pyodide.canvas.setCanvas2D(canvases[id]);
+ try {
+ self.pyodide.canvas.setCanvas2D(canvases[id]);
+ } catch (error) {
+ self.postMessage({ id, type: "stderr", payload: `Canvas setup failed: ${error.message}` });
+ }
}
self.pyodide.setStdin(new StdinHandler(inputs));
From 56de60b5972cf97f01554226fa3c728569cb21ca Mon Sep 17 00:00:00 2001
From: Mike Barkmin
Date: Sat, 9 May 2026 08:33:14 +0200
Subject: [PATCH 04/14] Aktualisieren von pyide.md
---
website/en/book/elements/pyide.md | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/website/en/book/elements/pyide.md b/website/en/book/elements/pyide.md
index e8dbde2e4..5f8825dbd 100644
--- a/website/en/book/elements/pyide.md
+++ b/website/en/book/elements/pyide.md
@@ -171,4 +171,18 @@ Stopping an infinite loop or a long lasting process is only possible by refreshi
'Cross-Origin-Embedder-Policy': 'require-corp'
'Cross-Origin-Opener-Policy': 'same-origin'
```
+:::
+
+## Pygame
+
+:::pyide{canvas}
+
+```python
+import pygame
+pygame.init()
+screen = pygame.display.set_mode((400, 300))
+screen.fill((0, 128, 255))
+pygame.display.flip()
+```
+
:::
\ No newline at end of file
From 5ab26e171438ab72edd56b8db9be2707c79b2e7b Mon Sep 17 00:00:00 2001
From: Mike Barkmin
Date: Sat, 9 May 2026 15:59:34 +0200
Subject: [PATCH 05/14] add graphical output support for pygame
---
.changeset/large-chicken-appear.md | 7 +
.../markdown/assets/directive-p5/client.js | 72 +++
.../markdown/assets/directive-p5/style.css | 39 +-
.../markdown/assets/directive-pyide/client.js | 443 ++++++++++++------
.../markdown/assets/directive-pyide/style.css | 64 ++-
.../assets/directive-pyide/webworker.js | 98 ----
.../markdown/assets/directive-typst/client.js | 68 +++
.../markdown/assets/directive-typst/style.css | 41 +-
.../assets/directive-webide/client.js | 72 +++
.../assets/directive-webide/style.css | 39 +-
packages/markdown/src/rehypeDirectiveP5.ts | 20 +-
packages/markdown/src/remarkDirectivePyide.ts | 26 +-
packages/markdown/src/remarkDirectiveTypst.ts | 22 +-
.../markdown/src/remarkDirectiveWebide.ts | 20 +-
.../rehypeDirectiveP5.test.ts.snap | 2 +-
.../remarkDirectivePyide.test.ts.snap | 6 +-
.../vscode/schemas/hyperbook.schema.json | 9 +
17 files changed, 783 insertions(+), 265 deletions(-)
create mode 100644 .changeset/large-chicken-appear.md
delete mode 100644 packages/markdown/assets/directive-pyide/webworker.js
diff --git a/.changeset/large-chicken-appear.md b/.changeset/large-chicken-appear.md
new file mode 100644
index 000000000..2cab78021
--- /dev/null
+++ b/.changeset/large-chicken-appear.md
@@ -0,0 +1,7 @@
+---
+"@hyperbook/markdown": minor
+"hyperbook-studio": minor
+"hyperbook": minor
+---
+
+Add graphical output support to pyide. For example for pygame.
diff --git a/packages/markdown/assets/directive-p5/client.js b/packages/markdown/assets/directive-p5/client.js
index a0e3d211e..f57f24ac0 100644
--- a/packages/markdown/assets/directive-p5/client.js
+++ b/packages/markdown/assets/directive-p5/client.js
@@ -27,7 +27,77 @@ hyperbook.p5 = (function () {
return sketchCode;
};
+ function setupSplitter(elem, container, editorContainer, splitter) {
+ if (!container || !editorContainer || !splitter) return;
+
+ const minPanelSize = 120;
+
+ const getIsHorizontal = () =>
+ getComputedStyle(elem).flexDirection.startsWith("row");
+
+ const applySplitSize = (rawSize, isHorizontal) => {
+ const total = isHorizontal ? elem.clientWidth : elem.clientHeight;
+ const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight;
+ const maxSize = Math.max(minPanelSize, total - splitterSize - minPanelSize);
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
+ container.style.flex = `0 0 ${clamped}px`;
+ return clamped;
+ };
+
+ const applyStoredSplitSize = () => {
+ const isHorizontal = getIsHorizontal();
+ elem.classList.toggle("split-horizontal", isHorizontal);
+ elem.classList.toggle("split-vertical", !isHorizontal);
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
+ const rawStored = Number(elem.dataset[key]);
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
+ container.style.flex = "";
+ return;
+ }
+ applySplitSize(rawStored, isHorizontal);
+ };
+
+ applyStoredSplitSize();
+
+ splitter.addEventListener("mousedown", (event) => {
+ event.preventDefault();
+
+ const isHorizontal = getIsHorizontal();
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
+ const startPointer = isHorizontal ? event.clientX : event.clientY;
+ const startSize = isHorizontal
+ ? container.getBoundingClientRect().width
+ : container.getBoundingClientRect().height;
+
+ elem.classList.add("resizing");
+
+ const onMouseMove = (moveEvent) => {
+ const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
+ const delta = pointer - startPointer;
+ const size = applySplitSize(startSize + delta, isHorizontal);
+ elem.dataset[key] = String(Math.round(size));
+ };
+
+ const onMouseUp = () => {
+ elem.classList.remove("resizing");
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ });
+
+ window.addEventListener("resize", applyStoredSplitSize);
+ }
+
function initElement(elem) {
+ if (elem.getAttribute("data-p5-initialized") === "true") return;
+ elem.setAttribute("data-p5-initialized", "true");
+
+ const container = elem.querySelector(".container");
+ const editorContainer = elem.querySelector(".editor-container");
+ const splitter = elem.querySelector(".splitter");
const editor = elem.getElementsByClassName("editor")[0];
/** @type {HTMLButtonElement} */
const update = elem.getElementsByClassName("update")[0];
@@ -38,6 +108,8 @@ hyperbook.p5 = (function () {
const resetEl = elem.getElementsByClassName("reset")[0];
const downloadEl = elem.getElementsByClassName("download")[0];
+ setupSplitter(elem, container, editorContainer, splitter);
+
if (frame) {
frame.srcdoc = frame.srcdoc.replaceAll(
"###ORIGIN###",
diff --git a/packages/markdown/assets/directive-p5/style.css b/packages/markdown/assets/directive-p5/style.css
index 25d73c910..9407d7259 100644
--- a/packages/markdown/assets/directive-p5/style.css
+++ b/packages/markdown/assets/directive-p5/style.css
@@ -13,6 +13,8 @@ code-input {
.directive-p5 .container {
width: 100%;
+ min-height: 120px;
+ min-width: 120px;
border: 1px solid var(--color-spacer);
border-radius: 8px;
overflow: hidden;
@@ -22,9 +24,44 @@ code-input {
width: 100%;
display: flex;
flex-direction: column;
+ min-height: 120px;
+ min-width: 120px;
height: 400px;
}
+.directive-p5 .splitter {
+ background: var(--color-spacer);
+ border-radius: 999px;
+ flex-shrink: 0;
+ touch-action: none;
+ opacity: 0.45;
+ transition: opacity 0.15s ease-in-out;
+}
+
+.directive-p5 .splitter:hover {
+ opacity: 0.65;
+}
+
+.directive-p5.split-vertical .splitter {
+ width: 100%;
+ height: 4px;
+ cursor: row-resize;
+}
+
+.directive-p5.split-horizontal .splitter {
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+}
+
+.directive-p5.resizing {
+ user-select: none;
+}
+
+.directive-p5.resizing .splitter {
+ opacity: 0.75;
+}
+
.directive-p5 .editor {
width: 100%;
border: 1px solid var(--color-spacer);
@@ -79,7 +116,7 @@ code-input {
@media screen and (min-width: 1024px) {
.directive-p5:not(.standalone) {
flex-direction: row;
- height: calc(100dvh - 128px);
+ height: calc(100dvh - 80px);
.container {
flex: 1;
height: 100% !important;
diff --git a/packages/markdown/assets/directive-pyide/client.js b/packages/markdown/assets/directive-pyide/client.js
index 79988ca54..22e450b61 100644
--- a/packages/markdown/assets/directive-pyide/client.js
+++ b/packages/markdown/assets/directive-pyide/client.js
@@ -16,85 +16,186 @@ hyperbook.python = (function () {
])
);
- const pyodideWorker = new Worker(
- `${HYPERBOOK_ASSETS}directive-pyide/webworker.js`
- );
+ class StdinHandler {
+ constructor(results, options) {
+ this.results = results;
+ this.idx = 0;
+ Object.assign(this, options);
+ }
+
+ stdin() {
+ return this.results[this.idx++];
+ }
+ }
+
+ const PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js";
+
+ const loadPyodideScript = () => {
+ if (window.loadPyodide) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = PYODIDE_CDN;
+ script.onload = () => resolve();
+ script.onerror = () => reject(new Error("Failed to load Pyodide"));
+ document.head.appendChild(script);
+ });
+ };
+
+ const pyodideReadyPromise = (async () => {
+ await loadPyodideScript();
+ return window.loadPyodide;
+ })();
- let callback = null;
/**
- * @type Uint8Array
+ * @type {Map}
*/
- let interruptBuffer;
+ const runtimes = new Map();
+
/**
- * @type Int32Array
+ * @type {Map}
*/
- let stdinBuffer;
- if (window.crossOriginIsolated) {
- interruptBuffer = new Uint8Array(new SharedArrayBuffer(1));
- pyodideWorker.postMessage({
- type: "setInterruptBuffer",
- payload: { interruptBuffer },
- });
- } else {
- interruptBuffer = new ArrayBuffer(1);
- pyodideWorker.postMessage({
- type: "setInterruptBuffer",
- payload: { interruptBuffer },
- });
- }
+ const executionStates = new Map();
+
+ const getExecutionState = (id) => {
+ if (!executionStates.has(id)) {
+ executionStates.set(id, {
+ running: false,
+ stopping: false,
+ stopRequested: false,
+ type: null,
+ });
+ }
+ return executionStates.get(id);
+ };
- const asyncRun = (id, type) => {
- if (callback) return;
-
- interruptBuffer[0] = 0;
- return (script, context) => {
- return new Promise((onSuccess) => {
- callback = onSuccess;
- updateRunning(id, type);
- pyodideWorker.postMessage({
- type: "run",
- payload: {
- ...context,
- python: script,
- },
- id,
- });
+ const getRuntime = async (id) => {
+ if (runtimes.has(id)) {
+ return runtimes.get(id);
+ }
+ const loadPyodide = await pyodideReadyPromise;
+ const pyodide = await loadPyodide();
+ runtimes.set(id, pyodide);
+ return pyodide;
+ };
+
+ const getOutput = (id) => {
+ return document.getElementById(id)?.getElementsByClassName("output")[0];
+ };
+
+ const appendOutputLine = (id, message) => {
+ const output = getOutput(id);
+ if (!output) return;
+ output.appendChild(document.createTextNode(message + "\n"));
+ };
+
+ const resetCanvas = (canvas) => {
+ if (!canvas) return;
+ const context = canvas.getContext("2d");
+ context?.clearRect(0, 0, canvas.width, canvas.height);
+ };
+
+ const executeScript = async (id, script, context = {}) => {
+ const filename = "";
+ try {
+ const pyodide = await getRuntime(id);
+ const { inputs = [], canvas, ...globalsContext } = context;
+
+ if (canvas) {
+ try {
+ resetCanvas(canvas);
+ pyodide.canvas.setCanvas2D(canvas);
+ } catch (error) {
+ appendOutputLine(id, `Canvas setup failed: ${error.message}`);
+ }
+ }
+
+ pyodide.setStdin(new StdinHandler(inputs));
+ pyodide.setStdout({
+ batched: (msg) => appendOutputLine(id, msg),
});
- };
+ pyodide.setStderr({
+ batched: (msg) => appendOutputLine(id, msg),
+ });
+
+ await pyodide.loadPackagesFromImports(script);
+ const dict = pyodide.globals.get("dict");
+ const globals = dict();
+ try {
+ for (const [key, value] of Object.entries(globalsContext)) {
+ globals.set(key, value);
+ }
+ const results = await pyodide.runPythonAsync(script, {
+ globals,
+ locals: globals,
+ filename,
+ });
+ return { results };
+ } finally {
+ globals.destroy();
+ dict.destroy();
+ }
+ } catch (error) {
+ let message = error.message;
+ if (message.startsWith("Traceback")) {
+ const lines = message?.split("\n") || [];
+ const i = lines.findIndex((line) => line.includes(filename));
+ message = lines[0] + "\n" + lines.slice(i).join("\n");
+ }
+ return { error: message };
+ }
};
- function interruptExecution() {
- // 2 stands for SIGINT.
- interruptBuffer[0] = 2;
- }
+ const requestStop = (id) => {
+ const state = getExecutionState(id);
+ if (!state.running || state.stopRequested) return;
+ state.stopRequested = true;
+ state.stopping = true;
+ appendOutputLine(id, "Stop requested. Finishing current execution...");
+ updateRunning();
+ };
- function reload() {
- window.location.reload();
- }
+ const handleStopClick = (event) => {
+ const elem = event.currentTarget.closest(".directive-pyide");
+ if (!elem?.id) return;
+ requestStop(elem.id);
+ };
- const updateRunning = (id, type) => {
+ const updateRunning = () => {
const elems = document.getElementsByClassName("directive-pyide");
for (let elem of elems) {
const run = elem.getElementsByClassName("run")[0];
const test = elem.getElementsByClassName("test")[0];
- if (callback) {
- if (elem.id === id && type === "run") {
- if (window.crossOriginIsolated) {
- run.textContent = hyperbook.i18n.get("pyide-running-click-to-stop");
- run.addEventListener("click", interruptExecution);
- } else {
- run.textContent = hyperbook.i18n.get("pyide-running-refresh-to-stop");
-
- run.addEventListener("click", reload);
- }
- } else if (test && elem.id === id && type === "test") {
- if (window.crossOriginIsolated) {
- test.textContent = hyperbook.i18n.get("pyide-testing-click-to-stop");
- test.addEventListener("click", interruptExecution);
- } else {
- test.textContent = hyperbook.i18n.get("pyide-testing-refresh-to-stop");
- test.addEventListener("click", reload);
+ const state = getExecutionState(elem.id);
+
+ run.removeEventListener("click", handleStopClick);
+ test?.removeEventListener("click", handleStopClick);
+ run.classList.remove("stopping");
+ test?.classList.remove("stopping");
+
+ if (state.running) {
+ if (state.type === "run") {
+ run.textContent = state.stopping
+ ? "Stopping..."
+ : hyperbook.i18n.get("pyide-running-click-to-stop");
+ run.disabled = false;
+ run.addEventListener("click", handleStopClick);
+ run.classList.toggle("stopping", state.stopping);
+ if (test) {
+ test.classList.add("running");
+ test.disabled = true;
}
+ } else if (state.type === "test" && test) {
+ test.textContent = state.stopping
+ ? "Stopping..."
+ : hyperbook.i18n.get("pyide-testing-click-to-stop");
+ test.disabled = false;
+ test.addEventListener("click", handleStopClick);
+ test.classList.toggle("stopping", state.stopping);
+ run.classList.add("running");
+ run.disabled = true;
} else {
run.classList.add("running");
run.disabled = true;
@@ -104,54 +205,103 @@ hyperbook.python = (function () {
}
}
} else {
+ run.classList.remove("stopping");
run.classList.remove("running");
run.textContent = hyperbook.i18n.get("pyide-run");
run.disabled = false;
- run.removeEventListener("click", interruptExecution);
- run.removeEventListener("click", reload);
if (test) {
+ test.classList.remove("stopping");
test.classList.remove("running");
test.textContent = hyperbook.i18n.get("pyide-test");
test.disabled = false;
- test.removeEventListener("click", interruptExecution);
- test.removeEventListener("click", reload);
}
}
}
};
- pyodideWorker.onmessage = (event) => {
- const { id, type, payload } = event.data;
- switch (type) {
- case "stdout": {
- const output = document
- .getElementById(id)
- .getElementsByClassName("output")[0];
- output.appendChild(document.createTextNode(payload + "\n"));
- break;
- }
- case "error": {
- const onSuccess = callback;
- onSuccess({ error: payload });
- break;
- }
- case "success": {
- const onSuccess = callback;
- onSuccess({ results: payload });
- break;
+ const setupSplitter = (elem, container, editorContainer, splitter) => {
+ if (!container || !editorContainer || !splitter) return;
+
+ const minPanelSize = 120;
+
+ const getIsHorizontal = () =>
+ getComputedStyle(elem).flexDirection.startsWith("row");
+
+ const applySplitSize = (rawSize, isHorizontal) => {
+ const total = isHorizontal ? elem.clientWidth : elem.clientHeight;
+ const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight;
+ const maxSize = Math.max(
+ minPanelSize,
+ total - splitterSize - minPanelSize
+ );
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
+ container.style.flex = `0 0 ${clamped}px`;
+ return clamped;
+ };
+
+ const applyStoredSplitSize = () => {
+ const isHorizontal = getIsHorizontal();
+ elem.classList.toggle("split-horizontal", isHorizontal);
+ elem.classList.toggle("split-vertical", !isHorizontal);
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
+ const rawStored = Number(elem.dataset[key]);
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
+ container.style.flex = "";
+ return;
}
- }
+ applySplitSize(rawStored, isHorizontal);
+ };
+
+ applyStoredSplitSize();
+
+ splitter.addEventListener("mousedown", (event) => {
+ event.preventDefault();
+
+ const isHorizontal = getIsHorizontal();
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
+ const startPointer = isHorizontal ? event.clientX : event.clientY;
+ const startSize = isHorizontal
+ ? container.getBoundingClientRect().width
+ : container.getBoundingClientRect().height;
+
+ elem.classList.add("resizing");
+
+ const onMouseMove = (moveEvent) => {
+ const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
+ const delta = pointer - startPointer;
+ const size = applySplitSize(startSize + delta, isHorizontal);
+ elem.dataset[key] = String(Math.round(size));
+ };
+
+ const onMouseUp = () => {
+ elem.classList.remove("resizing");
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ });
+
+ window.addEventListener("resize", applyStoredSplitSize);
};
const init = (root) => {
const elems = root.getElementsByClassName("directive-pyide");
for (let elem of elems) {
+ if (elem.getAttribute("data-pyide-initialized") === "true") continue;
+ elem.setAttribute("data-pyide-initialized", "true");
+
const editor = elem.getElementsByClassName("editor")[0];
+ const container = elem.getElementsByClassName("container")[0];
+ const editorContainer = elem.getElementsByClassName("editor-container")[0];
+ const splitter = elem.getElementsByClassName("splitter")[0];
const run = elem.getElementsByClassName("run")[0];
const test = elem.getElementsByClassName("test")[0];
const output = elem.getElementsByClassName("output")[0];
const canvas = elem.getElementsByClassName("canvas")[0];
+ const canvasWrapper = elem.getElementsByClassName("canvas-wrapper")[0] || canvas;
const input = elem.getElementsByClassName("input")[0];
const outputBtn = elem.getElementsByClassName("output-btn")[0];
const canvasBtn = elem.getElementsByClassName("canvas-btn")[0];
@@ -164,18 +314,6 @@ hyperbook.python = (function () {
const id = elem.id;
const hasCanvas = elem.getAttribute("data-canvas") === "true";
- if (hasCanvas && canvas) {
- try {
- const offscreenCanvas = canvas.transferControlToOffscreen();
- pyodideWorker.postMessage(
- { type: "setCanvas", id, payload: { canvas: offscreenCanvas } },
- [offscreenCanvas]
- );
- } catch (error) {
- console.error("Canvas transfer failed:", error.message);
- }
- }
-
copyEl?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(editor.value);
@@ -206,7 +344,7 @@ hyperbook.python = (function () {
if (canvasBtn) canvasBtn.classList.remove("active");
inputBtn.classList.add("active");
output.classList.add("hidden");
- if (canvas) canvas.classList.add("hidden");
+ if (canvasWrapper) canvasWrapper.classList.add("hidden");
input.classList.remove("hidden");
}
function showOutput() {
@@ -214,7 +352,7 @@ hyperbook.python = (function () {
if (canvasBtn) canvasBtn.classList.remove("active");
inputBtn.classList.remove("active");
output.classList.remove("hidden");
- if (canvas) canvas.classList.add("hidden");
+ if (canvasWrapper) canvasWrapper.classList.add("hidden");
input.classList.add("hidden");
}
function showCanvas() {
@@ -222,13 +360,14 @@ hyperbook.python = (function () {
if (canvasBtn) canvasBtn.classList.add("active");
inputBtn.classList.remove("active");
output.classList.add("hidden");
- if (canvas) canvas.classList.remove("hidden");
+ if (canvasWrapper) canvasWrapper.classList.remove("hidden");
input.classList.add("hidden");
}
outputBtn?.addEventListener("click", showOutput);
canvasBtn?.addEventListener("click", showCanvas);
inputBtn?.addEventListener("click", showInput);
+ setupSplitter(elem, container, editorContainer, splitter);
editor.addEventListener("code-input_load", async () => {
const result = await hyperbook.store.db.pyide.get(id);
@@ -243,35 +382,46 @@ hyperbook.python = (function () {
test?.addEventListener("click", async () => {
showOutput();
- if (callback) return;
+ const state = getExecutionState(id);
+ if (state.running) return;
+ state.running = true;
+ state.type = "test";
+ state.stopRequested = false;
+ state.stopping = false;
+ updateRunning();
output.innerHTML = "";
const script = editor.value;
- for (let test of tests) {
- const testCode = test.code.replace("#SCRIPT#", script);
-
- const heading = document.createElement("div");
- heading.innerHTML = `== Test ${test.name} ==`;
- heading.classList.add("test-heading");
- output.appendChild(heading);
-
- await asyncRun(id, "test")(testCode, {})
- .then(({ results, error }) => {
- if (results) {
- output.textContent += results;
- } else if (error) {
- output.textContent += error;
- }
- callback = null;
- updateRunning(id, "test");
- })
- .catch((e) => {
- output.textContent = `Error: ${e}`;
- console.log(e);
- callback = null;
- updateRunning(id, "test");
- });
+ try {
+ for (let test of tests) {
+ if (state.stopRequested) {
+ appendOutputLine(id, "Stopped pending test execution.");
+ break;
+ }
+
+ const testCode = test.code.replace("#SCRIPT#", script);
+
+ const heading = document.createElement("div");
+ heading.innerHTML = `== Test ${test.name} ==`;
+ heading.classList.add("test-heading");
+ output.appendChild(heading);
+
+ const { results, error } = await executeScript(id, testCode, {});
+ if (results) {
+ output.textContent += results;
+ } else if (error) {
+ output.textContent += error;
+ }
+ }
+ } catch (e) {
+ output.textContent = `Error: ${e}`;
+ console.log(e);
+ } finally {
+ state.running = false;
+ state.stopping = false;
+ state.type = null;
+ updateRunning();
}
});
@@ -281,28 +431,39 @@ hyperbook.python = (function () {
} else {
showOutput();
}
- if (callback) return;
+ const state = getExecutionState(id);
+ if (state.running) return;
+ state.running = true;
+ state.type = "run";
+ state.stopRequested = false;
+ state.stopping = false;
+ updateRunning();
const script = editor.value;
output.innerHTML = "";
- asyncRun(id, "run")(script, {
- inputs: input.value.split("\n"),
- })
- .then(({ results, error }) => {
+ try {
+ const { results, error } = await executeScript(id, script, {
+ inputs: input.value.split("\n"),
+ ...(hasCanvas && canvas ? { canvas } : {}),
+ });
+ if (!state.stopRequested) {
if (results) {
output.textContent += results;
} else if (error) {
output.textContent += error;
}
- callback = null;
- updateRunning(id, "run");
- })
- .catch((e) => {
- output.textContent = `Error: ${e}`;
- console.log(e);
- callback = null;
- updateRunning(id, "run");
- });
+ } else {
+ appendOutputLine(id, "Execution stopped.");
+ }
+ } catch (e) {
+ output.textContent = `Error: ${e}`;
+ console.log(e);
+ } finally {
+ state.running = false;
+ state.stopping = false;
+ state.type = null;
+ updateRunning();
+ }
});
}
};
diff --git a/packages/markdown/assets/directive-pyide/style.css b/packages/markdown/assets/directive-pyide/style.css
index 98561bf31..581b05f0b 100644
--- a/packages/markdown/assets/directive-pyide/style.css
+++ b/packages/markdown/assets/directive-pyide/style.css
@@ -14,7 +14,9 @@
.directive-pyide .container {
width: 100%;
overflow: hidden;
- height: 200px;
+ min-height: 120px;
+ min-width: 120px;
+ flex: 0 0 200px;
display: flex;
flex-direction: column;
}
@@ -35,16 +37,22 @@
font-family: hyperbook-monospace, monospace;
}
-.directive-pyide .canvas {
- width: 100%;
+.directive-pyide .canvas-wrapper {
+ overflow: auto;
height: 100%;
- min-height: 300px;
border: 1px solid var(--color-spacer);
border-radius: 8px;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
+.directive-pyide .canvas {
+ display: block;
+ width: auto;
+ height: auto;
+ min-height: 300px;
+}
+
.directive-pyide .hidden {
display: none;
}
@@ -53,7 +61,43 @@
width: 100%;
display: flex;
flex-direction: column;
- height: 400px;
+ min-height: 120px;
+ min-width: 120px;
+ flex: 1 1 400px;
+ overflow: hidden;
+}
+
+.directive-pyide .splitter {
+ background: var(--color-spacer);
+ border-radius: 999px;
+ flex-shrink: 0;
+ touch-action: none;
+ opacity: 0.45;
+ transition: opacity 0.15s ease-in-out;
+}
+
+.directive-pyide .splitter:hover {
+ opacity: 0.65;
+}
+
+.directive-pyide.split-vertical .splitter {
+ width: 100%;
+ height: 4px;
+ cursor: row-resize;
+}
+
+.directive-pyide.split-horizontal .splitter {
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+}
+
+.directive-pyide.resizing {
+ user-select: none;
+}
+
+.directive-pyide.resizing .splitter {
+ opacity: 0.75;
}
.directive-pyide .editor {
@@ -118,22 +162,26 @@
opacity: 0.5;
}
+.directive-pyide button.stopping {
+ pointer-events: auto;
+ cursor: progress;
+ opacity: 0.75;
+}
+
@media screen and (min-width: 1024px) {
.directive-pyide {
flex-direction: row;
- height: calc(100dvh - 128px);
+ height: calc(100dvh - 80px);
.output {
height: 100%;
}
.container {
- flex: 1;
height: 100% !important;
}
.editor-container {
- flex: 3;
height: 100%;
overflow: hidden;
}
diff --git a/packages/markdown/assets/directive-pyide/webworker.js b/packages/markdown/assets/directive-pyide/webworker.js
deleted file mode 100644
index 08b1cdfa1..000000000
--- a/packages/markdown/assets/directive-pyide/webworker.js
+++ /dev/null
@@ -1,98 +0,0 @@
-importScripts("https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js");
-
-class StdinHandler {
- constructor(results, options) {
- this.results = results;
- this.idx = 0;
- Object.assign(this, options);
- }
-
- stdin() {
- return this.results[this.idx++];
- }
-}
-
-async function loadPyodideAndPackages() {
- var pyodide = await loadPyodide();
- self.pyodide = pyodide;
- await self.pyodide.loadPackage([]);
-}
-let pyodideReadyPromise = loadPyodideAndPackages();
-
-const canvases = {};
-
-self.onmessage = async ({ data: { id, type, payload } }) => {
- switch (type) {
- case "setCanvas": {
- const { canvas } = payload;
- canvases[id] = canvas;
- break;
- }
- case "run": {
- // make sure loading is done
- await pyodideReadyPromise;
- // Don't bother yet with this line, suppose our API is built in such a way:
- const { python, inputs, ...context } = payload;
- // The worker copies the context in its own "memory" (an object mapping name to values)
- for (const key of Object.keys(context)) {
- self[key] = context[key];
- }
-
- if (canvases[id]) {
- try {
- self.pyodide.canvas.setCanvas2D(canvases[id]);
- } catch (error) {
- self.postMessage({ id, type: "stderr", payload: `Canvas setup failed: ${error.message}` });
- }
- }
-
- self.pyodide.setStdin(new StdinHandler(inputs));
-
- self.pyodide.setStdout({
- batched: (msg) => {
- self.postMessage({ id, type: "stdout", payload: msg });
- },
- });
-
- self.pyodide.setStderr({
- batched: (msg) => {
- self.postMessage({ id, type: "stderr", payload: msg });
- },
- });
-
- // Now is the easy part, the one that is similar to working in the main thread:
- const filename = "";
- try {
- await self.pyodide.loadPackagesFromImports(python);
- const dict = self.pyodide.globals.get("dict");
- const globals = dict();
- let results = await self.pyodide.runPythonAsync(python, {
- globals,
- locals: globals,
- filename,
- });
- globals.destroy();
- dict.destroy();
- self.postMessage({ type: "success", id, payload: results });
- } catch (error) {
- // clean up trackback
- let message = error.message;
- if (message.startsWith("Traceback")) {
- const lines = message?.split("\n") || [];
- const i = lines.findIndex((line) => line.includes(filename));
- message = lines[0] + "\n" + lines.slice(i).join("\n");
- self.postMessage({ type: "error", payload: message, id });
- }
- self.postMessage({ type: "error", payload: message, id });
- }
- break;
- }
- case "setInterruptBuffer": {
- const { interruptBuffer } = payload;
- // make sure loading is done
- await pyodideReadyPromise;
- self.pyodide.setInterruptBuffer(interruptBuffer);
- break;
- }
- }
-};
diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js
index fdaa4cae7..bf7ceeccb 100644
--- a/packages/markdown/assets/directive-typst/client.js
+++ b/packages/markdown/assets/directive-typst/client.js
@@ -1148,6 +1148,70 @@ hyperbook.typst = (function () {
// TYPST EDITOR
// ============================================================================
+ function setupSplitter(elem, container, editorContainer, splitter) {
+ if (!container || !editorContainer || !splitter) return;
+
+ const minPanelSize = 120;
+
+ const getIsHorizontal = () =>
+ getComputedStyle(elem).flexDirection.startsWith('row');
+
+ const applySplitSize = (rawSize, isHorizontal) => {
+ const total = isHorizontal ? elem.clientWidth : elem.clientHeight;
+ const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight;
+ const maxSize = Math.max(minPanelSize, total - splitterSize - minPanelSize);
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
+ container.style.flex = `0 0 ${clamped}px`;
+ return clamped;
+ };
+
+ const applyStoredSplitSize = () => {
+ const isHorizontal = getIsHorizontal();
+ elem.classList.toggle('split-horizontal', isHorizontal);
+ elem.classList.toggle('split-vertical', !isHorizontal);
+ const key = isHorizontal ? 'splitHorizontal' : 'splitVertical';
+ const rawStored = Number(elem.dataset[key]);
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
+ container.style.flex = '';
+ return;
+ }
+ applySplitSize(rawStored, isHorizontal);
+ };
+
+ applyStoredSplitSize();
+
+ splitter.addEventListener('mousedown', (event) => {
+ event.preventDefault();
+
+ const isHorizontal = getIsHorizontal();
+ const key = isHorizontal ? 'splitHorizontal' : 'splitVertical';
+ const startPointer = isHorizontal ? event.clientX : event.clientY;
+ const startSize = isHorizontal
+ ? container.getBoundingClientRect().width
+ : container.getBoundingClientRect().height;
+
+ elem.classList.add('resizing');
+
+ const onMouseMove = (moveEvent) => {
+ const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
+ const delta = pointer - startPointer;
+ const size = applySplitSize(startSize + delta, isHorizontal);
+ elem.dataset[key] = String(Math.round(size));
+ };
+
+ const onMouseUp = () => {
+ elem.classList.remove('resizing');
+ window.removeEventListener('mousemove', onMouseMove);
+ window.removeEventListener('mouseup', onMouseUp);
+ };
+
+ window.addEventListener('mousemove', onMouseMove);
+ window.addEventListener('mouseup', onMouseUp);
+ });
+
+ window.addEventListener('resize', applyStoredSplitSize);
+ }
+
class TypstEditor {
constructor({
elem,
@@ -1178,8 +1242,12 @@ hyperbook.typst = (function () {
this.preview = elem.querySelector('.typst-preview');
this.loadingIndicator = elem.querySelector('.typst-loading');
this.editor = elem.querySelector('.editor.typst');
+ this.editorContainer = elem.querySelector('.editor-container');
+ this.splitter = elem.querySelector('.splitter');
this.sourceTextarea = elem.querySelector('.typst-source');
+ setupSplitter(this.elem, this.previewContainer, this.editorContainer, this.splitter);
+
// Setup UI callbacks
this.setupUICallbacks();
diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css
index 24f8d65dc..3d6b19289 100644
--- a/packages/markdown/assets/directive-typst/style.css
+++ b/packages/markdown/assets/directive-typst/style.css
@@ -5,7 +5,7 @@
flex-direction: column;
overflow: hidden;
gap: 8px;
- height: calc(100dvh - 128px);
+ height: calc(100dvh - 80px);
}
code-input {
@@ -14,6 +14,8 @@ code-input {
.directive-typst .preview-container {
width: 100%;
+ min-height: 120px;
+ min-width: 120px;
border: 1px solid var(--color-spacer);
border-radius: 8px;
overflow: auto;
@@ -167,9 +169,44 @@ code-input {
width: 100%;
display: flex;
flex-direction: column;
+ min-height: 120px;
+ min-width: 120px;
height: 400px;
}
+.directive-typst .splitter {
+ background: var(--color-spacer);
+ border-radius: 999px;
+ flex-shrink: 0;
+ touch-action: none;
+ opacity: 0.45;
+ transition: opacity 0.15s ease-in-out;
+}
+
+.directive-typst .splitter:hover {
+ opacity: 0.65;
+}
+
+.directive-typst.split-vertical .splitter {
+ width: 100%;
+ height: 4px;
+ cursor: row-resize;
+}
+
+.directive-typst.split-horizontal .splitter {
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+}
+
+.directive-typst.resizing {
+ user-select: none;
+}
+
+.directive-typst.resizing .splitter {
+ opacity: 0.75;
+}
+
.directive-typst .file-tabs {
display: flex;
align-items: center;
@@ -450,7 +487,7 @@ code-input {
@media screen and (min-width: 1024px) {
.directive-typst:not(.preview-only) {
flex-direction: row;
- height: calc(100dvh - 128px);
+ height: calc(100dvh - 80px);
.preview-container {
flex: 1;
diff --git a/packages/markdown/assets/directive-webide/client.js b/packages/markdown/assets/directive-webide/client.js
index d7707297b..8b1ff9cbe 100644
--- a/packages/markdown/assets/directive-webide/client.js
+++ b/packages/markdown/assets/directive-webide/client.js
@@ -16,7 +16,77 @@ hyperbook.webide = (function () {
]),
);
+ function setupSplitter(elem, container, editorContainer, splitter) {
+ if (!container || !editorContainer || !splitter) return;
+
+ const minPanelSize = 120;
+
+ const getIsHorizontal = () =>
+ getComputedStyle(elem).flexDirection.startsWith("row");
+
+ const applySplitSize = (rawSize, isHorizontal) => {
+ const total = isHorizontal ? elem.clientWidth : elem.clientHeight;
+ const splitterSize = isHorizontal ? splitter.offsetWidth : splitter.offsetHeight;
+ const maxSize = Math.max(minPanelSize, total - splitterSize - minPanelSize);
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
+ container.style.flex = `0 0 ${clamped}px`;
+ return clamped;
+ };
+
+ const applyStoredSplitSize = () => {
+ const isHorizontal = getIsHorizontal();
+ elem.classList.toggle("split-horizontal", isHorizontal);
+ elem.classList.toggle("split-vertical", !isHorizontal);
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
+ const rawStored = Number(elem.dataset[key]);
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
+ container.style.flex = "";
+ return;
+ }
+ applySplitSize(rawStored, isHorizontal);
+ };
+
+ applyStoredSplitSize();
+
+ splitter.addEventListener("mousedown", (event) => {
+ event.preventDefault();
+
+ const isHorizontal = getIsHorizontal();
+ const key = isHorizontal ? "splitHorizontal" : "splitVertical";
+ const startPointer = isHorizontal ? event.clientX : event.clientY;
+ const startSize = isHorizontal
+ ? container.getBoundingClientRect().width
+ : container.getBoundingClientRect().height;
+
+ elem.classList.add("resizing");
+
+ const onMouseMove = (moveEvent) => {
+ const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
+ const delta = pointer - startPointer;
+ const size = applySplitSize(startSize + delta, isHorizontal);
+ elem.dataset[key] = String(Math.round(size));
+ };
+
+ const onMouseUp = () => {
+ elem.classList.remove("resizing");
+ window.removeEventListener("mousemove", onMouseMove);
+ window.removeEventListener("mouseup", onMouseUp);
+ };
+
+ window.addEventListener("mousemove", onMouseMove);
+ window.addEventListener("mouseup", onMouseUp);
+ });
+
+ window.addEventListener("resize", applyStoredSplitSize);
+ }
+
function initElement(elem) {
+ if (elem.getAttribute("data-webide-initialized") === "true") return;
+ elem.setAttribute("data-webide-initialized", "true");
+
+ const container = elem.querySelector(".container");
+ const editorContainer = elem.querySelector(".editor-container");
+ const splitter = elem.querySelector(".splitter");
const title = elem.getElementsByClassName("container-title")[0];
/** @type {HTMLTextAreaElement | null} */
const editorHTML = elem.querySelector(".editor.html");
@@ -39,6 +109,8 @@ hyperbook.webide = (function () {
/** @type {HTMLButtonElement} */
const downloadEl = elem.querySelector("button.download");
+ setupSplitter(elem, container, editorContainer, splitter);
+
resetEl?.addEventListener("click", () => {
if (window.confirm(hyperbook.i18n.get("webide-reset-prompt"))) {
hyperbook.store.db.webide.delete(id);
diff --git a/packages/markdown/assets/directive-webide/style.css b/packages/markdown/assets/directive-webide/style.css
index b20021ee2..70f95778c 100644
--- a/packages/markdown/assets/directive-webide/style.css
+++ b/packages/markdown/assets/directive-webide/style.css
@@ -13,6 +13,8 @@ code-input {
.directive-webide .container {
width: 100%;
+ min-height: 120px;
+ min-width: 120px;
border: 1px solid var(--color-spacer);
border-radius: 8px;
overflow: hidden;
@@ -47,9 +49,44 @@ code-input {
width: 100%;
display: flex;
flex-direction: column;
+ min-height: 120px;
+ min-width: 120px;
height: 400px;
}
+.directive-webide .splitter {
+ background: var(--color-spacer);
+ border-radius: 999px;
+ flex-shrink: 0;
+ touch-action: none;
+ opacity: 0.45;
+ transition: opacity 0.15s ease-in-out;
+}
+
+.directive-webide .splitter:hover {
+ opacity: 0.65;
+}
+
+.directive-webide.split-vertical .splitter {
+ width: 100%;
+ height: 4px;
+ cursor: row-resize;
+}
+
+.directive-webide.split-horizontal .splitter {
+ width: 4px;
+ height: 100%;
+ cursor: col-resize;
+}
+
+.directive-webide.resizing {
+ user-select: none;
+}
+
+.directive-webide.resizing .splitter {
+ opacity: 0.75;
+}
+
.directive-webide .editor {
width: 100%;
border: 1px solid var(--color-spacer);
@@ -109,7 +146,7 @@ code-input {
@media screen and (min-width: 1024px) {
.directive-webide:not(.standalone) {
flex-direction: row;
- height: calc(100dvh - 128px);
+ height: calc(100dvh - 80px);
.container {
flex: 1;
height: 100% !important;
diff --git a/packages/markdown/src/rehypeDirectiveP5.ts b/packages/markdown/src/rehypeDirectiveP5.ts
index d303359e8..49f0d7e11 100644
--- a/packages/markdown/src/rehypeDirectiveP5.ts
+++ b/packages/markdown/src/rehypeDirectiveP5.ts
@@ -77,10 +77,16 @@ ${(code.scripts ? [cdnLibraryUrl, ...code.scripts] : []).map((src) => `
" data-id="75cdf8b7911ccb1acf7563021378641adb954be794bd67bc63cde5abe10da43c">
-
+
+
print("Hello World")
@@ -23,9 +24,12 @@ exports[`remarkDirectivePyide > should transform pyide with canvas attribute 1`]
-
+
+
+
+
import pygame
diff --git a/platforms/vscode/schemas/hyperbook.schema.json b/platforms/vscode/schemas/hyperbook.schema.json
index a0f46268f..7956991fc 100644
--- a/platforms/vscode/schemas/hyperbook.schema.json
+++ b/platforms/vscode/schemas/hyperbook.schema.json
@@ -361,6 +361,15 @@
},
"trailingSlash": {
"type": "boolean"
+ },
+ "version": {
+ "description": "Controls how the Hyperbook version is displayed.\n- \"text\": Shows version below the \"Powered by Hyperbook\" label.\n- \"tooltip\": Shows version on hover of the \"Powered by Hyperbook\" label.\n- \"console\": Outputs version as ASCII art in the browser console (default).",
+ "enum": [
+ "console",
+ "text",
+ "tooltip"
+ ],
+ "type": "string"
}
},
"required": [
From 0cc17867b7386c94485b05b58c45807a1bfd8da3 Mon Sep 17 00:00:00 2001
From: Mike Barkmin
Date: Sat, 9 May 2026 17:16:44 +0200
Subject: [PATCH 06/14] change order in ci workflow
---
.github/workflows/pull-request.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml
index b590f15a8..893854c7c 100644
--- a/.github/workflows/pull-request.yml
+++ b/.github/workflows/pull-request.yml
@@ -20,7 +20,7 @@ jobs:
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- - name: Test packages
- run: pnpm test
- name: Build packages
run: pnpm build
+ - name: Test packages
+ run: pnpm test
From cdc93164c686d81cd4483908968b60af5a199f86 Mon Sep 17 00:00:00 2001
From: Mike Barkmin
Date: Sat, 9 May 2026 17:32:49 +0200
Subject: [PATCH 07/14] fix resize on touch devices
---
packages/markdown/assets/directive-p5/client.js | 17 ++++++++++-------
.../markdown/assets/directive-pyide/client.js | 17 ++++++++++-------
.../markdown/assets/directive-typst/client.js | 17 ++++++++++-------
.../markdown/assets/directive-webide/client.js | 17 ++++++++++-------
4 files changed, 40 insertions(+), 28 deletions(-)
diff --git a/packages/markdown/assets/directive-p5/client.js b/packages/markdown/assets/directive-p5/client.js
index f57f24ac0..162f0b005 100644
--- a/packages/markdown/assets/directive-p5/client.js
+++ b/packages/markdown/assets/directive-p5/client.js
@@ -59,8 +59,9 @@ hyperbook.p5 = (function () {
applyStoredSplitSize();
- splitter.addEventListener("mousedown", (event) => {
+ splitter.addEventListener("pointerdown", (event) => {
event.preventDefault();
+ splitter.setPointerCapture(event.pointerId);
const isHorizontal = getIsHorizontal();
const key = isHorizontal ? "splitHorizontal" : "splitVertical";
@@ -71,21 +72,23 @@ hyperbook.p5 = (function () {
elem.classList.add("resizing");
- const onMouseMove = (moveEvent) => {
+ const onPointerMove = (moveEvent) => {
const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = pointer - startPointer;
const size = applySplitSize(startSize + delta, isHorizontal);
elem.dataset[key] = String(Math.round(size));
};
- const onMouseUp = () => {
+ const onPointerUp = () => {
elem.classList.remove("resizing");
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
+ splitter.removeEventListener("pointermove", onPointerMove);
+ splitter.removeEventListener("pointerup", onPointerUp);
+ splitter.removeEventListener("pointercancel", onPointerUp);
};
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
+ splitter.addEventListener("pointermove", onPointerMove);
+ splitter.addEventListener("pointerup", onPointerUp);
+ splitter.addEventListener("pointercancel", onPointerUp);
});
window.addEventListener("resize", applyStoredSplitSize);
diff --git a/packages/markdown/assets/directive-pyide/client.js b/packages/markdown/assets/directive-pyide/client.js
index 22e450b61..32956ff61 100644
--- a/packages/markdown/assets/directive-pyide/client.js
+++ b/packages/markdown/assets/directive-pyide/client.js
@@ -254,8 +254,9 @@ hyperbook.python = (function () {
applyStoredSplitSize();
- splitter.addEventListener("mousedown", (event) => {
+ splitter.addEventListener("pointerdown", (event) => {
event.preventDefault();
+ splitter.setPointerCapture(event.pointerId);
const isHorizontal = getIsHorizontal();
const key = isHorizontal ? "splitHorizontal" : "splitVertical";
@@ -266,21 +267,23 @@ hyperbook.python = (function () {
elem.classList.add("resizing");
- const onMouseMove = (moveEvent) => {
+ const onPointerMove = (moveEvent) => {
const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = pointer - startPointer;
const size = applySplitSize(startSize + delta, isHorizontal);
elem.dataset[key] = String(Math.round(size));
};
- const onMouseUp = () => {
+ const onPointerUp = () => {
elem.classList.remove("resizing");
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
+ splitter.removeEventListener("pointermove", onPointerMove);
+ splitter.removeEventListener("pointerup", onPointerUp);
+ splitter.removeEventListener("pointercancel", onPointerUp);
};
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
+ splitter.addEventListener("pointermove", onPointerMove);
+ splitter.addEventListener("pointerup", onPointerUp);
+ splitter.addEventListener("pointercancel", onPointerUp);
});
window.addEventListener("resize", applyStoredSplitSize);
diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js
index bf7ceeccb..b31db5942 100644
--- a/packages/markdown/assets/directive-typst/client.js
+++ b/packages/markdown/assets/directive-typst/client.js
@@ -1180,8 +1180,9 @@ hyperbook.typst = (function () {
applyStoredSplitSize();
- splitter.addEventListener('mousedown', (event) => {
+ splitter.addEventListener('pointerdown', (event) => {
event.preventDefault();
+ splitter.setPointerCapture(event.pointerId);
const isHorizontal = getIsHorizontal();
const key = isHorizontal ? 'splitHorizontal' : 'splitVertical';
@@ -1192,21 +1193,23 @@ hyperbook.typst = (function () {
elem.classList.add('resizing');
- const onMouseMove = (moveEvent) => {
+ const onPointerMove = (moveEvent) => {
const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = pointer - startPointer;
const size = applySplitSize(startSize + delta, isHorizontal);
elem.dataset[key] = String(Math.round(size));
};
- const onMouseUp = () => {
+ const onPointerUp = () => {
elem.classList.remove('resizing');
- window.removeEventListener('mousemove', onMouseMove);
- window.removeEventListener('mouseup', onMouseUp);
+ splitter.removeEventListener('pointermove', onPointerMove);
+ splitter.removeEventListener('pointerup', onPointerUp);
+ splitter.removeEventListener('pointercancel', onPointerUp);
};
- window.addEventListener('mousemove', onMouseMove);
- window.addEventListener('mouseup', onMouseUp);
+ splitter.addEventListener('pointermove', onPointerMove);
+ splitter.addEventListener('pointerup', onPointerUp);
+ splitter.addEventListener('pointercancel', onPointerUp);
});
window.addEventListener('resize', applyStoredSplitSize);
diff --git a/packages/markdown/assets/directive-webide/client.js b/packages/markdown/assets/directive-webide/client.js
index 8b1ff9cbe..7c2d2747f 100644
--- a/packages/markdown/assets/directive-webide/client.js
+++ b/packages/markdown/assets/directive-webide/client.js
@@ -48,8 +48,9 @@ hyperbook.webide = (function () {
applyStoredSplitSize();
- splitter.addEventListener("mousedown", (event) => {
+ splitter.addEventListener("pointerdown", (event) => {
event.preventDefault();
+ splitter.setPointerCapture(event.pointerId);
const isHorizontal = getIsHorizontal();
const key = isHorizontal ? "splitHorizontal" : "splitVertical";
@@ -60,21 +61,23 @@ hyperbook.webide = (function () {
elem.classList.add("resizing");
- const onMouseMove = (moveEvent) => {
+ const onPointerMove = (moveEvent) => {
const pointer = isHorizontal ? moveEvent.clientX : moveEvent.clientY;
const delta = pointer - startPointer;
const size = applySplitSize(startSize + delta, isHorizontal);
elem.dataset[key] = String(Math.round(size));
};
- const onMouseUp = () => {
+ const onPointerUp = () => {
elem.classList.remove("resizing");
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
+ splitter.removeEventListener("pointermove", onPointerMove);
+ splitter.removeEventListener("pointerup", onPointerUp);
+ splitter.removeEventListener("pointercancel", onPointerUp);
};
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
+ splitter.addEventListener("pointermove", onPointerMove);
+ splitter.addEventListener("pointerup", onPointerUp);
+ splitter.addEventListener("pointercancel", onPointerUp);
});
window.addEventListener("resize", applyStoredSplitSize);
From 2d0b0de425da497dce5817707602d8958a7f8f4f Mon Sep 17 00:00:00 2001
From: Mike Barkmin
Date: Sun, 10 May 2026 00:18:02 +0200
Subject: [PATCH 08/14] add fullscreen mode
---
.../markdown/assets/directive-p5/client.js | 37 ++
.../markdown/assets/directive-p5/style.css | 20 +-
.../markdown/assets/directive-pyide/client.js | 512 +++++++++++++++---
.../markdown/assets/directive-pyide/style.css | 119 +++-
.../markdown/assets/directive-typst/client.js | 35 ++
.../markdown/assets/directive-typst/style.css | 24 +-
.../assets/directive-webide/client.js | 38 ++
.../assets/directive-webide/style.css | 20 +-
packages/markdown/locales/de.json | 11 +
packages/markdown/locales/en.json | 11 +
packages/markdown/src/rehypeDirectiveP5.ts | 13 +
packages/markdown/src/remarkDirectivePyide.ts | 102 ++--
packages/markdown/src/remarkDirectiveTypst.ts | 17 +-
.../markdown/src/remarkDirectiveWebide.ts | 13 +
.../remarkDirectivePyide.test.ts.snap | 19 +-
website/de/book/elements/pyide.md | 72 ++-
website/en/book/elements/pyide.md | 68 +--
17 files changed, 949 insertions(+), 182 deletions(-)
diff --git a/packages/markdown/assets/directive-p5/client.js b/packages/markdown/assets/directive-p5/client.js
index 162f0b005..9a005f553 100644
--- a/packages/markdown/assets/directive-p5/client.js
+++ b/packages/markdown/assets/directive-p5/client.js
@@ -94,6 +94,32 @@ hyperbook.p5 = (function () {
window.addEventListener("resize", applyStoredSplitSize);
}
+ const updateFullscreenButtonState = (elem, button) => {
+ if (!elem || !button) return;
+ const isFullscreen = document.fullscreenElement === elem;
+ button.textContent = hyperbook.i18n.get(
+ isFullscreen ? "ide-fullscreen-exit" : "ide-fullscreen-enter",
+ );
+ button.classList.toggle("active", isFullscreen);
+ };
+
+ const toggleFullscreen = async (elem) => {
+ if (!elem) return;
+ if (document.fullscreenElement === elem) {
+ await document.exitFullscreen();
+ return;
+ }
+ await elem.requestFullscreen();
+ };
+
+ const syncFullscreenButtons = () => {
+ const elems = document.querySelectorAll(".directive-p5");
+ elems.forEach((elem) => {
+ const fullscreen = elem.querySelector("button.fullscreen");
+ updateFullscreenButtonState(elem, fullscreen);
+ });
+ };
+
function initElement(elem) {
if (elem.getAttribute("data-p5-initialized") === "true") return;
elem.setAttribute("data-p5-initialized", "true");
@@ -110,9 +136,19 @@ hyperbook.p5 = (function () {
const copyEl = elem.getElementsByClassName("copy")[0];
const resetEl = elem.getElementsByClassName("reset")[0];
const downloadEl = elem.getElementsByClassName("download")[0];
+ const fullscreenEl = elem.getElementsByClassName("fullscreen")[0];
setupSplitter(elem, container, editorContainer, splitter);
+ fullscreenEl?.addEventListener("click", async () => {
+ try {
+ await toggleFullscreen(elem);
+ } catch (error) {
+ console.error(error.message);
+ }
+ });
+ updateFullscreenButtonState(elem, fullscreenEl);
+
if (frame) {
frame.srcdoc = frame.srcdoc.replaceAll(
"###ORIGIN###",
@@ -192,6 +228,7 @@ hyperbook.p5 = (function () {
});
observer.observe(document.body, { childList: true, subtree: true });
+ document.addEventListener("fullscreenchange", syncFullscreenButtons);
return { init };
})();
diff --git a/packages/markdown/assets/directive-p5/style.css b/packages/markdown/assets/directive-p5/style.css
index 9407d7259..57deec5cb 100644
--- a/packages/markdown/assets/directive-p5/style.css
+++ b/packages/markdown/assets/directive-p5/style.css
@@ -75,6 +75,7 @@ code-input {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
+ overflow: hidden;
}
.directive-p5 .buttons.bottom {
@@ -95,10 +96,15 @@ code-input {
cursor: pointer;
}
-.directive-p5 .buttons:last-child {
+.directive-p5 .buttons button:last-child {
border-right: none;
}
+.directive-p5 button.fullscreen {
+ flex: 0 0 auto;
+ min-width: 120px;
+}
+
.directive-p5 button:hover {
background-color: var(--color-spacer);
}
@@ -113,6 +119,18 @@ code-input {
margin: 0 auto;
}
+.directive-p5:fullscreen {
+ width: 100vw;
+ height: 100dvh !important;
+ padding: 12px;
+ box-sizing: border-box;
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
+.directive-p5:fullscreen::backdrop {
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
@media screen and (min-width: 1024px) {
.directive-p5:not(.standalone) {
flex-direction: row;
diff --git a/packages/markdown/assets/directive-pyide/client.js b/packages/markdown/assets/directive-pyide/client.js
index 32956ff61..40616d925 100644
--- a/packages/markdown/assets/directive-pyide/client.js
+++ b/packages/markdown/assets/directive-pyide/client.js
@@ -16,18 +16,6 @@ hyperbook.python = (function () {
])
);
- class StdinHandler {
- constructor(results, options) {
- this.results = results;
- this.idx = 0;
- Object.assign(this, options);
- }
-
- stdin() {
- return this.results[this.idx++];
- }
- }
-
const PYODIDE_CDN = "https://cdn.jsdelivr.net/pyodide/v0.29.4/full/pyodide.js";
const loadPyodideScript = () => {
@@ -58,6 +46,10 @@ hyperbook.python = (function () {
* @type {Map}
*/
const executionStates = new Map();
+ /**
+ * @type {Map}
+ */
+ const interruptBuffers = new Map();
const getExecutionState = (id) => {
if (!executionStates.has(id)) {
@@ -77,6 +69,15 @@ hyperbook.python = (function () {
}
const loadPyodide = await pyodideReadyPromise;
const pyodide = await loadPyodide();
+ if (
+ typeof SharedArrayBuffer !== "undefined" &&
+ window.crossOriginIsolated &&
+ typeof pyodide.setInterruptBuffer === "function"
+ ) {
+ const interruptBuffer = new Int32Array(new SharedArrayBuffer(4));
+ pyodide.setInterruptBuffer(interruptBuffer);
+ interruptBuffers.set(id, interruptBuffer);
+ }
runtimes.set(id, pyodide);
return pyodide;
};
@@ -91,6 +92,65 @@ hyperbook.python = (function () {
output.appendChild(document.createTextNode(message + "\n"));
};
+ const appendOutputErrorLine = (id, message) => {
+ const output = getOutput(id);
+ if (!output) return;
+ const line = document.createElement("span");
+ line.classList.add("error-line");
+ line.textContent = message + "\n";
+ output.appendChild(line);
+ };
+
+ const appendOutput = (output, message, isError = false) => {
+ if (!output || message === undefined || message === null) return;
+ if (isError) {
+ const line = document.createElement("span");
+ line.classList.add("error-line");
+ line.textContent = String(message);
+ output.appendChild(line);
+ return;
+ }
+ output.appendChild(document.createTextNode(String(message)));
+ };
+
+ const updateFullscreenButtonState = (elem, button) => {
+ if (!elem || !button) return;
+ const isFullscreen = document.fullscreenElement === elem;
+ button.textContent = hyperbook.i18n.get(
+ isFullscreen ? "ide-fullscreen-exit" : "ide-fullscreen-enter",
+ );
+ button.classList.toggle("active", isFullscreen);
+ };
+
+ const toggleFullscreen = async (elem) => {
+ if (!elem) return;
+ if (document.fullscreenElement === elem) {
+ await document.exitFullscreen();
+ return;
+ }
+ await elem.requestFullscreen();
+ };
+
+ const syncFullscreenButtons = () => {
+ const elems = document.getElementsByClassName("directive-pyide");
+ for (const elem of elems) {
+ const fullscreen = elem.getElementsByClassName("fullscreen")[0];
+ updateFullscreenButtonState(elem, fullscreen);
+ }
+ };
+
+ const releaseKeyboardCapture = (id) => {
+ const elem = document.getElementById(id);
+ if (!elem) return;
+ const canvas = elem.getElementsByClassName("canvas")[0];
+ const editor = elem.getElementsByClassName("editor")[0];
+ document.activeElement?.blur?.();
+ canvas?.blur?.();
+ window.setTimeout(() => {
+ editor?.focus?.();
+ }, 0);
+ };
+
const resetCanvas = (canvas) => {
if (!canvas) return;
const context = canvas.getContext("2d");
@@ -101,23 +161,31 @@ hyperbook.python = (function () {
const filename = "";
try {
const pyodide = await getRuntime(id);
- const { inputs = [], canvas, ...globalsContext } = context;
+ const { canvas, ...globalsContext } = context;
if (canvas) {
try {
resetCanvas(canvas);
pyodide.canvas.setCanvas2D(canvas);
} catch (error) {
- appendOutputLine(id, `Canvas setup failed: ${error.message}`);
+ appendOutputErrorLine(id, `Canvas setup failed: ${error.message}`);
}
}
- pyodide.setStdin(new StdinHandler(inputs));
+ pyodide.setStdin({
+ stdin: () => {
+ const value = window.prompt(hyperbook.i18n.get("pyide-input-prompt"));
+ if (value === null) {
+ return "";
+ }
+ return value;
+ },
+ });
pyodide.setStdout({
batched: (msg) => appendOutputLine(id, msg),
});
pyodide.setStderr({
- batched: (msg) => appendOutputLine(id, msg),
+ batched: (msg) => appendOutputErrorLine(id, msg),
});
await pyodide.loadPackagesFromImports(script);
@@ -150,11 +218,24 @@ hyperbook.python = (function () {
const requestStop = (id) => {
const state = getExecutionState(id);
- if (!state.running || state.stopRequested) return;
+ const hasRuntime = runtimes.has(id);
+ if ((!state.running && !hasRuntime) || state.stopRequested) return;
state.stopRequested = true;
state.stopping = true;
- appendOutputLine(id, "Stop requested. Finishing current execution...");
+ const interruptBuffer = interruptBuffers.get(id);
+ if (interruptBuffer) {
+ interruptBuffer[0] = 2;
+ appendOutputLine(id, "Stop requested. Interrupting execution...");
+ } else {
+ appendOutputLine(id, hyperbook.i18n.get("pyide-stop-reloading"));
+ }
+ releaseKeyboardCapture(id);
updateRunning();
+ if (!interruptBuffer) {
+ window.setTimeout(() => {
+ window.location.reload();
+ }, 50);
+ }
};
const handleStopClick = (event) => {
@@ -163,48 +244,99 @@ hyperbook.python = (function () {
requestStop(elem.id);
};
+ const getRunningInstanceId = () => {
+ const elems = document.getElementsByClassName("directive-pyide");
+ for (const elem of elems) {
+ if (getExecutionState(elem.id).running) {
+ return elem.id;
+ }
+ }
+ return null;
+ };
+
const updateRunning = () => {
+ const runningInstanceId = getRunningInstanceId();
const elems = document.getElementsByClassName("directive-pyide");
for (let elem of elems) {
const run = elem.getElementsByClassName("run")[0];
const test = elem.getElementsByClassName("test")[0];
+ const stop = elem.getElementsByClassName("stop")[0];
+ const editor = elem.getElementsByClassName("editor")[0];
+ const editorTextarea = editor?.querySelector("textarea");
const state = getExecutionState(elem.id);
-
- run.removeEventListener("click", handleStopClick);
- test?.removeEventListener("click", handleStopClick);
+ const hasRuntime = runtimes.has(elem.id);
+ const hasInterrupt = interruptBuffers.has(elem.id);
+ const lockedByOther =
+ runningInstanceId !== null &&
+ runningInstanceId !== elem.id &&
+ !state.running;
+
+ stop?.removeEventListener("click", handleStopClick);
run.classList.remove("stopping");
+ run.classList.remove("locked");
test?.classList.remove("stopping");
-
- if (state.running) {
- if (state.type === "run") {
- run.textContent = state.stopping
- ? "Stopping..."
- : hyperbook.i18n.get("pyide-running-click-to-stop");
- run.disabled = false;
- run.addEventListener("click", handleStopClick);
- run.classList.toggle("stopping", state.stopping);
+ test?.classList.remove("locked");
+ stop?.classList.remove("stopping");
+ elem.classList.toggle("locked-by-other", lockedByOther);
+
+ if (state.running || lockedByOther) {
+ editor?.setAttribute("disabled", "");
+ editor?.classList.add("running");
+ if (editorTextarea) {
+ editorTextarea.readOnly = true;
+ }
+ if (state.running && state.type === "run") {
+ run.textContent = hyperbook.i18n.get("pyide-running");
+ run.disabled = true;
+ run.classList.add("running");
if (test) {
test.classList.add("running");
test.disabled = true;
}
- } else if (state.type === "test" && test) {
- test.textContent = state.stopping
- ? "Stopping..."
- : hyperbook.i18n.get("pyide-testing-click-to-stop");
- test.disabled = false;
- test.addEventListener("click", handleStopClick);
- test.classList.toggle("stopping", state.stopping);
+ } else if (state.running && state.type === "test" && test) {
+ test.textContent = hyperbook.i18n.get("pyide-testing");
+ test.disabled = true;
+ test.classList.add("running");
run.classList.add("running");
run.disabled = true;
} else {
+ const lockLabel = lockedByOther
+ ? "pyide-locked-other-instance-running"
+ : "pyide-run";
+ run.textContent = hyperbook.i18n.get(lockLabel);
run.classList.add("running");
+ run.classList.toggle("locked", lockedByOther);
run.disabled = true;
if (test) {
+ test.textContent = hyperbook.i18n.get(
+ lockedByOther ? "pyide-locked-other-instance-running" : "pyide-test",
+ );
+ test.classList.toggle("locked", lockedByOther);
test.classList.add("running");
test.disabled = true;
}
}
+
+ if (stop) {
+ const stopLabel = hasInterrupt ? "pyide-stop" : "pyide-stop-refresh";
+ if (state.running) {
+ stop.textContent = state.stopping
+ ? hyperbook.i18n.get("pyide-stopping")
+ : hyperbook.i18n.get(stopLabel);
+ stop.disabled = false;
+ stop.addEventListener("click", handleStopClick);
+ } else {
+ stop.textContent = hyperbook.i18n.get(stopLabel);
+ stop.disabled = true;
+ }
+ stop.classList.toggle("stopping", state.stopping);
+ }
} else {
+ editor?.removeAttribute("disabled");
+ editor?.classList.remove("running");
+ if (editorTextarea) {
+ editorTextarea.readOnly = false;
+ }
run.classList.remove("stopping");
run.classList.remove("running");
run.textContent = hyperbook.i18n.get("pyide-run");
@@ -215,11 +347,28 @@ hyperbook.python = (function () {
test.textContent = hyperbook.i18n.get("pyide-test");
test.disabled = false;
}
+ if (stop) {
+ stop.classList.remove("stopping");
+ stop.classList.remove("running");
+ stop.textContent = hyperbook.i18n.get(
+ hasInterrupt ? "pyide-stop" : "pyide-stop-refresh",
+ );
+ stop.disabled = !hasRuntime;
+ if (hasRuntime) {
+ stop.addEventListener("click", handleStopClick);
+ }
+ }
}
}
};
- const setupSplitter = (elem, container, editorContainer, splitter) => {
+ const setupSplitter = (
+ elem,
+ container,
+ editorContainer,
+ splitter,
+ onSplitChanged,
+ ) => {
if (!container || !editorContainer || !splitter) return;
const minPanelSize = 120;
@@ -279,6 +428,88 @@ hyperbook.python = (function () {
splitter.removeEventListener("pointermove", onPointerMove);
splitter.removeEventListener("pointerup", onPointerUp);
splitter.removeEventListener("pointercancel", onPointerUp);
+ const splitHorizontal = Number(elem.dataset.splitHorizontal);
+ const splitVertical = Number(elem.dataset.splitVertical);
+ onSplitChanged?.({
+ ...(Number.isFinite(splitHorizontal) && splitHorizontal > 0
+ ? { splitHorizontal: Math.round(splitHorizontal) }
+ : {}),
+ ...(Number.isFinite(splitVertical) && splitVertical > 0
+ ? { splitVertical: Math.round(splitVertical) }
+ : {}),
+ });
+ };
+
+ splitter.addEventListener("pointermove", onPointerMove);
+ splitter.addEventListener("pointerup", onPointerUp);
+ splitter.addEventListener("pointercancel", onPointerUp);
+ });
+
+ window.addEventListener("resize", applyStoredSplitSize);
+ return applyStoredSplitSize;
+ };
+
+ const setupCanvasOutputSplitter = (
+ elem,
+ container,
+ canvasWrapper,
+ output,
+ splitter,
+ onSplitChanged,
+ ) => {
+ if (!elem || !container || !canvasWrapper || !output || !splitter) return;
+
+ const minPanelSize = 80;
+
+ const getAvailableHeight = () => {
+ const tabs = container.querySelector(".buttons");
+ const tabsHeight = tabs && tabs.offsetParent !== null ? tabs.offsetHeight : 0;
+ return container.clientHeight - tabsHeight - splitter.offsetHeight;
+ };
+
+ const applySplitSize = (rawSize) => {
+ const total = getAvailableHeight();
+ const maxSize = Math.max(minPanelSize, total - minPanelSize);
+ const clamped = Math.max(minPanelSize, Math.min(rawSize, maxSize));
+ canvasWrapper.style.flex = `0 0 ${clamped}px`;
+ output.style.flex = "1 1 0";
+ return clamped;
+ };
+
+ const applyStoredSplitSize = () => {
+ const rawStored = Number(elem.dataset.splitCanvasOutput);
+ if (!Number.isFinite(rawStored) || rawStored <= 0) {
+ canvasWrapper.style.flex = "1 1 0";
+ output.style.flex = "1 1 0";
+ return;
+ }
+ applySplitSize(rawStored);
+ };
+
+ splitter.addEventListener("pointerdown", (event) => {
+ event.preventDefault();
+ splitter.setPointerCapture(event.pointerId);
+
+ const startPointer = event.clientY;
+ const startSize = canvasWrapper.getBoundingClientRect().height;
+
+ elem.classList.add("resizing");
+
+ const onPointerMove = (moveEvent) => {
+ const delta = moveEvent.clientY - startPointer;
+ const size = applySplitSize(startSize + delta);
+ elem.dataset.splitCanvasOutput = String(Math.round(size));
+ };
+
+ const onPointerUp = () => {
+ elem.classList.remove("resizing");
+ splitter.removeEventListener("pointermove", onPointerMove);
+ splitter.removeEventListener("pointerup", onPointerUp);
+ splitter.removeEventListener("pointercancel", onPointerUp);
+ const splitCanvasOutput = Number(elem.dataset.splitCanvasOutput);
+ if (Number.isFinite(splitCanvasOutput) && splitCanvasOutput > 0) {
+ onSplitChanged?.({ splitCanvasOutput: Math.round(splitCanvasOutput) });
+ }
};
splitter.addEventListener("pointermove", onPointerMove);
@@ -287,6 +518,7 @@ hyperbook.python = (function () {
});
window.addEventListener("resize", applyStoredSplitSize);
+ return applyStoredSplitSize;
};
const init = (root) => {
@@ -302,24 +534,42 @@ hyperbook.python = (function () {
const splitter = elem.getElementsByClassName("splitter")[0];
const run = elem.getElementsByClassName("run")[0];
const test = elem.getElementsByClassName("test")[0];
+ const stop = elem.getElementsByClassName("stop")[0];
const output = elem.getElementsByClassName("output")[0];
const canvas = elem.getElementsByClassName("canvas")[0];
const canvasWrapper = elem.getElementsByClassName("canvas-wrapper")[0] || canvas;
- const input = elem.getElementsByClassName("input")[0];
+ const canvasOutputSplitter = elem.getElementsByClassName("canvas-output-splitter")[0];
+ const canvasHeader = elem.getElementsByClassName("canvas-header")[0];
+ const outputHeader = elem.getElementsByClassName("output-header")[0];
const outputBtn = elem.getElementsByClassName("output-btn")[0];
const canvasBtn = elem.getElementsByClassName("canvas-btn")[0];
- const inputBtn = elem.getElementsByClassName("input-btn")[0];
+ const canvasTabs = outputBtn?.closest(".buttons");
const copyEl = elem.getElementsByClassName("copy")[0];
const resetEl = elem.getElementsByClassName("reset")[0];
const downloadEl = elem.getElementsByClassName("download")[0];
+ const fullscreenEl = elem.getElementsByClassName("fullscreen")[0];
const id = elem.id;
const hasCanvas = elem.getAttribute("data-canvas") === "true";
+ let pyideState = { id };
+
+ const getEditorValue = () => {
+ const textarea = editor?.querySelector("textarea");
+ if (textarea) return textarea.value;
+ return typeof editor?.textContent === "string" ? editor.textContent : "";
+ };
+
+ pyideState = { ...pyideState, script: getEditorValue() };
+
+ const persistPyideState = (updates = {}) => {
+ pyideState = { ...pyideState, ...updates, id };
+ return hyperbook.store.db.pyide.put(pyideState);
+ };
copyEl?.addEventListener("click", async () => {
try {
- await navigator.clipboard.writeText(editor.value);
+ await navigator.clipboard.writeText(getEditorValue());
} catch (error) {
console.error(error.message);
}
@@ -332,70 +582,178 @@ hyperbook.python = (function () {
downloadEl?.addEventListener("click", () => {
const a = document.createElement("a");
- const blob = new Blob([editor.value], { type: "text/plain" });
+ const blob = new Blob([getEditorValue()], { type: "text/plain" });
a.href = URL.createObjectURL(blob);
a.download = `script-${id}.py`;
a.click();
});
+
+ fullscreenEl?.addEventListener("click", async () => {
+ try {
+ await toggleFullscreen(elem);
+ } catch (error) {
+ console.error(error.message);
+ }
+ });
+ updateFullscreenButtonState(elem, fullscreenEl);
let tests = [];
try {
tests = JSON.parse(atob(elem.getAttribute("data-tests")));
} catch (e) {}
- function showInput() {
- outputBtn.classList.remove("active");
- if (canvasBtn) canvasBtn.classList.remove("active");
- inputBtn.classList.add("active");
- output.classList.add("hidden");
- if (canvasWrapper) canvasWrapper.classList.add("hidden");
- input.classList.remove("hidden");
- }
- function showOutput() {
+ const isWideCanvasMode = () =>
+ hasCanvas && window.matchMedia("(min-width: 1024px)").matches;
+
+ let activeCanvasView = "output";
+
+ const showOutputTab = () => {
+ activeCanvasView = "output";
outputBtn.classList.add("active");
if (canvasBtn) canvasBtn.classList.remove("active");
- inputBtn.classList.remove("active");
+ canvasHeader?.classList.add("hidden");
+ outputHeader?.classList.add("hidden");
output.classList.remove("hidden");
if (canvasWrapper) canvasWrapper.classList.add("hidden");
- input.classList.add("hidden");
- }
- function showCanvas() {
+ canvasOutputSplitter?.classList.add("hidden");
+ };
+ const showCanvasTab = () => {
+ activeCanvasView = "canvas";
outputBtn.classList.remove("active");
if (canvasBtn) canvasBtn.classList.add("active");
- inputBtn.classList.remove("active");
+ canvasHeader?.classList.add("hidden");
+ outputHeader?.classList.add("hidden");
output.classList.add("hidden");
if (canvasWrapper) canvasWrapper.classList.remove("hidden");
- input.classList.add("hidden");
+ canvasOutputSplitter?.classList.add("hidden");
+ };
+
+ const applyStoredCanvasOutputSplit = setupCanvasOutputSplitter(
+ elem,
+ container,
+ canvasWrapper,
+ output,
+ canvasOutputSplitter,
+ (splitState) => {
+ void persistPyideState(splitState);
+ },
+ );
+
+ const applyCanvasOutputLayout = () => {
+ if (!hasCanvas || !canvasWrapper || !canvasOutputSplitter) return;
+ if (isWideCanvasMode()) {
+ elem.classList.add("canvas-split-mode");
+ canvasTabs?.classList.add("hidden");
+ output.classList.remove("hidden");
+ canvasWrapper.classList.remove("hidden");
+ canvasOutputSplitter.classList.remove("hidden");
+ canvasHeader?.classList.remove("hidden");
+ outputHeader?.classList.remove("hidden");
+ outputBtn.classList.add("active");
+ outputBtn.disabled = true;
+ if (canvasBtn) {
+ canvasBtn.classList.add("active");
+ canvasBtn.disabled = true;
+ }
+ applyStoredCanvasOutputSplit?.();
+ return;
+ }
+
+ elem.classList.remove("canvas-split-mode");
+ canvasTabs?.classList.remove("hidden");
+ output.style.flex = "";
+ canvasWrapper.style.flex = "";
+ outputBtn.disabled = false;
+ if (canvasBtn) {
+ canvasBtn.disabled = false;
+ }
+ if (activeCanvasView === "canvas") {
+ showCanvasTab();
+ } else {
+ showOutputTab();
+ }
+ };
+
+ function showOutput() {
+ if (isWideCanvasMode()) {
+ applyCanvasOutputLayout();
+ return;
+ }
+ showOutputTab();
+ }
+ function showCanvas() {
+ if (isWideCanvasMode()) {
+ applyCanvasOutputLayout();
+ return;
+ }
+ showCanvasTab();
}
outputBtn?.addEventListener("click", showOutput);
canvasBtn?.addEventListener("click", showCanvas);
- inputBtn?.addEventListener("click", showInput);
- setupSplitter(elem, container, editorContainer, splitter);
+ const applyStoredSplitSize = setupSplitter(
+ elem,
+ container,
+ editorContainer,
+ splitter,
+ (splitState) => {
+ void persistPyideState(splitState);
+ },
+ );
editor.addEventListener("code-input_load", async () => {
const result = await hyperbook.store.db.pyide.get(id);
if (result) {
- editor.value = result.script;
+ pyideState = { ...pyideState, ...result };
+ if (typeof result.script === "string") {
+ editor.value = result.script;
+ }
+ if (
+ Number.isFinite(result.splitHorizontal) &&
+ result.splitHorizontal > 0
+ ) {
+ elem.dataset.splitHorizontal = String(Math.round(result.splitHorizontal));
+ }
+ if (
+ Number.isFinite(result.splitVertical) &&
+ result.splitVertical > 0
+ ) {
+ elem.dataset.splitVertical = String(Math.round(result.splitVertical));
+ }
+ if (
+ Number.isFinite(result.splitCanvasOutput) &&
+ result.splitCanvasOutput > 0
+ ) {
+ elem.dataset.splitCanvasOutput = String(
+ Math.round(result.splitCanvasOutput),
+ );
+ }
+ applyStoredSplitSize?.();
+ applyCanvasOutputLayout();
}
});
+ window.addEventListener("resize", applyCanvasOutputLayout);
+ applyCanvasOutputLayout();
+
editor.addEventListener("input", () => {
- hyperbook.store.db.pyide.put({ id, script: editor.value });
+ void persistPyideState({ script: getEditorValue() });
});
test?.addEventListener("click", async () => {
showOutput();
const state = getExecutionState(id);
- if (state.running) return;
+ if (state.running || getRunningInstanceId() !== null) return;
state.running = true;
state.type = "test";
state.stopRequested = false;
state.stopping = false;
+ const interruptBuffer = interruptBuffers.get(id);
+ if (interruptBuffer) interruptBuffer[0] = 0;
updateRunning();
output.innerHTML = "";
- const script = editor.value;
+ const script = getEditorValue();
try {
for (let test of tests) {
if (state.stopRequested) {
@@ -412,18 +770,20 @@ hyperbook.python = (function () {
const { results, error } = await executeScript(id, testCode, {});
if (results) {
- output.textContent += results;
+ appendOutput(output, results);
} else if (error) {
- output.textContent += error;
+ appendOutput(output, error, true);
}
}
} catch (e) {
- output.textContent = `Error: ${e}`;
+ output.innerHTML = "";
+ appendOutput(output, `Error: ${e}`, true);
console.log(e);
} finally {
state.running = false;
state.stopping = false;
state.type = null;
+ releaseKeyboardCapture(id);
updateRunning();
}
});
@@ -435,39 +795,46 @@ hyperbook.python = (function () {
showOutput();
}
const state = getExecutionState(id);
- if (state.running) return;
+ if (state.running || getRunningInstanceId() !== null) return;
state.running = true;
state.type = "run";
state.stopRequested = false;
state.stopping = false;
+ const interruptBuffer = interruptBuffers.get(id);
+ if (interruptBuffer) interruptBuffer[0] = 0;
updateRunning();
- const script = editor.value;
+ const script = getEditorValue();
output.innerHTML = "";
try {
const { results, error } = await executeScript(id, script, {
- inputs: input.value.split("\n"),
...(hasCanvas && canvas ? { canvas } : {}),
});
if (!state.stopRequested) {
if (results) {
- output.textContent += results;
+ appendOutput(output, results);
} else if (error) {
- output.textContent += error;
+ showOutput();
+ appendOutput(output, error, true);
}
} else {
appendOutputLine(id, "Execution stopped.");
}
} catch (e) {
- output.textContent = `Error: ${e}`;
+ showOutput();
+ output.innerHTML = "";
+ appendOutput(output, `Error: ${e}`, true);
console.log(e);
} finally {
state.running = false;
state.stopping = false;
state.type = null;
+ releaseKeyboardCapture(id);
updateRunning();
}
});
+
+ stop?.addEventListener("click", handleStopClick);
}
};
@@ -488,6 +855,7 @@ hyperbook.python = (function () {
document.addEventListener("DOMContentLoaded", () => {
init(document);
});
+ document.addEventListener("fullscreenchange", syncFullscreenButtons);
return { init };
})();
diff --git a/packages/markdown/assets/directive-pyide/style.css b/packages/markdown/assets/directive-pyide/style.css
index 581b05f0b..cd16fe42c 100644
--- a/packages/markdown/assets/directive-pyide/style.css
+++ b/packages/markdown/assets/directive-pyide/style.css
@@ -37,6 +37,10 @@
font-family: hyperbook-monospace, monospace;
}
+.directive-pyide .output .error-line {
+ color: #b42318;
+}
+
.directive-pyide .canvas-wrapper {
overflow: auto;
height: 100%;
@@ -53,6 +57,37 @@
min-height: 300px;
}
+.directive-pyide .canvas-header,
+.directive-pyide .output-header {
+ border: 1px solid var(--color-spacer);
+ border-bottom: none;
+ border-top-left-radius: 8px;
+ border-top-right-radius: 8px;
+ padding: 8px 16px;
+ background-color: var(--color-spacer);
+ color: var(--color-text);
+ font-weight: 600;
+ text-align: center;
+}
+
+.directive-pyide .canvas-output-splitter {
+ display: none;
+ width: 100%;
+ height: 4px;
+ margin: 10px 0;
+ cursor: row-resize;
+ background: var(--color-spacer);
+ border-radius: 999px;
+ flex-shrink: 0;
+ touch-action: none;
+ opacity: 0.45;
+ transition: opacity 0.15s ease-in-out;
+}
+
+.directive-pyide .canvas-output-splitter:hover {
+ opacity: 0.65;
+}
+
.directive-pyide .hidden {
display: none;
}
@@ -106,6 +141,20 @@
flex: 1;
}
+.directive-pyide .editor.running {
+ pointer-events: none;
+ opacity: 0.7;
+}
+
+.directive-pyide.locked-by-other {
+ outline: 2px solid #f59e0b;
+ outline-offset: 2px;
+}
+
+.directive-pyide.locked-by-other .buttons {
+ border-color: #f59e0b;
+}
+
.directive-pyide .buttons {
display: flex;
border: 1px solid var(--color-spacer);
@@ -113,6 +162,7 @@
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
+ overflow: hidden;
}
.directive-pyide .buttons.bottom {
@@ -144,10 +194,26 @@
cursor: pointer;
}
-.directive-pyide .buttons:last-child {
+.directive-pyide .buttons button:last-child {
border-right: none;
}
+.directive-pyide .buttons:not(.bottom) button:first-child {
+ border-top-left-radius: 8px;
+}
+
+.directive-pyide .buttons:not(.bottom) button:last-child {
+ border-top-right-radius: 8px;
+}
+
+.directive-pyide .buttons.bottom button:first-child {
+ border-bottom-left-radius: 8px;
+}
+
+.directive-pyide .buttons.bottom button:last-child {
+ border-bottom-right-radius: 8px;
+}
+
.directive-pyide button.active {
background-color: var(--color-spacer);
}
@@ -168,6 +234,40 @@
opacity: 0.75;
}
+.directive-pyide button.locked {
+ color: #b45309;
+ font-weight: 600;
+}
+
+.directive-pyide button.fullscreen {
+ flex: 0 0 auto;
+ min-width: 120px;
+}
+
+.directive-pyide button.stop {
+ flex: 0 0 auto;
+ min-width: 96px;
+ color: #b42318;
+}
+
+.directive-pyide button.stop:disabled {
+ color: var(--color-text);
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.directive-pyide:fullscreen {
+ width: 100vw;
+ height: 100dvh !important;
+ padding: 12px;
+ box-sizing: border-box;
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
+.directive-pyide:fullscreen::backdrop {
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
@media screen and (min-width: 1024px) {
.directive-pyide {
flex-direction: row;
@@ -186,4 +286,21 @@
overflow: hidden;
}
}
+
+ .directive-pyide[data-canvas="true"].canvas-split-mode .canvas-wrapper,
+ .directive-pyide[data-canvas="true"].canvas-split-mode .output {
+ flex: 1 1 0;
+ min-height: 120px;
+ border-radius: 8px;
+ }
+
+ .directive-pyide[data-canvas="true"].canvas-split-mode .canvas-wrapper,
+ .directive-pyide[data-canvas="true"].canvas-split-mode .output {
+ border-top-left-radius: 0;
+ border-top-right-radius: 0;
+ }
+
+ .directive-pyide[data-canvas="true"].canvas-split-mode .canvas-output-splitter {
+ display: block;
+ }
}
diff --git a/packages/markdown/assets/directive-typst/client.js b/packages/markdown/assets/directive-typst/client.js
index b31db5942..0fcebfd6a 100644
--- a/packages/markdown/assets/directive-typst/client.js
+++ b/packages/markdown/assets/directive-typst/client.js
@@ -1215,6 +1215,25 @@ hyperbook.typst = (function () {
window.addEventListener('resize', applyStoredSplitSize);
}
+ const updateFullscreenButtonState = (elem, button) => {
+ if (!elem || !button) return;
+ const isFullscreen = document.fullscreenElement === elem;
+ button.textContent = i18nGet(
+ isFullscreen ? 'ide-fullscreen-exit' : 'ide-fullscreen-enter',
+ isFullscreen ? 'Exit Fullscreen' : 'Fullscreen',
+ );
+ button.classList.toggle('active', isFullscreen);
+ };
+
+ const toggleFullscreen = async (elem) => {
+ if (!elem) return;
+ if (document.fullscreenElement === elem) {
+ await document.exitFullscreen();
+ return;
+ }
+ await elem.requestFullscreen();
+ };
+
class TypstEditor {
constructor({
elem,
@@ -1248,6 +1267,7 @@ hyperbook.typst = (function () {
this.editorContainer = elem.querySelector('.editor-container');
this.splitter = elem.querySelector('.splitter');
this.sourceTextarea = elem.querySelector('.typst-source');
+ this.fullscreenBtn = elem.querySelector('.fullscreen');
setupSplitter(this.elem, this.previewContainer, this.editorContainer, this.splitter);
@@ -1256,6 +1276,11 @@ hyperbook.typst = (function () {
// Setup event handlers
this.setupEventHandlers();
+ this.handleFullscreenChange = () => {
+ updateFullscreenButtonState(this.elem, this.fullscreenBtn);
+ };
+ document.addEventListener('fullscreenchange', this.handleFullscreenChange);
+ updateFullscreenButtonState(this.elem, this.fullscreenBtn);
// Initialize
this.initialize();
@@ -1279,12 +1304,14 @@ hyperbook.typst = (function () {
const resetBtn = this.elem.querySelector('.reset');
const addSourceFileBtn = this.elem.querySelector('.add-source-file');
const addBinaryFileBtn = this.elem.querySelector('.add-binary-file');
+ const fullscreenBtn = this.elem.querySelector('.fullscreen');
downloadBtn?.addEventListener('click', () => this.handleExportPdf());
downloadProjectBtn?.addEventListener('click', () => this.handleExportProject());
resetBtn?.addEventListener('click', () => this.handleReset());
addSourceFileBtn?.addEventListener('click', () => this.handleAddSourceFile());
addBinaryFileBtn?.addEventListener('click', (e) => this.handleAddBinaryFile(e));
+ fullscreenBtn?.addEventListener('click', () => this.handleFullscreenToggle());
}
/**
@@ -1560,6 +1587,14 @@ hyperbook.typst = (function () {
window.location.reload();
}
}
+
+ async handleFullscreenToggle() {
+ try {
+ await toggleFullscreen(this.elem);
+ } catch (error) {
+ console.error(error.message);
+ }
+ }
}
// ============================================================================
diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css
index 3d6b19289..935c2f1d9 100644
--- a/packages/markdown/assets/directive-typst/style.css
+++ b/packages/markdown/assets/directive-typst/style.css
@@ -435,6 +435,7 @@ code-input {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
+ overflow: hidden;
}
.directive-typst .buttons.bottom {
@@ -465,10 +466,15 @@ code-input {
opacity: 0.6;
}
-.directive-typst .buttons:last-child {
+.directive-typst .buttons button:last-child {
border-right: none;
}
+.directive-typst button.fullscreen {
+ flex: 0 0 auto;
+ min-width: 120px;
+}
+
.directive-typst button:hover {
background-color: var(--color-spacer);
}
@@ -484,6 +490,22 @@ code-input {
display: none !important;
}
+.directive-typst:fullscreen {
+ width: 100vw;
+ height: 100dvh !important;
+ padding: 12px;
+ box-sizing: border-box;
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
+.directive-typst:fullscreen::backdrop {
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
+.directive-typst:fullscreen.preview-only {
+ height: 100dvh !important;
+}
+
@media screen and (min-width: 1024px) {
.directive-typst:not(.preview-only) {
flex-direction: row;
diff --git a/packages/markdown/assets/directive-webide/client.js b/packages/markdown/assets/directive-webide/client.js
index 7c2d2747f..2952d2788 100644
--- a/packages/markdown/assets/directive-webide/client.js
+++ b/packages/markdown/assets/directive-webide/client.js
@@ -83,6 +83,32 @@ hyperbook.webide = (function () {
window.addEventListener("resize", applyStoredSplitSize);
}
+ const updateFullscreenButtonState = (elem, button) => {
+ if (!elem || !button) return;
+ const isFullscreen = document.fullscreenElement === elem;
+ button.textContent = hyperbook.i18n.get(
+ isFullscreen ? "ide-fullscreen-exit" : "ide-fullscreen-enter",
+ );
+ button.classList.toggle("active", isFullscreen);
+ };
+
+ const toggleFullscreen = async (elem) => {
+ if (!elem) return;
+ if (document.fullscreenElement === elem) {
+ await document.exitFullscreen();
+ return;
+ }
+ await elem.requestFullscreen();
+ };
+
+ const syncFullscreenButtons = () => {
+ const elems = document.querySelectorAll(".directive-webide");
+ elems.forEach((elem) => {
+ const fullscreen = elem.querySelector("button.fullscreen");
+ updateFullscreenButtonState(elem, fullscreen);
+ });
+ };
+
function initElement(elem) {
if (elem.getAttribute("data-webide-initialized") === "true") return;
elem.setAttribute("data-webide-initialized", "true");
@@ -111,9 +137,20 @@ hyperbook.webide = (function () {
const resetEl = elem.querySelector("button.reset");
/** @type {HTMLButtonElement} */
const downloadEl = elem.querySelector("button.download");
+ /** @type {HTMLButtonElement} */
+ const fullscreenEl = elem.querySelector("button.fullscreen");
setupSplitter(elem, container, editorContainer, splitter);
+ fullscreenEl?.addEventListener("click", async () => {
+ try {
+ await toggleFullscreen(elem);
+ } catch (error) {
+ console.error(error.message);
+ }
+ });
+ updateFullscreenButtonState(elem, fullscreenEl);
+
resetEl?.addEventListener("click", () => {
if (window.confirm(hyperbook.i18n.get("webide-reset-prompt"))) {
hyperbook.store.db.webide.delete(id);
@@ -241,6 +278,7 @@ hyperbook.webide = (function () {
document.addEventListener("DOMContentLoaded", () => {
init(document);
});
+ document.addEventListener("fullscreenchange", syncFullscreenButtons);
// Observe for new elements added to the DOM
const observer = new MutationObserver((mutations) => {
diff --git a/packages/markdown/assets/directive-webide/style.css b/packages/markdown/assets/directive-webide/style.css
index 70f95778c..0f096e85f 100644
--- a/packages/markdown/assets/directive-webide/style.css
+++ b/packages/markdown/assets/directive-webide/style.css
@@ -104,6 +104,7 @@ code-input {
border-bottom: none;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
+ overflow: hidden;
}
.directive-webide .buttons.bottom {
@@ -128,10 +129,15 @@ code-input {
opacity: 0.6;
}
-.directive-webide .buttons:last-child {
+.directive-webide .buttons button:last-child {
border-right: none;
}
+.directive-webide button.fullscreen {
+ flex: 0 0 auto;
+ min-width: 120px;
+}
+
.directive-webide button:hover {
background-color: var(--color-spacer);
}
@@ -143,6 +149,18 @@ code-input {
background-color: white;
}
+.directive-webide:fullscreen {
+ width: 100vw;
+ height: 100dvh !important;
+ padding: 12px;
+ box-sizing: border-box;
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
+.directive-webide:fullscreen::backdrop {
+ background-color: var(--color-background, var(--color--background, #fff));
+}
+
@media screen and (min-width: 1024px) {
.directive-webide:not(.standalone) {
flex-direction: row;
diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json
index 47084102e..b5d80dcb7 100644
--- a/packages/markdown/locales/de.json
+++ b/packages/markdown/locales/de.json
@@ -27,15 +27,26 @@
"audio-play": "Abspielen/Pause",
"archive-offline": "Offline",
"download-offline": "Offline",
+ "ide-fullscreen-enter": "Vollbild",
+ "ide-fullscreen-exit": "Vollbild beenden",
"pyide-run": "Ausführen",
+ "pyide-running": "Wird ausgeführt ...",
"pyide-running-click-to-stop": "Wird ausgeführt (Klicken zum Stoppen) ...",
"pyide-running-refresh-to-stop": "Wird ausgeführt (Aktualisieren zum Stoppen) ...",
"pyide-test": "Testen",
+ "pyide-testing": "Test läuft ...",
"pyide-test-running-click-to-stop": "Test läuft (Klicken zum Stoppen) ...",
"pyide-test-running-refresh-to-stop": "Test läuft (Aktualisieren zum Stoppen) ...",
+ "pyide-stop": "Stoppen",
+ "pyide-stop-refresh": "Stoppen (Neuladen)",
+ "pyide-locked-other-instance-running": "Eine andere PyIDE-Instanz läuft",
+ "pyide-stopping": "Wird gestoppt ...",
+ "pyide-stop-reloading": "Zum Stoppen ist auf dieser Seite ein Neuladen nötig. Seite wird neu geladen...",
+ "pyide-stop-refresh-required": "Stopp angefordert. Wenn die Ausführung nicht stoppt, Seite neu laden oder COOP/COEP-Header aktivieren.",
"pyide-output": "Ausgabe",
"pyide-canvas": "Canvas",
"pyide-input": "Eingabe",
+ "pyide-input-prompt": "Eingabe erforderlich:",
"pyide-reset": "Zurücksetzen",
"pyide-copy": "Kopieren",
"pyide-download": "Herunterladen",
diff --git a/packages/markdown/locales/en.json b/packages/markdown/locales/en.json
index b281cbb7c..1088e91d2 100644
--- a/packages/markdown/locales/en.json
+++ b/packages/markdown/locales/en.json
@@ -27,15 +27,26 @@
"audio-play": "Play/Pause",
"archive-offline": "Offline",
"download-offline": "Offline",
+ "ide-fullscreen-enter": "Fullscreen",
+ "ide-fullscreen-exit": "Exit Fullscreen",
"pyide-run": "Run",
+ "pyide-running": "Running ...",
"pyide-running-click-to-stop": "Running (Click to stop) ...",
"pyide-running-refresh-to-stop": "Running (Refresh to stop) ...",
"pyide-test": "Test",
+ "pyide-testing": "Test running ...",
"pyide-test-running-click-to-stop": "Test running (Click to stop) ...",
"pyide-test-running-refresh-to-stop": "Test running (Refresh to stop) ...",
+ "pyide-stop": "Stop",
+ "pyide-stop-refresh": "Stop (refresh)",
+ "pyide-locked-other-instance-running": "Another PyIDE instance is running",
+ "pyide-stopping": "Stopping ...",
+ "pyide-stop-reloading": "Stopping requires a refresh on this page. Reloading now...",
+ "pyide-stop-refresh-required": "Stop requested. If execution does not stop, refresh the page or enable COOP/COEP headers.",
"pyide-output": "Output",
"pyide-canvas": "Canvas",
"pyide-input": "Input",
+ "pyide-input-prompt": "Input required:",
"pyide-reset": "Reset",
"pyide-copy": "Copy",
"pyide-download": "Download",
diff --git a/packages/markdown/src/rehypeDirectiveP5.ts b/packages/markdown/src/rehypeDirectiveP5.ts
index 49f0d7e11..55da357f0 100644
--- a/packages/markdown/src/rehypeDirectiveP5.ts
+++ b/packages/markdown/src/rehypeDirectiveP5.ts
@@ -174,6 +174,19 @@ ${(code.scripts ? [cdnLibraryUrl, ...code.scripts] : []).map((src) => `