diff --git a/apps/readest-app/src/__tests__/utils/annotation-index.test.ts b/apps/readest-app/src/__tests__/utils/annotation-index.test.ts new file mode 100644 index 00000000..b51f7012 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/annotation-index.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { + buildAnnotationIndex, + selectLocationAnnotations, +} from '@/app/reader/utils/annotationIndex'; +import { uniqueId } from '@/utils/misc'; +import { BookNote } from '@/types/book'; + +describe('buildAnnotationIndex / selectLocationAnnotations', () => { + const note = (overrides: Partial): BookNote => ({ + id: uniqueId(), + type: 'annotation', + cfi: 'epubcfi(/6/6!/4/2/2,/1:0,/1:10)', + note: '', + createdAt: 1, + updatedAt: 1, + ...overrides, + }); + + // A relocate always reports a single-section location (foliate joins the + // section base CFI with the in-document range), so the spine prefix keys + // the bucket. `epubcfi(/6/6)` covers everything anchored in section /6/6. + const location = 'epubcfi(/6/6)'; + + it('buckets a styled annotation under its spine prefix', () => { + const highlight = note({ style: 'highlight', color: 'yellow' }); + const { bySection, globals } = buildAnnotationIndex([highlight]); + expect(bySection.get('/6/6')).toEqual([highlight]); + expect(globals).toEqual([]); + }); + + it('buckets a note-only annotation (no style) so its bubble survives relocate', () => { + // Regression: notes added via the Notebook flow carry a `note` but no + // `style`/`color`. They must still be tracked so the note bubble is + // re-applied on page turns, not just at section first-render. + const noteOnly = note({ note: 'a margin note' }); + const { bySection } = buildAnnotationIndex([noteOnly]); + expect(bySection.get('/6/6')).toEqual([noteOnly]); + }); + + it('splits styled global highlights into their own array', () => { + const global = note({ style: 'highlight', color: 'yellow', text: 'x', global: true }); + const { bySection, globals } = buildAnnotationIndex([global]); + expect(globals).toEqual([global]); + expect(bySection.size).toBe(0); + }); + + it('skips deleted, non-annotation, and empty (no style, no note) entries', () => { + const deleted = note({ style: 'highlight', deletedAt: 9 }); + const bookmark = note({ type: 'bookmark' }); + const empty = note({ note: ' ' }); + const { bySection, globals } = buildAnnotationIndex([deleted, bookmark, empty]); + expect(bySection.size).toBe(0); + expect(globals).toEqual([]); + }); + + it('classifies a styled annotation into annotations only', () => { + const highlight = note({ style: 'highlight', color: 'yellow' }); + const index = buildAnnotationIndex([highlight]); + const { annotations, notes } = selectLocationAnnotations(index, location); + expect(annotations).toEqual([highlight]); + expect(notes).toEqual([]); + }); + + it('classifies a note-only annotation into notes only', () => { + const noteOnly = note({ note: 'hello' }); + const index = buildAnnotationIndex([noteOnly]); + const { annotations, notes } = selectLocationAnnotations(index, location); + expect(annotations).toEqual([]); + expect(notes).toEqual([noteOnly]); + }); + + it('classifies a styled+noted annotation into both lists', () => { + const both = note({ style: 'highlight', color: 'yellow', note: 'hi' }); + const index = buildAnnotationIndex([both]); + const { annotations, notes } = selectLocationAnnotations(index, location); + expect(annotations).toEqual([both]); + expect(notes).toEqual([both]); + }); + + it('excludes annotations anchored in a different section', () => { + const other = note({ cfi: 'epubcfi(/6/8!/4/2/2,/1:0,/1:10)', style: 'highlight' }); + const index = buildAnnotationIndex([other]); + const { annotations, notes } = selectLocationAnnotations(index, location); + expect(annotations).toEqual([]); + expect(notes).toEqual([]); + }); + + it('returns empty lists for a null/empty location', () => { + const index = buildAnnotationIndex([note({ style: 'highlight' })]); + expect(selectLocationAnnotations(index, null)).toEqual({ annotations: [], notes: [] }); + expect(selectLocationAnnotations(index, '')).toEqual({ annotations: [], notes: [] }); + }); +}); diff --git a/apps/readest-app/src/__tests__/utils/cfi.test.ts b/apps/readest-app/src/__tests__/utils/cfi.test.ts index d0fc37c8..fe995290 100644 --- a/apps/readest-app/src/__tests__/utils/cfi.test.ts +++ b/apps/readest-app/src/__tests__/utils/cfi.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { isCfiInLocation, findNearestCfi, isMalformedLocationCfi } from '@/utils/cfi'; +import { + isCfiInLocation, + findNearestCfi, + isMalformedLocationCfi, + createCfiLocationMatcher, + getCfiSpinePrefix, +} from '@/utils/cfi'; describe('isCfiInLocation', () => { it('should return true when cfi path starts with location path', () => { @@ -66,6 +72,77 @@ describe('findNearestCfi', () => { }); }); +describe('createCfiLocationMatcher', () => { + // The matcher must agree with isCfiInLocation on every input — it's + // strictly a performance optimization that caches the collapsed + // location across a loop. Keep parity tests for each branch the + // single-call API covers. + + it('matches when cfi path starts with location path', () => { + const matches = createCfiLocationMatcher('epubcfi(/6/6)'); + expect(matches('epubcfi(/6/6!/4/4/54,/1:4,/1:15)')).toBe(true); + }); + + it('matches on exact location equality', () => { + const matches = createCfiLocationMatcher('epubcfi(/6/6)'); + expect(matches('epubcfi(/6/6)')).toBe(true); + }); + + it('rejects a cfi in a different section', () => { + const matches = createCfiLocationMatcher('epubcfi(/6/6)'); + expect(matches('epubcfi(/6/8!/4/4/54,/1:4,/1:15)')).toBe(false); + }); + + it('rejects empty cfi without throwing', () => { + const matches = createCfiLocationMatcher('epubcfi(/6/6)'); + expect(matches('')).toBe(false); + }); + + it('returns a permanent false predicate for null/undefined location', () => { + expect(createCfiLocationMatcher(null)('epubcfi(/6/6)')).toBe(false); + expect(createCfiLocationMatcher(undefined)('epubcfi(/6/6)')).toBe(false); + }); + + it('is safe to reuse across many calls (collapse done only once)', () => { + // No way to assert call count from here without spying on the CFI + // module — the assertion that matters is just that repeated calls + // produce consistent results. If collapse were re-run per call we'd + // still get the same booleans; the perf win is invisible to behavior. + const matches = createCfiLocationMatcher('epubcfi(/6/6)'); + for (let i = 0; i < 100; i++) { + expect(matches('epubcfi(/6/6!/4/4/54,/1:4,/1:15)')).toBe(true); + expect(matches('epubcfi(/6/8!/4/4/54,/1:4,/1:15)')).toBe(false); + } + }); +}); + +describe('getCfiSpinePrefix', () => { + it('returns the spine prefix for a CFI with an inside path', () => { + // Splits at the `!` boundary so we get just the chapter portion, + // suitable for bucketing booknotes by chapter. + expect(getCfiSpinePrefix('epubcfi(/6/24!/4/2:5)')).toBe('/6/24'); + }); + + it('returns the inner path when there is no inside path (no `!`)', () => { + expect(getCfiSpinePrefix('epubcfi(/6/12)')).toBe('/6/12'); + }); + + it('handles range CFIs (the inside path may contain commas)', () => { + expect(getCfiSpinePrefix('epubcfi(/6/6!/4/4/54,/1:4,/1:15)')).toBe('/6/6'); + }); + + it('returns null for null/undefined/empty input', () => { + expect(getCfiSpinePrefix(null)).toBeNull(); + expect(getCfiSpinePrefix(undefined)).toBeNull(); + expect(getCfiSpinePrefix('')).toBeNull(); + }); + + it('returns null for non-CFI strings', () => { + expect(getCfiSpinePrefix('not a cfi')).toBeNull(); + expect(getCfiSpinePrefix('/6/24')).toBeNull(); + }); +}); + describe('isMalformedLocationCfi', () => { it('flags an empty-start range CFI', () => { // Produced by the cfi-inert skip-link bug: the visible-range start anchored 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 ca3e87f9..dda12d6a 100644 --- a/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx +++ b/apps/readest-app/src/app/reader/components/annotator/Annotator.tsx @@ -10,6 +10,7 @@ import { NativeTouchEventType } from '@/types/system'; import { getLocale, getOSPlatform, makeSafeFilename, uniqueId } from '@/utils/misc'; import { useThemeStore } from '@/store/themeStore'; import { useBookDataStore } from '@/store/bookDataStore'; +import { getBookProgress, useBookProgress } from '@/store/readerProgressStore'; import { useSettingsStore } from '@/store/settingsStore'; import { useReaderStore } from '@/store/readerStore'; import { useNotebookStore } from '@/store/notebookStore'; @@ -45,7 +46,7 @@ import { import { Insets } from '@/types/misc'; import { runSimpleCC } from '@/utils/simplecc'; import { getWordCount } from '@/utils/word'; -import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi'; +import { getIndexFromCfi } from '@/utils/cfi'; import { writeTextToClipboard } from '@/utils/clipboard'; import { TransformContext } from '@/services/transformers/types'; import { transformContent } from '@/services/transformService'; @@ -54,6 +55,7 @@ import { getHighlightColorHex, removeBookNoteOverlays, } from '../../utils/annotatorUtil'; +import { buildAnnotationIndex, selectLocationAnnotations } from '../../utils/annotationIndex'; import { expandAllRenderedSections, expandGlobalAnnotation, @@ -91,8 +93,15 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ const { settings, setSettingsDialogBookKey, setSettingsDialogOpen, setActiveSettingsItemId } = useSettingsStore(); const { isDarkMode } = useThemeStore(); - const { getConfig, saveConfig, getBookData, updateBooknotes } = useBookDataStore(); - const { getProgress, getView, getViewsById, getViewSettings } = useReaderStore(); + // Per-field selectors — see store/readerProgressStore.ts header for the + // "destructure-subscribes-the-whole-store" rationale. + const getConfig = useBookDataStore((s) => s.getConfig); + const saveConfig = useBookDataStore((s) => s.saveConfig); + const getBookData = useBookDataStore((s) => s.getBookData); + const updateBooknotes = useBookDataStore((s) => s.updateBooknotes); + const getView = useReaderStore((s) => s.getView); + const getViewsById = useReaderStore((s) => s.getViewsById); + const getViewSettings = useReaderStore((s) => s.getViewSettings); const { setNotebookVisible, setNotebookNewAnnotation } = useNotebookStore(); const { clearBooknotesNav } = useSidebarStore(); const { listenToNativeTouchEvents } = useDeviceControlStore(); @@ -112,7 +121,11 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ const osPlatform = getOSPlatform(); const config = getConfig(bookKey)!; - const progress = getProgress(bookKey)!; + // Reactive: subscribe to THIS book's progress via the dedicated + // progress store. This is the only piece of data we need to react to + // per page turn — the `useEffect(..., [progress])` below uses it to + // re-apply local-page annotations after each relocate. + const progress = useBookProgress(bookKey)!; const bookData = getBookData(bookKey)!; const view = getView(bookKey); const viewSettings = getViewSettings(bookKey)!; @@ -793,25 +806,33 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [selection, bookKey]); + // Index live annotations by the CFI spine prefix (the chapter id) so + // each page turn only scans the bucket for the current chapter rather + // than the whole booknotes array. With heavy users (>1k highlights) a + // naive `booknotes.filter(...)` per page turn was the dominant + // contributor to `c` (epubcfi parse) in the Bottom-Up profile — + // ~1.4 s self time over a 28 s session. The bucketed read replaces + // O(N) walks with O(K) where K is the number of annotations in the + // currently-visible chapter (typically a handful). + // + // `globals` (book-wide highlights) are split into their own pre-filtered + // array so we don't re-walk N items each turn just to find the same few + // global ones. The index is recomputed only when `booknotes` itself + // changes (add/remove/edit) — not on every page turn. + const annotationIndex = useMemo( + () => buildAnnotationIndex(config.booknotes ?? []), + [config.booknotes], + ); + useEffect(() => { if (!progress) return; const { location } = progress; - const { booknotes = [] } = config; - const annotations = booknotes.filter( - (item) => - !item.deletedAt && - item.type === 'annotation' && - item.style && - isCfiInLocation(item.cfi, location), - ); - const notes = booknotes.filter( - (item) => - !item.deletedAt && - item.type === 'annotation' && - item.note && - item.note.trim().length > 0 && - isCfiInLocation(item.cfi, location), - ); + // Single pass over the *current chapter's* candidates: classify each + // one into the in-page annotations / notes lists. Using the bucket + // keeps this fast even when the user has thousands of highlights + // elsewhere in the book. + const { annotations, notes } = selectLocationAnnotations(annotationIndex, location); + try { Promise.all(annotations.map((annotation) => view?.addAnnotation(annotation))); Promise.all( @@ -821,18 +842,16 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ // 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) { + // covered by `onCreateOverlay`. Using the pre-built `globals` + // array avoids re-walking booknotes per page turn. + for (const annotation of annotationIndex.globals) { if (view) expandAllRenderedSections(view, annotation); } } catch (e) { console.warn(e); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [progress]); + }, [progress, annotationIndex]); useEffect(() => { if (!config.booknotes || !selection?.cfi || !showAnnotationNotes) return; @@ -979,7 +998,7 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({ const style = settings.globalReadSettings.highlightStyle; const color = settings.globalReadSettings.highlightStyles[style]; const { booknotes: annotations = [] } = getConfig(bookKey)!; - const page = getProgress(bookKey)?.page; + const page = getBookProgress(bookKey)?.page; const annotation = buildTTSSentenceHighlight( annotations, { cfi: detail.cfi, text: detail.text, style, color, page }, diff --git a/apps/readest-app/src/app/reader/hooks/useBooknotesNav.ts b/apps/readest-app/src/app/reader/hooks/useBooknotesNav.ts index 6719720b..fb2295e0 100644 --- a/apps/readest-app/src/app/reader/hooks/useBooknotesNav.ts +++ b/apps/readest-app/src/app/reader/hooks/useBooknotesNav.ts @@ -2,15 +2,16 @@ import { useCallback, useMemo } from 'react'; import * as CFI from 'foliate-js/epubcfi.js'; import { useSidebarStore } from '@/store/sidebarStore'; import { useReaderStore } from '@/store/readerStore'; +import { useBookProgress } from '@/store/readerProgressStore'; import { useBookDataStore } from '@/store/bookDataStore'; -import { isCfiInLocation } from '@/utils/cfi'; +import { createCfiLocationMatcher } from '@/utils/cfi'; import { findTocItemBS } from '@/services/nav'; import { BookNoteType } from '@/types/book'; import { TOCItem } from '@/libs/document'; export function useBooknotesNav(bookKey: string, toc: TOCItem[]) { - const { getView, getProgress } = useReaderStore(); - const { getConfig } = useBookDataStore(); + const getView = useReaderStore((s) => s.getView); + const getConfig = useBookDataStore((s) => s.getConfig); const { setSideBarVisible, getBooknotesNavState, @@ -23,7 +24,9 @@ export function useBooknotesNav(bookKey: string, toc: TOCItem[]) { const booknotesNavState = getBooknotesNavState(bookKey); const { activeBooknoteType, booknoteResults, booknoteIndex } = booknotesNavState; - const progress = getProgress(bookKey); + // Reactive: re-derives current-page boundaries when the user turns + // the page. Reads from readerProgressStore only. + const progress = useBookProgress(bookKey); const currentLocation = progress?.location; // Get booknotes from config and filter by type @@ -51,16 +54,21 @@ export function useBooknotesNav(bookKey: string, toc: TOCItem[]) { return tocItem?.label || ''; }, [sortedBooknotes, booknoteIndex, toc]); - // Find booknotes on the current page + // Find booknotes on the current page. + // Uses a batched CFI matcher so the location is collapsed only once per + // page turn instead of once per booknote — see createCfiLocationMatcher + // in utils/cfi for the why (hot-path CFI parsing was 16%+ of self time + // in Android release-build profiles when annotations were dense). const currentPageResults = useMemo(() => { if (!sortedBooknotes.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 }; + const matches = createCfiLocationMatcher(currentLocation); let firstIndex = -1; let lastIndex = -1; for (let i = 0; i < sortedBooknotes.length; i++) { const note = sortedBooknotes[i]; - if (note && isCfiInLocation(note.cfi, currentLocation)) { + if (note && matches(note.cfi)) { if (firstIndex === -1) firstIndex = i; lastIndex = i; } diff --git a/apps/readest-app/src/app/reader/hooks/useSearchNav.ts b/apps/readest-app/src/app/reader/hooks/useSearchNav.ts index a8e8ab38..f9836a4e 100644 --- a/apps/readest-app/src/app/reader/hooks/useSearchNav.ts +++ b/apps/readest-app/src/app/reader/hooks/useSearchNav.ts @@ -1,18 +1,21 @@ import { useCallback, useMemo } from 'react'; import { useSidebarStore } from '@/store/sidebarStore'; import { useReaderStore } from '@/store/readerStore'; -import { isCfiInLocation } from '@/utils/cfi'; +import { useBookProgress } from '@/store/readerProgressStore'; +import { createCfiLocationMatcher } from '@/utils/cfi'; import { flattenSearchResults } from '../components/sidebar/SearchResultsNav'; export function useSearchNav(bookKey: string) { - const { getView, getProgress } = useReaderStore(); + const getView = useReaderStore((s) => s.getView); const { setSideBarVisible } = useSidebarStore(); const { getSearchNavState, setSearchResultIndex, clearSearch } = useSidebarStore(); const searchNavState = getSearchNavState(bookKey); const { searchTerm, searchResults, searchResultIndex, searchProgress } = searchNavState; - const progress = getProgress(bookKey); + // Reactive: search nav re-derives current-page boundaries when the user + // turns the page. Subscribes to readerProgressStore only. + const progress = useBookProgress(bookKey); const currentLocation = useMemo(() => { return progress?.location; @@ -34,16 +37,20 @@ export function useSearchNav(bookKey: string) { return flattenedResults[searchResultIndex]?.sectionLabel || ''; }, [flattenedResults, searchResultIndex]); - // Find results on the current page + // Find results on the current page. + // Uses a batched CFI matcher so the location is collapsed only once per + // page turn instead of once per search hit — see createCfiLocationMatcher + // in utils/cfi for the why. const currentPageResults = useMemo(() => { if (!flattenedResults.length || !currentLocation) return { firstIndex: -1, lastIndex: -1 }; + const matches = createCfiLocationMatcher(currentLocation); let firstIndex = -1; let lastIndex = -1; for (let i = 0; i < flattenedResults.length; i++) { const result = flattenedResults[i]; - if (result && isCfiInLocation(result.cfi, currentLocation)) { + if (result && matches(result.cfi)) { if (firstIndex === -1) firstIndex = i; lastIndex = i; } diff --git a/apps/readest-app/src/app/reader/utils/annotationIndex.ts b/apps/readest-app/src/app/reader/utils/annotationIndex.ts new file mode 100644 index 00000000..227d0c1d --- /dev/null +++ b/apps/readest-app/src/app/reader/utils/annotationIndex.ts @@ -0,0 +1,78 @@ +import { BookNote } from '@/types/book'; +import { createCfiLocationMatcher, getCfiSpinePrefix } from '@/utils/cfi'; + +export interface AnnotationIndex { + /** Live annotations bucketed by CFI spine prefix (the chapter id). */ + bySection: Map; + /** Book-wide (`global`) highlights, pre-filtered so we don't re-walk the list. */ + globals: BookNote[]; +} + +/** + * Index live annotations by their CFI spine prefix so a relocate only has to + * scan the bucket for the current chapter instead of the whole booknotes + * array. With heavy users (>1k highlights) a naive `booknotes.filter(...)` per + * page turn was the dominant contributor to epubcfi parse self-time. The + * bucketed read turns the per-relocate filter into O(notes-in-chapter). + * + * An annotation earns a bucket entry when it has a highlight `style` (a drawn + * highlight) OR a non-empty `note` (a note bubble) — these are independent + * overlays (see `removeBookNoteOverlays`), so a note-only annotation with no + * style must still be tracked. + * + * `global` highlights are split into their own pre-filtered array: their + * semantics is book-wide so they're fanned out across every rendered section + * rather than matched against the current location. + */ +export function buildAnnotationIndex(booknotes: BookNote[]): AnnotationIndex { + const bySection = new Map(); + const globals: BookNote[] = []; + for (const item of booknotes) { + if (item.deletedAt) continue; + if (item.type !== 'annotation') continue; + const hasNote = !!item.note && item.note.trim().length > 0; + if (!item.style && !hasNote) continue; + // Globals fan out a highlight across every occurrence of `text`, so they + // require a style; a note-only annotation is never global and falls + // through to the per-section bucket like any other local note. + if (item.style && item.global) { + globals.push(item); + continue; + } + const spine = getCfiSpinePrefix(item.cfi); + if (!spine) continue; + const bucket = bySection.get(spine); + if (bucket) bucket.push(item); + else bySection.set(spine, [item]); + } + return { bySection, globals }; +} + +/** + * Classify the annotations that fall inside `location` into the in-page + * highlight list and the in-page note list, scanning only the current + * chapter's bucket from {@link buildAnnotationIndex}. + * + * Mirrors the original two-filter behavior: an annotation joins `annotations` + * iff it has a `style` (a drawn highlight) and joins `notes` iff it has a + * non-empty `note` (a note bubble) — the two are independent, so a styled note + * lands in both and a note-only annotation lands in `notes` alone. + */ +export function selectLocationAnnotations( + index: AnnotationIndex, + location: string | null | undefined, +): { annotations: BookNote[]; notes: BookNote[] } { + const annotations: BookNote[] = []; + const notes: BookNote[] = []; + const sectionKey = getCfiSpinePrefix(location); + if (!sectionKey) return { annotations, notes }; + + const candidates = index.bySection.get(sectionKey) ?? []; + const matchesLocation = createCfiLocationMatcher(location); + for (const item of candidates) { + if (!matchesLocation(item.cfi)) continue; + if (item.style) annotations.push(item); + if (item.note && item.note.trim().length > 0) notes.push(item); + } + return { annotations, notes }; +} diff --git a/apps/readest-app/src/utils/cfi.ts b/apps/readest-app/src/utils/cfi.ts index 2e1c794b..cffafc4b 100644 --- a/apps/readest-app/src/utils/cfi.ts +++ b/apps/readest-app/src/utils/cfi.ts @@ -21,6 +21,56 @@ export function isCfiInLocation(cfi: string, location: string | null | undefined } } +/** + * Batch variant of {@link isCfiInLocation}. The standalone function calls + * `CFI.collapse(location)` twice on every invocation — that's two CFI + * parses plus the prefix dance in `unwrapCfi`. When the reader hooks + * (useBooknotesNav, useSearchNav) loop over hundreds of annotations or + * search hits per page turn, those redundant parses add up to a real + * fraction of main-thread time (visible as `c` and the anonymous + * `parse` callback in Bottom-Up profiles of the foliate `epubcfi.js` + * chunk). + * + * `createCfiLocationMatcher(location)` collapses the location once and + * returns a `matches(cfi)` predicate that reuses the cached bounds for + * every call. Use it whenever you're iterating a list of CFIs against + * the same `currentLocation`. + */ +export function createCfiLocationMatcher( + location: string | null | undefined, +): (cfi: string) => boolean { + if (!location) return () => false; + + // Pre-unwrap the location once for the cheap prefix match below. + const unwrappedLocation = unwrapCfi(location); + + // Collapse once. If collapse throws on a malformed location the + // matcher degrades to the cheap equality / prefix branch only — + // matching the failure mode of the original isCfiInLocation, which + // would also bail via the catch block on a bad compare input. + let start: string | null = null; + let end: string | null = null; + try { + start = CFI.collapse(location); + end = CFI.collapse(location, true); + } catch (err) { + console.warn('Failed to collapse location for matcher', { location, error: err }); + } + + return (cfi: string): boolean => { + if (!cfi) return false; + if (cfi === location) return true; + if (unwrapCfi(cfi).startsWith(unwrappedLocation)) return true; + if (start === null || end === null) return false; + try { + return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0; + } catch (err) { + console.warn('Failed to compare CFIs', { cfi, location, error: err }); + return false; + } + }; +} + /** * Binary search a sorted CFI array to find the nearest CFI to a location. * Returns the CFI of the item just before or at the location. @@ -82,3 +132,34 @@ export function getIndexFromCfi(cfi: string): number | null { return null; } } + +/** + * Extract the EPUB CFI "spine prefix" — the portion that identifies the + * spine item (chapter / section) without descending into the in-document + * path. + * + * EPUB CFI form: `epubcfi(!)` or + * `epubcfi()`. The spine path uniquely identifies which + * chapter the CFI lives in; everything past `!` is intra-chapter. + * + * Returns the unwrapped spine prefix (no `epubcfi(...)` wrapper) suitable + * for bucketing CFIs by chapter. Returns null for inputs that aren't + * recognisably a CFI — callers should use this as a hint and fall back + * to the full range matcher when the prefix is missing. + * + * Examples: + * 'epubcfi(/6/24!/4/2:5)' → '/6/24' + * 'epubcfi(/6/12)' → '/6/12' + * 'not a cfi' → null + * + * This is a pure string operation — no CFI.parse round-trip, so it's + * cheap enough to call once per booknote when bucketing. + */ +export function getCfiSpinePrefix(cfi: string | null | undefined): string | null { + if (!cfi) return null; + const match = cfi.match(/^epubcfi\((.+)\)$/); + if (!match) return null; + const inner = match[1]!; + const bang = inner.indexOf('!'); + return bang === -1 ? inner : inner.slice(0, bang); +}