diff --git a/js/demo/playground.js b/js/demo/playground.js index aef93e6..5da1539 100644 --- a/js/demo/playground.js +++ b/js/demo/playground.js @@ -82,6 +82,7 @@ function decodeSource(encoded) { } let ready = false; +let lastHtml = null; function renderNow() { if (!ready) { @@ -89,7 +90,13 @@ function renderNow() { } try { const html = render(source.value); - mount(output, html); + // Re-mounting tears down and rebuilds every chart and diagram, so skip it + // when an edit did not change the rendered output (whitespace, a typo then + // its fix). Keeps typing smooth on a graphics-heavy document. + if (html !== lastHtml) { + mount(output, html); + lastHtml = html; + } status.textContent = ""; status.classList.remove("pg-status-error"); } catch (error) { @@ -103,7 +110,7 @@ function renderNow() { let debounce; function scheduleRender() { clearTimeout(debounce); - debounce = setTimeout(renderNow, 150); + debounce = setTimeout(renderNow, 220); } async function loadPreset(name) { diff --git a/js/e2e/playground.spec.ts b/js/e2e/playground.spec.ts new file mode 100644 index 0000000..9e92744 --- /dev/null +++ b/js/e2e/playground.spec.ts @@ -0,0 +1,35 @@ +import { expect, test } from "@playwright/test"; + +// Guards the playground against the chart-overflow regression: a uPlot canvas +// sized once at mount used to stay wider than its column (a fallback width, or a +// stale measurement taken before a vertical scrollbar narrowed the pane), which +// left a permanent horizontal scrollbar on the preview. The runtime now reflows +// the chart to its container; this proves the preview never overflows sideways, +// at load and after a resize. + +// A narrow, short viewport: two columns (above the 720px stack breakpoint) so the +// preview pane is well under the chart's fallback width, and short enough that the +// document needs a vertical scrollbar, reproducing the width-shrink that bit us. +const NARROW = { width: 760, height: 520 }; + +async function horizontalOverflow(page: import("@playwright/test").Page): Promise { + return page.locator("#out").evaluate((el) => el.scrollWidth - el.clientWidth); +} + +test("the playground preview never overflows horizontally", async ({ page }) => { + await page.setViewportSize(NARROW); + await page.goto("/demo/playground.html"); + + // Wait for the real render path: wasm init, then the starter's chart upgrades + // into a uPlot canvas inside the preview pane. + await page.waitForSelector("#out canvas"); + // Let the reflow (a ResizeObserver coalesced into a frame) settle. + await page.waitForTimeout(250); + + expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1); + + // Shrinking the window must not reintroduce the overflow. + await page.setViewportSize({ width: 730, height: 520 }); + await page.waitForTimeout(250); + expect(await horizontalOverflow(page)).toBeLessThanOrEqual(1); +}); diff --git a/js/packages/runtime/src/components.ts b/js/packages/runtime/src/components.ts index 2eefa86..a4619de 100644 --- a/js/packages/runtime/src/components.ts +++ b/js/packages/runtime/src/components.ts @@ -83,6 +83,7 @@ class TabsElement extends AltElement { class ChartElement extends AltElement { #chart: UPlot | null = null; #observer: ResizeObserver | null = null; + #frame = 0; protected override enhance(): void { // Lazy-loaded; a failure leaves the accessible fallback table in place. @@ -107,23 +108,43 @@ class ChartElement extends AltElement { * has to be pushed in explicitly. */ #observeWidth(chart: UPlot): void { + // Measure and observe the block-level chart container, not the host: the + // custom element is inline by default, so its clientWidth is 0. The + // container's width tracks the column, and reconciling against the chart's + // own current width corrects any build-time mismatch. The 1px tolerance + // stops sub-pixel jitter from starting a resize/scrollbar feedback loop. + const box = this.querySelector(".alt-chart-canvas") ?? this; + const sync = (): void => { + const width = box.clientWidth; + if (width > 0 && Math.abs(width - chart.width) > 1) { + chart.setSize({ width, height: CHART_HEIGHT }); + } + }; + sync(); // correct any build-time mismatch immediately if (typeof ResizeObserver === "undefined") { return; } - let lastWidth = this.clientWidth; + // Coalesce a burst of resize callbacks (a window drag) into one redraw per + // frame; a full uPlot redraw per raw callback is what made resizing feel slow. this.#observer = new ResizeObserver(() => { - const width = this.clientWidth; - if (width > 0 && width !== lastWidth) { - lastWidth = width; - chart.setSize({ width, height: CHART_HEIGHT }); + if (this.#frame) { + return; } + this.#frame = requestAnimationFrame(() => { + this.#frame = 0; + sync(); + }); }); - this.#observer.observe(this); + this.#observer.observe(box); } disconnectedCallback(): void { this.#observer?.disconnect(); this.#observer = null; + if (this.#frame) { + cancelAnimationFrame(this.#frame); + this.#frame = 0; + } this.#chart?.destroy(); this.#chart = null; } diff --git a/js/packages/runtime/src/graphics.ts b/js/packages/runtime/src/graphics.ts index f43e6b5..2acb78f 100644 --- a/js/packages/runtime/src/graphics.ts +++ b/js/packages/runtime/src/graphics.ts @@ -67,8 +67,17 @@ export async function renderChart(host: HTMLElement): Promise { ticks: { stroke: grid, width: 1 }, }; + // The chart draws into a block-level container. The custom-element host is + // inline by default, so its own clientWidth is 0; the container is what has a + // real width to fit, measured before uPlot fills it. + const fallbackTable = host.querySelector("table"); + const canvas = document.createElement("div"); + canvas.className = "alt-chart-canvas"; + host.prepend(canvas); + const width = canvas.clientWidth || host.clientWidth || 640; + const opts: import("uplot").Options = { - width: host.clientWidth || 640, + width, height: CHART_HEIGHT, scales: { x: { time: false } }, // A crosshair cursor that highlights the nearest series, and drag to zoom the @@ -90,12 +99,6 @@ export async function renderChart(host: HTMLElement): Promise { ], }; - // Capture the fallback table before uPlot adds its own legend table, so we - // hide the right one. - const fallbackTable = host.querySelector("table"); - const canvas = document.createElement("div"); - canvas.className = "alt-chart-canvas"; - host.prepend(canvas); const chart = new uPlot(opts, aligned as never, canvas); // Keep the data table in the DOM for accessibility, but hide it visually now // that the chart is the primary representation.