@@ -3781,9 +3781,6 @@ document.addEventListener("selectionchange", () => {
37813781let pinchStartScale = 1.0 ;
37823782/** What we'd commit to if the gesture ended right now. */
37833783let 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. */
37883785let pinchSettleTimer : ReturnType < typeof setTimeout > | null = null ;
37893786/** computeFitScale() snapshot at gesture start (async — may be null briefly). */
@@ -3795,43 +3792,45 @@ let modeTransitionInFlight = false;
37953792function 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+
38073813function 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
38263825function 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