From 9614c6236065b3c48518e30bf69bff7c86af2fb6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 7 Jan 2026 16:13:37 +0100 Subject: [PATCH] feat(annotator): instant highlighting with mouse, touch or pen (#2881) --- .../annotator/AnnotationRangeEditor.tsx | 10 +- .../reader/components/annotator/Annotator.tsx | 27 +- .../app/reader/hooks/useAnnotationEditor.ts | 12 +- .../app/reader/hooks/useInstantAnnotation.ts | 302 ++++++++++++++++++ .../src/app/reader/hooks/useTextSelector.ts | 84 ++++- apps/readest-app/src/types/view.ts | 1 + packages/foliate-js | 2 +- 7 files changed, 407 insertions(+), 31 deletions(-) create mode 100644 apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx index 1189157b..0b22cef9 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx @@ -1,7 +1,7 @@ import clsx from 'clsx'; import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { BookNote } from '@/types/book'; +import { BookNote, HighlightColor } from '@/types/book'; import { Point, TextSelection } from '@/utils/sel'; import { useSettingsStore } from '@/store/settingsStore'; import { useAnnotationEditor } from '../../hooks/useAnnotationEditor'; @@ -105,6 +105,7 @@ interface AnnotationRangeEditorProps { bookKey: string; annotation: BookNote; selection: TextSelection; + handleColor: HighlightColor; getAnnotationText: (range: Range) => Promise; setSelection: React.Dispatch>; onStartEdit: () => void; @@ -114,6 +115,7 @@ const AnnotationRangeEditor: React.FC = ({ bookKey, annotation, selection, + handleColor, getAnnotationText, setSelection, onStartEdit, @@ -123,7 +125,7 @@ const AnnotationRangeEditor: React.FC = ({ useAnnotationEditor({ bookKey, annotation, getAnnotationText, setSelection }); const initializedRef = useRef(false); - const handleColor = getHighlightColorHex(settings, annotation.color) ?? '#FFFF00'; + const handleColorHex = getHighlightColorHex(settings, handleColor) ?? '#FFFF00'; const draggingRef = useRef<'start' | 'end' | null>(null); const startRef = useRef({ x: 0, y: 0 }); const endRef = useRef({ x: 0, y: 0 }); @@ -198,7 +200,7 @@ const AnnotationRangeEditor: React.FC = ({ = ({ = ({ bookKey }) => { isTextSelected, handleScroll, handleTouchStart, + handleTouchMove, handleTouchEnd, - handlePointerdown, - handlePointerup, + handlePointerDown, + handlePointerMove, + handlePointerCancel, + handlePointerUp, handleSelectionchange, handleShowPopup, handleUpToPopup, @@ -217,10 +220,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const detail = (event as CustomEvent).detail; const { doc, index } = detail; - const handleTouchmove = () => { + const handleTouchmove = (ev: TouchEvent) => { // Available on iOS, on Android not fired // To make the popup not follow the selection while dragging setShowAnnotPopup(false); + setEditingAnnotation(null); + handleTouchMove(ev); }; const handleNativeTouch = (event: CustomEvent) => { @@ -229,7 +234,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { handleTouchStart(); } else if (ev.type === 'touchend') { handleTouchEnd(); - handlePointerup(doc, index); + handlePointerUp(doc, index); } }; @@ -245,13 +250,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { view?.renderer?.addEventListener('scroll', () => { repositionPopups(); }); - detail.doc?.addEventListener('touchstart', handleTouchStart); - detail.doc?.addEventListener('touchmove', handleTouchmove); + const opts = { passive: false }; + detail.doc?.addEventListener('touchstart', handleTouchStart, opts); + detail.doc?.addEventListener('touchmove', handleTouchmove, opts); detail.doc?.addEventListener('touchend', handleTouchEnd); - detail.doc?.addEventListener('pointerdown', handlePointerdown); - detail.doc?.addEventListener('pointerup', (ev: PointerEvent) => - handlePointerup(doc, index, ev), - ); + detail.doc?.addEventListener('pointerdown', handlePointerDown.bind(null, doc, index), opts); + detail.doc?.addEventListener('pointermove', handlePointerMove.bind(null, doc, index), opts); + detail.doc?.addEventListener('pointercancel', handlePointerCancel.bind(null, doc, index)); + detail.doc?.addEventListener('pointerup', handlePointerUp.bind(null, doc, index)); detail.doc?.addEventListener('selectionchange', () => handleSelectionchange(doc)); // For PDF selections, enable right-click context menu to directly open translator popup. @@ -935,6 +941,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { bookKey={bookKey} annotation={editingAnnotation} selection={selection} + handleColor={selectedColor} getAnnotationText={getAnnotationText} setSelection={setSelection} onStartEdit={handleStartEditAnnotation} diff --git a/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts b/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts index d9cc45f4..bd25aeb5 100644 --- a/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts +++ b/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts @@ -67,6 +67,7 @@ export const useAnnotationEditor = ({ const contents = view.renderer.getContents(); if (!contents || contents.length === 0) return; + // the point is from viewport, need to adjust to each content's coordinate const findPositionAtPoint = (doc: Document, x: number, y: number) => { const frameElement = doc.defaultView?.frameElement; const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 }; @@ -102,22 +103,11 @@ export const useAnnotationEditor = ({ targetDoc = doc; targetIndex = index ?? 0; break; - } else if (sp && !startPos) { - startPos = sp; - targetDoc = doc; - targetIndex = index ?? 0; - } else if (ep && !endPos) { - endPos = ep; - if (!targetDoc) { - targetDoc = doc; - targetIndex = index ?? 0; - } } } if (!startPos || !endPos || !targetDoc) return; - // Create a new range from the positions const newRange = targetDoc.createRange(); try { const positionComparison = startPos.node.compareDocumentPosition(endPos.node); diff --git a/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts b/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts new file mode 100644 index 00000000..c3f4b262 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useInstantAnnotation.ts @@ -0,0 +1,302 @@ +import { useCallback, useRef } from 'react'; +import { BookNote } from '@/types/book'; +import { Point, TextSelection } from '@/utils/sel'; +import { useEnv } from '@/context/EnvContext'; +import { useSettingsStore } from '@/store/settingsStore'; +import { useBookDataStore } from '@/store/bookDataStore'; +import { useReaderStore } from '@/store/readerStore'; +import { uniqueId } from '@/utils/misc'; + +interface UseInstantAnnotationProps { + bookKey: string; + getAnnotationText: (range: Range) => Promise; + setSelection: React.Dispatch>; +} + +export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantAnnotationProps) => { + const { envConfig } = useEnv(); + const { settings } = useSettingsStore(); + const { getConfig, saveConfig, updateBooknotes } = useBookDataStore(); + const { getView, getViewsById, getViewSettings } = useReaderStore(); + + const startPointRef = useRef(null); + const startDocRef = useRef(null); + const startIndexRef = useRef(0); + 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 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 createRangeFromPoints = useCallback( + (doc: Document, startPoint: Point, endPoint: Point) => { + const startPos = findPositionAtPoint(doc, startPoint.x, startPoint.y); + const endPos = findPositionAtPoint(doc, endPoint.x, endPoint.y); + + if (!startPos || !endPos) return null; + + 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; + } + return newRange; + } catch (e) { + console.warn('Failed to create range:', e); + return null; + } + }, + [findPositionAtPoint], + ); + + 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 + }, []); + + 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 }; + startDocRef.current = doc; + startIndexRef.current = index; + previewAnnotationRef.current = null; + annotationIdRef.current = uniqueId(); + return true; + }, + [isInstantAnnotationEnabled, isSelectableContent], + ); + + 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 = createRangeFromPoints(doc, startPoint, endPoint); + if (!newRange) 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; + + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isInstantAnnotationEnabled, createRangeFromPoints], + ); + + const handleInstantAnnotationPointerCancel = useCallback(() => { + if (!isInstantAnnotationEnabled()) return false; + + startPointRef.current = null; + startDocRef.current = null; + clearPreviewAnnotation(); + return true; + }, [isInstantAnnotationEnabled, clearPreviewAnnotation]); + + 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; + startDocRef.current = null; + clearPreviewAnnotation(); + return false; + } + + const endPoint: Point = { x: ev.clientX, y: ev.clientY }; + const startPoint = startPointRef.current; + + startPointRef.current = null; + startDocRef.current = null; + + const distance = Math.sqrt( + Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2), + ); + if (distance < 10) { + clearPreviewAnnotation(); + return false; + } + + const newRange = createRangeFromPoints(doc, startPoint, endPoint); + if (!newRange) { + clearPreviewAnnotation(); + return false; + } + + const text = await getAnnotationText(newRange); + const cfi = view.getCFI(index, newRange); + + if (!text || !cfi || text.trim().length === 0) { + clearPreviewAnnotation(); + return false; + } + + clearPreviewAnnotation(); + const annotation = createAnnotation(cfi, text); + const views = getViewsById(bookKey.split('-')[0]!); + views.forEach((v) => v?.addAnnotation(annotation)); + + const config = getConfig(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, + id: annotations[existingIndex]!.id, + }; + } else { + annotations.push(annotation); + } + + const updatedConfig = updateBooknotes(bookKey, annotations); + if (updatedConfig) { + saveConfig(envConfig, bookKey, updatedConfig, settings); + } + + return true; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [isInstantAnnotationEnabled, createRangeFromPoints, getAnnotationText, clearPreviewAnnotation], + ); + + const cancelInstantAnnotation = useCallback(() => { + startPointRef.current = null; + startDocRef.current = null; + clearPreviewAnnotation(); + }, [clearPreviewAnnotation]); + + return { + isInstantAnnotationEnabled, + handleInstantAnnotationPointerDown, + handleInstantAnnotationPointerMove, + handleInstantAnnotationPointerCancel, + handleInstantAnnotationPointerUp, + cancelInstantAnnotation, + }; +}; diff --git a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts index 099c77ab..0d2a79f7 100644 --- a/apps/readest-app/src/app/reader/hooks/useTextSelector.ts +++ b/apps/readest-app/src/app/reader/hooks/useTextSelector.ts @@ -4,6 +4,7 @@ import { useReaderStore } from '@/store/readerStore'; import { getOSPlatform } from '@/utils/misc'; import { eventDispatcher } from '@/utils/event'; import { isPointerInsideSelection, TextSelection } from '@/utils/sel'; +import { useInstantAnnotation } from './useInstantAnnotation'; export const useTextSelector = ( bookKey: string, @@ -22,6 +23,16 @@ export const useTextSelector = ( const isTouchStarted = useRef(false); const selectionPosition = useRef(null); const lastPointerType = useRef('mouse'); + const isInstantAnnotating = useRef(false); + const isInstantAnnotated = useRef(false); + + const { + isInstantAnnotationEnabled, + handleInstantAnnotationPointerDown, + handleInstantAnnotationPointerMove, + handleInstantAnnotationPointerCancel, + handleInstantAnnotationPointerUp, + } = useInstantAnnotation({ bookKey, getAnnotationText, setSelection }); const isValidSelection = (sel: Selection) => { return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0; @@ -64,10 +75,65 @@ export const useTextSelector = ( }, 30); }; - const handlePointerdown = (e: PointerEvent) => { - lastPointerType.current = e.pointerType; + const startInstantAnnotating = (ev: PointerEvent) => { + isInstantAnnotating.current = true; + isInstantAnnotated.current = false; + if (view) view.renderer.scrollLocked = true; + (ev.target as HTMLElement).style.userSelect = 'none'; }; - const handlePointerup = (doc: Document, index: number, ev?: PointerEvent) => { + + const stopInstantAnnotating = (ev: PointerEvent) => { + isInstantAnnotating.current = false; + isInstantAnnotated.current = false; + if (view) view.renderer.scrollLocked = false; + (ev.target as HTMLElement).style.userSelect = ''; + }; + + const handlePointerDown = (doc: Document, index: number, ev: PointerEvent) => { + lastPointerType.current = ev.pointerType; + + if (isInstantAnnotationEnabled()) { + const handled = handleInstantAnnotationPointerDown(doc, index, ev); + if (handled) { + ev.preventDefault(); + startInstantAnnotating(ev); + } + } + }; + + const handlePointerMove = (doc: Document, index: number, ev: PointerEvent) => { + if (isInstantAnnotating.current) { + ev.preventDefault(); + isInstantAnnotated.current = handleInstantAnnotationPointerMove(doc, index, ev); + } + }; + + const handlePointerCancel = (_doc: Document, _index: number, ev: PointerEvent) => { + if (isInstantAnnotating.current) { + stopInstantAnnotating(ev); + handleInstantAnnotationPointerCancel(); + } + }; + + const handlePointerUp = async (doc: Document, index: number, ev?: PointerEvent) => { + if (isInstantAnnotating.current && ev) { + stopInstantAnnotating(ev); + const handled = await handleInstantAnnotationPointerUp(doc, index, ev); + if (handled) { + 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; @@ -86,6 +152,11 @@ export const useTextSelector = ( const handleTouchStart = () => { isTouchStarted.current = true; }; + const handleTouchMove = (ev: TouchEvent) => { + if (isInstantAnnotating.current && isInstantAnnotated.current) { + ev.preventDefault(); + } + }; const handleTouchEnd = () => { isTouchStarted.current = false; }; @@ -173,9 +244,12 @@ export const useTextSelector = ( isTextSelected, handleScroll, handleTouchStart, + handleTouchMove, handleTouchEnd, - handlePointerdown, - handlePointerup, + handlePointerDown, + handlePointerMove, + handlePointerCancel, + handlePointerUp, handleSelectionchange, handleShowPopup, handleUpToPopup, diff --git a/apps/readest-app/src/types/view.ts b/apps/readest-app/src/types/view.ts index db303afe..acf62301 100644 --- a/apps/readest-app/src/types/view.ts +++ b/apps/readest-app/src/types/view.ts @@ -48,6 +48,7 @@ export interface FoliateView extends HTMLElement { }; renderer: { scrolled?: boolean; + scrollLocked: boolean; size: number; // current page height viewSize: number; // whole document view height start: number; diff --git a/packages/foliate-js b/packages/foliate-js index 4eb5ca42..ffb82484 160000 --- a/packages/foliate-js +++ b/packages/foliate-js @@ -1 +1 @@ -Subproject commit 4eb5ca42a4c4ea36a8d43d0bd8349646d4974d65 +Subproject commit ffb8248449ac0cbe8611149d687b1a4d648adda1