import { useCallback, useRef } from 'react'; import { BookNote } from '@/types/book'; import { Point, TextSelection, snapRangeToWords } from '@/utils/sel'; import { useEnv } from '@/context/EnvContext'; import { useReaderStore } from '@/store/readerStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useBookDataStore } from '@/store/bookDataStore'; import { toParentViewportPoint } from '../utils/annotatorUtil'; import { uniqueId } from '@/utils/misc'; interface UseInstantAnnotationProps { bookKey: string; getAnnotationText: (range: Range) => Promise; setSelection: React.Dispatch>; setEditingAnnotation: React.Dispatch>; setExternalDragPoint: React.Dispatch>; } export const useInstantAnnotation = ({ bookKey, getAnnotationText, setSelection, setEditingAnnotation, setExternalDragPoint, }: UseInstantAnnotationProps) => { const { envConfig } = useEnv(); const { settings } = useSettingsStore(); const { getConfig, saveConfig, updateBooknotes } = useBookDataStore(); const { getView, getViewsById, getViewSettings, getProgress } = useReaderStore(); const startPointRef = useRef(null); // The DOM position the drag started at, captured once at pointer-down. The // range is rebuilt from this anchor each move so it survives a page scroll — // the start *coordinates* would otherwise resolve to whatever content scrolls // under them after a corner auto page-turn, losing the previous page's part. const startPosRef = useRef<{ node: Node; offset: number } | null>(null); const startDocRef = useRef(null); const startIndexRef = useRef(0); // Latest drag end-point (iframe coords), so the preview can be rebuilt from the // held position after an auto page-turn without waiting for the next move. const lastEndPointRef = useRef(null); const previewAnnotationRef = useRef(null); const annotationIdRef = useRef(uniqueId()); const isInstantAnnotationEnabled = useCallback(() => { const viewSettings = getViewSettings(bookKey); return ( viewSettings?.enableAnnotationQuickActions && viewSettings?.annotationQuickAction === 'highlight' ); }, [bookKey, getViewSettings]); const clearPreviewAnnotation = useCallback(() => { if (previewAnnotationRef.current) { const views = getViewsById(bookKey.split('-')[0]!); views.forEach((v) => v?.addAnnotation(previewAnnotationRef.current!, true)); previewAnnotationRef.current = null; } }, [bookKey, getViewsById]); const clearInstantAnnotationState = useCallback(() => { clearPreviewAnnotation(); setEditingAnnotation(null); setSelection(null); setExternalDragPoint(null); }, [clearPreviewAnnotation, setEditingAnnotation, setSelection, setExternalDragPoint]); const findPositionAtPoint = useCallback((doc: Document, x: number, y: number) => { if (doc.caretPositionFromPoint) { const pos = doc.caretPositionFromPoint(x, y); if (pos) return { node: pos.offsetNode, offset: pos.offset }; } if (doc.caretRangeFromPoint) { const range = doc.caretRangeFromPoint(x, y); if (range) return { node: range.startContainer, offset: range.startOffset }; } return null; }, []); const isSelectableContent = useCallback( (doc: Document, x: number, y: number): boolean => { const pos = findPositionAtPoint(doc, x, y); if (!pos) return false; // Must be a text node if (pos.node.nodeType !== Node.TEXT_NODE) return false; const textNode = pos.node as Text; const textLength = textNode.length; if (textLength === 0) return false; // Create a range around the caret position to get the character bounds const range = doc.createRange(); try { // Get bounds of character at or after the caret position const startOffset = Math.min(pos.offset, textLength - 1); const endOffset = Math.min(pos.offset + 1, textLength); range.setStart(textNode, startOffset); range.setEnd(textNode, endOffset); const rects = range.getClientRects(); for (const rect of rects) { const tolerance = 20; if ( x >= rect.left - tolerance && x <= rect.right + tolerance && y >= rect.top - tolerance && y <= rect.bottom + tolerance ) { return true; } } } catch { return false; } return false; }, [findPositionAtPoint], ); const rangeFromPositions = useCallback( ( doc: Document, startPos: { node: Node; offset: number }, endPos: { node: Node; offset: number }, ) => { const newRange = doc.createRange(); try { const positionComparison = startPos.node.compareDocumentPosition(endPos.node); const needsSwap = positionComparison & Node.DOCUMENT_POSITION_PRECEDING || (startPos.node === endPos.node && startPos.offset > endPos.offset); if (needsSwap) { newRange.setStart(endPos.node, endPos.offset); newRange.setEnd(startPos.node, startPos.offset); } else { newRange.setStart(startPos.node, startPos.offset); newRange.setEnd(endPos.node, endPos.offset); } if (newRange.collapsed) { return null; } snapRangeToWords(newRange); return newRange; } catch (e) { console.warn('Failed to create range:', e); return null; } }, [], ); // Build the range from the DOM-anchored start (captured at pointer-down) to the // current end-point. Anchoring the start to the DOM — not its coordinates — is // what lets the highlight continue across a corner auto page-turn. Falls back to // resolving the start coords when the down point did not land on a text node. const buildRangeFromAnchor = useCallback( (doc: Document, endPoint: Point) => { const endPos = findPositionAtPoint(doc, endPoint.x, endPoint.y); if (!endPos) return null; const startPos = startPosRef.current ?? (startPointRef.current ? findPositionAtPoint(doc, startPointRef.current.x, startPointRef.current.y) : null); if (!startPos) return null; return rangeFromPositions(doc, startPos, endPos); }, [findPositionAtPoint, rangeFromPositions], ); const createAnnotation = useCallback((cfi: string, text?: string) => { const style = settings.globalReadSettings.highlightStyle; const color = settings.globalReadSettings.highlightStyles[style]; const annotation: BookNote = { id: annotationIdRef.current, type: 'annotation', cfi, style, color, text: text ?? '', note: '', createdAt: Date.now(), updatedAt: Date.now(), }; return annotation; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Draw (or redraw) the live preview highlight for a range and reposition the // loupe. Shared by the pointer-move handler and the after-turn re-emit. const drawPreview = useCallback( (doc: Document, index: number, newRange: Range, dragPoint: Point): boolean => { const view = getView(bookKey); if (!view) return false; const cfi = view.getCFI(index, newRange); if (!cfi) return false; clearPreviewAnnotation(); const annotation = createAnnotation(cfi); const views = getViewsById(bookKey.split('-')[0]!); views.forEach((v) => v?.addAnnotation(annotation)); previewAnnotationRef.current = annotation; const progress = getProgress(bookKey); setEditingAnnotation(annotation); setSelection({ key: bookKey, text: '', cfi, page: progress?.page || 0, range: newRange, index, annotated: true, }); // Convert iframe pointer coords to parent viewport coords for the loupe setExternalDragPoint(toParentViewportPoint(doc, dragPoint.x, dragPoint.y)); return true; }, // eslint-disable-next-line react-hooks/exhaustive-deps [bookKey, clearPreviewAnnotation, createAnnotation, getViewsById], ); const handleInstantAnnotationPointerDown = useCallback( (doc: Document, index: number, ev: PointerEvent) => { if (!isInstantAnnotationEnabled()) return false; // Only handle primary button (left click / touch / stylus) if (ev.button !== 0) return false; if (!isSelectableContent(doc, ev.clientX, ev.clientY)) return false; startPointRef.current = { x: ev.clientX, y: ev.clientY }; // Anchor the start to the DOM so the range survives a page scroll. startPosRef.current = findPositionAtPoint(doc, ev.clientX, ev.clientY); lastEndPointRef.current = null; startDocRef.current = doc; startIndexRef.current = index; previewAnnotationRef.current = null; annotationIdRef.current = uniqueId(); return true; }, [isInstantAnnotationEnabled, isSelectableContent, findPositionAtPoint], ); const handleInstantAnnotationPointerMove = useCallback( (doc: Document, index: number, ev: PointerEvent) => { if (!isInstantAnnotationEnabled()) return false; const view = getView(bookKey); if (!startPointRef.current || !startDocRef.current || !view) { return false; } const endPoint: Point = { x: ev.clientX, y: ev.clientY }; const startPoint = startPointRef.current; const deltaX = Math.abs(endPoint.x - startPoint.x); const deltaY = Math.abs(endPoint.y - startPoint.y); const distance = Math.sqrt(Math.pow(deltaX, 2) + Math.pow(deltaY, 2)); // need a longer horizontal or vertical drag to avoid accidental selections if (distance < 20 || (deltaX / deltaY < 5 && deltaY / deltaX < 5 && distance < 30)) { return false; } const newRange = buildRangeFromAnchor(doc, endPoint); if (!newRange) return false; lastEndPointRef.current = endPoint; return drawPreview(doc, index, newRange, endPoint); }, // eslint-disable-next-line react-hooks/exhaustive-deps [isInstantAnnotationEnabled, buildRangeFromAnchor, drawPreview], ); // Rebuild the preview from the held position after a corner auto page-turn: the // start stays DOM-anchored on the previous page while the end-point coordinates // now resolve to content on the page just turned to. const reapplyInstantAnnotation = useCallback(() => { const doc = startDocRef.current; const endPoint = lastEndPointRef.current; if (!doc || !endPoint) return; const newRange = buildRangeFromAnchor(doc, endPoint); if (newRange) drawPreview(doc, startIndexRef.current, newRange, endPoint); }, [buildRangeFromAnchor, drawPreview]); const handleInstantAnnotationPointerCancel = useCallback(() => { if (!isInstantAnnotationEnabled()) return false; startPointRef.current = null; startPosRef.current = null; lastEndPointRef.current = null; startDocRef.current = null; clearInstantAnnotationState(); return true; }, [isInstantAnnotationEnabled, clearInstantAnnotationState]); const handleInstantAnnotationPointerUp = useCallback( async (doc: Document, index: number, ev: PointerEvent) => { if (!isInstantAnnotationEnabled()) return false; const view = getView(bookKey); if (!startPointRef.current || !view) { startPointRef.current = null; startPosRef.current = null; lastEndPointRef.current = null; startDocRef.current = null; clearInstantAnnotationState(); return false; } const endPoint: Point = { x: ev.clientX, y: ev.clientY }; const startPoint = startPointRef.current; const hadPreview = !!previewAnnotationRef.current; // Build before clearing the anchor (it reads startPosRef). const newRange = buildRangeFromAnchor(doc, endPoint); startPointRef.current = null; startPosRef.current = null; lastEndPointRef.current = null; startDocRef.current = null; const distance = Math.sqrt( Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2), ); // A barely-moved gesture is a tap, not a highlight — unless a preview was // actually drawn during the drag (after a cross-page turn the finger can // land near its start screen-position while the range spans pages). if (distance < 10 && !hadPreview) { clearInstantAnnotationState(); return false; } if (!newRange) { clearInstantAnnotationState(); return false; } const text = await getAnnotationText(newRange); const cfi = view.getCFI(index, newRange); if (!text || !cfi || text.trim().length === 0) { clearInstantAnnotationState(); return false; } clearInstantAnnotationState(); const annotation = createAnnotation(cfi, text); const views = getViewsById(bookKey.split('-')[0]!); views.forEach((v) => v?.addAnnotation(annotation)); const config = getConfig(bookKey)!; const progress = getProgress(bookKey)!; const { booknotes: annotations = [] } = config; const existingIndex = annotations.findIndex( (a) => a.cfi === cfi && a.type === 'annotation' && a.style && !a.deletedAt, ); if (existingIndex !== -1) { annotations[existingIndex] = { ...annotations[existingIndex]!, ...annotation, page: progress.page, id: annotations[existingIndex]!.id, }; } else { annotations.push({ ...annotation, page: progress.page }); } const updatedConfig = updateBooknotes(bookKey, annotations); if (updatedConfig) { saveConfig(envConfig, bookKey, updatedConfig, settings); } return true; }, // eslint-disable-next-line react-hooks/exhaustive-deps [ isInstantAnnotationEnabled, buildRangeFromAnchor, getAnnotationText, clearInstantAnnotationState, ], ); const cancelInstantAnnotation = useCallback(() => { startPointRef.current = null; startPosRef.current = null; lastEndPointRef.current = null; startDocRef.current = null; clearInstantAnnotationState(); }, [clearInstantAnnotationState]); return { isInstantAnnotationEnabled, handleInstantAnnotationPointerDown, handleInstantAnnotationPointerMove, handleInstantAnnotationPointerCancel, handleInstantAnnotationPointerUp, reapplyInstantAnnotation, cancelInstantAnnotation, }; };