Files
readest/apps/readest-app/src/app/reader/hooks/useTextSelector.ts
T
Huang Xin acd4a67dcf 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>
2026-06-23 11:32:41 +02:00

773 lines
32 KiB
TypeScript

import { useEffect, useRef } from 'react';
import { BookNote } from '@/types/book';
import { Insets } from '@/types/misc';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getOSPlatform } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import {
isHyphenHandleBugProneRange,
isPointerInsideSelection,
Point,
rangeFromAnchorToPoint,
repairJumpedSelectionRange,
TextSelection,
} from '@/utils/sel';
import { useInstantAnnotation } from './useInstantAnnotation';
const ZERO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
// The selection focus must rest in a screen corner for this long before the
// page auto-turns, so merely passing a corner mid-drag doesn't flip the page.
const AUTO_TURN_DWELL_MS = 500;
// The corner zone is a quarter-ellipse within this radius of the actual corner
// (as a fraction of each axis). Kept tight so it is only the corner itself — a
// larger/rectangular zone catches normal selections that end in the lower-right
// 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
// turns back. The zone is a quarter-ellipse of radius FRACTION around each
// corner. Returns null when the point is in neither.
const cornerOf = (x: number, y: number, w: number, h: number): Corner | null => {
if (w <= 0 || h <= 0) return null;
const rx = w * AUTO_TURN_CORNER_FRACTION;
const ry = h * AUTO_TURN_CORNER_FRACTION;
const inEllipse = (dx: number, dy: number) => (dx / rx) ** 2 + (dy / ry) ** 2 <= 1;
if (inEllipse(w - x, h - y)) return 'br';
if (inEllipse(x, y)) return 'tl';
return null;
};
// Map a window-coordinate point to the corner of the reading area it sits in,
// if any. Corners are measured against `area` (the visible text bounds in window
// coordinates) so they land on the text, not the page margins or a sidebar.
const cornerAt = (xWin: number, yWin: number, area: DOMRect | null): Corner | null => {
if (!area || area.width <= 0 || area.height <= 0) return null;
const x = xWin - area.left;
const y = yWin - area.top;
// Ignore a point outside the visible text (e.g. the selection caret jumping
// into the next, off-screen column while dragging at the edge).
if (x < 0 || x > area.width || y < 0 || y > area.height) return null;
return cornerOf(x, y, area.width, area.height);
};
// Window-coordinate position of the selection focus (caret), or null. The book
// content lives in a (possibly very wide, multi-column) iframe translated by the
// pagination offset, so map the caret from iframe space via the iframe element's
// on-screen rect.
const focusCaretWindowPos = (doc: Document, sel: Selection): { x: number; y: number } | null => {
const focusNode = sel.focusNode;
const win = doc.defaultView;
if (!focusNode || !win) return null;
let rect: DOMRect;
try {
const range = doc.createRange();
const offset =
focusNode.nodeType === Node.TEXT_NODE
? Math.min(sel.focusOffset, (focusNode.textContent ?? '').length)
: sel.focusOffset;
range.setStart(focusNode, offset);
range.collapse(true);
rect = range.getBoundingClientRect();
} catch {
return null;
}
// An unmeasurable range (e.g. focus on an empty element) collapses to 0,0,0,0.
if (rect.top === 0 && rect.bottom === 0 && rect.left === 0 && rect.right === 0) return null;
const feRect = win.frameElement?.getBoundingClientRect();
return {
x: (rect.left + rect.right) / 2 + (feRect?.left ?? 0),
y: (rect.top + rect.bottom) / 2 + (feRect?.top ?? 0),
};
};
// The reading frame in window coordinates: the <foliate-view> element's rect
// (a stable element, so it has a sensible page-sized width — unlike the visible
// text range, whose box spans the whole multi-column iframe), inset by the page
// content margins so the corner zone lands on the text area, not the margin.
// Falls back to the reading container (gridcell).
const getReadingAreaRect = (bookKey: string, insets: Insets = ZERO_INSETS): DOMRect | null => {
const cell = document.querySelector(`#gridcell-${bookKey}`);
if (!cell) return null;
const frame = cell.querySelector('foliate-view') ?? cell;
const r = frame.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
return new DOMRect(
r.left + insets.left,
r.top + insets.top,
Math.max(0, r.width - insets.left - insets.right),
Math.max(0, r.height - insets.top - insets.bottom),
);
};
export const useTextSelector = (
bookKey: string,
contentInsets: Insets,
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>,
setEditingAnnotation: React.Dispatch<React.SetStateAction<BookNote | null>>,
setExternalDragPoint: React.Dispatch<React.SetStateAction<Point | null>>,
getAnnotationText: (range: Range) => Promise<string>,
handleDismissPopup: () => void,
) => {
const { appService } = useEnv();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings, getProgress } = useReaderStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const osPlatform = getOSPlatform();
// The reading frame inset by the page content margins, used to measure the
// auto-turn corners so they land on the text area, not the margin.
const readingAreaRect = (): DOMRect | null => getReadingAreaRect(bookKey, contentInsets);
const isPopuped = useRef(false);
const isUpToPopup = useRef(false);
const isTextSelected = useRef(false);
const isTouchStarted = useRef(false);
const selectionPosition = useRef<number | null>(null);
const lastPointerType = useRef<string>('mouse');
// Whether a pointer drag (mouse/touch selection) is currently in progress.
// Desktop selections defer to pointerup, but a keyboard selection adjustment
// (#4728) has no pointer drag — handleSelectionchange uses this to refresh the
// popup/range for keyboard-driven changes while still deferring mid-drag.
const isPointerDown = useRef(false);
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
// to turn another page.
const engagedCorner = useRef<Corner | null>(null);
const isAutoTurning = useRef(false);
// Latest pointer position in window coords (from pointermove or, on Android,
// native touchmove). One of the engagement signals alongside the caret.
const pointerPos = useRef<{ x: number; y: number } | null>(null);
// Android hyphen selection-bounds bug (#1553): the selection anchor captured
// at the first selectionchange of a touch gesture, plus whether that initial
// range is prone to the bug (starts at the first word of a hyphenated
// paragraph). Evaluated once per gesture.
const gestureInitialRef = useRef<{ node: Node; offset: number; prone: boolean } | null>(null);
// While we mutate the DOM selection ourselves (handle suppression, custom
// handle drags), selectionchange events are echoes of our own writes —
// handleSelectionchange must ignore them. Cleared on a delay because
// selectionchange dispatches a task after the mutation.
const programmaticSelectionRef = useRef(false);
const programmaticClearTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const guardProgrammaticSelection = () => {
if (programmaticClearTimer.current) clearTimeout(programmaticClearTimer.current);
programmaticSelectionRef.current = true;
};
const releaseProgrammaticSelection = () => {
if (programmaticClearTimer.current) clearTimeout(programmaticClearTimer.current);
programmaticClearTimer.current = setTimeout(() => {
programmaticSelectionRef.current = false;
}, 150);
};
const {
isInstantAnnotationEnabled,
handleInstantAnnotationPointerDown,
handleInstantAnnotationPointerMove,
handleInstantAnnotationPointerCancel,
handleInstantAnnotationPointerUp,
} = useInstantAnnotation({
bookKey,
getAnnotationText,
setSelection,
setEditingAnnotation,
setExternalDragPoint,
});
const isValidSelection = (sel: Selection) => {
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
};
const makeSelection = async (
sel: Selection,
index: number,
rebuildRange = false,
handlesSuppressed = false,
) => {
isTextSelected.current = true;
const range = sel.getRangeAt(0);
if (rebuildRange) {
sel.removeAllRanges();
sel.addRange(range);
}
const progress = getProgress(bookKey);
setSelection({
key: bookKey,
text: await getAnnotationText(range),
cfi: view?.getCFI(index, range),
page: bookData?.isFixedLayout ? index + 1 : progress?.page || 0,
range,
index,
handlesSuppressed,
});
};
const startInstantAnnotating = (target: HTMLElement, startPoint: Point) => {
isInstantAnnotating.current = true;
isInstantAnnotated.current = false;
annotationStartPoint.current = startPoint;
instantAnnotationTarget.current = target;
if (view) view.renderer.scrollLocked = true;
target.style.userSelect = 'none';
};
const stopInstantAnnotating = () => {
isInstantAnnotating.current = false;
isInstantAnnotated.current = false;
annotationStartPoint.current = null;
if (view) view.renderer.scrollLocked = false;
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) => {
lastPointerType.current = ev.pointerType;
isPointerDown.current = true;
if (isInstantAnnotationEnabled()) {
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.target as HTMLElement, { x: ev.clientX, y: ev.clientY });
}
}
};
const handlePointerMove = (doc: Document, index: number, ev: PointerEvent) => {
// The listener lives on the book iframe's document, so ev.clientX/Y are in
// the (very wide, multi-column) iframe viewport. Map to window coordinates
// via the iframe element's on-screen rect, like the selection caret.
const feRect = doc.defaultView?.frameElement?.getBoundingClientRect();
pointerPos.current = {
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
// for vertical writing mode) since the user likely intends to scroll.
if (!isInstantAnnotated.current && annotationStartPoint.current) {
const dx = Math.abs(ev.clientX - annotationStartPoint.current.x);
const dy = Math.abs(ev.clientY - annotationStartPoint.current.y);
const distance = Math.sqrt(dx * dx + dy * dy);
const viewSettings = getViewSettings(bookKey);
const isScrollGesture = viewSettings?.vertical ? dy < 3 * dx : dx < 3 * dy;
if (distance >= 10 && isScrollGesture) {
stopInstantAnnotating();
handleInstantAnnotationPointerCancel();
return;
}
}
ev.preventDefault();
isInstantAnnotated.current = handleInstantAnnotationPointerMove(doc, index, ev);
return;
}
// Pointer-driven auto page-turn (#1354) for web/desktop/iOS, where the
// pointer is the reliable, stable signal at the corner. Android uses the
// caret in handleSelectionchange (no pointermove during a selection drag).
// Android uses native touchmove (handleNativeTouchMove) — the iframe
// pointermove doesn't fire there during a native selection drag.
const isAndroid = osPlatform === 'android' && appService?.isAndroidApp;
if (isAndroid) return;
const viewSettings = getViewSettings(bookKey);
const sel = doc.getSelection();
const valid = !!sel && isValidSelection(sel);
const corner = !viewSettings?.scrolled && valid ? pointerCornerNow() : null;
noteCorner(corner, doc);
};
// Android native touchmove — the pointer engagement signal during a native
// selection drag (the iframe pointermove doesn't fire there). The native x/y
// are physical device pixels relative to the window; convert to CSS px.
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);
const corner = !viewSettings?.scrolled && valid ? pointerCornerNow() : null;
noteCorner(corner, doc);
};
// Disengage and drop any pending corner page-turn (e.g. when the selection is
// cleared).
const cancelAutoTurn = () => {
engagedCorner.current = null;
if (autoTurnTimer.current) {
clearTimeout(autoTurnTimer.current);
autoTurnTimer.current = null;
}
};
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();
handleInstantAnnotationPointerCancel();
}
};
// Android (#1553): when a touch selection starts on the first word of a
// hyphenated paragraph, Blink paints the start handle on the paragraph's
// last hyphen and drag gestures re-anchor the selection base there. At
// gesture end: restore the intended range if the anchor jumped, then clear
// and re-add the selection through the Selection API — a selection that goes
// empty for one painted frame loses its touch-handle visibility, so the
// broken native handles disappear (the app's own handles take over) while
// the highlight stays.
const sanitizedGestureRef = useRef(false);
const sanitizeAndroidHyphenSelection = async (doc: Document, index: number) => {
if (sanitizedGestureRef.current) return;
const sel = doc.getSelection();
const win = doc.defaultView;
if (!sel || !win || !isValidSelection(sel)) return;
// Only act when THIS gesture produced a selection — a tap that merely
// dismisses an existing selection must not re-assert it here.
const initial = gestureInitialRef.current;
if (!initial) return;
const viewSettings = getViewSettings(bookKey);
// The initial range is prone (long-press on the first word), or the
// gesture dragged the selection start back onto a hyphenated paragraph
// start (upward selection) — either way the painted start bound is bogus.
const prone =
initial.prone || isHyphenHandleBugProneRange(sel.getRangeAt(0), viewSettings?.vertical);
if (!prone) return;
sanitizedGestureRef.current = true;
let finalRange = sel.getRangeAt(0);
if (initial.prone) {
// The corrupted drag can leave EITHER selection end at the bogus bound
// (base re-anchor or extent overshoot). The trustworthy facts are the
// gesture-initial anchor and the finger position (pointerPos, reset at
// touchstart and fed by native touchmove): rebuild between those when
// the gesture moved; otherwise fall back to the anchor-jump repair.
const p = pointerPos.current;
const feRect = win.frameElement?.getBoundingClientRect();
const clamped = p
? rangeFromAnchorToPoint(
doc,
initial.node,
initial.offset,
p.x - (feRect?.left ?? 0),
p.y - (feRect?.top ?? 0),
)
: null;
const repaired = clamped ?? repairJumpedSelectionRange(sel, initial.node, initial.offset);
if (repaired) finalRange = repaired;
}
guardProgrammaticSelection();
sel.removeAllRanges();
await new Promise<void>((resolve) =>
win.requestAnimationFrame(() => win.requestAnimationFrame(() => resolve())),
);
// A competing gesture may have touched the selection while we waited —
// e.g. a tap collapses the old selection to a caret at the tapped point,
// which can also mutate `finalRange` in place. Re-asserting then would
// resurrect a stale (or collapsed) selection, so bail out instead.
if (sel.rangeCount > 0 || finalRange.collapsed) {
releaseProgrammaticSelection();
return;
}
sel.addRange(finalRange);
releaseProgrammaticSelection();
await makeSelection(sel, index, false, true);
};
// Replace the live DOM selection from the custom selection handles. Guarded
// so the resulting selectionchange echoes are ignored; a commit refreshes
// the selection state (and thus the popup) once the drag ends.
const applyProgrammaticSelection = async (range: Range, index: number, commit: boolean) => {
const doc = range.startContainer.ownerDocument;
const sel = doc?.getSelection();
if (!doc || !sel) return;
guardProgrammaticSelection();
sel.removeAllRanges();
sel.addRange(range);
if (commit) {
releaseProgrammaticSelection();
await makeSelection(sel, index, false, true);
}
};
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();
const handled = await handleInstantAnnotationPointerUp(doc, index, ev);
if (handled) {
isTextSelected.current = true;
setTimeout(() => {
isTextSelected.current = false;
}, 200);
return;
} else {
// If instant annotation was not created, we let the event propagate
// as an iframe click event which relies on a mousedown event
(ev.target as Element)?.dispatchEvent(
new MouseEvent('mousedown', {
...ev,
bubbles: true,
cancelable: true,
}),
);
}
}
// Available on iOS and Desktop, fired at touchend or mouseup
// Note that on Android, we mock pointer events with native touch events
const sel = doc.getSelection() as Selection;
if (isValidSelection(sel)) {
const isPointerInside = ev && isPointerInsideSelection(sel, ev);
// iOS no longer needs a special path: the native plugin
// (ContextMenuSuppressor) suppresses the system selection menu, so
// iOS selections go through the same path as desktop.
if (isPointerInside) {
isUpToPopup.current = true;
makeSelection(sel, index, true);
} else if (appService?.isAndroidApp) {
isUpToPopup.current = false;
}
}
if (osPlatform === 'android' && appService?.isAndroidApp) {
await sanitizeAndroidHyphenSelection(doc, index);
}
};
const handleTouchStart = () => {
isTouchStarted.current = true;
gestureInitialRef.current = null;
sanitizedGestureRef.current = false;
// Pointer positions are per-gesture: a stale point from a previous touch
// must not steer this gesture's selection repair. Touch moves re-feed it.
pointerPos.current = null;
};
const handleTouchMove = (ev: TouchEvent) => {
if (isInstantAnnotating.current) {
ev.preventDefault();
}
};
const handleTouchEnd = () => {
isTouchStarted.current = false;
};
// The corner the latest pointer (pointermove / native touchmove) position is in.
const pointerCornerNow = (): Corner | null => {
const p = pointerPos.current;
return p ? cornerAt(p.x, p.y, readingAreaRect()) : null;
};
// The corner the selection caret (focus) is in.
const caretCornerNow = (doc: Document): Corner | null => {
const sel = doc.getSelection();
if (!sel || !isValidSelection(sel)) return null;
const pos = focusCaretWindowPos(doc, sel);
return pos ? cornerAt(pos.x, pos.y, readingAreaRect()) : null;
};
// Whether any input signal (pointer/touch or caret) is currently in corner `c`.
const inCorner = (c: Corner, doc: Document): boolean =>
pointerCornerNow() === c || caretCornerNow(doc) === c;
// Once a signal has stayed inside a corner for AUTO_TURN_DWELL_MS, turn one page
// (#1354). One turn per engagement — a signal must leave the corner and return
// to turn again (engagedCorner stays set after a turn so the dwell can't re-arm
// while held).
const armDwell = (corner: Corner, doc: Document) => {
if (autoTurnTimer.current) return;
autoTurnTimer.current = setTimeout(() => {
autoTurnTimer.current = null;
const sel = doc.getSelection();
// Skip if the selection ended or every signal left the corner during the dwell.
if (isAutoTurning.current || !sel || !isValidSelection(sel) || !inCorner(corner, doc)) return;
// On Android an active selection pins the container scroll (issue #873 in
// handleScroll). A deliberate page-turn IS a container scroll, so it gets
// snapped straight back unless we suspend the pin for the turn and then
// re-anchor it to the page we land on.
isAutoTurning.current = true;
// Logical next()/prev() so RTL books turn the correct way.
const turning = corner === 'br' ? view?.next() : view?.prev();
Promise.resolve(turning).finally(() => {
selectionPosition.current = view?.renderer?.containerPosition ?? selectionPosition.current;
isAutoTurning.current = false;
});
}, AUTO_TURN_DWELL_MS);
};
// Feed a corner detected from an input signal (pointer/touch/caret) into the
// dwell state machine. Entering a corner arms the dwell; once no signal is in
// the engaged corner any more it disengages so a re-entry can turn again.
const noteCorner = (corner: Corner | null, doc: Document) => {
if (isAutoTurning.current) return;
if (corner) {
if (engagedCorner.current !== corner) {
engagedCorner.current = corner;
armDwell(corner, doc);
}
} else if (engagedCorner.current && !inCorner(engagedCorner.current, doc)) {
engagedCorner.current = null;
if (autoTurnTimer.current) {
clearTimeout(autoTurnTimer.current);
autoTurnTimer.current = null;
}
}
};
const handleSelectionchange = (doc: Document, index: number) => {
// Echo of our own programmatic selection writes (handle suppression or a
// custom-handle drag) — not user input.
if (programmaticSelectionRef.current) return;
// Available on iOS, Android and Desktop, fired when the selection is changed.
// On Android native app, this is the primary way to detect text selection.
// On web with touch/pen in scroll mode, pointerup never fires (pointercancel
// fires instead when browser takes over for scrolling), so we also handle
// selectionchange for touch/pen input to pick up native text selections.
const isAndroid = osPlatform === 'android' && appService?.isAndroidApp;
const isTouchInput = lastPointerType.current === 'touch' || lastPointerType.current === 'pen';
const sel = doc.getSelection() as Selection;
const viewSettings = getViewSettings(bookKey);
// First selection of an Android touch gesture: remember the anchor and
// whether it is prone to the hyphen bounds bug (#1553), before any
// drag-extension can corrupt it.
if (isAndroid && !gestureInitialRef.current && isValidSelection(sel) && sel.anchorNode) {
gestureInitialRef.current = {
node: sel.anchorNode,
offset: sel.anchorOffset,
prone: isHyphenHandleBugProneRange(sel.getRangeAt(0), viewSettings?.vertical),
};
}
// Auto page-turn (#1354): the selection caret is one of the engagement
// signals on every platform (and the only one on Android during a native
// selection drag, where pointer/touch-move don't fire). Feed it into the same
// dwell machine the pointer uses.
if (isValidSelection(sel)) {
noteCorner(!viewSettings?.scrolled ? caretCornerNow(doc) : null, doc);
} else {
cancelAutoTurn();
}
// Desktop mouse selections defer to pointerup, but a keyboard selection
// adjustment (#4728) has no pointerup — process it as long as a pointer drag
// isn't in progress (mid-drag still defers to pointerup).
if (!isAndroid && !isTouchInput && isPointerDown.current) return;
if (isValidSelection(sel)) {
if (selectionPosition.current === null) {
// Save the absolute container scroll, not `renderer.start` — the
// latter is section-relative, so restoring it as `containerPosition`
// snaps multi-section paginated views back to the first rendered
// section (#873-related Android regression).
selectionPosition.current = view?.renderer?.containerPosition ?? null;
}
makeSelection(sel, index, false);
} else {
// Selection cleared (e.g. clicking outside the selection).
// Dismiss immediately on all platforms.
if (isTextSelected.current) {
handleDismissPopup();
isTextSelected.current = false;
}
selectionPosition.current = null;
}
};
const handleScroll = () => {
// Prevent the container from scrolling when text is selected in paginated mode
// FIXME: this is a workaround for issue #873
if (osPlatform !== 'android' || !appService?.isAndroidApp) return;
const viewSettings = getViewSettings(bookKey);
if (viewSettings?.scrolled) return;
// Don't fight a deliberate auto page-turn (#1354): without this the pin
// below snaps the container straight back to the selection-start page and
// the turn never sticks (the Android-only failure mode).
if (isAutoTurning.current) return;
if (isTextSelected.current && view?.renderer && selectionPosition.current !== null) {
view.renderer.containerPosition = selectionPosition.current;
}
};
const handleShowPopup = (showPopup: boolean) => {
setTimeout(() => {
if (showPopup && !isPopuped.current) {
isUpToPopup.current = false;
}
isPopuped.current = showPopup;
}, 500);
};
const handleUpToPopup = () => {
isUpToPopup.current = true;
};
const handleContextmenu = (event: Event) => {
if (appService?.isMobile) {
event.preventDefault();
event.stopPropagation();
return false;
} else if (lastPointerType.current === 'touch' || lastPointerType.current === 'pen') {
event.preventDefault();
event.stopPropagation();
return false;
}
return;
};
useEffect(() => {
const handleSingleClick = (): boolean => {
if (isUpToPopup.current) {
isUpToPopup.current = false;
return true;
}
if (isTextSelected.current) {
handleDismissPopup();
isTextSelected.current = false;
view?.deselect();
return true;
}
if (isPopuped.current) {
handleDismissPopup();
return true;
}
return false;
};
eventDispatcher.onSync('iframe-single-click', handleSingleClick);
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
}, []);
return {
isTextSelected,
isInstantAnnotating,
handleScroll,
handleTouchStart,
handleTouchMove,
handleTouchEnd,
handlePointerDown,
handlePointerMove,
handleNativeTouchMove,
handlePointerCancel,
handlePointerUp,
handleSelectionchange,
handleShowPopup,
handleUpToPopup,
handleContextmenu,
applyProgrammaticSelection,
};
};