feat(annotator): show the loupe when selecting text in instant annotation, closes #3539 (#3565)

This commit is contained in:
Huang Xin
2026-03-19 22:10:33 +08:00
committed by GitHub
parent 7f62cdcab9
commit eab83c6d3f
10 changed files with 282 additions and 31 deletions
@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from 'vitest';
import {
getEffectiveLoupePoint,
getExternalDragHandle,
toParentViewportPoint,
} from '@/app/reader/utils/annotatorUtil';
import { Point } from '@/utils/sel';
describe('getEffectiveLoupePoint', () => {
it('returns loupePoint when set (internal handle drag takes priority)', () => {
const loupePoint: Point = { x: 150, y: 180 };
const externalDragPoint: Point = { x: 280, y: 200 };
const result = getEffectiveLoupePoint(loupePoint, externalDragPoint);
expect(result).toBe(loupePoint);
});
it('returns externalDragPoint when loupePoint is null (smooth pointer tracking)', () => {
const externalDragPoint: Point = { x: 280, y: 200 };
const result = getEffectiveLoupePoint(null, externalDragPoint);
expect(result).toBe(externalDragPoint);
});
it('returns null when both loupePoint and externalDragPoint are null', () => {
const result = getEffectiveLoupePoint(null, null);
expect(result).toBeNull();
});
it('returns null when externalDragPoint is undefined', () => {
const result = getEffectiveLoupePoint(null, undefined);
expect(result).toBeNull();
});
});
describe('getExternalDragHandle', () => {
const currentStart: Point = { x: 100, y: 200 };
const currentEnd: Point = { x: 300, y: 200 };
it('forward drag — externalDragPoint closer to end → returns end', () => {
const result = getExternalDragHandle({ x: 280, y: 200 }, currentStart, currentEnd);
expect(result).toBe('end');
});
it('backward drag — externalDragPoint closer to start → returns start', () => {
const result = getExternalDragHandle({ x: 120, y: 200 }, currentStart, currentEnd);
expect(result).toBe('start');
});
it('vertical text — works with vertical coordinates', () => {
const vStart: Point = { x: 200, y: 100 };
const vEnd: Point = { x: 200, y: 400 };
const result = getExternalDragHandle({ x: 200, y: 350 }, vStart, vEnd);
expect(result).toBe('end');
});
it('equal distance — returns end (deterministic tie-breaking)', () => {
// Midpoint between start(100,200) and end(300,200) is (200,200)
// distToStart === distToEnd, so !(distToStart < distToEnd) → returns 'end'
const result = getExternalDragHandle({ x: 200, y: 200 }, currentStart, currentEnd);
expect(result).toBe('end');
});
});
describe('toParentViewportPoint', () => {
it('adds frameRect offset to coordinates', () => {
const mockFrameElement = {
getBoundingClientRect: vi.fn(() => ({
top: 50,
left: 80,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: vi.fn(),
})),
};
const doc = {
defaultView: {
frameElement: mockFrameElement,
},
} as unknown as Document;
const result = toParentViewportPoint(doc, 100, 200);
expect(result).toEqual({ x: 180, y: 250 });
});
it('defaults to {0,0} offset when no frameElement (detached doc)', () => {
const doc = {
defaultView: null,
} as unknown as Document;
const result = toParentViewportPoint(doc, 100, 200);
expect(result).toEqual({ x: 100, y: 200 });
});
it('handles non-zero iframe offset (e.g., sidebar shifts iframe right)', () => {
const mockFrameElement = {
getBoundingClientRect: vi.fn(() => ({
top: 0,
left: 250,
right: 0,
bottom: 0,
width: 0,
height: 0,
x: 0,
y: 0,
toJSON: vi.fn(),
})),
};
const doc = {
defaultView: {
frameElement: mockFrameElement,
},
} as unknown as Document;
const result = toParentViewportPoint(doc, 50, 100);
expect(result).toEqual({ x: 300, y: 100 });
});
});
@@ -35,6 +35,7 @@ import {
} from '@/utils/style';
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
import { getIndexFromCfi } from '@/utils/cfi';
import { useUICSS } from '@/hooks/useUICSS';
import {
handleKeydown,
@@ -253,9 +254,16 @@ const FoliateViewer: React.FC<{
}
setTimeout(() => {
const sectionIndex = detail.index;
const booknotes = config.booknotes || [];
booknotes
.filter((item) => !item.deletedAt && item.type === 'annotation' && item.style)
.filter(
(item) =>
!item.deletedAt &&
item.type === 'annotation' &&
item.style &&
getIndexFromCfi(item.cfi) === sectionIndex,
)
.forEach((annotation) => viewRef.current?.addAnnotation(annotation));
}, 100);
@@ -9,7 +9,11 @@ import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useResponsiveSize } from '@/hooks/useResponsiveSize';
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
import {
getEffectiveLoupePoint,
getExternalDragHandle,
getHighlightColorHex,
} from '../../utils/annotatorUtil';
import MagnifierLoupe from './MagnifierLoupe';
interface HandleProps {
@@ -136,6 +140,7 @@ interface AnnotationRangeEditorProps {
annotation: BookNote;
selection: TextSelection;
handleColor: HighlightColor;
externalDragPoint?: Point | null;
getAnnotationText: (range: Range) => Promise<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
onStartEdit: () => void;
@@ -147,6 +152,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
annotation,
selection,
handleColor,
externalDragPoint,
getAnnotationText,
setSelection,
onStartEdit,
@@ -247,12 +253,17 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
return null;
}
const showLoupe = loupePoint !== null && appService?.isMobile && !viewSettings?.vertical;
const effectiveLoupePoint = getEffectiveLoupePoint(loupePoint, externalDragPoint);
const activeHandle =
draggingHandle ??
(externalDragPoint ? getExternalDragHandle(externalDragPoint, currentStart, currentEnd) : null);
const showLoupe = effectiveLoupePoint !== null && appService?.isMobile && !viewSettings?.vertical;
return (
<div className='pointer-events-none fixed inset-0 z-50'>
<Handle
hidden={draggingHandle === 'end'}
hidden={activeHandle === 'end'}
position={currentStart}
isVertical={isVertical}
type='start'
@@ -262,7 +273,7 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
onDragEnd={handleDragEnd}
/>
<Handle
hidden={draggingHandle === 'start'}
hidden={activeHandle === 'start'}
position={currentEnd}
isVertical={isVertical}
type='end'
@@ -271,10 +282,10 @@ const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
onDrag={handleEndDrag}
onDragEnd={handleDragEnd}
/>
{showLoupe && (
{showLoupe && effectiveLoupePoint && (
<MagnifierLoupe
bookKey={bookKey}
dragPoint={loupePoint}
dragPoint={effectiveLoupePoint}
isVertical={isVertical}
color={handleColorHex}
/>
@@ -20,7 +20,7 @@ import { useFoliateEvents } from '../../hooks/useFoliateEvents';
import { useNotesSync } from '../../hooks/useNotesSync';
import { useReadwiseSync } from '../../hooks/useReadwiseSync';
import { useTextSelector } from '../../hooks/useTextSelector';
import { Position, TextSelection } from '@/utils/sel';
import { Point, Position, TextSelection } from '@/utils/sel';
import { getPopupPosition, getPosition, getTextFromRange } from '@/utils/sel';
import { eventDispatcher } from '@/utils/event';
import { findTocItemBS } from '@/utils/toc';
@@ -79,6 +79,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [showAnnotationNotes, setShowAnnotationNotes] = useState(false);
const [annotationNotes, setAnnotationNotes] = useState<BookNote[]>([]);
const [editingAnnotation, setEditingAnnotation] = useState<BookNote | null>(null);
const [externalDragPoint, setExternalDragPoint] = useState<Point | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [exportData, setExportData] = useState<{
booknotes: BookNote[];
@@ -207,6 +208,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const {
isTextSelected,
isInstantAnnotating,
handleScroll,
handleTouchStart,
handleTouchMove,
@@ -219,7 +221,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleShowPopup,
handleUpToPopup,
handleContextmenu,
} = useTextSelector(bookKey, setSelection, getAnnotationText, handleDismissPopup);
} = useTextSelector(
bookKey,
setSelection,
setEditingAnnotation,
setExternalDragPoint,
getAnnotationText,
handleDismissPopup,
);
const handleDismissPopupAndSelection = () => {
handleDismissPopup();
@@ -235,7 +244,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
// Available on iOS, on Android not fired
// To make the popup not follow the selection while dragging
setShowAnnotPopup(false);
setEditingAnnotation(null);
if (!isInstantAnnotating.current) {
setEditingAnnotation(null);
}
handleTouchMove(ev);
};
@@ -1003,6 +1014,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
annotation={editingAnnotation}
selection={selection}
handleColor={selectedColor}
externalDragPoint={externalDragPoint}
getAnnotationText={getAnnotationText}
setSelection={setSelection}
onStartEdit={handleStartEditAnnotation}
@@ -18,14 +18,31 @@ const MagnifierLoupe: React.FC<MagnifierLoupeProps> = ({
color,
}) => {
const { getView } = useReaderStore();
const radius = useResponsiveSize(52);
const gap = useResponsiveSize(22);
const margin = useResponsiveSize(8);
const radius = useResponsiveSize(28);
useEffect(() => {
return () => {
const view = getView(bookKey);
view?.renderer.hideLoupe?.();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookKey]);
// Update loupe position on every drag move (fast path — no DOM recreation).
useEffect(() => {
const view = getView(bookKey);
if (!view) return;
view.renderer.showLoupe?.(dragPoint.x, dragPoint.y, { isVertical, color, radius });
return () => view.renderer.hideLoupe?.();
}, [bookKey, dragPoint, getView, isVertical, color, radius]);
view.renderer.showLoupe?.(dragPoint.x, dragPoint.y, {
isVertical,
color,
gap,
margin,
radius,
magnification: 1.1,
});
}, [bookKey, dragPoint, getView, isVertical, color, radius, gap, margin]);
return null;
};
@@ -2,18 +2,27 @@ 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 { useReaderStore } from '@/store/readerStore';
import { toParentViewportPoint } from '../utils/annotatorUtil';
import { uniqueId } from '@/utils/misc';
interface UseInstantAnnotationProps {
bookKey: string;
getAnnotationText: (range: Range) => Promise<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
setEditingAnnotation: React.Dispatch<React.SetStateAction<BookNote | null>>;
setExternalDragPoint: React.Dispatch<React.SetStateAction<Point | null>>;
}
export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantAnnotationProps) => {
export const useInstantAnnotation = ({
bookKey,
getAnnotationText,
setSelection,
setEditingAnnotation,
setExternalDragPoint,
}: UseInstantAnnotationProps) => {
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
@@ -41,6 +50,13 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
}
}, [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);
@@ -200,6 +216,21 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
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, ev.clientX, ev.clientY));
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -211,9 +242,9 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
startPointRef.current = null;
startDocRef.current = null;
clearPreviewAnnotation();
clearInstantAnnotationState();
return true;
}, [isInstantAnnotationEnabled, clearPreviewAnnotation]);
}, [isInstantAnnotationEnabled, clearInstantAnnotationState]);
const handleInstantAnnotationPointerUp = useCallback(
async (doc: Document, index: number, ev: PointerEvent) => {
@@ -223,7 +254,7 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
if (!startPointRef.current || !view) {
startPointRef.current = null;
startDocRef.current = null;
clearPreviewAnnotation();
clearInstantAnnotationState();
return false;
}
@@ -237,13 +268,13 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
Math.pow(endPoint.x - startPoint.x, 2) + Math.pow(endPoint.y - startPoint.y, 2),
);
if (distance < 10) {
clearPreviewAnnotation();
clearInstantAnnotationState();
return false;
}
const newRange = createRangeFromPoints(doc, startPoint, endPoint);
if (!newRange) {
clearPreviewAnnotation();
clearInstantAnnotationState();
return false;
}
@@ -251,11 +282,11 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
const cfi = view.getCFI(index, newRange);
if (!text || !cfi || text.trim().length === 0) {
clearPreviewAnnotation();
clearInstantAnnotationState();
return false;
}
clearPreviewAnnotation();
clearInstantAnnotationState();
const annotation = createAnnotation(cfi, text);
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((v) => v?.addAnnotation(annotation));
@@ -286,14 +317,19 @@ export const useInstantAnnotation = ({ bookKey, getAnnotationText }: UseInstantA
return true;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isInstantAnnotationEnabled, createRangeFromPoints, getAnnotationText, clearPreviewAnnotation],
[
isInstantAnnotationEnabled,
createRangeFromPoints,
getAnnotationText,
clearInstantAnnotationState,
],
);
const cancelInstantAnnotation = useCallback(() => {
startPointRef.current = null;
startDocRef.current = null;
clearPreviewAnnotation();
}, [clearPreviewAnnotation]);
clearInstantAnnotationState();
}, [clearInstantAnnotationState]);
return {
isInstantAnnotationEnabled,
@@ -1,15 +1,18 @@
import { useEffect, useRef } from 'react';
import { BookNote } from '@/types/book';
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 { isPointerInsideSelection, TextSelection } from '@/utils/sel';
import { isPointerInsideSelection, Point, TextSelection } from '@/utils/sel';
import { useInstantAnnotation } from './useInstantAnnotation';
export const useTextSelector = (
bookKey: string,
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,
) => {
@@ -35,7 +38,13 @@ export const useTextSelector = (
handleInstantAnnotationPointerMove,
handleInstantAnnotationPointerCancel,
handleInstantAnnotationPointerUp,
} = useInstantAnnotation({ bookKey, getAnnotationText, setSelection });
} = useInstantAnnotation({
bookKey,
getAnnotationText,
setSelection,
setEditingAnnotation,
setExternalDragPoint,
});
const isValidSelection = (sel: Selection) => {
return sel && sel.toString().trim().length > 0 && sel.rangeCount > 0;
@@ -125,7 +134,6 @@ export const useTextSelector = (
stopInstantAnnotating(ev);
const handled = await handleInstantAnnotationPointerUp(doc, index, ev);
if (handled) {
isTextSelected.current = true;
return;
} else {
// If instant annotation was not created, we let the event propagate
@@ -251,6 +259,7 @@ export const useTextSelector = (
return {
isTextSelected,
isInstantAnnotating,
handleScroll,
handleTouchStart,
handleTouchMove,
@@ -1,6 +1,7 @@
import { HIGHLIGHT_COLOR_HEX } from '@/services/constants';
import { HighlightColor } from '@/types/book';
import { SystemSettings } from '@/types/settings';
import { Point } from '@/utils/sel';
export const getHighlightColorHex = (
settings: SystemSettings,
@@ -11,3 +12,32 @@ export const getHighlightColorHex = (
const customColors = settings.globalReadSettings.customHighlightColors;
return customColors?.[color] ?? HIGHLIGHT_COLOR_HEX[color];
};
export function getEffectiveLoupePoint(
loupePoint: Point | null,
externalDragPoint: Point | null | undefined,
): Point | null {
return loupePoint ?? externalDragPoint ?? null;
}
export function getExternalDragHandle(
externalDragPoint: Point,
currentStart: Point,
currentEnd: Point,
): 'start' | 'end' {
const distToStart = Math.hypot(
externalDragPoint.x - currentStart.x,
externalDragPoint.y - currentStart.y,
);
const distToEnd = Math.hypot(
externalDragPoint.x - currentEnd.x,
externalDragPoint.y - currentEnd.y,
);
return distToStart < distToEnd ? 'start' : 'end';
}
export function toParentViewportPoint(doc: Document, x: number, y: number): Point {
const frameElement = doc.defaultView?.frameElement;
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
return { x: x + frameRect.left, y: y + frameRect.top };
}
+9 -1
View File
@@ -94,9 +94,17 @@ export interface FoliateView extends HTMLElement {
showLoupe?: (
x: number,
y: number,
options: { isVertical: boolean; color: string; radius: number },
options: {
isVertical: boolean;
color: string;
gap: number;
margin: number;
radius: number;
magnification: number;
},
) => void;
hideLoupe?: () => void;
destroyLoupe?: () => void;
pinchZoom?: (ratio: number) => void;
pinchEnd?: () => void;
};