feat(reader): add "Clear Annotations" entry to the book menu (#4175)

Adds a "Clear Annotations" item to the book menu. Picking it opens a confirm dialog and, on confirm, soft-deletes every type='annotation' booknote on the active book by stamping deletedAt, removes overlays from live views, persists via saveConfig, and resets sidebar browse state. Bookmarks and excerpts are untouched. The dialog lives in Annotator (per-book, long-lived) and is wired up via a new 'clear-annotations' event so it survives the dropdown menu unmounting.
This commit is contained in:
loveheaven
2026-05-16 10:12:46 +08:00
committed by GitHub
parent 787bbf2103
commit 1a3d393e74
2 changed files with 101 additions and 1 deletions
@@ -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<BookNote | null>(null);
const [externalDragPoint, setExternalDragPoint] = useState<Point | null>(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 && (
<ModalPortal>
<Alert
title={_('Clear Annotations')}
message={_('Are you sure to clear all {{count}} highlights and notes?', {
count: clearAnnotationsCount,
})}
onCancel={() => setClearAnnotationsCount(0)}
onConfirm={() => {
setClearAnnotationsCount(0);
performClearAnnotations();
}}
/>
</ModalPortal>
)}
</div>
);
};
@@ -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<BookMenuProps> = ({ 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<BookMenuProps> = ({ 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 (
<Menu
className={clsx('book-menu dropdown-content z-20 shadow-2xl', menuClassName)}
@@ -200,6 +220,11 @@ const BookMenu: React.FC<BookMenuProps> = ({ menuClassName, setIsDropdownOpen })
<MenuItem label={_('Proofread')} onClick={showProofreadRulesWindow} />
<hr aria-hidden='true' className='border-base-200 my-1' />
<MenuItem label={_('Export Annotations')} onClick={handleExportAnnotations} />
<MenuItem
label={_('Clear Annotations')}
disabled={annotationsToClear === 0}
onClick={handleClearAnnotations}
/>
<MenuItem
label={_('Sort TOC by Page')}
Icon={isSortedTOC ? MdCheck : undefined}