ext-apps/tests/e2e/pdf-viewer-zoom.spec.ts
Olivier Chafik 68c58b60b7
feat(pdf-server): fit-to-page on fullscreen + pinch-to-zoom (#583)
* feat(pdf-server): refit on inline→fullscreen + pinch-to-zoom in fullscreen

Two related fixes for the fullscreen reading experience:

1) Inline → fullscreen left the page at the cramped inline scale.
   computeFitToWidthScale() returned null when the natural page
   already fit (availableWidth >= pageWidth), so going from a
   narrow inline iframe (~67%) to a wide fullscreen container
   produced null → no rescale. Now caps at min(1.0, fit) so the
   refit handler sees 1.0 ≠ 0.67 and re-renders. Display-mode
   handling moves before the containerDimensions refit so the
   .fullscreen padding lands before we measure clientWidth, and
   the inline→fullscreen edge triggers refit explicitly (some
   hosts send displayMode without containerDimensions).

2) Pinch-to-zoom in fullscreen. Two input paths:
   - wheel + ctrlKey → trackpad pinch (Chrome/FF/Edge/Safari).
     exp(-deltaY/100) per pdf.js; 150ms settle then commit.
   - two-finger touch → mobile. Distance ratio against touchstart;
     commit on touchend, revert on touchcancel.
   Both apply a CSS transform to .page-wrapper for live feedback,
   then commit one renderPage() at gesture end (renderPage is
   heavy: PDF.js raster + TextLayer + AnnotationLayer rebuild).
   touch-action: pan-x pan-y on the fullscreen canvas-container
   keeps native panning working while we own the pinch.

Incidental: resetZoom (Cmd+0) now snaps to fit, not blindly 1.0,
so it doesn't overflow a narrow inline iframe; ZOOM_MIN/MAX
constants; shared pageWrapperEl ref.

* test(pdf-viewer-zoom): start narrow — basic-host doesn't emit containerDimensions on resize

The first test loaded at 1400w then resized to 500w expecting the
viewer to refit, but basic-host only sends containerDimensions at
init (implementation.ts:297) — never on window resize. Start narrow
so the initial fit lands <100%, then widen + fullscreen. Matches
the webtop verification flow that confirmed 67% → 100%.

* fix(pdf-server): drive fullscreen refit from ResizeObserver, not host messages

The displayMode hook ran refitToWidth() but Claude Desktop resizes
the iframe element AFTER (or independently of) sending the displayMode
update — clientWidth was still the inline width when we measured.

ResizeObserver on the canvas-container fires after the iframe has
actually laid out at the new size, so the measurement is fresh.
Gated to width-growth only: in inline mode renderPage() →
requestFitToContent() → host shrinks iframe to page width, which
would otherwise loop the scale down to ZOOM_MIN. Growth-only covers
inline→fullscreen and window-widen without the feedback loop.

* fix(basic-host): emit containerDimensions on iframe resize

Per spec, hosts notify views of container dimension changes via
host-context-changed. basic-host only sent containerDimensions once
at init — never on window resize or after the fullscreen panel
toggle. ResizeObserver on the iframe sends {width, maxHeight: 6000}
on every layout change; height stays flexible so views can keep
driving it via sendSizeChanged.

Doesn't replace the viewer-side ResizeObserver (defensive against
hosts we don't control), but makes the reference host spec-compliant.

* fix(pdf-server): scale UP to fill width in fullscreen, not just down

The previous fit cap of 1.0 meant a 612pt page in a 1400px fullscreen
container stayed at 100% — natural size, acres of empty space.
Fullscreen now caps at ZOOM_MAX so the page fills the available
width. Inline keeps the 1.0 cap (the iframe sizes to the page via
sendSizeChanged; growing past 1.0 would just make the iframe huge).

Exit-fullscreen refits back down to ≤1.0; if userHasZoomed (so
refit no-ops) fall back to requestFitToContent so the iframe still
shrinks to whatever scale they left it at.

* test(pdf-viewer-zoom): expect fullscreen to fill width (>100%), pinch out

Fullscreen fit now scales up past 100%, so the entering-fullscreen
assertion is >inlineZoom+50, not =100. The pinch test pinches OUT
(deltaY>0) so it doesn't immediately hit ZOOM_MAX from a ~220%
starting point.

* fix(pdf-server): fullscreen fit-to-PAGE, not fit-to-width

