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).
This commit is contained in:
Olivier Chafik 2026-04-02 03:43:47 +01:00 • committed by GitHub
commit 68c58b60b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 445 additions and 35 deletions

View file

@ -306,6 +306,28 @@ export function newAppBridge(
appBridge.sendHostContextChange({ theme: newTheme });
});
// Per spec, the host SHOULD notify the view when container dimensions
// change. A ResizeObserver on the iframe covers window resize, layout
// shifts, and the inline↔fullscreen panel toggle (which React applies
// a tick after onrequestdisplaymode returns — sending containerDimensions
// alongside displayMode there would race the layout). Height stays
// flexible (maxHeight) so the view can keep driving it via sendSizeChanged.
const iframeResizeObserver = new ResizeObserver(([entry]) => {
const width = Math.round(entry.contentRect.width);
if (width > 0) {
appBridge.sendHostContextChange({
containerDimensions: { width, maxHeight: 6000 },
});
}
});
iframeResizeObserver.observe(iframe);
// AppBridge inherits Protocol's onclose hook — chain disposal there.
const prevOnclose = appBridge.onclose;
appBridge.onclose = () => {
iframeResizeObserver.disconnect();
prevOnclose?.();
};
// Register all handlers before calling connect(). The view can start
// sending requests immediately after the initialization handshake, so any
// handlers registered after connect() might miss early requests.

View file

