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.
This commit is contained in:
2026-09-09 13:00:39 -04:00
parent 94dad5e089
commit 905218dd4b
+58 -1
View File
@@ -406,6 +406,10 @@ document.addEventListener("alpine:init", () => {
tapZonesEnabled: true as boolean, tapZonesEnabled: true as boolean,
tapZoneSize: 30 as number, tapZoneSize: 30 as number,
tapZoneTimer: null as ReturnType<typeof setTimeout> | null, tapZoneTimer: null as ReturnType<typeof setTimeout> | 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 { highlightItems: [] as {
id: string; id: string;
text: string; text: string;
@@ -691,6 +695,14 @@ document.addEventListener("alpine:init", () => {
// out to the host document, so the viewport listeners miss them). // out to the host document, so the viewport listeners miss them).
if (window.matchMedia("(pointer: coarse)").matches) { if (window.matchMedia("(pointer: coarse)").matches) {
this.attachTapZoneListeners(doc as unknown as HTMLElement, true); 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; // Text selection → highlight popover (reflowable EPUB only;
// fixed-layout highlight overlays are a later milestone). // fixed-layout highlight overlays are a later milestone).
@@ -991,6 +1003,26 @@ document.addEventListener("alpine:init", () => {
if (!window.matchMedia("(pointer: coarse)").matches) return; if (!window.matchMedia("(pointer: coarse)").matches) return;
const vp = document.getElementById("reader-viewport"); const vp = document.getElementById("reader-viewport");
if (vp) this.attachTapZoneListeners(vp as HTMLElement, false); 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) { attachTapZoneListeners(surface: HTMLElement, isDoc: boolean) {
let downX = 0; let downX = 0;
@@ -998,6 +1030,12 @@ document.addEventListener("alpine:init", () => {
let downT = 0; let downT = 0;
let downId = -1; let downId = -1;
let moved = false; 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( surface.addEventListener(
"pointerdown", "pointerdown",
(e: PointerEvent) => { (e: PointerEvent) => {
@@ -1007,6 +1045,21 @@ document.addEventListener("alpine:init", () => {
downT = Date.now(); downT = Date.now();
downId = e.pointerId; downId = e.pointerId;
moved = false; 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 }, { passive: true },
); );
@@ -1024,7 +1077,7 @@ document.addEventListener("alpine:init", () => {
(e: PointerEvent) => { (e: PointerEvent) => {
if (e.pointerId !== downId) return; if (e.pointerId !== downId) return;
downId = -1; downId = -1;
if (moved || Date.now() - downT > 500) return; if (moved || longPressed || Date.now() - downT > 500) return;
if (!this.tapZonesEnabled) return; if (!this.tapZonesEnabled) return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if ( if (
@@ -1035,6 +1088,10 @@ document.addEventListener("alpine:init", () => {
return; return;
const sel = isDoc ? (surface as any).getSelection?.() : null; const sel = isDoc ? (surface as any).getSelection?.() : null;
if (sel?.toString?.()) return; 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 // No tap actions while a fixed-layout page is zoomed — taps then
// belong to the content (and double-tap zoom). // belong to the content (and double-tap zoom).
if (this.isFixedLayout && this.renderer?.zoom != null) return; if (this.isFixedLayout && this.renderer?.zoom != null) return;