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"> -
+