Skip to content

Add double-tap-and-drag zoom gesture to 1up/2up modes - #1591

Open
cdrini wants to merge 4 commits into
internetarchive:masterfrom
cdrini:double-tap-drag-zoom
Open

Add double-tap-and-drag zoom gesture to 1up/2up modes#1591
cdrini wants to merge 4 commits into
internetarchive:masterfrom
cdrini:double-tap-drag-zoom

Conversation

@cdrini

@cdrini cdrini commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

QA: https://deploy-preview-1591--ia-bookreader.netlify.app/bookreaderdemo/demo-internetarchive?ocaid=theworksofplato01platiala#page/n9/mode/2up


Double-tap-and-drag to zoom is a common interaction on mobile devices, allowing an easy one-handed alternative to pinch-zooming. First notably added to Google Maps, it has since become a standard throughout Android, and is now present in Chrome, Firefox, Drive PDF viewer, and now -- BookReader!

Here is how it works:

Double-tap-and-drag to zoom helper graphic from Google Maps

(src)

Note: plain double tapping currently does nothing ; this will be a future expansion in #1587 . This is a stepping stone to that feature.

Note: On iOS this doesn't seem to be as prevalent, but I do see it in Apple Maps -- although of course the direction was reversed -- dragging up zoomed in instead of our. I made BR reverse the direction for iOS as a result, but not sure... UPDATE: removed it from iOS ; it seems like iOS has another action for double-tap-drag ; text selection! So just disabled on iOS for now. UPDATE: Turned it on for iOS so folks can try it and we can discuss.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 64.56%. Comparing base (18c1983) to head (798158b).
⚠️ Report is 6 commits behind head on master.

Additional details and impacted files
@@            Coverage Diff             @@
##           master    #1591      +/-   ##
==========================================
+ Coverage   63.81%   64.56%   +0.75%     
==========================================
  Files          69       69              
  Lines        6256     6358     +102     
  Branches     1391     1412      +21     
==========================================
+ Hits         3992     4105     +113     
+ Misses       2225     2214      -11     
  Partials       39       39              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

cdrini and others added 2 commits August 25, 2026 20:52
…ow, reuse pinch-zoom pipeline

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…natively

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cdrini
cdrini force-pushed the double-tap-drag-zoom branch 2 times, most recently from 9de00c2 to 7c0fdfb Compare August 26, 2026 07:25
@cdrini
cdrini marked this pull request as ready for review August 26, 2026 07:27
…te against its native text-selection gesture

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

@jbuckner jbuckner left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really like this, and the state machine is well thought through. Notes inline.

The _pinchEnd one on ModeSmoothZoom.js:357 looks like a real blocker, the touch-action lock on :379 I think strands scrolls, and the multi-touch guards on :268/:308 are dead on every browser we ship to. The rest are nits plus one question about plain double-tap.

Lint is clean and all 42 tests pass.

if (!this.activeTouch) return;

if (this.activeTouch.dragEngaged) {
await this._pinchEnd();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can hang. _drawPinchZoomFrame only resolves pinchMoveFramePromise when curScale != newScale, so any frame that doesn't change the scale leaves it pending forever, and _pinchEnd awaits it.

Pinch rarely hits that, but this gesture hits it easily since the scale only depends on dy. Any horizontal-only move at the end of the drag does it:

down (50,50) t=0 ; up t=10 ; down (50,50) t=100
move (50,90) t=120   // engage, zoom in
move (80,90) t=140   // sideways only, same dy, so same scale
up   (80,90) t=160   // never completes

When I ran that, _handleTapUp never finished, so touch-action stayed none, BRsmooth-zooming and willChange: transform stayed on, and attachCtrlZoom() / attachScrollListeners() never ran. In 1up that drops the scroll -> updateVisibleRegion listener, so scrolling stops loading pages until another zoom gesture completes cleanly.

Think it's just a matter of resolving pinchMoveFramePromiseRes in the curScale == newScale case too.

// Preemptively block native panning for a possible second tap, since
// touchAction is read by the browser at the *start* of that touch --
// reacting to it once that touch is already moving is too late.
this.mode.$container.style.touchAction = "none";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This locks the whole container for ~300ms after every tap, and I think it strands touches that turn out not to be double-taps.

If the next touch is more than 100px away it's not a candidate, so _handleTapDown calls _restoreTouchAction(), but as your comment here says, that's too late for a touch that already started. And _handleTapMove bails at if (!this.activeTouch.isDoubleTapCandidate) return, so nothing takes over either. So: tap, then flick-scroll somewhere else within 300ms, and nothing happens at all. Most noticeable in 1up where scrolling is the main thing you do.

Chrome's own version doesn't have this problem since it resolves the ambiguity natively. The Samsung Internet path below already handles this exact shape though (permanent touch-action: none plus interact.draggable -> _dragMove). Could we route non-candidate touches through _dragMove while the lock is on?

* @param {{ pointerType: string, clientX: number, clientY: number, timeStamp: number, originalEvent: Event }} e
*/
_handleTapDown = (e) => {
const multiTouch = (e.originalEvent?.touches?.length ?? 1) > 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this ever fires. interact.js switches to Pointer Events whenever window.PointerEvent exists (browser.supportsPointerEvent, interact.js:403), which is Chrome/Edge/Firefox and Safari 13+. So originalEvent is a PointerEvent and has no .touches at all, and the ?? 1 always wins. Same for the copy in _handleTapMove on line 308.

I checked with a PointerEvent-shaped fake event: a "second finger" during an engaged drag doesn't cancel, activeTouch and pinching both stay set. The 'a second finger touching down cancels an in-progress double-tap-drag' test only passes because it builds touches: [{}, {}], which won't happen in a browser.

The down path gets rescued by the this.pinching check so it's not as bad as it looks, but the move path is unguarded. TouchesMonitor further down reads raw touchstart/touchend, which fire everywhere, so attaching it on all touch platforms instead of just iOS would make these guards real. (FWIW e.interaction.pointers won't work, the proxy only exposes interactable, element, prepared, pointerIsDown, pointerWasMoved.)


// If this tap turns out to be the first of a double-tap(-drag) zoom,
// don't flip the page out from under it.
if (await this.smoothZoomer.isSingleTap()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: should the second tap be suppressed too? Tap 1's isSingleTap() resolves false so its flip is skipped, but tap 2's resolves true and flips. So a plain double-tap on a page still flips it once.

The description says plain double-tapping does nothing right now, and the comment in _handleTapUp says not to let the second tap seed a third, so I wasn't sure if that's intended. Probably matters for #1587.

this.detachCtrlZoom();
clearTimeout(this.doubleTapWindowTimer);
this.doubleTapWindowTimer = null;
this._resolveSingleTap(false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: detach() doesn't restore touch-action or clear pendingTap / activeTouch. Tap once, then switch modes within 300ms, and the old container keeps touch-action: none until it gets re-attached.

A _restoreTouchAction() right after this line should cover it. The _resolveSingleTap(false) above already nulls the resolver, so it won't accidentally flip a page.

startY: e.clientY,
isDoubleTapCandidate,
dragEngaged: false,
startScale: this.mode.scale,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startScale doesn't look like it's read anywhere. Leftover?

* count as a double-tap. Matches Android's ViewConfiguration.DOUBLE_TAP_TIMEOUT
* and Flutter's kDoubleTapTimeout, both 300ms; iOS doesn't publish a number.
*/
const DOUBLE_TAP_TIME_MS = 300;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the doc says end-of-first-tap to start-of-second, but the code measures down to down (line 370 and the "measured from the first tap's down" test both say so).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants