From 71af91608fdf6f42509f059afd314e66211db2f6 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Mon, 5 Jan 2026 16:25:58 +0100 Subject: [PATCH] feat(annotator): support editing text range of highlights (#2870) --- .../annotator/AnnotationRangeEditor.tsx | 218 ++++++++++++++++++ .../reader/components/annotator/Annotator.tsx | 95 ++++---- .../app/reader/hooks/useAnnotationEditor.ts | 202 ++++++++++++++++ apps/readest-app/src/utils/style.ts | 2 +- 4 files changed, 476 insertions(+), 41 deletions(-) create mode 100644 apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx create mode 100644 apps/readest-app/src/app/reader/hooks/useAnnotationEditor.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 new file mode 100644 index 00000000..1189157b --- /dev/null +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationRangeEditor.tsx @@ -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 = ({ + 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 ( +
+ + {/* Stem/line */} + + {/* Circle handle */} + + +
+ ); +}; + +interface AnnotationRangeEditorProps { + bookKey: string; + annotation: BookNote; + selection: TextSelection; + getAnnotationText: (range: Range) => Promise; + setSelection: React.Dispatch>; + onStartEdit: () => void; +} + +const AnnotationRangeEditor: React.FC = ({ + 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({ x: 0, y: 0 }); + const endRef = useRef({ x: 0, y: 0 }); + const [currentStart, setCurrentStart] = useState({ x: 0, y: 0 }); + const [currentEnd, setCurrentEnd] = useState({ 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 ( +
+ + +
+ ); +}; + +export default AnnotationRangeEditor; diff --git a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx index 10b67379..7a0dc686 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -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([]); + const [editingAnnotation, setEditingAnnotation] = useState(null); const [selectedStyle, setSelectedStyle] = useState( 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 && ( + + )} ); }; diff --git a/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts b/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts new file mode 100644 index 00000000..d9cc45f4 --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useAnnotationEditor.ts @@ -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; + setSelection: React.Dispatch>; +} + +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(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, + }; +}; diff --git a/apps/readest-app/src/utils/style.ts b/apps/readest-app/src/utils/style.ts index 360f4bfc..a70fdef2 100644 --- a/apps/readest-app/src/utils/style.ts +++ b/apps/readest-app/src/utils/style.ts @@ -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;