Fit-to-width on a wide screen filled the width but left the page
scrolling vertically — "fits" should mean the whole page is visible.
computeFitScale (renamed) now returns min(widthFit, heightFit) in
fullscreen so the page sits centred without scroll. Inline keeps
fit-to-width capped at 1.0 (height is host-driven via sendSizeChanged).

ResizeObserver now reacts to height changes too in fullscreen — the
inline width-growth-only gate stays (requestFitToContent loop guard),
but in fullscreen requestFitToContent early-returns so any size change
is safe to refit on.

e2e: assert no overflow in fullscreen instead of a magic zoom %.

* fix(pdf-server): respect safe-area insets in fullscreen fit-to-page

The host's chat input / home indicator overlays the bottom of the
fullscreen iframe (reported via safeAreaInsets). The toolbar already
honoured top/left/right; the canvas-container now adds the bottom +
side insets to its padding so the page sits above the overlay.
computeFitScale subtracts padding, so fit-to-page lands clear.
ResizeObserver watches the content box, so inset changes re-trigger
the fit.

* fix(pdf-server): clear pinch transform in same frame as canvas resize

commitPinch cleared the CSS transform BEFORE renderPage's first
await — for one frame the page snapped back to the old rendered
size, then jumped forward when the canvas resized. renderPage now
clears the transform immediately after setting canvas.style.width/
height, so the size handoff is atomic.

Also bump the wheel settle timer 150→200ms; slow trackpad pinches
can leave >150ms gaps and would commit mid-gesture.

* fix(pdf-server): no blank flash on pinch commit; clamp Ctrl+mousewheel; basic-host observer cleanup

Three review follow-ups:

1) Snap on pinch release: setting canvasEl.width clears the backing
   store, so between the resize and page.render() completing the
   page was blank. Snapshot the old bitmap first and drawImage it
   scaled as a placeholder; the real render paints over it. Gated
   on a transform being present so page-nav renders aren't affected.

2) Ctrl + physical mouse wheel sent deltaY≈±100 → exp(±1)≈2.7× per
   notch, slamming straight to ZOOM_MIN/MAX. Clamp per-event delta
   to ±25 (≈±28% per notch); trackpad pinch deltas are ~±1-10 so
   the clamp is a no-op there.

3) basic-host's iframe ResizeObserver had no disposal path. Chained
   disconnect() into appBridge.onclose (Protocol base class hook).

* simplify(pdf-server): pinch snap was transform-origin mismatch, not bitmap

The snap on pinch release was the page jumping vertically:
transform-origin: center center scales around the wrapper's middle,
but the flex container is align-items: flex-start so on commit the
canvas grows from the TOP. Preview top edge moved up; committed top
edge stays put → jump down. transform-origin: 50% 0 matches the
layout anchor, no jump.

