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 b1d2fed3..1889fe18 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -13,6 +13,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useReaderStore } from '@/store/readerStore'; import { useNotebookStore } from '@/store/notebookStore'; +import { useSidebarStore } from '@/store/sidebarStore'; import { useCustomDictionaryStore } from '@/store/customDictionaryStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; @@ -38,7 +39,7 @@ import { getWordCount } from '@/utils/word'; import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi'; import { TransformContext } from '@/services/transformers/types'; import { transformContent } from '@/services/transformService'; -import { getHighlightColorHex } from '../../utils/annotatorUtil'; +import { getHighlightColorHex, removeBookNoteOverlays } from '../../utils/annotatorUtil'; import { annotationToolButtons } from './AnnotationTools'; import AnnotationRangeEditor from './AnnotationRangeEditor'; import AnnotationPopup from './AnnotationPopup'; @@ -49,6 +50,8 @@ import useShortcuts from '@/hooks/useShortcuts'; import ProofreadPopup from './ProofreadPopup'; import { setProofreadRulesVisibility } from '@/app/reader/components/ProofreadRules'; import ExportMarkdownDialog from './ExportMarkdownDialog'; +import Alert from '@/components/Alert'; +import ModalPortal from '@/components/ModalPortal'; const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const _ = useTranslation(); @@ -59,6 +62,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore(); const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore(); const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore(); + const { clearBooknotesNav } = useSidebarStore(); const { listenToNativeTouchEvents } = useDeviceControlStore(); const { loadCustomDictionaries } = useCustomDictionaryStore(); @@ -99,6 +103,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const [editingAnnotation, setEditingAnnotation] = useState(null); const [externalDragPoint, setExternalDragPoint] = useState(null); const [showExportDialog, setShowExportDialog] = useState(false); + // "Clear Annotations" confirm dialog. Hosted here (and not in BookMenu) + // because the menu unmounts the moment the user picks the entry, which + // would otherwise tear down the dialog state immediately. + const [clearAnnotationsCount, setClearAnnotationsCount] = useState(0); const [exportData, setExportData] = useState<{ booknotes: BookNote[]; booknoteGroups: { [href: string]: BooknoteGroup }; @@ -475,8 +483,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { useEffect(() => { eventDispatcher.on('export-annotations', handleExportMarkdown); + eventDispatcher.on('clear-annotations', handleClearAnnotations); return () => { eventDispatcher.off('export-annotations', handleExportMarkdown); + eventDispatcher.off('clear-annotations', handleClearAnnotations); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -927,6 +937,56 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { setExportData(null); }; + // Show the confirm dialog when the BookMenu fires "clear-annotations" + // for this book. We snapshot the count up-front so the dialog shows a + // stable number even if `getConfig` updates underneath us. + const handleClearAnnotations = (event: CustomEvent) => { + const { bookKey: targetBookKey } = event.detail; + if (bookKey !== targetBookKey) return; + const cfg = getConfig(bookKey); + const count = (cfg?.booknotes ?? []).filter( + (n) => n.type === 'annotation' && !n.deletedAt, + ).length; + if (count === 0) return; + setClearAnnotationsCount(count); + }; + + // Soft-delete every type='annotation' booknote on the active book by + // stamping `deletedAt`. Bookmarks and excerpts are intentionally left + // alone — they live in distinct sidebar tabs. + const performClearAnnotations = () => { + const latestConfig = getConfig(bookKey); + if (!latestConfig) return; + const { booknotes: storedNotes = [] } = latestConfig; + const now = Date.now(); + const views = getViewsById(bookKey.split('-')[0]!); + let cleared = 0; + storedNotes.forEach((note) => { + if (note.type === 'annotation' && !note.deletedAt) { + note.deletedAt = now; + cleared += 1; + // Drop the rendered overlay so the page reflects the cleared + // state immediately without waiting for a relocate. + views.forEach((view) => removeBookNoteOverlays(view, note)); + } + }); + if (cleared === 0) return; + + const updatedConfig = updateBooknotes(bookKey, storedNotes); + if (updatedConfig) { + saveConfig(envConfig, bookKey, updatedConfig, settings); + } + // Reset any browse-mode state in the annotations sidebar tab so it + // doesn't keep paging through stale (now soft-deleted) entries. + clearBooknotesNav(bookKey); + + eventDispatcher.dispatch('toast', { + type: 'info', + message: _('Cleared {{count}} highlights and notes.', { count: cleared }), + timeout: 2000, + }); + }; + const selectionAnnotated = selection?.annotated; const toolButtons = annotationToolButtons.map(({ type, label, Icon }) => { switch (type) { @@ -1082,6 +1142,21 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { onExport={handleConfirmExport} /> )} + {clearAnnotationsCount > 0 && ( + + setClearAnnotationsCount(0)} + onConfirm={() => { + setClearAnnotationsCount(0); + performClearAnnotations(); + }} + /> + + )} ); }; diff --git a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx index 1b156476..724b98e1 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BookMenu.tsx @@ -5,6 +5,7 @@ import { MdCheck } from 'react-icons/md'; import { useRouter } from 'next/navigation'; import { useEnv } from '@/context/EnvContext'; import { useAuth } from '@/context/AuthContext'; +import { useBookDataStore } from '@/store/bookDataStore'; import { useReaderStore } from '@/store/readerStore'; import { useLibraryStore } from '@/store/libraryStore'; import { useSidebarStore } from '@/store/sidebarStore'; @@ -38,11 +39,23 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) const { getVisibleLibrary } = useLibraryStore(); const { openParallelView } = useBooksManager(); const { sideBarBookKey } = useSidebarStore(); + const { getConfig } = useBookDataStore(); const { parallelViews, setParallel, unsetParallel } = useParallelViewStore(); const viewSettings = getViewSettings(sideBarBookKey!); const [isSortedTOC, setIsSortedTOC] = React.useState(viewSettings?.sortedTOC || false); + // Used purely to grey out "Clear Annotations" when there's nothing to + // clear. The actual delete + confirm dialog lives in Annotator (which + // outlives this dropdown menu, so the dialog isn't unmounted along + // with the menu when the user clicks the entry). + const annotationsToClear = React.useMemo(() => { + if (!sideBarBookKey) return 0; + const cfg = getConfig(sideBarBookKey); + if (!cfg?.booknotes) return 0; + return cfg.booknotes.filter((n) => n.type === 'annotation' && !n.deletedAt).length; + }, [sideBarBookKey, getConfig]); + const handleParallelView = (id: string) => { openParallelView(id); setIsDropdownOpen?.(false); @@ -115,6 +128,13 @@ const BookMenu: React.FC = ({ menuClassName, setIsDropdownOpen }) } }; + // Routed through Annotator (per-book, long-lived) so that the + // confirmation dialog isn't unmounted with the dropdown menu. + const handleClearAnnotations = () => { + eventDispatcher.dispatch('clear-annotations', { bookKey: sideBarBookKey }); + setIsDropdownOpen?.(false); + }; + return ( = ({ menuClassName, setIsDropdownOpen }) +