From 905218dd4b0fab9cc433a27e3031d42b8b87ef23 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Wed, 9 Sep 2026 13:00:39 -0400 Subject: [PATCH] fix(reader): stop tap zones from paging during long-press text selection Selecting text near the left edge on a touch browser could page back instead: a long-press released just inside the 500ms tap window (Android selection engages at ~400-500ms, right at the guard boundary) or landing on a margin resolved as a tap, armed the 280ms debounce, and nothing ever cancelled it. Four hardenings in the tap-zone pipeline: - contextmenu (Android's long-press-engaged signal) suppresses the matching pointerup from counting as a tap, closing the duration race - pointercancel (the browser taking over the gesture) now resets the tracked pointer so stale state can never match a later touch - selectionchange on the host document and every content iframe cancels an armed tap action: a selection appearing right after finger-lift means the 'tap' was a long-press selection engaging - the host-viewport pointerup honors the tracked any-selection flag, closing the blind spot where selections in iframes or the host's own fixed-layout text layer were invisible to the host surface (the per-surface check only ran for iframe docs) Purely touch-path (coarse pointer) changes; desktop behavior untouched. --- web/src/reader/reader.ts | 59 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/web/src/reader/reader.ts b/web/src/reader/reader.ts index 2af8020..0c62f5c 100644 --- a/web/src/reader/reader.ts +++ b/web/src/reader/reader.ts @@ -406,6 +406,10 @@ document.addEventListener("alpine:init", () => { tapZonesEnabled: true as boolean, tapZoneSize: 30 as number, tapZoneTimer: null as ReturnType | null, + // Any live text selection, host document or content iframe. Fed by + // selectionchange listeners (touch devices); tap zones stand down + // while one exists. + anySelection: false as boolean, highlightItems: [] as { id: string; text: string; @@ -691,6 +695,14 @@ document.addEventListener("alpine:init", () => { // out to the host document, so the viewport listeners miss them). if (window.matchMedia("(pointer: coarse)").matches) { this.attachTapZoneListeners(doc as unknown as HTMLElement, true); + // Same for selectionchange: a selection inside the iframe must + // cancel armed tap actions and feed the host-surface guard. + doc.addEventListener("selectionchange", () => { + const sel = doc.getSelection(); + this.noteSelectionActivity( + !!sel && !sel.isCollapsed && !!sel.toString(), + ); + }); } // Text selection → highlight popover (reflowable EPUB only; // fixed-layout highlight overlays are a later milestone). @@ -991,6 +1003,26 @@ document.addEventListener("alpine:init", () => { if (!window.matchMedia("(pointer: coarse)").matches) return; const vp = document.getElementById("reader-viewport"); if (vp) this.attachTapZoneListeners(vp as HTMLElement, false); + // Host-document selections (fixed-layout/PDF text layers, margins): + // selectionchange never crosses iframe boundaries, so register per + // surface. + document.addEventListener("selectionchange", () => { + const sel = document.getSelection(); + this.noteSelectionActivity( + !!sel && !sel.isCollapsed && !!sel.toString(), + ); + }); + }, + // Record selection state and abort any armed tap action: a selection + // appearing right after finger-lift means the "tap" was actually a + // long-press selection engaging, and paging away would destroy the + // gesture the user just made. + noteSelectionActivity(hasSelection: boolean) { + this.anySelection = hasSelection; + if (hasSelection && this.tapZoneTimer) { + clearTimeout(this.tapZoneTimer); + this.tapZoneTimer = null; + } }, attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) { let downX = 0; @@ -998,6 +1030,12 @@ document.addEventListener("alpine:init", () => { let downT = 0; let downId = -1; let moved = false; + // Android fires contextmenu when a long-press engages text + // selection: that press must never resolve into a tap action, even + // when it was released inside the 500ms tap window (the selection + // engaging and the guard racing is exactly how corner selections + // used to page back instead). + let longPressed = false; surface.addEventListener( "pointerdown", (e: PointerEvent) => { @@ -1007,6 +1045,21 @@ document.addEventListener("alpine:init", () => { downT = Date.now(); downId = e.pointerId; moved = false; + longPressed = false; + }, + { passive: true }, + ); + surface.addEventListener("contextmenu", () => { + longPressed = true; + }, { passive: true }); + // The browser takes over the gesture (text selection, scroll) with + // pointercancel — no pointerup will follow. Drop the tracked + // pointer so stale state can never match a later touch. + surface.addEventListener( + "pointercancel", + () => { + downId = -1; + moved = false; }, { passive: true }, ); @@ -1024,7 +1077,7 @@ document.addEventListener("alpine:init", () => { (e: PointerEvent) => { if (e.pointerId !== downId) return; downId = -1; - if (moved || Date.now() - downT > 500) return; + if (moved || longPressed || Date.now() - downT > 500) return; if (!this.tapZonesEnabled) return; const target = e.target as HTMLElement | null; if ( @@ -1035,6 +1088,10 @@ document.addEventListener("alpine:init", () => { return; const sel = isDoc ? (surface as any).getSelection?.() : null; if (sel?.toString?.()) return; + // Host-surface blind spot: selections living in content iframes + // (or the host's own fixed-layout text layer) never show in a + // per-surface check — the tracked flag covers them. + if (!isDoc && this.anySelection) return; // No tap actions while a fixed-layout page is zoomed — taps then // belong to the content (and double-tap zoom). if (this.isFixedLayout && this.renderer?.zoom != null) return;