diff --git a/apps/readest-app/src/__tests__/utils/annotator-util.test.ts b/apps/readest-app/src/__tests__/utils/annotator-util.test.ts index 3b50818a..3882f75b 100644 --- a/apps/readest-app/src/__tests__/utils/annotator-util.test.ts +++ b/apps/readest-app/src/__tests__/utils/annotator-util.test.ts @@ -2,11 +2,13 @@ import { describe, it, expect, vi } from 'vitest'; import { getExternalDragHandle, getHighlightColorLabel, + removeBookNoteOverlays, toParentViewportPoint, } from '@/app/reader/utils/annotatorUtil'; import { Point } from '@/utils/sel'; -import { UserHighlightColor } from '@/types/book'; +import { BookNote, UserHighlightColor } from '@/types/book'; import { SystemSettings } from '@/types/settings'; +import { FoliateView, NOTE_PREFIX } from '@/types/view'; describe('getExternalDragHandle', () => { const currentStart: Point = { x: 100, y: 200 }; @@ -140,3 +142,86 @@ describe('getHighlightColorLabel', () => { expect(getHighlightColorLabel(settings, 'red')).toBeUndefined(); }); }); + +describe('removeBookNoteOverlays', () => { + const makeView = () => { + const addAnnotation = vi.fn(); + const view = { addAnnotation } as unknown as FoliateView; + return { view, addAnnotation }; + }; + + const baseNote = (overrides: Partial = {}): BookNote => ({ + id: 'id-1', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2)', + note: '', + createdAt: 1, + updatedAt: 1, + ...overrides, + }); + + it('removes only the highlight overlay for a highlight-only annotation', () => { + const { view, addAnnotation } = makeView(); + const note = baseNote({ style: 'highlight', color: 'yellow' }); + + removeBookNoteOverlays(view, note); + + expect(addAnnotation).toHaveBeenCalledTimes(1); + expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true); + const passed = addAnnotation.mock.calls[0]![0] as BookNote & { value: string }; + expect(passed.value.startsWith(NOTE_PREFIX)).toBe(false); + }); + + it('removes only the note overlay for a note-only annotation', () => { + const { view, addAnnotation } = makeView(); + const note = baseNote({ note: 'my comment' }); + + removeBookNoteOverlays(view, note); + + expect(addAnnotation).toHaveBeenCalledTimes(1); + expect(addAnnotation).toHaveBeenCalledWith( + expect.objectContaining({ value: `${NOTE_PREFIX}${note.cfi}` }), + true, + ); + }); + + it('removes both overlays when the annotation has a highlight and a note', () => { + const { view, addAnnotation } = makeView(); + const note = baseNote({ style: 'underline', color: 'red', note: 'my comment' }); + + removeBookNoteOverlays(view, note); + + expect(addAnnotation).toHaveBeenCalledTimes(2); + const values = addAnnotation.mock.calls.map( + (call) => (call[0] as BookNote & { value: string }).value, + ); + expect(values).toContain(note.cfi); + expect(values).toContain(`${NOTE_PREFIX}${note.cfi}`); + for (const call of addAnnotation.mock.calls) { + expect(call[1]).toBe(true); + } + }); + + it('does nothing for a bookmark (no highlight, no note text)', () => { + const { view, addAnnotation } = makeView(); + const bookmark = baseNote({ type: 'bookmark' }); + + removeBookNoteOverlays(view, bookmark); + + expect(addAnnotation).not.toHaveBeenCalled(); + }); + + it('treats whitespace-only note text as empty and skips the note overlay', () => { + const { view, addAnnotation } = makeView(); + const note = baseNote({ style: 'highlight', note: ' \n ' }); + + removeBookNoteOverlays(view, note); + + expect(addAnnotation).toHaveBeenCalledTimes(1); + expect(addAnnotation).toHaveBeenCalledWith(expect.objectContaining({ value: note.cfi }), true); + }); + + it('is a no-op when view is null', () => { + expect(() => removeBookNoteOverlays(null, baseNote({ style: 'highlight' }))).not.toThrow(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx index 3b037551..2d9874ac 100644 --- a/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx +++ b/apps/readest-app/src/app/reader/components/sidebar/BooknoteItem.tsx @@ -13,7 +13,7 @@ import { useBookDataStore } from '@/store/bookDataStore'; import { useTranslation } from '@/hooks/useTranslation'; import { useResponsiveSize } from '@/hooks/useResponsiveSize'; import { eventDispatcher } from '@/utils/event'; -import { NOTE_PREFIX } from '@/types/view'; +import { removeBookNoteOverlays } from '../../utils/annotatorUtil'; import useScrollToItem from '../../hooks/useScrollToItem'; import TextButton from '@/components/TextButton'; import TextEditor, { TextEditorRef } from '@/components/TextEditor'; @@ -66,9 +66,7 @@ const BooknoteItem: React.FC = ({ bookKey, item, isNearest, o if (item.id === note.id) { item.deletedAt = Date.now(); const views = getViewsById(bookKey.split('-')[0]!); - views.forEach((view) => - view?.addAnnotation({ ...item, value: `${NOTE_PREFIX}${item.cfi}` }, true), - ); + views.forEach((view) => removeBookNoteOverlays(view, item)); } }); const updatedConfig = updateBooknotes(bookKey, booknotes); diff --git a/apps/readest-app/src/app/reader/utils/annotatorUtil.ts b/apps/readest-app/src/app/reader/utils/annotatorUtil.ts index f8943d93..26143930 100644 --- a/apps/readest-app/src/app/reader/utils/annotatorUtil.ts +++ b/apps/readest-app/src/app/reader/utils/annotatorUtil.ts @@ -1,6 +1,7 @@ import { HIGHLIGHT_COLOR_HEX } from '@/services/constants'; -import { DEFAULT_HIGHLIGHT_COLORS, HighlightColor } from '@/types/book'; +import { BookNote, DEFAULT_HIGHLIGHT_COLORS, HighlightColor } from '@/types/book'; import { SystemSettings } from '@/types/settings'; +import { FoliateView, NOTE_PREFIX } from '@/types/view'; import { Point } from '@/utils/sel'; export const isDefaultHighlightColor = ( @@ -62,3 +63,26 @@ export function toParentViewportPoint(doc: Document, x: number, y: number): Poin const frameRect = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 }; return { x: x + frameRect.left, y: y + frameRect.top }; } + +/** + * Remove any overlays drawn for a BookNote from the given view. + * + * A single BookNote can have up to two overlays attached: + * - a highlight/underline/squiggly overlay (keyed by the raw CFI) + * - a note bubble overlay (keyed by `${NOTE_PREFIX}${cfi}`) + * + * The set of overlays drawn is defined by the progress-sync effect in + * Annotator.tsx, and this helper mirrors those filters so that deleting + * an annotation from the sidebar clears every overlay that was drawn + * for it, not just the note bubble. + */ +export function removeBookNoteOverlays(view: FoliateView | null, note: BookNote): void { + if (!view) return; + if (note.type !== 'annotation') return; + if (note.style) { + view.addAnnotation({ ...note, value: note.cfi }, true); + } + if (note.note && note.note.trim().length > 0) { + view.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` }, true); + } +}