Drop the canvas-snapshot placeholder (over-engineered, wrong fix),
the dead transition='' lines (.page-wrapper has no CSS transition),
and the containerDimensions→refit hook (ResizeObserver covers actual
size changes; the host message isn't load-bearing).
2026-04-02 03:43:47 +01:00

168 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { test, expect, type Page } from "@playwright/test";
/**
* PDF Viewer zoom + fullscreen-fit tests.
*
* Covers:
* - Inline → fullscreen refits the page (the cramped inline scale is dropped)
* - Trackpad pinch (wheel + ctrlKey) zooms the page in fullscreen
* - Pinch zoom is ignored in inline mode
*/
test.setTimeout(120000);
function getAppFrame(page: Page) {
return page.frameLocator("iframe").first().frameLocator("iframe").first();
}
async function loadPdfServer(page: Page) {
await page.goto("/?theme=hide");
await expect(page.locator("select").first()).toBeEnabled({ timeout: 30000 });
await page.locator("select").first().selectOption({ label: "PDF Server" });
await page.click('button:has-text("Call Tool")');
// Wait for nested app iframe to mount
const outer = page.frameLocator("iframe").first();
await expect(outer.locator("iframe")).toBeVisible({ timeout: 30000 });
}
async function waitForPdfRender(page: Page) {
const app = getAppFrame(page);
// Canvas reports a non-zero CSS width once renderPage() has sized it.
// toBeVisible alone isn't enough — the canvas exists at 0×0 before
// first paint, so a fast test would race the render.
await expect
.poll(
async () => {
const w = await app
.locator("#pdf-canvas")
.evaluate((el: HTMLCanvasElement) => parseFloat(el.style.width));
return w > 0 ? w : 0;
},
{ timeout: 30000 },
)
.toBeGreaterThan(0);
}
/** Read the current zoom level (e.g. "65%") and return the integer percent. */
async function readZoomPercent(page: Page): Promise<number> {
const text = await getAppFrame(page).locator("#zoom-level").textContent();
const m = text?.match(/(\d+)%/);
if (!m) throw new Error(`Unexpected zoom-level text: ${text}`);
return parseInt(m[1], 10);
}
test.describe("PDF Viewer — fullscreen fit + pinch zoom", () => {
test("entering fullscreen drops the inline shrink-to-fit scale", async ({
page,
}) => {
// Start narrow so the initial fit-to-width lands below 100%.
// The default arxiv PDF is ~612pt wide; a 500px iframe forces a
// fit scale around 60-70%.
await page.setViewportSize({ width: 500, height: 800 });
await loadPdfServer(page);
await waitForPdfRender(page);
const app = getAppFrame(page);
const inlineZoom = await readZoomPercent(page);
expect(inlineZoom).toBeLessThan(100);
// Widen + give plenty of height, then enter fullscreen. Fullscreen uses
// fit-to-PAGE (whole page visible), so the resulting zoom is whichever
// axis is tighter. We just assert it's higher than the cramped inline
// value AND that the page actually fits without scrolling.
await page.setViewportSize({ width: 1400, height: 1000 });
await app.locator("#fullscreen-btn").click();
await expect(app.locator(".main.fullscreen")).toBeVisible({
timeout: 5000,
});
await expect
.poll(() => readZoomPercent(page), { timeout: 5000 })
.toBeGreaterThan(inlineZoom);
// Whole page visible → no scroll inside the canvas-container.
const overflows = await app
.locator(".canvas-container")
.evaluate(
(el: HTMLElement) =>
el.scrollHeight > el.clientHeight + 2 ||
el.scrollWidth > el.clientWidth + 2,
);
expect(overflows).toBe(false);
});
test("trackpad pinch (wheel + ctrlKey) zooms in fullscreen", async ({
page,
}) => {
await page.setViewportSize({ width: 1400, height: 800 });
await loadPdfServer(page);
await waitForPdfRender(page);
const app = getAppFrame(page);
// Go fullscreen first — pinch is fullscreen-only.
await app.locator("#fullscreen-btn").click();
await expect(app.locator(".main.fullscreen")).toBeVisible({
timeout: 5000,
});
// Let the entering-fullscreen fit-to-page refit settle.
await page.waitForTimeout(500);
const before = await readZoomPercent(page);
// Dispatch a synthetic trackpad pinch-OUT on the canvas-container.
// ctrlKey:true is what Chrome/FF/Edge/Safari emit for trackpad pinch;
// deltaY > 0 = zoom out. We pinch out so we don't hit ZOOM_MAX.
// Can't use page.mouse.wheel — it doesn't expose ctrlKey.
await app.locator(".canvas-container").evaluate((el) => {
el.dispatchEvent(
new WheelEvent("wheel", {
deltaY: 50,
ctrlKey: true,
bubbles: true,
cancelable: true,
}),
);
});
// The viewer applies a CSS transform live, then commits to a real
// renderPage() after a 150ms settle timer. exp(-50/100) ≈ 0.607.
await expect
.poll(() => readZoomPercent(page), { timeout: 5000 })
.toBeLessThan(before * 0.8);
// The CSS transform should be cleared once committed (so the canvas
// isn't double-scaled — the new render IS the new scale).
await expect
.poll(
() =>
app
.locator(".page-wrapper")
.evaluate((el: HTMLElement) => el.style.transform),
{ timeout: 5000 },
)
.toBe("");
});
test("trackpad pinch is ignored outside fullscreen", async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 800 });
await loadPdfServer(page);
await waitForPdfRender(page);
const app = getAppFrame(page);
const before = await readZoomPercent(page);
await app.locator(".canvas-container").evaluate((el) => {
el.dispatchEvent(
new WheelEvent("wheel", {
deltaY: -50,
ctrlKey: true,
bubbles: true,
cancelable: true,
}),
);
});
// No settle timer should have started — zoom stays put.
await page.waitForTimeout(300);
expect(await readZoomPercent(page)).toBe(before);
});
});