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:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user