fix(reader): require a still-hold before instant-highlight on touch (#4745)

* fix(reader): require a still-hold before instant-highlight on touch

Instant Highlight (the highlighter quick action) engaged on every
pointer-down over text, calling preventDefault, which swallowed the
single tap / swipe that turns the page on Android. Tapping the side
margins still worked only because they are not selectable text; the
synthetic-click fallback was also dead on Android (native touchend
calls handlePointerUp with no event).

Gate engagement behind a 300ms still hold for touch/pen: a tap
releases first and a swipe moves first, so both fall through to
pagination, and only a deliberate still hold starts drag-to-highlight.
Mouse input keeps engaging immediately (click vs. press-drag is
already unambiguous).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(agent): update agent memories

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-23 17:32:41 +08:00
committed by GitHub
parent b1346bf16d
commit acd4a67dcf
7 changed files with 538 additions and 163 deletions
@@ -27,6 +27,17 @@ const AUTO_TURN_DWELL_MS = 500;
// of the page and turns the page unexpectedly.
const AUTO_TURN_CORNER_FRACTION = 0.15;
// Instant-highlight quick action: on touch a plain tap and a swipe are both
// page-turn gestures, so the highlight must not engage on pointer-down or it
// swallows the tap/swipe (an Android tap-to-paginate regression). It only engages
// after the finger has held still on the text for this long; a tap releases first
// and a swipe moves first, so both fall through to pagination. Mouse input is not
// gated — a click vs. a press-drag is already unambiguous.
const INSTANT_HOLD_MS = 300;
// Movement past this many CSS px during the hold means the user is swiping, not
// settling in to highlight, so the pending engagement is cancelled.
const INSTANT_HOLD_MOVE_PX = 10;
type Corner = 'br' | 'tl';
// Which screen corner a point sits in: bottom-right turns forward, top-left
@@ -138,6 +149,17 @@ export const useTextSelector = (
const isInstantAnnotating = useRef(false);
const isInstantAnnotated = useRef(false);
const annotationStartPoint = useRef<Point | null>(null);
// The element instant annotating set `user-select: none` on, so the exact same
// element is restored on release (the pointerup target may differ once the
// finger has moved across nodes).
const instantAnnotationTarget = useRef<HTMLElement | null>(null);
// Pending instant-highlight still-hold (touch/pen). While a hold is in flight
// these remember the press so the timer can engage at the same spot; the gate
// is armed in handlePointerDown and dropped by a release or a swipe.
const instantHoldTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const instantHoldTarget = useRef<HTMLElement | null>(null);
const instantHoldStartClient = useRef<Point | null>(null);
const instantHoldStartWindow = useRef<{ x: number; y: number } | null>(null);
const autoTurnTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// The corner an input signal is currently engaged in. Stays set after a turn so
// the dwell can't re-arm while held — a signal must leave the corner and return
@@ -214,20 +236,78 @@ export const useTextSelector = (
});
};
const startInstantAnnotating = (ev: PointerEvent) => {
const startInstantAnnotating = (target: HTMLElement, startPoint: Point) => {
isInstantAnnotating.current = true;
isInstantAnnotated.current = false;
annotationStartPoint.current = { x: ev.clientX, y: ev.clientY };
annotationStartPoint.current = startPoint;
instantAnnotationTarget.current = target;
if (view) view.renderer.scrollLocked = true;
(ev.target as HTMLElement).style.userSelect = 'none';
target.style.userSelect = 'none';
};
const stopInstantAnnotating = (ev: PointerEvent) => {
const stopInstantAnnotating = () => {
isInstantAnnotating.current = false;
isInstantAnnotated.current = false;
annotationStartPoint.current = null;
if (view) view.renderer.scrollLocked = false;
(ev.target as HTMLElement).style.userSelect = '';
if (instantAnnotationTarget.current) {
instantAnnotationTarget.current.style.userSelect = '';
instantAnnotationTarget.current = null;
}
};
// Drop a pending still-hold without engaging (tap released early, finger
// swiped, or the gesture was cancelled).
const cancelInstantHold = () => {
if (instantHoldTimer.current) {
clearTimeout(instantHoldTimer.current);
instantHoldTimer.current = null;
}
instantHoldTarget.current = null;
instantHoldStartClient.current = null;
instantHoldStartWindow.current = null;
};
// Begin the touch still-hold: engage the instant annotation only once the
// finger has stayed put on the text for INSTANT_HOLD_MS. preventDefault is NOT
// called here, so a tap or swipe that bows out keeps its native page-turn.
const armInstantHold = (doc: Document, ev: PointerEvent) => {
const feRect = doc.defaultView?.frameElement?.getBoundingClientRect();
instantHoldTarget.current = ev.target as HTMLElement;
instantHoldStartClient.current = { x: ev.clientX, y: ev.clientY };
instantHoldStartWindow.current = {
x: ev.clientX + (feRect?.left ?? 0),
y: ev.clientY + (feRect?.top ?? 0),
};
if (instantHoldTimer.current) clearTimeout(instantHoldTimer.current);
instantHoldTimer.current = setTimeout(() => {
instantHoldTimer.current = null;
const target = instantHoldTarget.current;
const startClient = instantHoldStartClient.current;
const start = instantHoldStartWindow.current;
const now = pointerPos.current;
cancelInstantHold();
if (!target || !startClient) return;
// Backstop the move-driven cancel: if the finger drifted during the hold,
// treat it as a swipe and bow out.
if (start && now && Math.hypot(now.x - start.x, now.y - start.y) > INSTANT_HOLD_MOVE_PX) {
handleInstantAnnotationPointerCancel();
return;
}
startInstantAnnotating(target, startClient);
}, INSTANT_HOLD_MS);
};
// While a still-hold is pending, a move past the threshold means the user is
// swiping to turn the page — cancel so the swipe isn't swallowed.
const maybeCancelInstantHoldOnMove = () => {
const start = instantHoldStartWindow.current;
const now = pointerPos.current;
if (!instantHoldTimer.current || !start || !now) return;
if (Math.hypot(now.x - start.x, now.y - start.y) > INSTANT_HOLD_MOVE_PX) {
cancelInstantHold();
handleInstantAnnotationPointerCancel();
}
};
const handlePointerDown = (doc: Document, index: number, ev: PointerEvent) => {
@@ -235,10 +315,16 @@ export const useTextSelector = (
isPointerDown.current = true;
if (isInstantAnnotationEnabled()) {
const handled = handleInstantAnnotationPointerDown(doc, index, ev);
if (handled) {
const eligible = handleInstantAnnotationPointerDown(doc, index, ev);
if (!eligible) return;
const isTouch = ev.pointerType === 'touch' || ev.pointerType === 'pen';
if (isTouch) {
// Touch: gate behind a still hold so a tap or swipe still turns the page.
armInstantHold(doc, ev);
} else {
// Mouse: a press-drag is an unambiguous highlight intent; engage at once.
ev.preventDefault();
startInstantAnnotating(ev);
startInstantAnnotating(ev.target as HTMLElement, { x: ev.clientX, y: ev.clientY });
}
}
};
@@ -252,6 +338,7 @@ export const useTextSelector = (
x: ev.clientX + (feRect?.left ?? 0),
y: ev.clientY + (feRect?.top ?? 0),
};
maybeCancelInstantHoldOnMove();
if (isInstantAnnotating.current) {
// In scroll mode, detect gesture direction before committing to annotation.
// Cancel if the gesture is along the scroll axis (vertical for normal, horizontal
@@ -263,7 +350,7 @@ export const useTextSelector = (
const viewSettings = getViewSettings(bookKey);
const isScrollGesture = viewSettings?.vertical ? dy < 3 * dx : dx < 3 * dy;
if (distance >= 10 && isScrollGesture) {
stopInstantAnnotating(ev);
stopInstantAnnotating();
handleInstantAnnotationPointerCancel();
return;
}
@@ -293,6 +380,7 @@ export const useTextSelector = (
const handleNativeTouchMove = (x: number, y: number, doc: Document) => {
const dpr = window.devicePixelRatio || 1;
pointerPos.current = { x: x / dpr, y: y / dpr };
maybeCancelInstantHoldOnMove();
const viewSettings = getViewSettings(bookKey);
const sel = doc.getSelection();
const valid = !!sel && isValidSelection(sel);
@@ -310,13 +398,17 @@ export const useTextSelector = (
}
};
const handlePointerCancel = (_doc: Document, _index: number, ev: PointerEvent) => {
const handlePointerCancel = (_doc: Document, _index: number, _ev: PointerEvent) => {
isPointerDown.current = false;
// A pending still-hold that never engaged: drop it so a swipe-takeover
// (Android fires pointercancel when the browser starts scrolling) keeps its
// native page-turn instead of being swallowed.
cancelInstantHold();
// NB: don't cancel the auto-turn here — on Android pointercancel fires mid
// edge-drag (browser takes over for scrolling), which is exactly when the
// user is dragging into the corner. Cancel only on a real release.
if (isInstantAnnotating.current) {
stopInstantAnnotating(ev);
stopInstantAnnotating();
handleInstantAnnotationPointerCancel();
}
};
@@ -404,8 +496,11 @@ export const useTextSelector = (
const handlePointerUp = async (doc: Document, index: number, ev?: PointerEvent) => {
isPointerDown.current = false;
// A tap (or a long-press shorter than the hold) that never engaged: drop the
// pending still-hold so the tap falls through to a page turn.
if (instantHoldTimer.current) cancelInstantHold();
if (isInstantAnnotating.current && ev) {
stopInstantAnnotating(ev);
stopInstantAnnotating();
const handled = await handleInstantAnnotationPointerUp(doc, index, ev);
if (handled) {
isTextSelected.current = true;
@@ -651,6 +746,7 @@ export const useTextSelector = (
return () => {
eventDispatcher.offSync('iframe-single-click', handleSingleClick);
if (autoTurnTimer.current) clearTimeout(autoTurnTimer.current);
if (instantHoldTimer.current) clearTimeout(instantHoldTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);