forked from akai/readest
perf(cfi): bucket booknotes per chapter and batch-collapse location matcher (#4561)
* perf(cfi): bucket booknotes per chapter and batch-collapse location matcher
When iterating a list of CFIs against the same currentLocation (Annotator
on every page turn, useSearchNav, useBooknotesNav), the standalone
isCfiInLocation collapses the location twice per CFI. With 1000+
booknotes -- which a heavy user reported -- that's 2000 CFI parses
per page turn. The foliate epubcfi.js chunk showed up as ~15% of
self time in Bottom-Up profiles of the release Android build.
Fix:
- createCfiLocationMatcher(location) collapses once and returns a
matches(cfi) predicate that reuses the cached bounds. O(N) calls
become 1 collapse + N compares.
- getCfiSpinePrefix(cfi) extracts the spine path via pure string ops
(no CFI.parse round-trip) for use as a chapter bucket key.
- Annotator builds annotationIndex = { bySection, globals } via
useMemo([config.booknotes]) once when booknotes change, not per
page turn. The progress-driven effect then only scans the current
chapter's bucket -- ~50 CFIs in a typical book instead of all 1000.
globals are pre-filtered too.
- useSearchNav / useBooknotesNav switch to the batched matcher for
the same reason.
Includes parity tests covering empty/malformed inputs, equality
shortcut, prefix shortcut, in-range, and out-of-range cases.
* fix(annotator): keep note-only annotations in the per-chapter bucket
The booknote bucketing gated entries on `item.style`, which dropped
note-only annotations (a `note` with no highlight style/color, created
via the Notebook flow) from the per-relocate re-apply path. Their note
bubble was no longer redrawn on relocate or when booknotes changed while
a section stayed rendered.
Restore the original two-list semantics: bucket on style OR note, then
classify per location (annotations need a style, notes need a note).
Extract the logic into a dedicated, unit-tested `annotationIndex` module
(buildAnnotationIndex + selectLocationAnnotations) instead of inlining it
in Annotator, matching the reader/utils domain-named convention.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>): 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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, BookNote[]>;
|
||||
/** 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<string, BookNote[]>();
|
||||
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 };
|
||||
}
|
||||
@@ -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(<spine-path>!<inside-path>)` or
|
||||
* `epubcfi(<spine-path>)`. 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user