* 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).
168 lines
5.7 KiB
TypeScript
168 lines
5.7 KiB
TypeScript
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);
|
||
});
|
||
});
|