@ -326,6 +326,17 @@ body {
.main.fullscreen .canvas-container {
min-height: 0; /* Allow flex item to shrink below content size */
overflow: auto; /* Scroll within the document area only */
/* Let JS own pinch-zoom (we re-render the PDF, not the viewport).
* pan-x pan-y keeps native scrolling/panning working. iOS Safari only
* partially honours this — preventDefault on touchmove backs it up. */
touch-action: pan-x pan-y;
/* Keep the page clear of host overlays (chat input, home indicator).
* computeFitScale subtracts padding, so fit-to-page lands above these.
* The container background still extends edge-to-edge (.main.fullscreen
* has padding:0). Top inset is on the toolbar; the rest land here. */
padding-bottom: calc(1rem + var(--safe-bottom, 0px));
padding-left: calc(1rem + var(--safe-left, 0px));
padding-right: calc(1rem + var(--safe-right, 0px));
}
/* Search Button */

View file

@ -128,6 +128,8 @@ let pdfDocument: pdfjsLib.PDFDocumentProxy | null = null;
let currentPage = 1;
let totalPages = 0;
let scale = 1.0;
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 3.0;
let pdfUrl = "";
let pdfTitle: string | undefined;
let viewUUID: string | undefined;
@ -209,6 +211,7 @@ const searchCloseBtn = document.getElementById(
) as HTMLButtonElement;
const highlightLayerEl = document.getElementById("highlight-layer")!;
const annotationLayerEl = document.getElementById("annotation-layer")!;
const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement;
// formLayerEl → imported from ./viewer-state.js
const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
const downloadBtn = document.getElementById(
@ -256,32 +259,99 @@ let currentDisplayMode: "inline" | "fullscreen" = "inline";
let userHasZoomed = false;
/**
* Compute a scale that fits the PDF page width to the available container width.
* Returns null if the container isn't visible or the page width is unavailable.
* Compute the scale that best fits the PDF page to the container.
* Returns null only when the container hasn't laid out yet.
*
* Inline mode: fit-to-WIDTH capped at 1.0. We shrink to fit a narrow chat
* column but don't blow up past natural size — the iframe sizes itself to
* the page via sendSizeChanged, so growing past 1.0 would just make the
* iframe huge.
*
* Fullscreen mode: fit-to-PAGE capped at ZOOM_MAX. The whole page is visible
* without scrolling (min of width-fit and height-fit). On a wide screen this
* typically lands well above 1.0; on a phone in portrait, width is the
* tighter constraint and it degrades to fit-to-width.
*/
async function computeFitToWidthScale(): Promise<number | null> {
async function computeFitScale(): Promise<number | null> {
if (!pdfDocument) return null;
try {
const page = await pdfDocument.getPage(currentPage);
const naturalViewport = page.getViewport({ scale: 1.0 });
const pageWidth = naturalViewport.width;
const pageHeight = naturalViewport.height;
const container = canvasContainerEl as HTMLElement;
const containerStyle = getComputedStyle(container);
const paddingLeft = parseFloat(containerStyle.paddingLeft);
const paddingRight = parseFloat(containerStyle.paddingRight);
const availableWidth = container.clientWidth - paddingLeft - paddingRight;
const padX =
parseFloat(containerStyle.paddingLeft) +
parseFloat(containerStyle.paddingRight);
const padY =
parseFloat(containerStyle.paddingTop) +
parseFloat(containerStyle.paddingBottom);
const availableWidth = container.clientWidth - padX;
const availableHeight = container.clientHeight - padY;
if (availableWidth <= 0 || pageWidth <= 0) return null;
if (availableWidth >= pageWidth) return null; // Already fits
return availableWidth / pageWidth;
const widthFit = availableWidth / pageWidth;
if (currentDisplayMode !== "fullscreen") {
return Math.min(1.0, widthFit);
}
// Fullscreen: fit the WHOLE page. If height isn't measurable yet
// (early layout) fall back to width-fit.
const heightFit =
availableHeight > 0 && pageHeight > 0
? availableHeight / pageHeight
: widthFit;
return Math.min(ZOOM_MAX, widthFit, heightFit);
} catch {
return null;
}
}
/**
* Re-apply the auto-fit scale if the user hasn't taken over zoom. Runs on
* container resize (ResizeObserver) and display-mode transitions.
*
* The ResizeObserver path is the load-bearing one. Hosts disagree on
* what they send and when:
* - basic-host sends containerDimensions only at init, never on resize
* - Claude Desktop resizes the iframe and sends displayMode, but the
* iframe element may not have its new size yet when the message lands
* The observer fires after the iframe has actually laid out at the new
* size, so clientWidth is fresh. The hostContextChanged hooks are kept
* as a fast path / belt-and-suspenders.
*/
async function refitScale(): Promise<void> {
if (!pdfDocument || userHasZoomed) return;
const fitScale = await computeFitScale();
if (fitScale !== null && Math.abs(fitScale - scale) > 0.01) {
scale = fitScale;
log.info("Refit scale:", scale);
renderPage();
}
}
// Refit when the container actually changes size. In INLINE mode this is
// gated to width-GROWTH only — renderPage() → requestFitToContent() → host
// resizes iframe to the (smaller) page width would otherwise re-trigger the
// observer and walk the scale down to ZOOM_MIN. In FULLSCREEN
// requestFitToContent early-returns so there's no loop, and fit-to-page
// needs height changes too (rotation, browser chrome on mobile).
let lastContainerW = 0;
let lastContainerH = 0;
const containerResizeObserver = new ResizeObserver(([entry]) => {
const { width: w, height: h } = entry.contentRect;
const grewW = w > lastContainerW + 1;
const changed =
Math.abs(w - lastContainerW) > 1 || Math.abs(h - lastContainerH) > 1;
lastContainerW = w;
lastContainerH = h;
if (currentDisplayMode === "fullscreen" ? changed : grewW) refitScale();
});
containerResizeObserver.observe(canvasContainerEl as HTMLElement);
/**
* Request the host to resize the app to fit the current PDF page.
* Only applies in inline mode - fullscreen mode uses scrolling.
@ -303,13 +373,9 @@ function requestFitToContent() {
}
// Get actual element dimensions
const canvasContainerEl = document.querySelector(
".canvas-container",
) as HTMLElement;
const pageWrapperEl = document.querySelector(".page-wrapper") as HTMLElement;
const toolbarEl = document.querySelector(".toolbar") as HTMLElement;
if (!canvasContainerEl || !toolbarEl || !pageWrapperEl) {
if (!toolbarEl) {
return;
}
@ -2982,6 +3048,9 @@ async function renderPage() {
// Set display size in CSS pixels
canvasEl.style.width = `${viewport.width}px`;
canvasEl.style.height = `${viewport.height}px`;
// Drop any pinch preview transform in the same frame as the canvas
// resize so the size handoff is atomic.
pageWrapperEl.style.transform = "";
// Scale context for retina
ctx.scale(dpr, dpr);
@ -3249,20 +3318,24 @@ function scrollSelectionIntoView(): void {
function zoomIn() {
userHasZoomed = true;
scale = Math.min(scale + 0.25, 3.0);
scale = Math.min(scale + 0.25, ZOOM_MAX);
renderPage().then(scrollSelectionIntoView);
}
function zoomOut() {
userHasZoomed = true;
scale = Math.max(scale - 0.25, 0.5);
scale = Math.max(scale - 0.25, ZOOM_MIN);
renderPage().then(scrollSelectionIntoView);
}
function resetZoom() {
userHasZoomed = false;
scale = 1.0;
renderPage().then(scrollSelectionIntoView);
// Re-fit rather than blindly snapping to 1.0 — in a narrow inline iframe
// 1.0 overflows, and in fullscreen 1.0 leaves the page floating in space.
computeFitScale().then((fitScale) => {
scale = fitScale ?? 1.0;
renderPage().then(scrollSelectionIntoView);
});
}
async function toggleFullscreen() {
@ -3666,6 +3739,59 @@ document.addEventListener("selectionchange", () => {
}, 300);
});
// --- Pinch zoom (fullscreen only) ---
//
// Covers two input paths:
// 1. wheel + ctrlKey → trackpad pinch on macOS Safari/Chrome/FF and
// Windows precision touchpads. The browser synthesizes
// these on pinch; deltaY < 0 is zoom-in.
// 2. two-finger touch → mobile Safari / Chrome Android. We track the
// distance between the two touches and scale by the
// ratio against the initial distance.
//
// Both paths apply a CSS transform to .page-wrapper for live feedback (GPU,
// no canvas re-render per frame), then commit to a real renderPage() once
// the gesture settles. renderPage() is heavy (PDF.js page.render → canvas,
// TextLayer, AnnotationLayer all rebuilt) — way too slow for touchmove.
/** Scale at gesture start. The CSS transform is relative to the rendered
* canvas, so previewScale / pinchStartScale is what we paint. */
let pinchStartScale = 1.0;
/** What we'd commit to if the gesture ended right now. */
let previewScale = 1.0;
/** Debounce timer — wheel events have no end event, so we wait for quiet. */
let pinchSettleTimer: ReturnType<typeof setTimeout> | null = null;
function beginPinch() {
pinchStartScale = scale;
previewScale = scale;
// transform-origin matches the flex layout's anchor (justify-content:
// center, align-items: flex-start) so the preview and the committed
// canvas grow from the same point — otherwise the page jumps on release.
pageWrapperEl.style.transformOrigin = "50% 0";
}
function updatePinch(nextScale: number) {
previewScale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, nextScale));
// Transform is RELATIVE to the rendered canvas (which sits at
// pinchStartScale), so a previewScale equal to pinchStartScale → ratio 1.
pageWrapperEl.style.transform = `scale(${previewScale / pinchStartScale})`;
zoomLevelEl.textContent = `${Math.round(previewScale * 100)}%`;
}
function commitPinch() {
if (Math.abs(previewScale - scale) < 0.01) {
// Dead-zone — no re-render. Clear here since renderPage won't run.
pageWrapperEl.style.transform = "";
return;
}
userHasZoomed = true;
scale = previewScale;
// renderPage clears the transform in the same frame as the canvas
// resize (after its first await) so there's no snap-back.
renderPage().then(scrollSelectionIntoView);
}
// Horizontal scroll/swipe to change pages (disabled when zoomed)
let horizontalScrollAccumulator = 0;
const SCROLL_THRESHOLD = 50;
@ -3675,6 +3801,28 @@ canvasContainerEl.addEventListener(
(event) => {
const e = event as WheelEvent;
// Trackpad pinch arrives as wheel with ctrlKey set (Chrome/FF/Edge on
// macOS+Windows, Safari on macOS). MUST check before the deltaX/deltaY
// comparison below — pinch deltas come through on deltaY.
if (e.ctrlKey && currentDisplayMode === "fullscreen") {
e.preventDefault();
if (pinchSettleTimer === null) beginPinch();
// exp(-deltaY * k) makes equal-magnitude in/out deltas inverse —
// pinch out then back lands where you started. Clamp per event so a
// physical mouse wheel (deltaY ≈ ±100/notch) doesn't slam to the
// limit; trackpad pinch deltas are ~±1-10 so the clamp is a no-op.
const d = Math.max(-25, Math.min(25, e.deltaY));
updatePinch(previewScale * Math.exp(-d * 0.01));
if (pinchSettleTimer) clearTimeout(pinchSettleTimer);
// 200ms — slow trackpad pinches can leave >150ms gaps between wheel
// events, which would commit-then-restart and feel steppy.
pinchSettleTimer = setTimeout(() => {
pinchSettleTimer = null;
commitPinch();
}, 200);
return;
}
// Only intercept horizontal scroll, let vertical scroll through
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
@ -3695,6 +3843,58 @@ canvasContainerEl.addEventListener(
{ passive: false },
);
// Two-finger touch pinch. We listen on the container (not page-wrapper)
// because the wrapper transforms during the gesture and would shift the
// touch target out from under the fingers.
let touchStartDist = 0;
function touchDist(t: TouchList): number {
const dx = t[0].clientX - t[1].clientX;
const dy = t[0].clientY - t[1].clientY;
return Math.hypot(dx, dy);
}
canvasContainerEl.addEventListener(
"touchstart",
(event) => {
const e = event as TouchEvent;
if (e.touches.length !== 2 || currentDisplayMode !== "fullscreen") return;
// No preventDefault here — keep iOS Safari happy. We block native
// pinch-zoom via touch-action CSS + preventDefault on touchmove.
touchStartDist = touchDist(e.touches);
if (touchStartDist > 0) beginPinch();
},
{ passive: true },
);
canvasContainerEl.addEventListener(
"touchmove",
(event) => {
const e = event as TouchEvent;
if (e.touches.length !== 2 || touchStartDist === 0) return;
e.preventDefault(); // stop the browser zooming the whole viewport
updatePinch(pinchStartScale * (touchDist(e.touches) / touchStartDist));
},
{ passive: false },
);
canvasContainerEl.addEventListener("touchend", (event) => {
const e = event as TouchEvent;
// Gesture ends when we drop below two fingers. e.touches is the
// REMAINING set — lifting one of two leaves length 1.
if (touchStartDist === 0 || e.touches.length >= 2) return;
touchStartDist = 0;
commitPinch();
});
canvasContainerEl.addEventListener("touchcancel", () => {
if (touchStartDist === 0) return;
touchStartDist = 0;
// Cancelled (call, app-switch) → revert, don't commit a half-gesture.
pageWrapperEl.style.transform = "";
zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
});
// Parse tool result
function parseToolResult(result: CallToolResult): {
url: string;
@ -4130,10 +4330,10 @@ app.ontoolresult = async (result: CallToolResult) => {
// and here makes the empty viewer visible. The annotation/form scans
// below are O(numPages) and do NOT block the canvas (page.render only
// needs canvasContext+viewport), so they run after.
const fitScale = await computeFitToWidthScale();
const fitScale = await computeFitScale();
if (fitScale !== null) {
scale = fitScale;
log.info("Fit-to-width scale:", scale);
log.info("Initial fit scale:", scale);
}
await renderPage();
@ -4493,19 +4693,10 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
mainEl.style.paddingLeft = `${left}px`;
}
// Recompute fit-to-width when container dimensions change
if (ctx.containerDimensions && pdfDocument && !userHasZoomed) {
log.info("Container dimensions changed:", ctx.containerDimensions);
computeFitToWidthScale().then((fitScale) => {
if (fitScale !== null && Math.abs(fitScale - scale) > 0.01) {
scale = fitScale;
log.info("Recomputed fit-to-width scale:", scale);
renderPage();
}
});
}
// Handle display mode changes
// Display-mode handling MUST run before the fit-to-width recompute below.
// Toggling .fullscreen flips `padding: 0 !important` on mainEl, which
// changes how much width the canvas-container actually gets. Measuring
// before the class lands sees the wrong padding.
if (ctx.displayMode) {
const wasFullscreen = currentDisplayMode === "fullscreen";
currentDisplayMode = ctx.displayMode as "inline" | "fullscreen";
@ -4516,12 +4707,25 @@ function handleHostContextChanged(ctx: McpUiHostContext) {
if (panelState.open) {
setAnnotationPanelOpen(true);
}
// When exiting fullscreen, request resize to fit content
if (wasFullscreen && !isFullscreen && pdfDocument) {
requestFitToContent();
if (wasFullscreen !== isFullscreen) {
// Mode changed → refit. computeFitScale reads displayMode, so
// this scales UP to fill on enter and back DOWN to ≤1.0 on exit.
// refitScale → renderPage → requestFitToContent handles the
// host-resize on exit. If userHasZoomed, refit no-ops; on exit fall
// back to requestFitToContent so the iframe still shrinks to whatever
// scale the user left it at.
void refitScale().then(() => {
if (!isFullscreen && userHasZoomed) requestFitToContent();
});
}
updateFullscreenButton();
}
// ResizeObserver on canvasContainerEl drives refit on actual size change;
// ctx.containerDimensions is logged for debugging but isn't load-bearing.
if (ctx.containerDimensions) {
log.info("Container dimensions changed:", ctx.containerDimensions);
}
}
app.onteardown = async () => {
@ -4534,6 +4738,11 @@ app.onteardown = async () => {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = null;
}
if (pinchSettleTimer) {
clearTimeout(pinchSettleTimer);
pinchSettleTimer = null;
}
containerResizeObserver.disconnect();
return {};
};

View file

@ -0,0 +1,168 @@
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);
});
});