From d5dcab375524796e2eb6db7dd3368ac792b8b086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Vogel?= <104342347+cedricvogel-leo@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:00:30 +0100 Subject: [PATCH] The embed at most 80% of the course's visible height (P53b) The iframe gets the height the embed's content needs, at most 80% of the visible height of the element that scrolls the course (LiaScript's .lia-slide__container, else the window) minus the block's own rows, measured anew whenever that element or the block changes size (window, fullscreen, rotation, presentation mode, LiaScript's top bar). The fixed 1200px and the window height measured once at load are gone. "large": while visualizing, the embed is as tall as it may be. Co-Authored-By: Claude Opus 5.5 --- README.md | 106 ++++++++++++++++++++++++++++------------ scripts/check-ready.mjs | 58 ++++++++++++++++++++++ 2 files changed, 133 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 6b49ec7..a33e67b 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,56 @@ window.__glpythonMount = (args, send) => { const unknownFeatures = requestedFeatures.filter((name) => !featureNames.includes(name)) const hasFeature = (name) => requestedFeatures.includes(name) const large = options.includes("large") - // "large": the visualizer at least 60% of the window - the slide in - // LiaScript's presentation mode - passed in pixels, since the iframe's - // own height follows its content. - const largeVisualizerHeight = Math.round(window.innerHeight * 0.6) - // A visualization-only block starts at the height a short visualization - // ends up with (with "large": its visualizer's), and never gets smaller. - // Until its trace arrives, the embed reports only its loading state - // (160px), so the iframe would first shrink and then grow again - in a - // course e.g. right when a quiz's solution opens. - const minHeight = mode !== "visual" ? 170 : large ? largeVisualizerHeight : 190 + // The embed's height: the height its content needs (its glpython:resize), + // at most 80% of the height the course shows right now - that of the + // element that scrolls the course's content (LiaScript's + // .lia-slide__container, else the window) - so that the whole block with + // its buttons fits into view, with some text around it. That height + // changes with the window, fullscreen, an iPad's rotation, the + // presentation mode and LiaScript's top bar, so it is measured anew on + // every change (fitHeight). A taller step scrolls the visualizer's code + // inside the embed, between its step bar and its footer. "large": while + // visualizing, the embed is as tall as it may be. A visualization-only + // block starts at the height a short visualization ends up with (with + // "large": its maximum), and never gets smaller: until its trace arrives, + // the embed reports only its loading state (160px), so the iframe would + // first shrink and then grow again - in a course e.g. right when a quiz's + // solution opens. + const visibleShare = 0.8 + let contentHeight = null + let visualizing = mode === "visual" + let observedScroller + const heightObserver = new ResizeObserver(() => fitHeight()) + const scrollingAncestor = (element) => { + for (let ancestor = element?.parentElement; ancestor; ancestor = ancestor.parentElement) { + if (/(auto|scroll|overlay)/.test(getComputedStyle(ancestor).overflowY)) return ancestor + } + return null + } + const fitHeight = () => { + // The block (LiaScript wraps its inner part in a paragraph of its own). + const box = frame?.closest(".glpython-embed") ?? frame?.parentElement + if (!box) return + const scroller = scrollingAncestor(box) + if (scroller !== observedScroller) { + observedScroller = scroller + heightObserver.disconnect() + if (scroller) heightObserver.observe(scroller) + heightObserver.observe(box) + } + const visible = scroller ? scroller.clientHeight : window.innerHeight + // A hidden slide has no height; it is measured when it shows. + if (visible === 0 || (contentHeight === null && mode !== "visual")) return + // The block's own rows above the iframe (buttons, libraries, notes) + // count in - unless one opened there (a library's file, the IDE link) + // would leave the iframe less than half of the visible height. + const rows = box.getBoundingClientRect().height - frame.getBoundingClientRect().height + const maxHeight = Math.round(Math.max(visible * visibleShare - rows, visible * 0.5)) + const minHeight = Math.min(maxHeight, large && visualizing ? maxHeight : mode === "visual" ? 190 : 170) + const height = `${Math.max(minHeight, Math.min(contentHeight ?? minHeight, maxHeight))}px` + if (frame.style.height !== height) frame.style.height = height + } + window.addEventListener("resize", () => fitHeight()) // The course's own language (its "language:" header, which LiaScript sets // as the page's lang attribute), else the browser's, else English. Only the // primary subtag counts ("de-CH" -> "de"); an unsupported language such as @@ -654,9 +694,11 @@ window.__glpythonMount = (args, send) => { pythonBusy = message.busy === true attach() } else if (message.type === "glpython:resize" && Number.isFinite(message.height)) { - // The embed's height follows its content (the visualizer too, unless - // "large" asks for 60% of the window). - frame.style.height = `${Math.max(minHeight, Math.min(message.height, 1200))}px` + // Since glpython-editor v0.1.35 it also says whether it visualizes + // (for "large"); an older embed visualizes only as "visual". + contentHeight = message.height + if (typeof message.visualizing === "boolean") visualizing = message.visualizing + fitHeight() } else if (message.type === "glpython:error") { showError(message.message || strings.embedError) } @@ -754,8 +796,10 @@ window.__glpythonMount = (args, send) => { if (frame !== nextFrame) { runtimeReady = false pythonBusy = false - // The new iframe says "ready" again. + // The new iframe says "ready" again, and reports its height. textWait?.stop() + contentHeight = null + visualizing = mode === "visual" } frame = nextFrame error = document.getElementById(`glpython-error-${sessionId}`) @@ -802,19 +846,12 @@ window.__glpythonMount = (args, send) => { libraries.then(showLibraries) if (frame.dataset.glpythonInitialized === "true") return frame.dataset.glpythonInitialized = "true" - if (mode === "visual") frame.style.height = `${minHeight}px` + fitHeight() frame.title = `${strings.frameTitle} (${mode})` const query = new URLSearchParams({ parentOrigin: window.location.origin, mode, lang: language }) if (visualizerMode) query.set("visualizer", visualizerMode) if (featuresOption) query.set("features", requestedFeatures.filter((name) => featureNames.includes(name)).join(",")) - // The course window's height, which the iframe can't see: while - // visualizing, the embed keeps one height (that of the tallest step), at - // most 80% of it - a taller step scrolls inside the visualizer. - query.set("windowHeight", String(window.innerHeight)) - if (large) { - query.set("size", "large") - query.set("visualizerHeight", String(largeVisualizerHeight)) - } + if (large) query.set("size", "large") frame.src = `${embedUrl}?${query}` } @@ -1194,14 +1231,21 @@ expression's tree; any other call keeps its box below the tree, which says why also in «Ersetzung»). Labels are Python's (`+`, `quadrat`, `[ ]`), with no parentheses. Without `tree` there's no «Baum» button. To start a block in «Baum», pass `tree` too: `@GLPython.visual(tree features=visualize,tree)`. -`large` makes the code 20px (line height 1.6) and the visualizer at least 60% of -the window, e.g. for the projector in presentation mode. - -While stepping through a visualization, the block keeps one height, so the -course page doesn't move: the visualizer is as tall as the tallest step from -the start, and the console as tall as the output at the last step. The -visualizer is at most 80% of the course window; a taller step (e.g. a deep -recursion in «Kasten») scrolls inside it, following the current step. +`large` makes the code 20px (line height 1.6) and, while visualizing, the +block as tall as it may be (see below), e.g. for the projector in presentation +mode. + +The block is as tall as its content, but at most 80% of the height the course +shows right now (LiaScript's content area, without its top bar; in fullscreen, +after a rotation or with the top bar hidden in LiaScript's settings, it adapts +at once). So the whole block, with its buttons, fits into view with some text +around it. A taller visualization scrolls its code inside the visualizer, +following the current step, while the step bar above and the global variables +below stay in view; a taller output scrolls the console. Scrolling past the +end of the code goes on with the page. While stepping through a +visualization, the block keeps one height, so the course page doesn't move: +the visualizer is as tall as the tallest step from the start, and the console +as tall as the output at the last step. A course builds its own profiles from these options as macros in its header, passing `@0` on so a block can add more options (a mode or library URLs): diff --git a/scripts/check-ready.mjs b/scripts/check-ready.mjs index 25f917e..3b04083 100644 --- a/scripts/check-ready.mjs +++ b/scripts/check-ready.mjs @@ -216,6 +216,64 @@ try { } await linkContext.close() } + + // The embed's height (P53b): what its content needs (glpython:resize), at + // most 80% of the visible height of the element that scrolls the course + // (here a stand-in for LiaScript's .lia-slide__container) with the + // block's buttons; measured anew when that element changes size. With + // "large", while visualizing, that maximum. + { + const heightPage = ` +
+${[['tall', 'eval', []], ['big', 'eval', ['large']]].map(([id, mode, options]) => `
+
+ ${template.replaceAll('@0', id).replaceAll('@1', mode)} +

Text

+
`).join('\n')} +
+${page.slice(page.indexOf('