From 085bb9c9093938232d174e76dbf8d1398fb5a39a Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 00:42:06 -0400 Subject: [PATCH 1/9] fix(pdf-server): translucent highlights, pinch to/from fullscreen, toolbar layout Highlights rendered opaque: inline `background: ` overrode the CSS rgba(...,0.35). Now convert def.color via cssColorToRgb and force alpha 0.35. Added a toHaveCSS regression check in pdf-annotations.spec.ts. Pinch-in while inline (wheel ctrlKey deltaY<0 or two-finger spread >1.15x) now enters fullscreen. Pinch-out in fullscreen past 0.9x of fit-scale, when already at/below fit, exits to inline and clears userHasZoomed so refit sizes the inline view. previewScaleRaw tracks the unclamped intent so the exit fires even when fit ~= ZOOM_MIN. A modeTransitionInFlight latch (held 250ms post-toggle) keeps the gesture tail from re-toggling or immediately zooming the new view. Fullscreen toolbar: flex-wrap: nowrap and tighter 0.25rem vertical padding (min-height 40px + safe-top instead of 48px + wrap). Search bar top/right now follow --safe-top/--safe-right so it sits flush below the toolbar instead of overlapping it. Base .canvas-container gets touch-action: pan-x pan-y so the inline pinch is capturable on iOS. --- examples/pdf-server/src/mcp-app.css | 18 ++++++- examples/pdf-server/src/mcp-app.ts | 81 +++++++++++++++++++++++++++-- tests/e2e/pdf-annotations.spec.ts | 6 +++ 3 files changed, 99 insertions(+), 6 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index b6bbb43b..e490ca69 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -227,6 +227,10 @@ body { align-items: flex-start; padding: 1rem; background: var(--bg200); + /* JS owns pinch (inline pinch-in → fullscreen). pan-x/pan-y keeps native + * scrolling; the fullscreen rule below repeats this with a comment on the + * iOS-Safari preventDefault backstop. */ + touch-action: pan-x pan-y; } .page-wrapper { @@ -310,9 +314,21 @@ body { } .main.fullscreen .toolbar { - padding-top: calc(0.5rem + var(--safe-top, 0px)); + padding-top: calc(0.25rem + var(--safe-top, 0px)); + padding-bottom: 0.25rem; padding-left: calc(0.5rem + var(--safe-left, 0px)); padding-right: calc(0.5rem + var(--safe-right, 0px)); + min-height: calc(40px + var(--safe-top, 0px)); + /* Inline can wrap (narrow chat bubble); fullscreen has the width, and + * wrapping would double the bar height + desync the search-bar offset. */ + flex-wrap: nowrap; +} + +.main.fullscreen .search-bar { + /* Track the toolbar's actual height (safe-area grows it). -1px overlaps + * the toolbar border so the dropdown looks attached. */ + top: calc(40px + var(--safe-top, 0px) - 1px); + right: calc(var(--safe-right, 0px) - 1px); } .main.fullscreen .viewer { diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 7e53959a..0a243fa5 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -27,6 +27,7 @@ import { type ImageAnnotation, type NoteAnnotation, type FreetextAnnotation, + cssColorToRgb, serializeDiff, deserializeDiff, mergeAnnotations, @@ -1933,13 +1934,20 @@ function renderAnnotation( viewport: { width: number; height: number; scale: number }, ): HTMLElement[] { switch (def.type) { - case "highlight": + case "highlight": { + // Force translucency: def.color is an opaque hex (e.g. "#ffff00"), which + // would override the rgba()/mix-blend-mode in CSS and hide the text. + const rgb = def.color ? cssColorToRgb(def.color) : null; + const bg = rgb + ? `rgba(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)}, 0.35)` + : undefined; return renderRectsAnnotation( def.rects, "annotation-highlight", viewport, - def.color ? { background: def.color } : {}, + bg ? { background: bg } : {}, ); + } case "underline": return renderRectsAnnotation( def.rects, @@ -3759,12 +3767,23 @@ document.addEventListener("selectionchange", () => { let pinchStartScale = 1.0; /** What we'd commit to if the gesture ended right now. */ let previewScale = 1.0; +/** Unclamped target — used to detect "pinched out past fit" even when + * previewScale is pinned at ZOOM_MIN. */ +let previewScaleRaw = 1.0; /** Debounce timer — wheel events have no end event, so we wait for quiet. */ let pinchSettleTimer: ReturnType | null = null; +/** computeFitScale() snapshot at gesture start (async — may be null briefly). */ +let fitScaleAtPinchStart: number | null = null; +/** Guards against firing toggleFullscreen() once per wheel event during a + * single inline pinch-in gesture. */ +let modeTransitionInFlight = false; function beginPinch() { pinchStartScale = scale; previewScale = scale; + previewScaleRaw = scale; + fitScaleAtPinchStart = null; + void computeFitScale().then((s) => (fitScaleAtPinchStart = s)); // 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. @@ -3772,6 +3791,7 @@ function beginPinch() { } function updatePinch(nextScale: number) { + previewScaleRaw = nextScale; 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. @@ -3780,6 +3800,23 @@ function updatePinch(nextScale: number) { } function commitPinch() { + // Pinching out past fit while already at (or below) fit → user wants to + // leave fullscreen, not zoom further out. 0.9× threshold so a slight + // overshoot doesn't eject them. + if ( + currentDisplayMode === "fullscreen" && + fitScaleAtPinchStart !== null && + pinchStartScale <= fitScaleAtPinchStart + 0.01 && + previewScaleRaw < fitScaleAtPinchStart * 0.9 + ) { + pageWrapperEl.style.transform = ""; + userHasZoomed = false; // let refitScale() size the inline view + modeTransitionInFlight = true; + void toggleFullscreen().finally(() => { + setTimeout(() => (modeTransitionInFlight = false), 250); + }); + return; + } if (Math.abs(previewScale - scale) < 0.01) { // Dead-zone — no re-render. Clear here since renderPage won't run. pageWrapperEl.style.transform = ""; @@ -3804,8 +3841,23 @@ canvasContainerEl.addEventListener( // 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") { + if (e.ctrlKey) { e.preventDefault(); + if (currentDisplayMode !== "fullscreen") { + // Inline: pinch-in (deltaY<0) is a request to go fullscreen. + // Pinch-out is ignored — nothing smaller than inline. + if (e.deltaY < 0 && !modeTransitionInFlight) { + modeTransitionInFlight = true; + void toggleFullscreen().finally(() => { + // Hold the latch through the settle window so the tail of the + // gesture doesn't immediately start zooming the new fullscreen + // view (or, worse, re-toggle). + setTimeout(() => (modeTransitionInFlight = false), 250); + }); + } + return; + } + if (modeTransitionInFlight) return; // swallow gesture tail post-toggle 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 @@ -3858,7 +3910,7 @@ canvasContainerEl.addEventListener( "touchstart", (event) => { const e = event as TouchEvent; - if (e.touches.length !== 2 || currentDisplayMode !== "fullscreen") return; + if (e.touches.length !== 2) return; // No preventDefault here — keep iOS Safari happy. We block native // pinch-zoom via touch-action CSS + preventDefault on touchmove. touchStartDist = touchDist(e.touches); @@ -3873,7 +3925,21 @@ canvasContainerEl.addEventListener( 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)); + const ratio = touchDist(e.touches) / touchStartDist; + if (currentDisplayMode !== "fullscreen") { + // Inline: a clear pinch-in means "go fullscreen". 1.15× threshold + // avoids triggering on jittery two-finger taps/scrolls. + if (ratio > 1.15 && !modeTransitionInFlight) { + modeTransitionInFlight = true; + touchStartDist = 0; // end this gesture; fullscreen will refit + pageWrapperEl.style.transform = ""; + void toggleFullscreen().finally(() => { + setTimeout(() => (modeTransitionInFlight = false), 250); + }); + } + return; + } + updatePinch(pinchStartScale * ratio); }, { passive: false }, ); @@ -3884,6 +3950,11 @@ canvasContainerEl.addEventListener("touchend", (event) => { // REMAINING set — lifting one of two leaves length 1. if (touchStartDist === 0 || e.touches.length >= 2) return; touchStartDist = 0; + if (currentDisplayMode !== "fullscreen") { + // Inline pinch that didn't cross the threshold — discard preview. + pageWrapperEl.style.transform = ""; + return; + } commitPinch(); }); diff --git a/tests/e2e/pdf-annotations.spec.ts b/tests/e2e/pdf-annotations.spec.ts index 9d4ab958..1b8077bb 100644 --- a/tests/e2e/pdf-annotations.spec.ts +++ b/tests/e2e/pdf-annotations.spec.ts @@ -151,6 +151,12 @@ test.describe("PDF Server - Annotations", () => { // Check that a highlight annotation element was rendered const highlightEl = appFrame.locator(".annotation-highlight"); await expect(highlightEl.first()).toBeVisible({ timeout: 5000 }); + // Regression: highlight must be translucent (not opaque hex), so text + // underneath remains readable. + await expect(highlightEl.first()).toHaveCSS( + "background-color", + /rgba\(255, 255, 0, 0\.35\)/, + ); }); test("add_annotations renders multiple annotation types", async ({ From 0ae6de308554012f49bc4d5797f32b0e84be707c Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 00:46:16 -0400 Subject: [PATCH 2/9] fix(pdf-server): drop --safe-top from fullscreen toolbar padding Hosts wrap fullscreen in their own header (title + close), which already clears the top safe-area. Adding --safe-top to our toolbar padding-top double-dipped, leaving a visible gap between the host header and our toolbar. Keep --safe-left/right (host header doesn't cover the sides). --- examples/pdf-server/src/mcp-app.css | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.css b/examples/pdf-server/src/mcp-app.css index e490ca69..4f035209 100644 --- a/examples/pdf-server/src/mcp-app.css +++ b/examples/pdf-server/src/mcp-app.css @@ -314,20 +314,19 @@ body { } .main.fullscreen .toolbar { - padding-top: calc(0.25rem + var(--safe-top, 0px)); - padding-bottom: 0.25rem; - padding-left: calc(0.5rem + var(--safe-left, 0px)); - padding-right: calc(0.5rem + var(--safe-right, 0px)); - min-height: calc(40px + var(--safe-top, 0px)); + /* Hosts wrap fullscreen in their own header (title + close), which already + * clears the top safe-area — so no --safe-top here, only sides. */ + padding: 0.25rem calc(0.5rem + var(--safe-right, 0px)) 0.25rem + calc(0.5rem + var(--safe-left, 0px)); + min-height: 40px; /* Inline can wrap (narrow chat bubble); fullscreen has the width, and * wrapping would double the bar height + desync the search-bar offset. */ flex-wrap: nowrap; } .main.fullscreen .search-bar { - /* Track the toolbar's actual height (safe-area grows it). -1px overlaps - * the toolbar border so the dropdown looks attached. */ - top: calc(40px + var(--safe-top, 0px) - 1px); + /* -1px overlaps the toolbar border so the dropdown looks attached. */ + top: 39px; right: calc(var(--safe-right, 0px) - 1px); } From 4ea893ea9837859ab3242f3fe7c71ea97c5a84eb Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 00:49:40 -0400 Subject: [PATCH 3/9] feat(pdf-server): horizontal swipe changes pages whenever page fits width Previously gated on scale <= 1.0, which blocked page-nav in fullscreen where fit-scale is often >100%. Now gate on actual horizontal overflow (scrollWidth > clientWidth) so swipe works at any fit-to-width scale and still defers to native panning once you zoom past it. --- examples/pdf-server/src/mcp-app.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 0a243fa5..52942804 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3878,10 +3878,15 @@ canvasContainerEl.addEventListener( // Only intercept horizontal scroll, let vertical scroll through if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; - // When zoomed, let natural panning happen (no page changes) - if (scale > 1.0) return; + // If the page overflows horizontally, let native panning handle it + // (no page changes). Checking actual overflow rather than `scale > 1.0` + // because fullscreen fit-scale is often >100% with the page still fully + // visible — we want swipe-to-page there. +1 absorbs sub-pixel rounding. + if (canvasContainerEl.scrollWidth > canvasContainerEl.clientWidth + 1) { + return; + } - // At 100% zoom, handle page navigation + // No horizontal overflow → swipe changes pages. e.preventDefault(); horizontalScrollAccumulator += e.deltaX; if (horizontalScrollAccumulator > SCROLL_THRESHOLD) { From 4b2703bb5f4763cc0b51eeca16a56ba93d931307 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 00:56:29 -0400 Subject: [PATCH 4/9] fix(pdf-server): refit to inline width after pinch-out exits fullscreen handleHostContextChanged calls refitScale() before the iframe has actually shrunk, and the ResizeObserver's inline branch only refits on width *growth* (to avoid a requestFitToContent shrink-loop). So the fullscreen->inline shrink never triggered a refit and the page stayed at the fullscreen scale. Add a one-shot forceNextResizeRefit flag, set on fullscreen->inline (both the pinch-out path and handleHostContextChanged), consumed by the ResizeObserver on the next size change. One-shot keeps the shrink-loop guard intact for ordinary inline resizes. --- examples/pdf-server/src/mcp-app.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 52942804..5e56ba2e 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -342,6 +342,11 @@ async function refitScale(): Promise { // needs height changes too (rotation, browser chrome on mobile). let lastContainerW = 0; let lastContainerH = 0; +/** One-shot: refit on the next resize even if it's a shrink in inline mode. + * Set on fullscreen→inline so the page snaps to the new (smaller) width + * once the host has actually resized the iframe — the inline `grewW` gate + * would otherwise swallow that shrink. */ +let forceNextResizeRefit = false; const containerResizeObserver = new ResizeObserver(([entry]) => { const { width: w, height: h } = entry.contentRect; const grewW = w > lastContainerW + 1; @@ -349,7 +354,12 @@ const containerResizeObserver = new ResizeObserver(([entry]) => { Math.abs(w - lastContainerW) > 1 || Math.abs(h - lastContainerH) > 1; lastContainerW = w; lastContainerH = h; - if (currentDisplayMode === "fullscreen" ? changed : grewW) refitScale(); + if (forceNextResizeRefit && changed) { + forceNextResizeRefit = false; + refitScale(); + } else if (currentDisplayMode === "fullscreen" ? changed : grewW) { + refitScale(); + } }); containerResizeObserver.observe(canvasContainerEl as HTMLElement); @@ -3811,6 +3821,7 @@ function commitPinch() { ) { pageWrapperEl.style.transform = ""; userHasZoomed = false; // let refitScale() size the inline view + forceNextResizeRefit = true; // ResizeObserver inline path ignores shrinks modeTransitionInFlight = true; void toggleFullscreen().finally(() => { setTimeout(() => (modeTransitionInFlight = false), 250); @@ -4783,6 +4794,12 @@ function handleHostContextChanged(ctx: McpUiHostContext) { if (panelState.open) { setAnnotationPanelOpen(true); } + if (!isFullscreen) { + // The iframe shrink lands after this handler; let the ResizeObserver + // do one refit on that shrink (its inline branch normally ignores + // shrinks to avoid a requestFitToContent feedback loop). + forceNextResizeRefit = true; + } 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. From 6d0774f6c6dee9e390d5c2ccda8730a08127fb5d Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 00:57:47 -0400 Subject: [PATCH 5/9] feat(pdf-server): floor fullscreen zoom at fit-to-page In fullscreen, pinch-out and the zoom-out button now floor at the fit-to-page scale instead of ZOOM_MIN, so the page never shrinks below fully-visible (no dead margin around it). previewScaleRaw stays unclamped so a continued pinch-out past fit still triggers exit-to-inline. --- examples/pdf-server/src/mcp-app.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 5e56ba2e..3a6e2925 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3340,9 +3340,13 @@ function zoomIn() { renderPage().then(scrollSelectionIntoView); } -function zoomOut() { +async function zoomOut() { userHasZoomed = true; - scale = Math.max(scale - 0.25, ZOOM_MIN); + // Fullscreen floor is fit-to-page (anything smaller is dead margin). + const fit = + currentDisplayMode === "fullscreen" ? await computeFitScale() : null; + const floor = fit !== null ? Math.max(ZOOM_MIN, fit) : ZOOM_MIN; + scale = Math.max(scale - 0.25, floor); renderPage().then(scrollSelectionIntoView); } @@ -3802,7 +3806,14 @@ function beginPinch() { function updatePinch(nextScale: number) { previewScaleRaw = nextScale; - previewScale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, nextScale)); + // In fullscreen, never shrink below fit — fit-to-page is "fully visible", + // so anything smaller just adds dead margin. previewScaleRaw stays + // unclamped so the exit-to-inline check in commitPinch() still fires. + const floor = + currentDisplayMode === "fullscreen" && fitScaleAtPinchStart !== null + ? Math.max(ZOOM_MIN, fitScaleAtPinchStart) + : ZOOM_MIN; + previewScale = Math.min(ZOOM_MAX, Math.max(floor, 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})`; From 3a43bb91f09ac03ad4cd38ba4bf520e46c49aef2 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 01:00:52 -0400 Subject: [PATCH 6/9] fix(pdf-server): wheel pinch-out can exit fullscreen again The fit-floor pinned previewScale at fit, but the wheel handler multiplied the *clamped* previewScale, so it could never accumulate below fit*0.9 to trigger exit. Drive the wheel accumulator off previewScaleRaw instead, and bound previewScaleRaw to [floor*0.7, ZOOM_MAX] so it can cross the 0.9 exit threshold without drifting unboundedly (which would make direction reversal feel sticky). Touch path was unaffected (absolute ratio). --- examples/pdf-server/src/mcp-app.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 3a6e2925..85a77acf 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3805,14 +3805,17 @@ function beginPinch() { } function updatePinch(nextScale: number) { - previewScaleRaw = nextScale; // In fullscreen, never shrink below fit — fit-to-page is "fully visible", - // so anything smaller just adds dead margin. previewScaleRaw stays - // unclamped so the exit-to-inline check in commitPinch() still fires. + // so anything smaller just adds dead margin. const floor = currentDisplayMode === "fullscreen" && fitScaleAtPinchStart !== null ? Math.max(ZOOM_MIN, fitScaleAtPinchStart) : ZOOM_MIN; + // previewScaleRaw is the wheel handler's accumulator AND the exit-to-inline + // signal. It must be allowed past `floor` (so commitPinch sees < fit*0.9) + // but bounded so reversing direction doesn't have to unwind a huge + // overshoot before the visible scale moves again. + previewScaleRaw = Math.min(ZOOM_MAX, Math.max(floor * 0.7, nextScale)); previewScale = Math.min(ZOOM_MAX, Math.max(floor, nextScale)); // Transform is RELATIVE to the rendered canvas (which sits at // pinchStartScale), so a previewScale equal to pinchStartScale → ratio 1. @@ -3886,7 +3889,10 @@ canvasContainerEl.addEventListener( // 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)); + // Drive off previewScaleRaw (not previewScale) so we can accumulate + // past the fit-floor and trigger exit-to-inline. previewScaleRaw is + // itself bounded in updatePinch() so reversal stays responsive. + updatePinch(previewScaleRaw * 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. From 22bf95ed1f8195d2e364afa04b85a66112c9e325 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 01:02:07 -0400 Subject: [PATCH 7/9] fix(pdf-server): always refit to inline width on fullscreen exit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit userHasZoomed stayed true after Escape/button/host-× exits, so refitScale() bailed even though forceNextResizeRefit let the ResizeObserver call it. Clear userHasZoomed in handleHostContextChanged whenever we land inline — fullscreen zoom level is meaningless there. Dropped the now-dead requestFitToContent fallback in the same block. --- examples/pdf-server/src/mcp-app.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 85a77acf..223948fe 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -4812,21 +4812,19 @@ function handleHostContextChanged(ctx: McpUiHostContext) { setAnnotationPanelOpen(true); } if (!isFullscreen) { + // Fullscreen zoom level is meaningless inline — always refit on exit, + // however it was triggered (pinch, button, host Escape/×). + userHasZoomed = false; // The iframe shrink lands after this handler; let the ResizeObserver // do one refit on that shrink (its inline branch normally ignores // shrinks to avoid a requestFitToContent feedback loop). forceNextResizeRefit = true; } 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(); - }); + // Fast-path refit (computeFitScale reads displayMode). The iframe may + // not have its final size yet — the ResizeObserver one-shot above + // covers the inline-shrink case once it does. + void refitScale(); } updateFullscreenButton(); } From c1f91c50c5fac5c1286b6d9d16ef21a0bcf763ad Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 01:07:52 -0400 Subject: [PATCH 8/9] refactor(pdf-server): rubber-band pinch-out instead of dual-tracked clamp The previewScaleRaw side-channel made the gesture feel dead (page pinned at fit, no feedback) and the wheel accumulator interaction was fragile. New model: previewScale is the only tracked value. In fullscreen it may overshoot down to 0.75*fit so the user *sees* the page pull away as they pinch out. On commit: - started near fit (<=1.05*fit) AND preview <0.9*fit -> exit to inline - otherwise clamp committed scale to >=fit (overshoot snaps back) beginPinch seeds fitScaleAtPinchStart synchronously from when !userHasZoomed (the common enter-fullscreen-at-fit case) so the first frame already has the right floor; the async computeFitScale refines it. --- examples/pdf-server/src/mcp-app.ts | 64 +++++++++++++++--------------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/examples/pdf-server/src/mcp-app.ts b/examples/pdf-server/src/mcp-app.ts index 223948fe..7ea0bf4d 100644 --- a/examples/pdf-server/src/mcp-app.ts +++ b/examples/pdf-server/src/mcp-app.ts @@ -3781,9 +3781,6 @@ document.addEventListener("selectionchange", () => { let pinchStartScale = 1.0; /** What we'd commit to if the gesture ended right now. */ let previewScale = 1.0; -/** Unclamped target — used to detect "pinched out past fit" even when - * previewScale is pinned at ZOOM_MIN. */ -let previewScaleRaw = 1.0; /** Debounce timer — wheel events have no end event, so we wait for quiet. */ let pinchSettleTimer: ReturnType | null = null; /** computeFitScale() snapshot at gesture start (async — may be null briefly). */ @@ -3795,8 +3792,10 @@ let modeTransitionInFlight = false; function beginPinch() { pinchStartScale = scale; previewScale = scale; - previewScaleRaw = scale; - fitScaleAtPinchStart = null; + // Seed synchronously when we can (at fit ⇔ !userHasZoomed) so the very + // first updatePinch already has the right floor — avoids a one-frame + // jitter when the async computeFitScale resolves mid-gesture. + fitScaleAtPinchStart = userHasZoomed ? null : scale; void computeFitScale().then((s) => (fitScaleAtPinchStart = s)); // transform-origin matches the flex layout's anchor (justify-content: // center, align-items: flex-start) so the preview and the committed @@ -3804,19 +3803,19 @@ function beginPinch() { pageWrapperEl.style.transformOrigin = "50% 0"; } +/** Fit-to-page floor for fullscreen (committed scale never goes below this). + * The preview is allowed to overshoot down to 0.75×fit for rubber-band + * feedback; release below 0.9×fit exits to inline, otherwise snaps to fit. */ +function pinchFitFloor(): number | null { + return currentDisplayMode === "fullscreen" ? fitScaleAtPinchStart : null; +} + function updatePinch(nextScale: number) { - // In fullscreen, never shrink below fit — fit-to-page is "fully visible", - // so anything smaller just adds dead margin. - const floor = - currentDisplayMode === "fullscreen" && fitScaleAtPinchStart !== null - ? Math.max(ZOOM_MIN, fitScaleAtPinchStart) - : ZOOM_MIN; - // previewScaleRaw is the wheel handler's accumulator AND the exit-to-inline - // signal. It must be allowed past `floor` (so commitPinch sees < fit*0.9) - // but bounded so reversing direction doesn't have to unwind a huge - // overshoot before the visible scale moves again. - previewScaleRaw = Math.min(ZOOM_MAX, Math.max(floor * 0.7, nextScale)); - previewScale = Math.min(ZOOM_MAX, Math.max(floor, nextScale)); + const fit = pinchFitFloor(); + // Rubber-band: preview may dip to 0.75×fit so the user sees the page pull + // away as they pinch out. Committed scale is clamped to fit in commitPinch. + const previewFloor = fit !== null ? fit * 0.75 : ZOOM_MIN; + previewScale = Math.min(ZOOM_MAX, Math.max(previewFloor, 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})`; @@ -3824,14 +3823,14 @@ function updatePinch(nextScale: number) { } function commitPinch() { - // Pinching out past fit while already at (or below) fit → user wants to - // leave fullscreen, not zoom further out. 0.9× threshold so a slight - // overshoot doesn't eject them. + const fit = pinchFitFloor(); + // Pinched out past fit (page visibly pulled away) → exit fullscreen. + // Only when the gesture *started* near fit, so a single big pinch-out + // from deep zoom lands at fit instead of ejecting unexpectedly. if ( - currentDisplayMode === "fullscreen" && - fitScaleAtPinchStart !== null && - pinchStartScale <= fitScaleAtPinchStart + 0.01 && - previewScaleRaw < fitScaleAtPinchStart * 0.9 + fit !== null && + pinchStartScale <= fit * 1.05 && + previewScale < fit * 0.9 ) { pageWrapperEl.style.transform = ""; userHasZoomed = false; // let refitScale() size the inline view @@ -3842,13 +3841,19 @@ function commitPinch() { }); return; } - if (Math.abs(previewScale - scale) < 0.01) { - // Dead-zone — no re-render. Clear here since renderPage won't run. + // Committed scale never below fit in fullscreen — overshoot snaps back. + const target = + fit !== null + ? Math.max(fit, previewScale) + : Math.max(ZOOM_MIN, previewScale); + if (Math.abs(target - scale) < 0.01) { + // Snap-back / dead-zone — no re-render needed. pageWrapperEl.style.transform = ""; + zoomLevelEl.textContent = `${Math.round(scale * 100)}%`; return; } userHasZoomed = true; - scale = previewScale; + scale = target; // 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); @@ -3889,10 +3894,7 @@ canvasContainerEl.addEventListener( // 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)); - // Drive off previewScaleRaw (not previewScale) so we can accumulate - // past the fit-floor and trigger exit-to-inline. previewScaleRaw is - // itself bounded in updatePinch() so reversal stays responsive. - updatePinch(previewScaleRaw * Math.exp(-d * 0.01)); + 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. From 0cb9af4646020d0c73af50db8c856fea7a91adb3 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Thu, 2 Apr 2026 01:20:48 -0400 Subject: [PATCH 9/9] test(pdf-server): update inline-pinch e2e for new fullscreen behavior 'trackpad pinch is ignored outside fullscreen' is no longer the contract. Replace with two tests: pinch-in inline -> .main.fullscreen appears; pinch-out inline -> zoom unchanged, no fullscreen. The two pdf-annotations.spec.ts failures in the previous run were flaky (passed on retry). --- tests/e2e/pdf-viewer-zoom.spec.ts | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/tests/e2e/pdf-viewer-zoom.spec.ts b/tests/e2e/pdf-viewer-zoom.spec.ts index 28e5d36d..6258faa3 100644 --- a/tests/e2e/pdf-viewer-zoom.spec.ts +++ b/tests/e2e/pdf-viewer-zoom.spec.ts @@ -142,13 +142,13 @@ test.describe("PDF Viewer — fullscreen fit + pinch zoom", () => { .toBe(""); }); - test("trackpad pinch is ignored outside fullscreen", async ({ page }) => { + test("trackpad pinch-in while inline enters 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 expect(app.locator(".main.fullscreen")).toHaveCount(0); await app.locator(".canvas-container").evaluate((el) => { el.dispatchEvent( @@ -161,8 +161,33 @@ test.describe("PDF Viewer — fullscreen fit + pinch zoom", () => { ); }); - // No settle timer should have started — zoom stays put. + // Pinch-in should request fullscreen, not zoom the inline view. + await expect(app.locator(".main.fullscreen")).toHaveCount(1, { + timeout: 5000, + }); + }); + + test("trackpad pinch-out while inline is a no-op", 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, // pinch-out + ctrlKey: true, + bubbles: true, + cancelable: true, + }), + ); + }); + await page.waitForTimeout(300); expect(await readZoomPercent(page)).toBe(before); + await expect(app.locator(".main.fullscreen")).toHaveCount(0); }); });