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:
parent
800f6d08fd
commit
68c58b60b7
4 changed files with 445 additions and 35 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
|
||||
|
|
|
|||
168
tests/e2e/pdf-viewer-zoom.spec.ts
Normal file
168
tests/e2e/pdf-viewer-zoom.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue