diff --git a/.changeset/fix-pytamaro-show-graphic.md b/.changeset/fix-pytamaro-show-graphic.md new file mode 100644 index 000000000..86116785e --- /dev/null +++ b/.changeset/fix-pytamaro-show-graphic.md @@ -0,0 +1,5 @@ +--- +"@hyperbook/markdown": patch +--- + +Fix pytamaro show_graphic not rendering images in pyide output. Parse `@@@PYTAMARO_DATA_URI_BEGIN@@@` / `@@@PYTAMARO_DATA_URI_END@@@` markers in stdout and render them as inline `` elements. 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/.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 diff --git a/packages/markdown/assets/directive-p5/client.js b/packages/markdown/assets/directive-p5/client.js index a0e3d211e..12278b0ee 100644 --- a/packages/markdown/assets/directive-p5/client.js +++ b/packages/markdown/assets/directive-p5/client.js @@ -27,7 +27,107 @@ 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("pointerdown", (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + 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 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 onPointerUp = () => { + elem.classList.remove("resizing"); + splitter.removeEventListener("pointermove", onPointerMove); + splitter.removeEventListener("pointerup", onPointerUp); + splitter.removeEventListener("pointercancel", onPointerUp); + }; + + splitter.addEventListener("pointermove", onPointerMove); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + } + + const updateFullscreenButtonState = (elem, button) => { + if (!elem || !button) return; + const isFullscreen = document.fullscreenElement === elem; + const label = hyperbook.i18n.get("ide-fullscreen-enter"); + button.textContent = "⛶"; + button.title = label; + button.setAttribute("aria-label", label); + 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"); + + 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]; @@ -37,6 +137,18 @@ 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( @@ -117,6 +229,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 25d73c910..751d25730 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); @@ -38,6 +75,7 @@ code-input { border-bottom: none; border-bottom-left-radius: 0; border-bottom-right-radius: 0; + overflow: hidden; } .directive-p5 .buttons.bottom { @@ -58,10 +96,17 @@ 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: 42px; + width: 42px; + padding: 8px 0; +} + .directive-p5 button:hover { background-color: var(--color-spacer); } @@ -76,10 +121,22 @@ 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; - 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 46ae7f485..31bd5cd3b 100644 --- a/packages/markdown/assets/directive-pyide/client.js +++ b/packages/markdown/assets/directive-pyide/client.js @@ -16,154 +16,1046 @@ hyperbook.python = (function () { ]) ); - const pyodideWorker = new Worker( - `${HYPERBOOK_ASSETS}directive-pyide/webworker.js` - ); + 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 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 installedMicropipPackages = new Map(); + + /** + * @type {Map} + */ + const executionStates = new Map(); + /** + * @type {Map} + */ + const interruptBuffers = 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 getRuntime = async (id) => { + if (runtimes.has(id)) { + return runtimes.get(id); + } + const loadPyodide = await pyodideReadyPromise; + const pyodide = await loadPyodide(); + if (typeof pyodide.registerJsModule === "function") { + pyodide.registerJsModule("pytamaro_js_ffi", createPytamaroJsFFI()); + } + 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; + }; + + const PYTAMARO_URI_BEGIN = "@@@PYTAMARO_DATA_URI_BEGIN@@@"; + const PYTAMARO_URI_END = "@@@PYTAMARO_DATA_URI_END@@@"; + /** + * @type {Map} + */ + const pytamaroStdoutCarry = new Map(); + + const getOutput = (id) => { + return document.getElementById(id)?.getElementsByClassName("output")[0]; + }; + + /** + * Renders a message that may contain pytamaro data URI image markers into + * the given container, creating elements for each embedded image. + */ + const renderOutputSegments = (container, message) => { + let remaining = String(message); + while (remaining.length > 0) { + const beginIdx = remaining.indexOf(PYTAMARO_URI_BEGIN); + if (beginIdx === -1) { + container.appendChild(document.createTextNode(remaining)); + break; + } + if (beginIdx > 0) { + container.appendChild(document.createTextNode(remaining.slice(0, beginIdx))); + } + const afterBegin = remaining.slice(beginIdx + PYTAMARO_URI_BEGIN.length); + const endIdx = afterBegin.indexOf(PYTAMARO_URI_END); + if (endIdx === -1) { + container.appendChild(document.createTextNode(remaining.slice(beginIdx))); + break; + } + const dataUri = afterBegin.slice(0, endIdx); + const img = document.createElement("img"); + img.src = dataUri; + img.style.maxWidth = "100%"; + img.style.display = "block"; + container.appendChild(img); + remaining = afterBegin.slice(endIdx + PYTAMARO_URI_END.length); + } + }; + + const getTrailingPrefixLength = (text, marker) => { + const max = Math.min(text.length, marker.length - 1); + for (let len = max; len > 0; len -= 1) { + if (text.endsWith(marker.slice(0, len))) { + return len; + } + } + return 0; + }; + + const appendText = (output, text) => { + if (!text) return; + output.appendChild(document.createTextNode(text)); + }; + + const appendOutputLine = (id, message) => { + const output = getOutput(id); + if (!output) return; + const msg = String(message ?? ""); + const carry = pytamaroStdoutCarry.get(id) || ""; + let combined = carry + msg; + pytamaroStdoutCarry.delete(id); + + // Fast path for regular stdout chunks. + if (!combined.includes(PYTAMARO_URI_BEGIN) && carry.length === 0) { + const partialBeginLength = getTrailingPrefixLength(combined, PYTAMARO_URI_BEGIN); + if (partialBeginLength > 0) { + const visible = combined.slice(0, combined.length - partialBeginLength); + appendText(output, visible); + pytamaroStdoutCarry.set(id, combined.slice(combined.length - partialBeginLength)); + } else { + appendText(output, combined); + } + return; + } + + while (combined.length > 0) { + const beginIdx = combined.indexOf(PYTAMARO_URI_BEGIN); + if (beginIdx === -1) { + const partialBeginLength = getTrailingPrefixLength(combined, PYTAMARO_URI_BEGIN); + const visible = combined.slice(0, combined.length - partialBeginLength); + appendText(output, visible); + if (partialBeginLength > 0) { + pytamaroStdoutCarry.set(id, combined.slice(combined.length - partialBeginLength)); + } + break; + } + + appendText(output, combined.slice(0, beginIdx)); + const afterBegin = combined.slice(beginIdx + PYTAMARO_URI_BEGIN.length); + const endIdx = afterBegin.indexOf(PYTAMARO_URI_END); + if (endIdx === -1) { + // Keep incomplete marker and continue when the next stdout chunk arrives. + pytamaroStdoutCarry.set(id, combined.slice(beginIdx)); + break; + } + + const dataUri = afterBegin.slice(0, endIdx); + const img = document.createElement("img"); + img.src = dataUri; + img.style.maxWidth = "100%"; + img.style.display = "block"; + output.appendChild(img); + combined = afterBegin.slice(endIdx + PYTAMARO_URI_END.length); + } + }; + + const appendOutputErrorLine = (id, message) => { + const output = getOutput(id); + if (!output) return; + const line = document.createElement("span"); + line.classList.add("error-line"); + line.textContent = String(message); + 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; + } + const msg = String(message); + if (msg.includes(PYTAMARO_URI_BEGIN)) { + renderOutputSegments(output, msg); + return; + } + output.appendChild(document.createTextNode(msg)); + }; + + const clearPytamaroStdoutCarry = (id) => { + pytamaroStdoutCarry.delete(id); + }; + + const updateFullscreenButtonState = (elem, button) => { + if (!elem || !button) return; + const isFullscreen = document.fullscreenElement === elem; + const label = hyperbook.i18n.get("ide-fullscreen-enter"); + button.textContent = "⛶"; + button.title = label; + button.setAttribute("aria-label", label); + 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"); + context?.clearRect(0, 0, canvas.width, canvas.height); + }; + + const createPytamaroJsFFI = () => { + const floatBuffer = new ArrayBuffer(4); + const floatView = new DataView(floatBuffer); + + const unProxy = (obj) => { + if (typeof obj === "object" && obj !== null && typeof obj.toJs === "function") { + try { + return obj.toJs({ pyproxies: [], dict_converter: Object.fromEntries }); + } catch (e) { + console.error("Error converting PyProxy:", e); + return obj; + } + } + return obj; }; + + const uint32ToFloat = (u32) => { + floatView.setUint32(0, u32 >>> 0, false); + return floatView.getFloat32(0, false); + }; + + const decodePoint = (value, width, height) => { + const packed = typeof value === "bigint" ? value : BigInt(value || 0); + const x = uint32ToFloat(Number((packed >> 32n) & 0xffffffffn)); + const y = uint32ToFloat(Number(packed & 0xffffffffn)); + return { x: x * width * 0.5, y: -y * height * 0.5 }; + }; + + const colorToCss = (value) => { + const argb = + typeof value === "bigint" + ? Number(value & 0xffffffffn) + : Number(value >>> 0); + const a = ((argb >> 24) & 0xff) / 255; + const r = (argb >> 16) & 0xff; + const g = (argb >> 8) & 0xff; + const b = argb & 0xff; + return `rgba(${r}, ${g}, ${b}, ${a})`; + }; + + const rotatePoint = (point, pivot, angleRad) => { + const dx = point.x - pivot.x; + const dy = point.y - pivot.y; + const cos = Math.cos(angleRad); + const sin = Math.sin(angleRad); + return { + x: pivot.x + dx * cos - dy * sin, + y: pivot.y + dx * sin + dy * cos, + }; + }; + + const buildGraphic = (specs) => { + const stack = []; + const measureCanvas = document.createElement("canvas"); + const measureCtx = measureCanvas.getContext("2d"); + + for (const spec of specs || []) { + if (!spec || typeof spec !== "object") continue; + const type = spec.t; + if ( + type === "Empty" || + type === "Rectangle" || + type === "Ellipse" || + type === "CircularSector" || + type === "Triangle" || + type === "Text" + ) { + let width = 0; + let height = 0; + let pin = { x: 0, y: 0 }; + let draw = () => {}; + + if (type === "Rectangle") { + width = Math.max(0, Number(spec.width) || 0); + height = Math.max(0, Number(spec.height) || 0); + const fill = colorToCss(spec.color); + draw = (ctx) => { + ctx.fillStyle = fill; + ctx.fillRect(-width / 2, -height / 2, width, height); + }; + } else if (type === "Ellipse") { + width = Math.max(0, Number(spec.width) || 0); + height = Math.max(0, Number(spec.height) || 0); + const fill = colorToCss(spec.color); + draw = (ctx) => { + ctx.beginPath(); + ctx.ellipse(0, 0, width / 2, height / 2, 0, 0, 2 * Math.PI); + ctx.fillStyle = fill; + ctx.fill(); + }; + } else if (type === "CircularSector") { + const radius = Math.max(0, Number(spec.radius) || 0); + const angle = Number(spec.angle) || 0; + width = radius * 2; + height = radius * 2; + const fill = colorToCss(spec.color); + draw = (ctx) => { + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, radius, 0, (-angle * Math.PI) / 180, true); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + }; + } else if (type === "Triangle") { + const side1 = Math.max(0, Number(spec.side1) || 0); + const side2 = Math.max(0, Number(spec.side2) || 0); + const angle = (Number(spec.angle) || 0) * (Math.PI / 180); + const p1 = { x: 0, y: 0 }; + const p2 = { x: side1, y: 0 }; + const p3 = { x: side2 * Math.cos(angle), y: -side2 * Math.sin(angle) }; + const centroid = { + x: (p1.x + p2.x + p3.x) / 3, + y: (p1.y + p2.y + p3.y) / 3, + }; + const points = [p1, p2, p3].map((p) => ({ + x: p.x - centroid.x, + y: p.y - centroid.y, + })); + const xs = points.map((p) => p.x); + const ys = points.map((p) => p.y); + width = Math.max(...xs) - Math.min(...xs); + height = Math.max(...ys) - Math.min(...ys); + const fill = colorToCss(spec.color); + draw = (ctx) => { + ctx.beginPath(); + ctx.moveTo(points[0].x, points[0].y); + ctx.lineTo(points[1].x, points[1].y); + ctx.lineTo(points[2].x, points[2].y); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + }; + } else if (type === "Text") { + const text = String(spec.text || ""); + const fontName = String(spec.font_name || "sans-serif"); + const textSize = Math.max(1, Number(spec.text_size) || 1); + const fill = colorToCss(spec.color); + measureCtx.font = `${textSize}px ${fontName}`; + const metrics = measureCtx.measureText(text); + width = Math.max(0, metrics.width || 0); + const ascent = metrics.actualBoundingBoxAscent || textSize * 0.8; + const descent = metrics.actualBoundingBoxDescent || textSize * 0.2; + height = ascent + descent; + pin = { x: -width / 2, y: (ascent - descent) / 2 }; + draw = (ctx) => { + ctx.fillStyle = fill; + ctx.font = `${textSize}px ${fontName}`; + ctx.textAlign = "left"; + ctx.textBaseline = "alphabetic"; + const centerY = (descent - ascent) / 2; + ctx.fillText(text, -width / 2, -centerY); + }; + } + + stack.push({ width, height, pin, draw }); + continue; + } + + if (type === "Pin") { + const child = stack.pop(); + if (!child) continue; + const pin = decodePoint(spec.pin, child.width, child.height); + stack.push({ ...child, pin }); + continue; + } + + if (type === "Rotate") { + const child = stack.pop(); + if (!child) continue; + const angleDeg = Number(spec.angle) || 0; + const angleRad = (-angleDeg * Math.PI) / 180; + const corners = [ + { x: -child.width / 2, y: -child.height / 2 }, + { x: child.width / 2, y: -child.height / 2 }, + { x: child.width / 2, y: child.height / 2 }, + { x: -child.width / 2, y: child.height / 2 }, + ].map((p) => rotatePoint(p, child.pin, angleRad)); + const minX = Math.min(...corners.map((p) => p.x)); + const maxX = Math.max(...corners.map((p) => p.x)); + const minY = Math.min(...corners.map((p) => p.y)); + const maxY = Math.max(...corners.map((p) => p.y)); + const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; + const offset = { x: -center.x, y: -center.y }; + const pin = { x: child.pin.x + offset.x, y: child.pin.y + offset.y }; + + stack.push({ + width: maxX - minX, + height: maxY - minY, + pin, + draw: (ctx) => { + ctx.save(); + ctx.translate(offset.x, offset.y); + ctx.translate(child.pin.x, child.pin.y); + ctx.rotate(angleRad); + ctx.translate(-child.pin.x, -child.pin.y); + child.draw(ctx); + ctx.restore(); + }, + }); + continue; + } + + if (type === "Compose") { + const bg = stack.pop(); + const fg = stack.pop(); + if (!fg || !bg) continue; + const fgPin = spec.fg_pin + ? decodePoint(spec.fg_pin, fg.width, fg.height) + : fg.pin; + const bgPin = spec.bg_pin + ? decodePoint(spec.bg_pin, bg.width, bg.height) + : bg.pin; + + const bgCenter = { x: 0, y: 0 }; + const fgCenter = { x: bgPin.x - fgPin.x, y: bgPin.y - fgPin.y }; + const minX = Math.min( + fgCenter.x - fg.width / 2, + bgCenter.x - bg.width / 2, + ); + const maxX = Math.max( + fgCenter.x + fg.width / 2, + bgCenter.x + bg.width / 2, + ); + const minY = Math.min( + fgCenter.y - fg.height / 2, + bgCenter.y - bg.height / 2, + ); + const maxY = Math.max( + fgCenter.y + fg.height / 2, + bgCenter.y + bg.height / 2, + ); + + const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }; + const fgOffset = { + x: fgCenter.x - center.x, + y: fgCenter.y - center.y, + }; + const bgOffset = { + x: bgCenter.x - center.x, + y: bgCenter.y - center.y, + }; + const pin = spec.pin + ? decodePoint(spec.pin, maxX - minX, maxY - minY) + : { x: bgPin.x - center.x, y: bgPin.y - center.y }; + + stack.push({ + width: maxX - minX, + height: maxY - minY, + pin, + draw: (ctx) => { + ctx.save(); + ctx.translate(bgOffset.x, bgOffset.y); + bg.draw(ctx); + ctx.restore(); + ctx.save(); + ctx.translate(fgOffset.x, fgOffset.y); + fg.draw(ctx); + ctx.restore(); + }, + }); + } + } + + return ( + stack[stack.length - 1] || { + width: 0, + height: 0, + pin: { x: 0, y: 0 }, + draw: () => {}, + } + ); + }; + + return { + js_graphic_size: (specs) => { + try { + const unproxiedSpecs = unProxy(specs); + const graphic = buildGraphic(unproxiedSpecs); + return { width: graphic.width, height: graphic.height }; + } catch (e) { + console.error("js_graphic_size error:", e); + throw e; + } + }, + js_render_graphic: (specs, scalingFactor, debug) => { + try { + const unproxiedSpecs = unProxy(specs); + const graphic = buildGraphic(unproxiedSpecs); + const width = Math.max(1, Math.ceil(graphic.width)); + const height = Math.max(1, Math.ceil(graphic.height)); + const scale = Math.max(1, Number(scalingFactor) || 1); + const canvas = document.createElement("canvas"); + canvas.width = Math.max(1, Math.ceil(width * scale)); + canvas.height = Math.max(1, Math.ceil(height * scale)); + const ctx = canvas.getContext("2d"); + ctx.scale(scale, scale); + ctx.translate(width / 2, height / 2); + graphic.draw(ctx); + + if (debug) { + ctx.strokeStyle = "red"; + ctx.lineWidth = 1 / scale; + ctx.strokeRect(-width / 2, -height / 2, width, height); + ctx.strokeStyle = "rgba(255, 255, 0, 0.8)"; + ctx.beginPath(); + ctx.moveTo(graphic.pin.x - 8, graphic.pin.y); + ctx.lineTo(graphic.pin.x + 8, graphic.pin.y); + ctx.moveTo(graphic.pin.x, graphic.pin.y - 8); + ctx.lineTo(graphic.pin.x, graphic.pin.y + 8); + ctx.stroke(); + } + + return canvas.toDataURL("image/png"); + } catch (e) { + console.error("js_render_graphic error:", e); + throw e; + } + }, + js_save: (filename, content) => { + const link = document.createElement("a"); + link.href = String(content || ""); + link.download = String(filename || "graphic.png"); + link.click(); + }, + }; + }; + + const ensureMicropipPackages = async (id, pyodide, packages = []) => { + if (packages.length === 0) return; + if (!installedMicropipPackages.has(id)) { + installedMicropipPackages.set(id, new Set()); + } + const installed = installedMicropipPackages.get(id); + const toInstall = packages.filter((pkg) => !installed.has(pkg)); + if (toInstall.length === 0) return; + + await pyodide.loadPackage("micropip"); + const micropip = pyodide.pyimport("micropip"); + try { + for (const pkg of toInstall) { + await micropip.install(pkg); + installed.add(pkg); + } + } finally { + micropip?.destroy?.(); + } }; - function interruptExecution() { - // 2 stands for SIGINT. - interruptBuffer[0] = 2; - } + const executeScript = async (id, script, context = {}, packages = []) => { + const filename = ""; + try { + const pyodide = await getRuntime(id); + const { canvas, ...globalsContext } = context; + const decoder = new TextDecoder("utf-8"); - function reload() { - window.location.reload(); - } + if (canvas) { + try { + resetCanvas(canvas); + pyodide.canvas.setCanvas2D(canvas); + } catch (error) { + appendOutputErrorLine(id, `Canvas setup failed: ${error.message}`); + } + } - const updateRunning = (id, type) => { + pyodide.setStdin({ + stdin: () => { + const value = window.prompt(hyperbook.i18n.get("pyide-input-prompt")); + if (value === null) { + return ""; + } + return value; + }, + }); + pyodide.setStdout({ + write: (msg) => { + const text = typeof msg === "string" ? msg : decoder.decode(msg); + appendOutputLine(id, text); + return msg?.length ?? text.length; + }, + }); + pyodide.setStderr({ + write: (msg) => { + const text = typeof msg === "string" ? msg : decoder.decode(msg); + appendOutputErrorLine(id, text); + return msg?.length ?? text.length; + }, + }); + + await ensureMicropipPackages(id, pyodide, packages); + 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 }; + } + }; + + const requestStop = (id) => { + const state = getExecutionState(id); + const hasRuntime = runtimes.has(id); + if ((!state.running && !hasRuntime) || state.stopRequested) return; + state.stopRequested = true; + state.stopping = true; + 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) => { + const elem = event.currentTarget.closest(".directive-pyide"); + if (!elem?.id) return; + 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]; - 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"); + const stop = elem.getElementsByClassName("stop")[0]; + const editor = elem.getElementsByClassName("editor")[0]; + const editorTextarea = editor?.querySelector("textarea"); + const state = getExecutionState(elem.id); + const hasRuntime = runtimes.has(elem.id); + const hasInterrupt = interruptBuffers.has(elem.id); + const lockedByOther = + runningInstanceId !== null && + runningInstanceId !== elem.id && + !state.running; - 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); + stop?.removeEventListener("click", handleStopClick); + run.classList.remove("stopping"); + run.classList.remove("locked"); + test?.classList.remove("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.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"); 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); + } + if (stop) { + stop.classList.remove("stopping"); + stop.classList.remove("running"); + stop.textContent = hyperbook.i18n.get( + hasInterrupt ? "pyide-stop" : "pyide-stop-refresh", + ); + stop.disabled = true; } } } }; - 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; + const setupSplitter = ( + elem, + container, + editorContainer, + splitter, + onSplitChanged, + ) => { + 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; } - case "success": { - const onSuccess = callback; - onSuccess({ results: payload }); - break; + applySplitSize(rawStored, isHorizontal); + }; + + applyStoredSplitSize(); + + splitter.addEventListener("pointerdown", (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + 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 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 onPointerUp = () => { + elem.classList.remove("resizing"); + 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); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + return 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 stop = elem.getElementsByClassName("stop")[0]; const output = elem.getElementsByClassName("output")[0]; - const input = elem.getElementsByClassName("input")[0]; + const canvas = elem.getElementsByClassName("canvas")[0]; + const canvasWrapper = elem.getElementsByClassName("canvas-wrapper")[0] || canvas; + 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 inputBtn = elem.getElementsByClassName("input-btn")[0]; + const canvasBtn = elem.getElementsByClassName("canvas-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"; + const additionalPackages = Array.from( + new Set( + (elem.getAttribute("data-packages") || "") + .split(",") + .map((pkg) => pkg.trim()) + .filter((pkg) => pkg.length > 0), + ), + ); + const hasPytamaroPackage = additionalPackages.some( + (pkg) => pkg.toLowerCase() === "pytamaro", + ); + const scriptLooksLikePytamaro = (script) => { + return /\bfrom\s+pytamaro\s+import\b|\bimport\s+pytamaro\b/.test(script); + }; + 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); } @@ -176,102 +1068,269 @@ 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() { + 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"); + canvasHeader?.classList.add("hidden"); + outputHeader?.classList.add("hidden"); + output.classList.remove("hidden"); + if (canvasWrapper) canvasWrapper.classList.add("hidden"); + canvasOutputSplitter?.classList.add("hidden"); + }; + const showCanvasTab = () => { + activeCanvasView = "canvas"; outputBtn.classList.remove("active"); - inputBtn.classList.add("active"); + if (canvasBtn) canvasBtn.classList.add("active"); + canvasHeader?.classList.add("hidden"); + outputHeader?.classList.add("hidden"); output.classList.add("hidden"); - input.classList.remove("hidden"); - } + if (canvasWrapper) canvasWrapper.classList.remove("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() { - outputBtn.classList.add("active"); - inputBtn.classList.remove("active"); - output.classList.remove("hidden"); - input.classList.add("hidden"); + if (isWideCanvasMode()) { + applyCanvasOutputLayout(); + return; + } + showOutputTab(); + } + function showCanvas() { + if (isWideCanvasMode()) { + applyCanvasOutputLayout(); + return; + } + showCanvasTab(); } outputBtn?.addEventListener("click", showOutput); - inputBtn?.addEventListener("click", showInput); + canvasBtn?.addEventListener("click", showCanvas); + 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(); - if (callback) return; + const state = getExecutionState(id); + 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 = ""; + clearPytamaroStdoutCarry(id); - 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"); - }); + const script = getEditorValue(); + 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, + {}, + additionalPackages, + ); + if (results) { + appendOutput(output, results); + } else if (error) { + appendOutput(output, error, true); + } + } + } catch (e) { + output.innerHTML = ""; + appendOutput(output, `Error: ${e}`, true); + console.log(e); + } finally { + clearPytamaroStdoutCarry(id); + state.running = false; + state.stopping = false; + state.type = null; + releaseKeyboardCapture(id); + updateRunning(); } }); run?.addEventListener("click", async () => { - showOutput(); - if (callback) return; + const script = getEditorValue(); + const useOutputForPytamaro = hasPytamaroPackage || scriptLooksLikePytamaro(script); + if (hasCanvas && !useOutputForPytamaro) { + showCanvas(); + } else { + showOutput(); + } + const state = getExecutionState(id); + 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; output.innerHTML = ""; - asyncRun(id, "run")(script, { - inputs: input.value.split("\n"), - }) - .then(({ results, error }) => { + clearPytamaroStdoutCarry(id); + try { + const { results, error } = await executeScript(id, script, { + ...(hasCanvas && canvas && !useOutputForPytamaro ? { canvas } : {}), + }, additionalPackages); + if (!state.stopRequested) { if (results) { - output.textContent += results; + appendOutput(output, results); } else if (error) { - output.textContent += error; + showOutput(); + appendOutput(output, error, true); } - 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) { + showOutput(); + output.innerHTML = ""; + appendOutput(output, `Error: ${e}`, true); + console.log(e); + } finally { + clearPytamaroStdoutCarry(id); + state.running = false; + state.stopping = false; + state.type = null; + releaseKeyboardCapture(id); + updateRunning(); + } }); + + stop?.addEventListener("click", handleStopClick); } }; @@ -292,6 +1351,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 29e846681..2ff929fcf 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,6 +37,57 @@ font-family: hyperbook-monospace, monospace; } +.directive-pyide .output .error-line { + color: #b42318; +} + +.directive-pyide .canvas-wrapper { + overflow: auto; + height: 100%; + 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 .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; } @@ -43,7 +96,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 { @@ -52,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); @@ -59,6 +162,7 @@ border-bottom: none; border-bottom-left-radius: 0; border-bottom-right-radius: 0; + overflow: hidden; } .directive-pyide .buttons.bottom { @@ -90,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); } @@ -108,24 +228,81 @@ opacity: 0.5; } +.directive-pyide button.stopping { + pointer-events: auto; + cursor: progress; + opacity: 0.75; +} + +.directive-pyide button.locked { + color: #b45309; + font-weight: 600; +} + +.directive-pyide button.fullscreen { + flex: 0 0 auto; + min-width: 42px; + width: 42px; + padding: 8px 0; +} + +.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; - 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; } } + + .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-pyide/webworker.js b/packages/markdown/assets/directive-pyide/webworker.js deleted file mode 100644 index 5629b34f6..000000000 --- a/packages/markdown/assets/directive-pyide/webworker.js +++ /dev/null @@ -1,86 +0,0 @@ -// 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 { - 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(); - -self.onmessage = async ({ data: { id, type, payload } }) => { - switch (type) { - 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]; - } - - 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..ece842e6d 100644 --- a/packages/markdown/assets/directive-typst/client.js +++ b/packages/markdown/assets/directive-typst/client.js @@ -1148,6 +1148,92 @@ 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('pointerdown', (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + 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 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 onPointerUp = () => { + elem.classList.remove('resizing'); + splitter.removeEventListener('pointermove', onPointerMove); + splitter.removeEventListener('pointerup', onPointerUp); + splitter.removeEventListener('pointercancel', onPointerUp); + }; + + splitter.addEventListener('pointermove', onPointerMove); + splitter.addEventListener('pointerup', onPointerUp); + splitter.addEventListener('pointercancel', onPointerUp); + }); + + window.addEventListener('resize', applyStoredSplitSize); + } + + const updateFullscreenButtonState = (elem, button) => { + if (!elem || !button) return; + const isFullscreen = document.fullscreenElement === elem; + const label = i18nGet('ide-fullscreen-enter', 'Fullscreen'); + button.textContent = '⛶'; + button.title = label; + button.setAttribute('aria-label', label); + 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, @@ -1178,13 +1264,24 @@ 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'); + this.fullscreenBtn = elem.querySelector('.fullscreen'); + this.editorInitialized = false; + + setupSplitter(this.elem, this.previewContainer, this.editorContainer, this.splitter); // Setup UI callbacks this.setupUICallbacks(); // 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(); @@ -1208,12 +1305,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()); } /** @@ -1221,8 +1320,9 @@ hyperbook.typst = (function () { */ async initialize() { if (this.editor) { - // Edit mode - wait for code-input to load - this.editor.addEventListener('code-input_load', async () => { + const initializeEditor = async () => { + if (this.editorInitialized) return; + this.editorInitialized = true; await this.restoreState(); this.uiManager.updateTabs(); this.uiManager.updateBinaryFilesList(); @@ -1235,7 +1335,13 @@ hyperbook.typst = (function () { this.saveState(); debouncedRerender(); }); - }); + }; + + // Edit mode - wait for code-input to load (or initialize immediately if already ready) + this.editor.addEventListener('code-input_load', initializeEditor); + if (this.editor.querySelector('textarea')) { + await initializeEditor(); + } } else if (this.sourceTextarea) { // Preview mode const initialCode = this.sourceTextarea.value; @@ -1261,7 +1367,7 @@ hyperbook.typst = (function () { const result = await window.store?.typst?.get(this.id); if (result) { - this.editor.value = result.code; + this.setEditorValue(result.code || this.fileManager.getCurrentContent()); if (result.sourceFiles) { this.fileManager.sourceFiles = result.sourceFiles; @@ -1280,11 +1386,11 @@ hyperbook.typst = (function () { ); if (file) { this.fileManager.currentFile = file; - this.editor.value = this.fileManager.getCurrentContent(); + this.setEditorValue(this.fileManager.getCurrentContent()); } } } else { - this.editor.value = this.fileManager.getCurrentContent(); + this.setEditorValue(this.fileManager.getCurrentContent()); } } @@ -1294,11 +1400,11 @@ hyperbook.typst = (function () { async saveState() { if (!this.editor) return; - this.fileManager.updateCurrentContent(this.editor.value); + this.fileManager.updateCurrentContent(this.getEditorValue()); await window.store?.typst?.put({ id: this.id, - code: this.editor.value, + code: this.getEditorValue(), sourceFiles: this.fileManager.getSourceFiles(), binaryFiles: this.binaryFiles, currentFile: this.fileManager.currentFile.filename, @@ -1311,7 +1417,7 @@ hyperbook.typst = (function () { rerender() { if (!this.editor) return; - this.fileManager.updateCurrentContent(this.editor.value); + this.fileManager.updateCurrentContent(this.getEditorValue()); const mainFile = this.fileManager.findMainFile(); const mainCode = mainFile @@ -1338,9 +1444,9 @@ hyperbook.typst = (function () { */ handleFileSwitch(filename) { if (this.editor) { - this.fileManager.updateCurrentContent(this.editor.value); + this.fileManager.updateCurrentContent(this.getEditorValue()); const content = this.fileManager.switchTo(filename); - this.editor.value = content; + this.setEditorValue(content); this.uiManager.updateTabs(); this.saveState(); } @@ -1351,7 +1457,7 @@ hyperbook.typst = (function () { */ handleFilesChange() { if (this.editor) { - this.editor.value = this.fileManager.getCurrentContent(); + this.setEditorValue(this.fileManager.getCurrentContent()); } this.saveState(); this.rerender(); @@ -1389,7 +1495,7 @@ hyperbook.typst = (function () { } if (this.editor) { - this.editor.value = this.fileManager.getCurrentContent(); + this.setEditorValue(this.fileManager.getCurrentContent()); } this.uiManager.updateTabs(); @@ -1463,7 +1569,7 @@ hyperbook.typst = (function () { */ async handleExportProject() { const mainFile = this.fileManager.findMainFile(); - const code = mainFile ? mainFile.content : (this.editor ? this.editor.value : ''); + const code = mainFile ? mainFile.content : this.getEditorValue(); await this.exporter.export({ code, @@ -1489,6 +1595,40 @@ hyperbook.typst = (function () { window.location.reload(); } } + + async handleFullscreenToggle() { + try { + await toggleFullscreen(this.elem); + } catch (error) { + console.error(error.message); + } + } + + getEditorValue() { + if (!this.editor) return ''; + const textarea = this.editor.querySelector('textarea'); + if (textarea) return textarea.value; + try { + if (typeof this.editor.value === 'string') { + return this.editor.value; + } + } catch (e) {} + return this.editor.textContent || ''; + } + + setEditorValue(value) { + if (!this.editor) return; + const normalizedValue = value ?? ''; + const textarea = this.editor.querySelector('textarea'); + if (textarea) { + textarea.value = normalizedValue; + } + try { + this.editor.value = normalizedValue; + } catch (e) { + this.editor.textContent = normalizedValue; + } + } } // ============================================================================ diff --git a/packages/markdown/assets/directive-typst/style.css b/packages/markdown/assets/directive-typst/style.css index 24f8d65dc..518a8296e 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; @@ -398,6 +435,7 @@ code-input { border-bottom: none; border-bottom-left-radius: 0; border-bottom-right-radius: 0; + overflow: hidden; } .directive-typst .buttons.bottom { @@ -428,10 +466,17 @@ 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: 42px; + width: 42px; + padding: 8px 0; +} + .directive-typst button:hover { background-color: var(--color-spacer); } @@ -447,10 +492,26 @@ 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; - 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..62e5b58de 100644 --- a/packages/markdown/assets/directive-webide/client.js +++ b/packages/markdown/assets/directive-webide/client.js @@ -16,7 +16,107 @@ 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("pointerdown", (event) => { + event.preventDefault(); + splitter.setPointerCapture(event.pointerId); + + 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 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 onPointerUp = () => { + elem.classList.remove("resizing"); + splitter.removeEventListener("pointermove", onPointerMove); + splitter.removeEventListener("pointerup", onPointerUp); + splitter.removeEventListener("pointercancel", onPointerUp); + }; + + splitter.addEventListener("pointermove", onPointerMove); + splitter.addEventListener("pointerup", onPointerUp); + splitter.addEventListener("pointercancel", onPointerUp); + }); + + window.addEventListener("resize", applyStoredSplitSize); + } + + const updateFullscreenButtonState = (elem, button) => { + if (!elem || !button) return; + const isFullscreen = document.fullscreenElement === elem; + const label = hyperbook.i18n.get("ide-fullscreen-enter"); + button.textContent = "⛶"; + button.title = label; + button.setAttribute("aria-label", label); + 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"); + + 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"); @@ -38,6 +138,19 @@ 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"))) { @@ -166,6 +279,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 b20021ee2..677d99663 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); @@ -67,6 +104,7 @@ code-input { border-bottom: none; border-bottom-left-radius: 0; border-bottom-right-radius: 0; + overflow: hidden; } .directive-webide .buttons.bottom { @@ -91,10 +129,17 @@ 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: 42px; + width: 42px; + padding: 8px 0; +} + .directive-webide button:hover { background-color: var(--color-spacer); } @@ -106,10 +151,22 @@ 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; - height: calc(100dvh - 128px); + height: calc(100dvh - 80px); .container { flex: 1; height: 100% !important; diff --git a/packages/markdown/locales/de.json b/packages/markdown/locales/de.json index c07e52b96..b5d80dcb7 100644 --- a/packages/markdown/locales/de.json +++ b/packages/markdown/locales/de.json @@ -27,14 +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 7f7b47248..1088e91d2 100644 --- a/packages/markdown/locales/en.json +++ b/packages/markdown/locales/en.json @@ -27,14 +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/package.json b/packages/markdown/package.json index 954785003..ab2f6b488 100644 --- a/packages/markdown/package.json +++ b/packages/markdown/package.json @@ -85,7 +85,7 @@ "@types/object-hash": "3.0.6", "@types/pako": "2.0.4", "@types/qrcode-svg": "^1.1.5", - "@webcoder49/code-input": "^2.2.1", + "@webcoder49/code-input": "^2.8.2", "abcjs": "^6.6.0", "chalk": "^5.4.1", "chokidar": "4.0.3", diff --git a/packages/markdown/src/rehypeDirectiveP5.ts b/packages/markdown/src/rehypeDirectiveP5.ts index d303359e8..40ede4dc4 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"> -
+