How user input flows through the reader, and the contracts every input
handler must respect. The modules in src/lib/reader/input/ are the
load-bearing implementation of this document — their doc comments carry the
details; this file is the map.
┌─────────────────────────────┐
│ Reader.svelte │
│ keyboard shortcuts, │
│ page-flip + overlay │
│ intents, volume nav │
└─────────┬───────────────────┘
props (callbacks) │ keyboard / $pagedZoom
┌─────────────────────────┼─────────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────────┐
│ PagedViewport │ │ VerticalScroll │ │ HorizontalScroll │
│ │ │ Reader │ │ Reader │
└───────┬───────┘ └───────┬───────┘ └─────────┬─────────┘
│ each surface owns ALL of its pointer input │
▼ ▼ ▼
PointerGestureTracker · TapDiscriminator · classifySwipe · MotionGate
(src/lib/reader/input/ — shared machinery)
Each reading surface owns every gesture that starts on it. Reader owns
keyboard shortcuts and supplies intent callbacks (onPageFlip,
onOverlayToggle); it never touches pointer events.
| Module | Owns |
|---|---|
pointer-tracker.ts |
THE pointer state machine: pan threshold, pinch upgrade/downgrade/re-baseline, capture policy, leak-proof window-level release, wasDrag/wasPinch |
gesture-target.ts |
Element-role classification (textbox / interactive / page) and the keyboard-ignore guard |
tap.ts |
Tap vs double-tap timing (300 ms), deferred vs immediate commit, text-box dismissal swallowing |
swipe.ts |
Pure swipe-to-flip classification (edge gating #186, pinch suppression, thresholds) |
motion-gate.ts |
The interrupt contract: what must stop before new motion starts (beforeZoom / beforeManualPan / beforeAnimatedScroll / beforeNav) |
Surfaces hold only policy (config objects) and actuation (scroll writes, camera moves); classification and lifecycle live in the shared modules.
OCR text boxes own their gestures (see gesture-target.ts):
- Double-tap on a text box is the AnkiConnect card-capture gesture.
TextBoxes.svelte handles it and
stopPropagation()s; surfaces must also filter taps by role so pointer-based detection never zooms from a text box. Breaking this breaks users' Anki mining flow. - Mouse/pen drags on a text box are text selection (Yomitan/Migaku scanning) — never a pan. Single-finger touch is the exception: it pans everywhere, because touch has no drag-selection gesture.
- After interacting with a text box, the next tap outside is a
dismissal — it must not toggle the overlay
(
TapDiscriminator.noteTextBoxInteraction).
Two pointers upgrade to pinch no matter where they pressed (text box
included). Pointer-set changes re-baseline; dropping to one pointer
ends the pinch. The survivor continues as a pan only on surfaces with
incremental deltas (pinchSurvivorPans — paged); absolute-baseline
surfaces (scroll readers) require a fresh press.
pointerup/pointercancel listen on the window, always. A release landing
on an overlay or outside the browser must still clean the pointer map —
phantom entries get misread as pinches (this was a live production bug).
Every handler that starts motion opens with the gate call matching its
intent. Never call finishNow/stop/stopPan combinations inline — add
to the gate if a new intent appears.
A touch swipe flips the page only if no pannable content was hidden in the
swipe's direction when the gesture began. Gestures that pinched never
flip (tracker.wasPinch).
- Scroll readers: deferred — overlay toggles 300 ms late so a double-tap (zoom) never flashes it.
- Paged: immediate — overlay toggles instantly on every tap; a double-tap toggles twice (net zero) and zooms, reproducing the native click/click/dblclick sequence paged mode has always had.
Zoom settles carry a SettleReason (zoom-controller.ts): only
'gesture'/'interrupt' settles report reading progress; 'nav' and
'reset' settles are superseded by whatever caused them.
| Policy | PagedViewport | Scroll readers |
|---|---|---|
| Capture | deferred (at drag threshold) | immediate (at pan press) |
| Pan deltas | incremental → camera.adjustView |
totals → absolute scroll from baselines |
| Text-box pan | suppressed for mouse/pen only | suppressed for all pointer types |
| Pinch survivor | keeps panning | ignored until fresh press |
| Tap commit | immediate | deferred (300 ms) |
| Swipe-to-flip | yes (mobile setting, edge-gated) | no (panning is the scroll) |
| Wheel | zoom, gap, or camera glide | zoom, gap, or (native/strip) scroll |
Ctrl/meta+shift+wheel is the page-gap adjustment chord on every surface
(paged writes pagedGap; scroll readers write scrollGap and sync
pageDividers), checked before the zoom intent. Binding rule: wheel combos
with a native browser meaning keep that meaning tuned for the reader
(ctrl+wheel zooms, shift+wheel stays a horizontal pan); the gap chord is
deliberately one no browser binds.
Unit tests cover the shared machinery (src/lib/reader/input/*.test.ts,
src/lib/reader/page-nav.test.ts); Playwright e2e covers zoom geometry
(e2e/zoom.spec.ts). When changing gesture behavior, test manually with a
Japanese-learning extension (Yomitan/Migaku) enabled and verify the Anki
double-tap flow still captures.