From ae81cd01515707f6db1eae4c4741ab75912b9cb3 Mon Sep 17 00:00:00 2001 From: loveheaven Date: Fri, 22 May 2026 00:59:07 +0800 Subject: [PATCH] feat(annotator): support global highlights that fan out across all matching positions (#4257) Introduces a 'global' annotation flag so a highlight/note created on one occurrence of a phrase is automatically applied to every matching occurrence in the book (and stays applied across reloads). Renders these expansions as transient overlays without creating duplicate persisted notes. This flag will not show when the book is fixed layout like PDF or CBZ. - types: add 'global?: boolean' to BookNote and DBBookNote; transform layer round-trips the field, with regression coverage ensuring older clients do not clobber it on write-back. - db: new migration 013_add_book_notes_global.sql adds nullable 'global' column to public.book_notes; init schema.sql updated to match. - annotator: new utils/globalAnnotations.ts handles cfi expansion / text-match search across the spine and overlay synthesis. Annotator.tsx fans out global notes on load and on overlay creation; AnnotationPopup and HighlightOptions expose a toggle to mark a highlight as global. - sync path is transparent: a global note created on another device is fanned out locally on next render with no extra UI required. --- .../src/__tests__/utils/transform.test.ts | 111 ++++++ .../components/annotator/AnnotationPopup.tsx | 9 + .../reader/components/annotator/Annotator.tsx | 137 +++++++- .../components/annotator/HighlightOptions.tsx | 26 ++ .../src/app/reader/utils/globalAnnotations.ts | 326 ++++++++++++++++++ apps/readest-app/src/types/book.ts | 8 + apps/readest-app/src/types/records.ts | 1 + apps/readest-app/src/utils/transform.ts | 4 + docker/volumes/db/init/schema.sql | 1 + .../migrations/013_add_book_notes_global.sql | 10 + 10 files changed, 622 insertions(+), 11 deletions(-) create mode 100644 apps/readest-app/src/app/reader/utils/globalAnnotations.ts create mode 100644 docker/volumes/db/migrations/013_add_book_notes_global.sql diff --git a/apps/readest-app/src/__tests__/utils/transform.test.ts b/apps/readest-app/src/__tests__/utils/transform.test.ts index 67a11ffd..f4a12f95 100644 --- a/apps/readest-app/src/__tests__/utils/transform.test.ts +++ b/apps/readest-app/src/__tests__/utils/transform.test.ts @@ -118,6 +118,117 @@ describe('transformBookNoteFromDB with xpointer fields', () => { }); }); +describe('transformBookNote with global flag', () => { + const baseNote: BookNote = { + bookHash: 'abc123', + id: 'note-g', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + text: 'highlighted text', + note: '', + style: 'highlight', + color: 'yellow', + createdAt: 1700000000000, + updatedAt: 1700000001000, + }; + + it('passes through global=true when serializing to DB', () => { + const note: BookNote = { ...baseNote, global: true }; + const dbNote = transformBookNoteToDB(note, 'user1'); + expect(dbNote.global).toBe(true); + }); + + it('passes through global=false when serializing to DB', () => { + const note: BookNote = { ...baseNote, global: false }; + const dbNote = transformBookNoteToDB(note, 'user1'); + expect(dbNote.global).toBe(false); + }); + + it('omits global when undefined (legacy notes)', () => { + const dbNote = transformBookNoteToDB(baseNote, 'user1'); + expect(dbNote.global).toBeUndefined(); + }); + + it('reads global=true from DB', () => { + const dbNote: DBBookNote = { + user_id: 'user1', + book_hash: 'abc123', + id: 'note-g', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + text: 'highlighted text', + note: '', + global: true, + created_at: '2023-11-14T22:13:20.000Z', + updated_at: '2023-11-14T22:13:21.000Z', + }; + const note = transformBookNoteFromDB(dbNote); + expect(note.global).toBe(true); + }); + + it('leaves global undefined when missing from DB', () => { + const dbNote: DBBookNote = { + user_id: 'user1', + book_hash: 'abc123', + id: 'note-g', + type: 'annotation', + cfi: 'epubcfi(/6/4!/4/2/1:0)', + text: 'highlighted text', + note: '', + created_at: '2023-11-14T22:13:20.000Z', + updated_at: '2023-11-14T22:13:21.000Z', + }; + const note = transformBookNoteFromDB(dbNote); + expect(note.global).toBeUndefined(); + }); + + it('round-trips global=true through DB transform', () => { + const note: BookNote = { ...baseNote, global: true }; + const db = transformBookNoteToDB(note, 'user1'); + const dbRecord: DBBookNote = { + ...db, + created_at: new Date(note.createdAt).toISOString(), + updated_at: new Date(note.updatedAt).toISOString(), + }; + const restored = transformBookNoteFromDB(dbRecord); + expect(restored.global).toBe(true); + }); + + // Regression: an old client that has not been updated to know about + // `global` will receive a note with global=true from the server, then + // upsert the same row back during a later sync. The legacy client only + // copies fields it knows about, so its outgoing payload omits `global`. + // We simulate that here by stripping `global` from the round-tripped + // BookNote and re-serializing — the resulting DB payload also omits the + // column, and the server-side merge therefore preserves the existing + // global=true value (the column is left untouched on UPDATE). + it('legacy client round-trip does not actively clear global (column omitted)', () => { + const note: BookNote = { ...baseNote, global: true }; + const dbFromUpdated = transformBookNoteToDB(note, 'user1'); + const dbRecord: DBBookNote = { + ...dbFromUpdated, + created_at: new Date(note.createdAt).toISOString(), + updated_at: new Date(note.updatedAt).toISOString(), + }; + const restored = transformBookNoteFromDB(dbRecord); + + // Simulate a legacy client: it doesn't know about `global`, so the + // field is dropped from the in-memory note before it gets sent back. + const legacyOutgoing: BookNote = { ...restored }; + delete (legacyOutgoing as { global?: boolean }).global; + + const legacyDb = transformBookNoteToDB(legacyOutgoing, 'user1'); + expect(legacyDb.global).toBeUndefined(); + // The destructure-spread inside transformBookNoteToDB assigns + // global=undefined as an own property, but JSON.stringify drops keys + // whose value is undefined. So the wire payload sent to Supabase has + // no `global` column, and the server-side merge preserves whatever is + // already stored — which is exactly what we want for legacy clients. + expect(Object.hasOwn(legacyDb, 'global')).toBe(true); + expect(JSON.parse(JSON.stringify(legacyDb))).not.toHaveProperty('global'); + }); +}); + describe('transformBookConfigToDB / transformBookConfigFromDB rsvpPosition', () => { const baseConfig: BookConfig = { bookHash: 'hash1', diff --git a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx index a529e51a..2a354820 100644 --- a/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/AnnotationPopup.tsx @@ -26,6 +26,9 @@ interface AnnotationPopupProps { selectedColor: HighlightColor; popupWidth: number; popupHeight: number; + globalToggleAvailable?: boolean; + globalToggleActive?: boolean; + onToggleGlobal?: () => void; onHighlight: (update?: boolean) => void; onDismiss: () => void; } @@ -43,6 +46,9 @@ const AnnotationPopup: React.FC = ({ selectedColor, popupWidth, popupHeight, + globalToggleAvailable, + globalToggleActive, + onToggleGlobal, onHighlight, onDismiss, }) => { @@ -104,6 +110,9 @@ const AnnotationPopup: React.FC = ({ popupHeight={isVertical ? popupWidth : popupHeight} selectedStyle={selectedStyle} selectedColor={selectedColor} + globalToggleAvailable={globalToggleAvailable} + globalToggleActive={globalToggleActive} + onToggleGlobal={onToggleGlobal} onHandleHighlight={onHighlight} /> ) 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 f6d084c7..83952b72 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -48,6 +48,13 @@ import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi'; import { TransformContext } from '@/services/transformers/types'; import { transformContent } from '@/services/transformService'; import { getHighlightColorHex, removeBookNoteOverlays } from '../../utils/annotatorUtil'; +import { + expandAllRenderedSections, + expandGlobalAnnotation, + isSyntheticGlobalValue, + removeGlobalAnnotationOverlays, + sourceCfiFromSyntheticValue, +} from '../../utils/globalAnnotations'; import { annotationToolButtons } from './AnnotationTools'; import AnnotationRangeEditor from './AnnotationRangeEditor'; import AnnotationPopup from './AnnotationPopup'; @@ -369,13 +376,19 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const onCreateOverlay = (event: Event) => { const detail = (event as CustomEvent).detail; const { booknotes = [] } = getConfig(bookKey)!; - booknotes - .filter( - (booknote) => - booknote.type === 'annotation' && - !booknote.deletedAt && - getIndexFromCfi(booknote.cfi) === detail.index, - ) + // Resolve the live (doc, overlayer) pair for this section so we can + // fan out global annotations across every text-occurrence in it. + const sectionContent = view?.renderer?.getContents().find((c) => c.index === detail.index) as + | { doc?: Document; index?: number } + | undefined; + const sectionDoc = sectionContent?.doc; + + const activeAnnotations = booknotes.filter((b) => b.type === 'annotation' && !b.deletedAt); + + // 1. Draw native overlays only for notes whose anchor (cfi) lives + // inside this section — same as before. + activeAnnotations + .filter((booknote) => getIndexFromCfi(booknote.cfi) === detail.index) .map((annotation) => { try { view?.addAnnotation(annotation); @@ -383,6 +396,21 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { console.warn('Failed to add annotation', { annotation, error: err }); } }); + + // 2. Fan out every `global` annotation in this newly-rendered + // section, regardless of which section originally anchored it. + // `expandGlobalAnnotation` already skips the home anchor when the + // synthetic CFI collides with `note.cfi`. + if (sectionDoc) { + for (const annotation of activeAnnotations) { + if (!annotation.global) continue; + try { + expandGlobalAnnotation(view ?? null, annotation, sectionDoc, detail.index); + } catch (err) { + console.warn('Failed to expand global annotation', { annotation, error: err }); + } + } + } }; const onDrawAnnotation = (event: Event) => { @@ -431,7 +459,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { const { value, index, range } = detail; const { booknotes = [] } = getConfig(bookKey)!; const isNote = value.startsWith(NOTE_PREFIX); - const cfi = isNote ? value.replace(NOTE_PREFIX, '') : value; + const rawValue = isNote ? value.replace(NOTE_PREFIX, '') : value; + // A click on a fan-out copy of a global annotation reports a + // synthetic value (`${cfi}#g${i}`); map it back to the source + // booknote so the popup behaves identically to clicking the + // original anchor. + const cfi = isSyntheticGlobalValue(rawValue) ? sourceCfiFromSyntheticValue(rawValue) : rawValue; const annotations = booknotes.filter( (booknote) => booknote.type === 'annotation' && !booknote.deletedAt && booknote.cfi === cfi, ); @@ -649,6 +682,17 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { Promise.all( notes.map((note) => view?.addAnnotation({ ...note, value: `${NOTE_PREFIX}${note.cfi}` })), ); + // Fan-out for any annotation flagged `global`. Semantics is + // book-wide, so we don't filter by `location` here: every note + // with `global=true` gets expanded across every section that + // happens to be rendered right now. Sections rendered later are + // covered by `onCreateOverlay`. + const globalAnnotations = booknotes.filter( + (item) => !item.deletedAt && item.type === 'annotation' && item.style && item.global, + ); + for (const annotation of globalAnnotations) { + if (view) expandAllRenderedSections(view, annotation); + } } catch (e) { console.warn(e); } @@ -756,13 +800,28 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { ); const views = getViewsById(bookKey.split('-')[0]!); if (existingIndex !== -1) { - views.forEach((view) => view?.addAnnotation(annotation, true)); + const existing = annotations[existingIndex]!; + // Tear down both the original anchor and any global fan-outs that + // were drawn for the previous style/color, so the redraw below + // doesn't end up overlaying two highlights at the same position. + views.forEach((view) => view?.addAnnotation(existing, true)); + if (existing.global) { + views.forEach((view) => removeGlobalAnnotationOverlays(view, existing)); + } if (update) { - annotation.id = annotations[existingIndex]!.id; + annotation.id = existing.id; + // Carry the existing `global` flag forward — toggling color/style + // shouldn't silently demote a global highlight back to single-range. + if (existing.global) annotation.global = true; annotations[existingIndex] = annotation; views.forEach((view) => view?.addAnnotation(annotation)); + if (annotation.global) { + views.forEach((view) => { + if (view) expandAllRenderedSections(view, annotation); + }); + } } else { - annotations[existingIndex]!.deletedAt = Date.now(); + existing.deletedAt = Date.now(); handleDismissPopup(); } } else { @@ -777,6 +836,43 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { } }; + /** + * Toggle the `global` flag on the annotation currently anchored at + * `selection.cfi`. When enabling, fan out overlays for every other + * occurrence of `selection.text` in the same section; when disabling, + * tear them down. The original anchor highlight at `cfi` is left + * untouched in either direction. + * + * Hidden for fixed-layout formats (PDF/CBZ) because they don't expose + * a per-section text DOM we can scan. + */ + const handleToggleGlobal = () => { + if (!selection || !selection.cfi || !selection.text) return; + if (bookData.isFixedLayout) return; + const { booknotes: annotations = [] } = config; + const idx = annotations.findIndex( + (a) => a.type === 'annotation' && a.style && !a.deletedAt && a.cfi === selection.cfi, + ); + if (idx === -1) return; + const existing = annotations[idx]!; + const nextGlobal = !existing.global; + annotations[idx] = { ...existing, global: nextGlobal, updatedAt: Date.now() }; + const updatedConfig = updateBooknotes(bookKey, annotations); + if (updatedConfig) { + saveConfig(envConfig, bookKey, updatedConfig, settings); + } + + const views = getViewsById(bookKey.split('-')[0]!); + if (nextGlobal) { + const updated = annotations[idx]!; + views.forEach((v) => { + if (v) expandAllRenderedSections(v, updated); + }); + } else { + views.forEach((v) => removeGlobalAnnotationOverlays(v, existing)); + } + }; + const handleAnnotate = () => { if (!selection || !selection.text) return; const { sectionHref: href } = progress; @@ -1146,6 +1242,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { }; const selectionAnnotated = selection?.annotated; + // For the ✓ (global) toggle in HighlightOptions: figure out whether + // the booknote anchored at the current selection is currently global, + // and whether the toggle should be shown at all (only meaningful for + // re-flowable formats with a non-empty selection text). + const currentAnnotation = selection?.cfi + ? config.booknotes?.find( + (a) => a.type === 'annotation' && a.style && !a.deletedAt && a.cfi === selection.cfi, + ) + : undefined; + const globalToggleAvailable = + !bookData.isFixedLayout && + !!selection?.annotated && + !!currentAnnotation && + !!selection?.text && + selection.text.trim().length > 0; + const globalToggleActive = !!currentAnnotation?.global; const toolButtons = annotationToolButtons.map(({ type, label, Icon }) => { switch (type) { case 'copy': @@ -1255,6 +1367,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => { selectedColor={selectedColor} popupWidth={annotPopupWidth} popupHeight={annotPopupHeight} + globalToggleAvailable={globalToggleAvailable} + globalToggleActive={globalToggleActive} + onToggleGlobal={handleToggleGlobal} onHighlight={handleHighlight} onDismiss={handleDismissPopupAndSelection} /> diff --git a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx index 850ea93b..73342f3c 100644 --- a/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/HighlightOptions.tsx @@ -1,6 +1,7 @@ import clsx from 'clsx'; import React, { useEffect, useRef, useState } from 'react'; import { FaCheckCircle } from 'react-icons/fa'; +import { MdLibraryAddCheck } from 'react-icons/md'; import { DEFAULT_HIGHLIGHT_COLORS, HighlightColor, HighlightStyle } from '@/types/book'; import { useEnv } from '@/context/EnvContext'; import { useThemeStore } from '@/store/themeStore'; @@ -33,6 +34,9 @@ interface HighlightOptionsProps { triangleDir: 'up' | 'down' | 'left' | 'right'; selectedStyle: HighlightStyle; selectedColor: HighlightColor; + globalToggleAvailable?: boolean; + globalToggleActive?: boolean; + onToggleGlobal?: () => void; onHandleHighlight: (update: boolean) => void; } @@ -47,6 +51,9 @@ const HighlightOptions: React.FC = ({ triangleDir, selectedStyle: _selectedStyle, selectedColor: _selectedColor, + globalToggleAvailable = false, + globalToggleActive = false, + onToggleGlobal, onHandleHighlight, }) => { const _ = useTranslation(); @@ -244,6 +251,25 @@ const HighlightOptions: React.FC = ({ ))} + {globalToggleAvailable && ( + + )} +
value.includes(SYNTH_MARKER); + +export const sourceCfiFromSyntheticValue = (value: string): string => { + const idx = value.indexOf(SYNTH_MARKER); + return idx === -1 ? value : value.slice(0, idx); +}; + +/** + * Build a synthetic foliate-annotation `value` (also serves as overlay key) + * for an occurrence of a global note's text. We include the section index in + * the key so the same note can fan out across multiple sections without + * collisions on the overlayer. + */ +const makeSyntheticValue = (note: BookNote, sectionIndex: number, occurrence: number): string => + `${note.cfi}${SYNTH_MARKER}${sectionIndex}-${occurrence}`; + +/** + * Strip ruby annotation chunks (`` and ``) from a string. + * Used as a fallback matcher when the stored `note.text` contains the ruby + * readings (because the selection's `toString()` concatenated base + reading) + * but the section's text nodes — after we skip `/` in the walker — + * obviously do not. + */ +const stripRuby = (s: string): string => s.replace(/[\s\S]*?<\/r[tp]>/g, ''); + +/** + * Some EPUBs encode ruby readings as bare adjacent characters interleaved + * with the base text in the selection string, e.g. selecting 「漢字」 with + * furigana 「かんじ」 may end up as "漢かん字じ". We strip CJK kana that + * immediately follows kanji as a last-ditch fallback so cross-section + * global highlights still match the base-text DOM. + * + * This is intentionally heuristic and only kicks in when the exact text + * fails to match anywhere — false positives just mean we drop the global + * fan-out, never that we highlight the wrong text. + */ +const collapseInlineFurigana = (s: string): string => { + // Remove sequences of hiragana/katakana that sit between two CJK + // ideographs (a very common pattern for inlined readings). + return s.replace( + /([\u3400-\u9fff\uF900-\uFAFF])[\u3041-\u309F\u30A0-\u30FF]+(?=[\u3400-\u9fff\uF900-\uFAFF])/g, + '$1', + ); +}; + +/** + * Walk every Text node in `doc.body` (skipping `` ruby annotations and + * inline `