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 `