feat(annotator): instant highlighting with mouse, touch or pen (#2881)

This commit is contained in:
Huang Xin
2026-01-07 16:13:37 +01:00
committed by GitHub
parent 5ffaac5e67
commit 9614c62360
7 changed files with 407 additions and 31 deletions
@@ -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<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
onStartEdit: () => void;
@@ -114,6 +115,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
bookKey,
annotation,
selection,
handleColor,
getAnnotationText,
setSelection,
onStartEdit,
@@ -123,7 +125,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
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<Point>({ x: 0, y: 0 });
const endRef = useRef<Point>({ x: 0, y: 0 });
@@ -198,7 +200,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
<Handle
position={currentStart}
type='start'
color={handleColor}
color={handleColorHex}
onDragStart={handleStartDragStart}
onDrag={handleStartDrag}
onDragEnd={handleDragEnd}
@@ -206,7 +208,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
<Handle
position={currentEnd}
type='end'
color={handleColor}
color={handleColorHex}
onDragStart={handleEndDragStart}
onDrag={handleEndDrag}
onDragEnd={handleDragEnd}
@@ -198,9 +198,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ 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}
@@ -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);
@@ -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<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
}
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<Point | null>(null);
const startDocRef = useRef<Document | null>(null);
const startIndexRef = useRef<number>(0);
const previewAnnotationRef = useRef<BookNote | null>(null);
const annotationIdRef = useRef<string>(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,
};
};
@@ -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<number | null>(null);
const lastPointerType = useRef<string>('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,
+1
View File
@@ -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;