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 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>, setEditingAnnotation: React.Dispatch>, setExternalDragPoint: React.Dispatch>, getAnnotationText: (range: Range) => Promise, 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(null); const lastPointerType = useRef('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(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(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 | null>(null); const instantHoldTarget = useRef(null); const instantHoldStartClient = useRef(null); const instantHoldStartWindow = useRef<{ x: number; y: number } | null>(null); const autoTurnTimer = useRef | 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(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 | 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((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, }; };