feat(annotator): support editing text range of highlights (#2870)

This commit is contained in:
Huang Xin
2026-01-05 16:25:58 +01:00
committed by GitHub
parent 9603b61776
commit 71af91608f
4 changed files with 476 additions and 41 deletions
@@ -0,0 +1,218 @@
import clsx from 'clsx';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { BookNote } from '@/types/book';
import { Point, TextSelection } from '@/utils/sel';
import { useSettingsStore } from '@/store/settingsStore';
import { useAnnotationEditor } from '../../hooks/useAnnotationEditor';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
interface HandleProps {
position: Point;
type: 'start' | 'end';
color: string;
onDragStart: () => void;
onDrag: (point: Point) => void;
onDragEnd: () => void;
}
const Handle: React.FC<HandleProps> = ({
position,
type,
color,
onDragStart,
onDrag,
onDragEnd,
}) => {
const isDragging = useRef(false);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
e.preventDefault();
e.stopPropagation();
isDragging.current = true;
onDragStart();
(e.target as HTMLElement).setPointerCapture(e.pointerId);
},
[onDragStart],
);
const handlePointerMove = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
e.preventDefault();
e.stopPropagation();
onDrag({ x: e.clientX, y: e.clientY });
},
[onDrag],
);
const handlePointerUp = useCallback(
(e: React.PointerEvent) => {
if (!isDragging.current) return;
e.preventDefault();
e.stopPropagation();
isDragging.current = false;
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
onDragEnd();
},
[onDragEnd],
);
const size = 24;
const circleRadius = 8;
const stemHeight = 8;
return (
<div
className='pointer-events-auto absolute z-50 cursor-grab touch-none active:cursor-grabbing'
style={{
left: position.x - size / 2,
top: type === 'start' ? position.y - size : position.y - size / 2,
width: size,
height: size + stemHeight,
}}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
>
<svg
width={size}
height={size + stemHeight}
viewBox={`0 0 ${size} ${size + stemHeight}`}
className={clsx(type === 'start' && 'rotate-180')}
style={{ transform: type === 'start' ? 'rotate(180deg)' : undefined }}
>
{/* Stem/line */}
<line
x1={size / 2}
y1={0}
x2={size / 2}
y2={stemHeight}
stroke={color}
strokeWidth={2}
strokeLinecap='round'
/>
{/* Circle handle */}
<circle cx={size / 2} cy={stemHeight + circleRadius} r={circleRadius} fill={color} />
</svg>
</div>
);
};
interface AnnotationRangeEditorProps {
bookKey: string;
annotation: BookNote;
selection: TextSelection;
getAnnotationText: (range: Range) => Promise<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
onStartEdit: () => void;
}
const AnnotationRangeEditor: React.FC<AnnotationRangeEditorProps> = ({
bookKey,
annotation,
selection,
getAnnotationText,
setSelection,
onStartEdit,
}) => {
const { settings } = useSettingsStore();
const { handlePositions, getHandlePositionsFromRange, handleAnnotationRangeChange } =
useAnnotationEditor({ bookKey, annotation, getAnnotationText, setSelection });
const initializedRef = useRef(false);
const handleColor = getHighlightColorHex(settings, annotation.color) ?? '#FFFF00';
const draggingRef = useRef<'start' | 'end' | null>(null);
const startRef = useRef<Point>({ x: 0, y: 0 });
const endRef = useRef<Point>({ x: 0, y: 0 });
const [currentStart, setCurrentStart] = useState<Point>({ x: 0, y: 0 });
const [currentEnd, setCurrentEnd] = useState<Point>({ x: 0, y: 0 });
useEffect(() => {
if (initializedRef.current) return;
initializedRef.current = true;
const range = selection.range;
const positions = getHandlePositionsFromRange(range);
if (positions) {
setTimeout(() => {
setCurrentStart(positions.start);
setCurrentEnd(positions.end);
}, 0);
startRef.current = positions.start;
endRef.current = positions.end;
}
}, [annotation, selection, getHandlePositionsFromRange]);
useEffect(() => {
if (!handlePositions || draggingRef.current) return;
setTimeout(() => {
setCurrentStart(handlePositions.start);
setCurrentEnd(handlePositions.end);
}, 0);
startRef.current = handlePositions.start;
endRef.current = handlePositions.end;
}, [handlePositions]);
const handleStartDragStart = useCallback(() => {
draggingRef.current = 'start';
onStartEdit();
}, [onStartEdit]);
const handleEndDragStart = useCallback(() => {
draggingRef.current = 'end';
onStartEdit();
}, [onStartEdit]);
const handleStartDrag = useCallback(
(point: Point) => {
setCurrentStart(point);
startRef.current = point;
handleAnnotationRangeChange(point, endRef.current, true);
},
[handleAnnotationRangeChange],
);
const handleEndDrag = useCallback(
(point: Point) => {
setCurrentEnd(point);
endRef.current = point;
handleAnnotationRangeChange(startRef.current, point, true);
},
[handleAnnotationRangeChange],
);
const handleDragEnd = useCallback(() => {
draggingRef.current = null;
handleAnnotationRangeChange(startRef.current, endRef.current, false);
}, [handleAnnotationRangeChange]);
if (currentStart.x === 0 && currentStart.y === 0) {
return null;
}
return (
<div className='pointer-events-none fixed inset-0 z-40'>
<Handle
position={currentStart}
type='start'
color={handleColor}
onDragStart={handleStartDragStart}
onDrag={handleStartDrag}
onDragEnd={handleDragEnd}
/>
<Handle
position={currentEnd}
type='end'
color={handleColor}
onDragStart={handleEndDragStart}
onDrag={handleEndDrag}
onDragEnd={handleDragEnd}
/>
</div>
);
};
export default AnnotationRangeEditor;
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { RiDeleteBinLine } from 'react-icons/ri';
import * as CFI from 'foliate-js/epubcfi.js';
@@ -30,6 +30,7 @@ import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
import { annotationToolButtons } from './AnnotationTools';
import AnnotationRangeEditor from './AnnotationRangeEditor';
import AnnotationPopup from './AnnotationPopup';
import WiktionaryPopup from './WiktionaryPopup';
import WikipediaPopup from './WikipediaPopup';
@@ -72,6 +73,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const [highlightOptionsVisible, setHighlightOptionsVisible] = useState(false);
const [showAnnotationNotes, setShowAnnotationNotes] = useState(false);
const [annotationNotes, setAnnotationNotes] = useState<BookNote[]>([]);
const [editingAnnotation, setEditingAnnotation] = useState<BookNote | null>(null);
const [selectedStyle, setSelectedStyle] = useState<HighlightStyle>(
settings.globalReadSettings.highlightStyle,
@@ -80,6 +82,13 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
settings.globalReadSettings.highlightStyles[selectedStyle],
);
const showingPopup =
showAnnotPopup ||
showWiktionaryPopup ||
showWikipediaPopup ||
showDeepLPopup ||
showProofreadPopup;
const popupPadding = useResponsiveSize(10);
const trianglePadding = popupPadding * 2 + 6;
const maxWidth = window.innerWidth - 2 * popupPadding;
@@ -150,6 +159,27 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelectedColor(settings.globalReadSettings.highlightStyles[selectedStyle]);
}, [settings.globalReadSettings.highlightStyles, selectedStyle]);
const transformCtx: TransformContext = useMemo(
() => ({
bookKey,
viewSettings: getViewSettings(bookKey)!,
userLocale: getLocale(),
content: '',
transformers: ['punctuation'],
reversePunctuationTransform: true,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const getAnnotationText = useCallback(
async (range: Range) => {
transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
return await transformContent(transformCtx);
},
[primaryLang, transformCtx],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const handleDismissPopup = useCallback(
throttle(() => {
@@ -159,24 +189,11 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setShowWikipediaPopup(false);
setShowDeepLPopup(false);
setShowProofreadPopup(false);
setEditingAnnotation(null);
}, 500),
[],
);
const transformCtx: TransformContext = {
bookKey,
viewSettings: getViewSettings(bookKey)!,
userLocale: getLocale(),
content: '',
transformers: ['punctuation'],
reversePunctuationTransform: true,
};
const getAnnotationText = async (range: Range) => {
transformCtx['content'] = getTextFromRange(range, primaryLang.startsWith('ja') ? ['rt'] : []);
return await transformContent(transformCtx);
};
const {
isTextSelected,
handleScroll,
@@ -327,6 +344,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (isNote) {
setShowAnnotationNotes(true);
setHighlightOptionsVisible(false);
setEditingAnnotation(null);
} else {
setShowAnnotationNotes(false);
setAnnotationNotes([]);
@@ -334,6 +352,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
setSelectedStyle(style);
setSelectedColor(color);
}
if (style && range) {
setEditingAnnotation(annotation);
}
}
setSelection(selection);
handleUpToPopup();
@@ -342,28 +363,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
useEffect(() => {
handleShowPopup(
showAnnotPopup ||
showWiktionaryPopup ||
showWikipediaPopup ||
showDeepLPopup ||
showProofreadPopup,
);
handleShowPopup(showingPopup);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showAnnotPopup, showWiktionaryPopup, showWikipediaPopup, showDeepLPopup, showProofreadPopup]);
}, [showingPopup]);
// When popups are visible, update their positions on scroll events
useEffect(() => {
const view = getView(bookKey);
if (!view?.renderer) return;
const onScroll = () => {
if (
showAnnotPopup ||
showWiktionaryPopup ||
showWikipediaPopup ||
showDeepLPopup ||
showProofreadPopup
) {
if (showingPopup) {
repositionPopups();
}
};
@@ -372,15 +381,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
view.renderer.removeEventListener('scroll', onScroll);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
bookKey,
showAnnotPopup,
showWiktionaryPopup,
showWikipediaPopup,
showDeepLPopup,
showProofreadPopup,
repositionPopups,
]);
}, [bookKey, showingPopup, repositionPopups]);
useEffect(() => {
eventDispatcher.on('export-annotations', handleExportMarkdown);
@@ -614,7 +615,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
views.forEach((view) => view?.addAnnotation(annotation));
} else {
annotations[existingIndex]!.deletedAt = Date.now();
setShowAnnotPopup(false);
handleDismissPopup();
}
} else {
annotations.push(annotation);
@@ -689,6 +690,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
}
};
const handleStartEditAnnotation = useCallback(() => {
setShowAnnotPopup(false);
}, []);
// Keyboard shortcuts: trigger actions only if there's an active selection and popup hidden
useShortcuts(
{
@@ -925,6 +930,16 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
onDismiss={handleDismissPopupAndSelection}
/>
)}
{editingAnnotation && editingAnnotation.color && selection && (
<AnnotationRangeEditor
bookKey={bookKey}
annotation={editingAnnotation}
selection={selection}
getAnnotationText={getAnnotationText}
setSelection={setSelection}
onStartEdit={handleStartEditAnnotation}
/>
)}
</div>
);
};
@@ -0,0 +1,202 @@
import { useCallback, useRef, useState } from 'react';
import { BookNote } from '@/types/book';
import { Point, TextSelection } from '@/utils/sel';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useBookDataStore } from '@/store/bookDataStore';
interface HandlePositions {
start: Point;
end: Point;
}
interface UseAnnotationEditorProps {
bookKey: string;
annotation: BookNote;
getAnnotationText: (range: Range) => Promise<string>;
setSelection: React.Dispatch<React.SetStateAction<TextSelection | null>>;
}
export const useAnnotationEditor = ({
bookKey,
annotation,
getAnnotationText,
setSelection,
}: UseAnnotationEditorProps) => {
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView, getViewsById } = useReaderStore();
const view = getView(bookKey);
const editingAnnotationRef = useRef(annotation);
const [handlePositions, setHandlePositions] = useState<HandlePositions | null>(null);
const getHandlePositionsFromRange = useCallback(
(range: Range): HandlePositions | null => {
const gridFrame = document.querySelector(`#gridcell-${bookKey}`);
if (!gridFrame) return null;
const rects = Array.from(range.getClientRects());
if (rects.length === 0) return null;
const firstRect = rects[0]!;
const lastRect = rects[rects.length - 1]!;
const frameElement = range.commonAncestorContainer.ownerDocument?.defaultView?.frameElement;
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
return {
start: {
x: frameRect.left + firstRect.left,
y: frameRect.top + firstRect.top,
},
end: {
x: frameRect.left + lastRect.right,
y: frameRect.top + lastRect.bottom,
},
};
},
[bookKey],
);
const handleAnnotationRangeChange = useCallback(
async (startPoint: Point, endPoint: Point, isDragging: boolean) => {
if (!editingAnnotationRef.current || !view) return;
const contents = view.renderer.getContents();
if (!contents || contents.length === 0) return;
const findPositionAtPoint = (doc: Document, x: number, y: number) => {
const frameElement = doc.defaultView?.frameElement;
const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
const adjustedX = x - frameRect.left;
const adjustedY = y - frameRect.top;
if (doc.caretPositionFromPoint) {
const pos = doc.caretPositionFromPoint(adjustedX, adjustedY);
if (pos) return { node: pos.offsetNode, offset: pos.offset };
}
if (doc.caretRangeFromPoint) {
const range = doc.caretRangeFromPoint(adjustedX, adjustedY);
if (range) return { node: range.startContainer, offset: range.startOffset };
}
return null;
};
let startPos = null;
let endPos = null;
let targetDoc: Document | null = null;
let targetIndex = 0;
for (const content of contents) {
const { doc, index } = content;
if (!doc) continue;
const sp = findPositionAtPoint(doc, startPoint.x, startPoint.y);
const ep = findPositionAtPoint(doc, endPoint.x, endPoint.y);
if (sp && ep) {
startPos = sp;
endPos = ep;
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);
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) {
console.warn('Range is collapsed');
return;
}
} catch (e) {
console.warn('Failed to create range:', e);
return;
}
const newPositions = getHandlePositionsFromRange(newRange);
if (newPositions) {
setHandlePositions(newPositions);
}
const newCfi = view.getCFI(targetIndex, newRange);
const newText = await getAnnotationText(newRange);
if (newCfi && newText) {
const config = getConfig(bookKey)!;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex(
(a) => a.id === editingAnnotationRef.current.id && !a.deletedAt,
);
if (existingIndex !== -1) {
const updatedAnnotation: BookNote = {
...annotations[existingIndex]!,
cfi: newCfi,
text: newText,
updatedAt: Date.now(),
};
const views = getViewsById(bookKey.split('-')[0]!);
views.forEach((v) => v?.addAnnotation(editingAnnotationRef.current, true));
views.forEach((v) => v?.addAnnotation(updatedAnnotation));
editingAnnotationRef.current = updatedAnnotation;
if (!isDragging) {
annotations[existingIndex] = updatedAnnotation;
const updatedConfig = updateBooknotes(bookKey, annotations);
if (updatedConfig) {
saveConfig(envConfig, bookKey, updatedConfig, settings);
}
setSelection({
key: bookKey,
annotated: true,
text: newText,
cfi: newCfi,
range: newRange,
index: targetIndex,
});
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[bookKey, getHandlePositionsFromRange, getAnnotationText, setSelection],
);
return {
handlePositions,
setHandlePositions,
getHandlePositionsFromRange,
handleAnnotationRangeChange,
};
};
+1 -1
View File
@@ -242,7 +242,7 @@ const getLayoutStyles = (
}
html, body {
${writingMode === 'auto' ? '' : `writing-mode: ${writingMode} !important;`}
${vertical ? 'font-feature-settings: "vrt2" 1, "vert" 1; text-orientation: upright;' : ''}
${writingMode.includes('vertical') ? 'font-feature-settings: "vrt2" 1, "vert" 1; text-orientation: upright;' : ''}
text-align: var(--default-text-align);
max-height: unset;
-webkit-touch-callout: none;