Skip to content

Commit c1f91c5

Browse files
committed
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.
1 parent 22bf95e commit c1f91c5

1 file changed

Lines changed: 33 additions & 31 deletions

File tree

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

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3781,9 +3781,6 @@ document.addEventListener("selectionchange", () => {
37813781
let pinchStartScale = 1.0;
37823782
/** What we'd commit to if the gesture ended right now. */
37833783
let previewScale = 1.0;
3784-
/** Unclamped target — used to detect "pinched out past fit" even when
3785-
* previewScale is pinned at ZOOM_MIN. */
3786-
let previewScaleRaw = 1.0;
37873784
/** Debounce timer — wheel events have no end event, so we wait for quiet. */
37883785
let pinchSettleTimer: ReturnType<typeof setTimeout> | null = null;
37893786
/** computeFitScale() snapshot at gesture start (async — may be null briefly). */
@@ -3795,43 +3792,45 @@ let modeTransitionInFlight = false;
37953792
function beginPinch() {
37963793
pinchStartScale = scale;
37973794
previewScale = scale;
3798-
previewScaleRaw = scale;
3799-
fitScaleAtPinchStart = null;
3795+
// Seed synchronously when we can (at fit ⇔ !userHasZoomed) so the very
3796+
// first updatePinch already has the right floor — avoids a one-frame
3797+
// jitter when the async computeFitScale resolves mid-gesture.
3798+
fitScaleAtPinchStart = userHasZoomed ? null : scale;
38003799
void computeFitScale().then((s) => (fitScaleAtPinchStart = s));
38013800
// transform-origin matches the flex layout's anchor (justify-content:
38023801
// center, align-items: flex-start) so the preview and the committed
38033802
// canvas grow from the same point — otherwise the page jumps on release.
38043803
pageWrapperEl.style.transformOrigin = "50% 0";
38053804
}
38063805

3806+
/** Fit-to-page floor for fullscreen (committed scale never goes below this).
3807+
* The preview is allowed to overshoot down to 0.75×fit for rubber-band
3808+
* feedback; release below 0.9×fit exits to inline, otherwise snaps to fit. */
3809+
function pinchFitFloor(): number | null {
3810+
return currentDisplayMode === "fullscreen" ? fitScaleAtPinchStart : null;
3811+
}
3812+
38073813
function updatePinch(nextScale: number) {
3808-
// In fullscreen, never shrink below fit — fit-to-page is "fully visible",
3809-
// so anything smaller just adds dead margin.
3810-
const floor =
3811-
currentDisplayMode === "fullscreen" && fitScaleAtPinchStart !== null
3812-
? Math.max(ZOOM_MIN, fitScaleAtPinchStart)
3813-
: ZOOM_MIN;
3814-
// previewScaleRaw is the wheel handler's accumulator AND the exit-to-inline
3815-
// signal. It must be allowed past `floor` (so commitPinch sees < fit*0.9)
3816-
// but bounded so reversing direction doesn't have to unwind a huge
3817-
// overshoot before the visible scale moves again.
3818-
previewScaleRaw = Math.min(ZOOM_MAX, Math.max(floor * 0.7, nextScale));
3819-
previewScale = Math.min(ZOOM_MAX, Math.max(floor, nextScale));
3814+
const fit = pinchFitFloor();
3815+
// Rubber-band: preview may dip to 0.75×fit so the user sees the page pull
3816+
// away as they pinch out. Committed scale is clamped to fit in commitPinch.
3817+
const previewFloor = fit !== null ? fit * 0.75 : ZOOM_MIN;
3818+
previewScale = Math.min(ZOOM_MAX, Math.max(previewFloor, nextScale));
38203819
// Transform is RELATIVE to the rendered canvas (which sits at
38213820
// pinchStartScale), so a previewScale equal to pinchStartScale → ratio 1.
38223821
pageWrapperEl.style.transform = `scale(${previewScale / pinchStartScale})`;
38233822
zoomLevelEl.textContent = `${Math.round(previewScale * 100)}%`;
38243823
}
38253824

38263825
function commitPinch() {
3827-
// Pinching out past fit while already at (or below) fit → user wants to
3828-
// leave fullscreen, not zoom further out. 0.9× threshold so a slight
3829-
// overshoot doesn't eject them.
3826+
const fit = pinchFitFloor();
3827+
// Pinched out past fit (page visibly pulled away) → exit fullscreen.
3828+
// Only when the gesture *started* near fit, so a single big pinch-out
3829+
// from deep zoom lands at fit instead of ejecting unexpectedly.
38303830
if (
3831-
currentDisplayMode === "fullscreen" &&
3832-
fitScaleAtPinchStart !== null &&
3833-
pinchStartScale <= fitScaleAtPinchStart + 0.01 &&
3834-
previewScaleRaw < fitScaleAtPinchStart * 0.9
3831+
fit !== null &&
3832+
pinchStartScale <= fit * 1.05 &&
3833+
previewScale < fit * 0.9
38353834
) {
38363835
pageWrapperEl.style.transform = "";
38373836
userHasZoomed = false; // let refitScale() size the inline view
@@ -3842,13 +3841,19 @@ function commitPinch() {
38423841
});
38433842
return;
38443843
}
3845-
if (Math.abs(previewScale - scale) < 0.01) {
3846-
// Dead-zone — no re-render. Clear here since renderPage won't run.
3844+
// Committed scale never below fit in fullscreen — overshoot snaps back.
3845+
const target =
3846+
fit !== null
3847+
? Math.max(fit, previewScale)
3848+
: Math.max(ZOOM_MIN, previewScale);
3849+
if (Math.abs(target - scale) < 0.01) {
3850+
// Snap-back / dead-zone — no re-render needed.
38473851
pageWrapperEl.style.transform = "";
3852+
zoomLevelEl.textContent = `${Math.round(scale * 100)}%`;
38483853
return;
38493854
}
38503855
userHasZoomed = true;
3851-
scale = previewScale;
3856+
scale = target;
38523857
// renderPage clears the transform in the same frame as the canvas
38533858
// resize (after its first await) so there's no snap-back.
38543859
renderPage().then(scrollSelectionIntoView);
@@ -3889,10 +3894,7 @@ canvasContainerEl.addEventListener(
38893894
// physical mouse wheel (deltaY ≈ ±100/notch) doesn't slam to the
38903895
// limit; trackpad pinch deltas are ~±1-10 so the clamp is a no-op.
38913896
const d = Math.max(-25, Math.min(25, e.deltaY));
3892-
// Drive off previewScaleRaw (not previewScale) so we can accumulate
3893-
// past the fit-floor and trigger exit-to-inline. previewScaleRaw is
3894-
// itself bounded in updatePinch() so reversal stays responsive.
3895-
updatePinch(previewScaleRaw * Math.exp(-d * 0.01));
3897+
updatePinch(previewScale * Math.exp(-d * 0.01));
38963898
if (pinchSettleTimer) clearTimeout(pinchSettleTimer);
38973899
// 200ms — slow trackpad pinches can leave >150ms gaps between wheel
38983900
// events, which would commit-then-restart and feel steppy.

0 commit comments

Comments
 (0)