Skip to content

Commit 085bb9c

Browse files
committed
fix(pdf-server): translucent highlights, pinch to/from fullscreen, toolbar layout
Highlights rendered opaque: inline `background: <hex>` 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.
1 parent 9da4a75 commit 085bb9c

3 files changed

Lines changed: 99 additions & 6 deletions

File tree

examples/pdf-server/src/mcp-app.css

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,10 @@ body {
227227
align-items: flex-start;
228228
padding: 1rem;
229229
background: var(--bg200);
230+
/* JS owns pinch (inline pinch-in → fullscreen). pan-x/pan-y keeps native
231+
* scrolling; the fullscreen rule below repeats this with a comment on the
232+
* iOS-Safari preventDefault backstop. */
233+
touch-action: pan-x pan-y;
230234
}
231235

232236
.page-wrapper {
@@ -310,9 +314,21 @@ body {
310314
}
311315

312316
.main.fullscreen .toolbar {
313-
padding-top: calc(0.5rem + var(--safe-top, 0px));
317+
padding-top: calc(0.25rem + var(--safe-top, 0px));
318+
padding-bottom: 0.25rem;
314319
padding-left: calc(0.5rem + var(--safe-left, 0px));
315320
padding-right: calc(0.5rem + var(--safe-right, 0px));
321+
min-height: calc(40px + var(--safe-top, 0px));
322+
/* Inline can wrap (narrow chat bubble); fullscreen has the width, and
323+
* wrapping would double the bar height + desync the search-bar offset. */
324+
flex-wrap: nowrap;
325+
}
326+
327+
.main.fullscreen .search-bar {
328+
/* Track the toolbar's actual height (safe-area grows it). -1px overlaps
329+
* the toolbar border so the dropdown looks attached. */
330+
top: calc(40px + var(--safe-top, 0px) - 1px);
331+
right: calc(var(--safe-right, 0px) - 1px);
316332
}
317333

318334
.main.fullscreen .viewer {

examples/pdf-server/src/mcp-app.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
type ImageAnnotation,
2828
type NoteAnnotation,
2929
type FreetextAnnotation,
30+
cssColorToRgb,
3031
serializeDiff,
3132
deserializeDiff,
3233
mergeAnnotations,
@@ -1933,13 +1934,20 @@ function renderAnnotation(
19331934
viewport: { width: number; height: number; scale: number },
19341935
): HTMLElement[] {
19351936
switch (def.type) {
1936-
case "highlight":
1937+
case "highlight": {
1938+
// Force translucency: def.color is an opaque hex (e.g. "#ffff00"), which
1939+
// would override the rgba()/mix-blend-mode in CSS and hide the text.
1940+
const rgb = def.color ? cssColorToRgb(def.color) : null;
1941+
const bg = rgb
1942+
? `rgba(${Math.round(rgb.r * 255)}, ${Math.round(rgb.g * 255)}, ${Math.round(rgb.b * 255)}, 0.35)`
1943+
: undefined;
19371944
return renderRectsAnnotation(
19381945
def.rects,
19391946
"annotation-highlight",
19401947
viewport,
1941-
def.color ? { background: def.color } : {},
1948+
bg ? { background: bg } : {},
19421949
);
1950+
}
19431951
case "underline":
19441952
return renderRectsAnnotation(
19451953
def.rects,
@@ -3759,19 +3767,31 @@ document.addEventListener("selectionchange", () => {
37593767
let pinchStartScale = 1.0;
37603768
/** What we'd commit to if the gesture ended right now. */
37613769
let previewScale = 1.0;
3770+
/** Unclamped target — used to detect "pinched out past fit" even when
3771+
* previewScale is pinned at ZOOM_MIN. */
3772+
let previewScaleRaw = 1.0;
37623773
/** Debounce timer — wheel events have no end event, so we wait for quiet. */
37633774
let pinchSettleTimer: ReturnType<typeof setTimeout> | null = null;
3775+
/** computeFitScale() snapshot at gesture start (async — may be null briefly). */
3776+
let fitScaleAtPinchStart: number | null = null;
3777+
/** Guards against firing toggleFullscreen() once per wheel event during a
3778+
* single inline pinch-in gesture. */
3779+
let modeTransitionInFlight = false;
37643780

37653781
function beginPinch() {
37663782
pinchStartScale = scale;
37673783
previewScale = scale;
3784+
previewScaleRaw = scale;
3785+
fitScaleAtPinchStart = null;
3786+
void computeFitScale().then((s) => (fitScaleAtPinchStart = s));
37683787
// transform-origin matches the flex layout's anchor (justify-content:
37693788
// center, align-items: flex-start) so the preview and the committed
37703789
// canvas grow from the same point — otherwise the page jumps on release.
37713790
pageWrapperEl.style.transformOrigin = "50% 0";
37723791
}
37733792

37743793
function updatePinch(nextScale: number) {
3794+
previewScaleRaw = nextScale;
37753795
previewScale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, nextScale));
37763796
// Transform is RELATIVE to the rendered canvas (which sits at
37773797
// pinchStartScale), so a previewScale equal to pinchStartScale → ratio 1.
@@ -3780,6 +3800,23 @@ function updatePinch(nextScale: number) {
37803800
}
37813801

37823802
function commitPinch() {
3803+
// Pinching out past fit while already at (or below) fit → user wants to
3804+
// leave fullscreen, not zoom further out. 0.9× threshold so a slight
3805+
// overshoot doesn't eject them.
3806+
if (
3807+
currentDisplayMode === "fullscreen" &&
3808+
fitScaleAtPinchStart !== null &&
3809+
pinchStartScale <= fitScaleAtPinchStart + 0.01 &&
3810+
previewScaleRaw < fitScaleAtPinchStart * 0.9
3811+
) {
3812+
pageWrapperEl.style.transform = "";
3813+
userHasZoomed = false; // let refitScale() size the inline view
3814+
modeTransitionInFlight = true;
3815+
void toggleFullscreen().finally(() => {
3816+
setTimeout(() => (modeTransitionInFlight = false), 250);
3817+
});
3818+
return;
3819+
}
37833820
if (Math.abs(previewScale - scale) < 0.01) {
37843821
// Dead-zone — no re-render. Clear here since renderPage won't run.
37853822
pageWrapperEl.style.transform = "";
@@ -3804,8 +3841,23 @@ canvasContainerEl.addEventListener(
38043841
// Trackpad pinch arrives as wheel with ctrlKey set (Chrome/FF/Edge on
38053842
// macOS+Windows, Safari on macOS). MUST check before the deltaX/deltaY
38063843
// comparison below — pinch deltas come through on deltaY.
3807-
if (e.ctrlKey && currentDisplayMode === "fullscreen") {
3844+
if (e.ctrlKey) {
38083845
e.preventDefault();
3846+
if (currentDisplayMode !== "fullscreen") {
3847+
// Inline: pinch-in (deltaY<0) is a request to go fullscreen.
3848+
// Pinch-out is ignored — nothing smaller than inline.
3849+
if (e.deltaY < 0 && !modeTransitionInFlight) {
3850+
modeTransitionInFlight = true;
3851+
void toggleFullscreen().finally(() => {
3852+
// Hold the latch through the settle window so the tail of the
3853+
// gesture doesn't immediately start zooming the new fullscreen
3854+
// view (or, worse, re-toggle).
3855+
setTimeout(() => (modeTransitionInFlight = false), 250);
3856+
});
3857+
}
3858+
return;
3859+
}
3860+
if (modeTransitionInFlight) return; // swallow gesture tail post-toggle
38093861
if (pinchSettleTimer === null) beginPinch();
38103862
// exp(-deltaY * k) makes equal-magnitude in/out deltas inverse —
38113863
// pinch out then back lands where you started. Clamp per event so a
@@ -3858,7 +3910,7 @@ canvasContainerEl.addEventListener(
38583910
"touchstart",
38593911
(event) => {
38603912
const e = event as TouchEvent;
3861-
if (e.touches.length !== 2 || currentDisplayMode !== "fullscreen") return;
3913+
if (e.touches.length !== 2) return;
38623914
// No preventDefault here — keep iOS Safari happy. We block native
38633915
// pinch-zoom via touch-action CSS + preventDefault on touchmove.
38643916
touchStartDist = touchDist(e.touches);
@@ -3873,7 +3925,21 @@ canvasContainerEl.addEventListener(
38733925
const e = event as TouchEvent;
38743926
if (e.touches.length !== 2 || touchStartDist === 0) return;
38753927
e.preventDefault(); // stop the browser zooming the whole viewport
3876-
updatePinch(pinchStartScale * (touchDist(e.touches) / touchStartDist));
3928+
const ratio = touchDist(e.touches) / touchStartDist;
3929+
if (currentDisplayMode !== "fullscreen") {
3930+
// Inline: a clear pinch-in means "go fullscreen". 1.15× threshold
3931+
// avoids triggering on jittery two-finger taps/scrolls.
3932+
if (ratio > 1.15 && !modeTransitionInFlight) {
3933+
modeTransitionInFlight = true;
3934+
touchStartDist = 0; // end this gesture; fullscreen will refit
3935+
pageWrapperEl.style.transform = "";
3936+
void toggleFullscreen().finally(() => {
3937+
setTimeout(() => (modeTransitionInFlight = false), 250);
3938+
});
3939+
}
3940+
return;
3941+
}
3942+
updatePinch(pinchStartScale * ratio);
38773943
},
38783944
{ passive: false },
38793945
);
@@ -3884,6 +3950,11 @@ canvasContainerEl.addEventListener("touchend", (event) => {
38843950
// REMAINING set — lifting one of two leaves length 1.
38853951
if (touchStartDist === 0 || e.touches.length >= 2) return;
38863952
touchStartDist = 0;
3953+
if (currentDisplayMode !== "fullscreen") {
3954+
// Inline pinch that didn't cross the threshold — discard preview.
3955+
pageWrapperEl.style.transform = "";
3956+
return;
3957+
}
38873958
commitPinch();
38883959
});
38893960

tests/e2e/pdf-annotations.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ test.describe("PDF Server - Annotations", () => {
151151
// Check that a highlight annotation element was rendered
152152
const highlightEl = appFrame.locator(".annotation-highlight");
153153
await expect(highlightEl.first()).toBeVisible({ timeout: 5000 });
154+
// Regression: highlight must be translucent (not opaque hex), so text
155+
// underneath remains readable.
156+
await expect(highlightEl.first()).toHaveCSS(
157+
"background-color",
158+
/rgba\(255, 255, 0, 0\.35\)/,
159+
);
154160
});
155161

156162
test("add_annotations renders multiple annotation types", async ({

0 commit comments

Comments
 (0)