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:
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user