Files
readest/apps/readest-app/src/app/reader/hooks/useBooknotesNav.ts
T
loveheaven 7f57af8f90 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>
2026-06-12 18:06:13 +02:00

187 lines
6.4 KiB
TypeScript

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 { 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 = useReaderStore((s) => s.getView);
const getConfig = useBookDataStore((s) => s.getConfig);
const {
setSideBarVisible,
getBooknotesNavState,
setActiveBooknoteType,
setBooknoteResults,
setBooknoteIndex,
clearBooknotesNav,
} = useSidebarStore();
const booknotesNavState = getBooknotesNavState(bookKey);
const { activeBooknoteType, booknoteResults, booknoteIndex } = booknotesNavState;
// 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
const allBooknotes = useMemo(() => {
const config = getConfig(bookKey);
return config?.booknotes?.filter((note) => !note.deletedAt) || [];
}, [bookKey, getConfig]);
// Sort booknotes by CFI order
const sortedBooknotes = useMemo(() => {
if (!booknoteResults) return [];
return [...booknoteResults].sort((a, b) => CFI.compare(a.cfi, b.cfi));
}, [booknoteResults]);
const totalResults = sortedBooknotes.length;
const hasBooknotes = booknoteResults && totalResults > 0;
const showBooknotesNav = hasBooknotes && activeBooknoteType !== null;
// Get current section label
const currentSection = useMemo(() => {
if (!sortedBooknotes.length || booknoteIndex >= sortedBooknotes.length) return '';
const currentNote = sortedBooknotes[booknoteIndex];
if (!currentNote) return '';
const tocItem = findTocItemBS(toc, currentNote.cfi);
return tocItem?.label || '';
}, [sortedBooknotes, booknoteIndex, toc]);
// 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 && matches(note.cfi)) {
if (firstIndex === -1) firstIndex = i;
lastIndex = i;
}
}
if (firstIndex !== -1) {
setTimeout(() => setBooknoteIndex(bookKey, firstIndex), 0);
}
return { firstIndex, lastIndex };
}, [sortedBooknotes, currentLocation, bookKey, setBooknoteIndex]);
// Navigate to a specific booknote
const navigateToBooknote = useCallback(
(index: number) => {
if (!sortedBooknotes.length) return;
if (index < 0 || index >= sortedBooknotes.length) return;
const note = sortedBooknotes[index];
if (note) {
setBooknoteIndex(bookKey, index);
getView(bookKey)?.goTo(note.cfi);
}
},
[bookKey, sortedBooknotes, setBooknoteIndex, getView],
);
// Start navigation for a specific booknote type
const startNavigation = useCallback(
(type: BookNoteType) => {
const filtered = allBooknotes.filter((note) => note.type === type);
if (filtered.length === 0) return;
const sorted = [...filtered].sort((a, b) => CFI.compare(a.cfi, b.cfi));
setActiveBooknoteType(bookKey, type);
setBooknoteResults(bookKey, sorted);
setBooknoteIndex(bookKey, 0);
// Navigate to first booknote
if (sorted.length > 0) {
getView(bookKey)?.goTo(sorted[0]!.cfi);
}
},
[allBooknotes, bookKey, setActiveBooknoteType, setBooknoteResults, setBooknoteIndex, getView],
);
const handleShowResults = useCallback(() => {
setSideBarVisible(true);
}, [setSideBarVisible]);
const handleClose = useCallback(() => {
clearBooknotesNav(bookKey);
}, [clearBooknotesNav, bookKey]);
// Navigate to the previous page with booknotes
const handlePrevious = useCallback(() => {
const { firstIndex } = currentPageResults;
if (firstIndex > 0) {
navigateToBooknote(firstIndex - 1);
} else if (firstIndex === -1 && booknoteIndex > 0) {
navigateToBooknote(booknoteIndex - 1);
}
}, [currentPageResults, booknoteIndex, navigateToBooknote]);
// Navigate to the next page with booknotes
const handleNext = useCallback(() => {
const { lastIndex } = currentPageResults;
if (lastIndex >= 0 && lastIndex < totalResults - 1) {
navigateToBooknote(lastIndex + 1);
} else if (lastIndex === -1 && booknoteIndex < totalResults - 1) {
navigateToBooknote(booknoteIndex + 1);
}
}, [currentPageResults, totalResults, booknoteIndex, navigateToBooknote]);
// Check if there are booknotes before/after the current page
const hasPreviousPage =
currentPageResults.firstIndex > 0 ||
(currentPageResults.firstIndex === -1 && booknoteIndex > 0);
const hasNextPage =
(currentPageResults.lastIndex >= 0 && currentPageResults.lastIndex < totalResults - 1) ||
(currentPageResults.lastIndex === -1 && booknoteIndex < totalResults - 1);
// Get counts for each booknote type
const bookmarkCount = useMemo(
() => allBooknotes.filter((n) => n.type === 'bookmark').length,
[allBooknotes],
);
const annotationCount = useMemo(
() => allBooknotes.filter((n) => n.type === 'annotation').length,
[allBooknotes],
);
const excerptCount = useMemo(
() => allBooknotes.filter((n) => n.type === 'excerpt').length,
[allBooknotes],
);
return {
activeBooknoteType,
currentSection,
booknoteIndex,
totalResults,
showBooknotesNav,
hasPreviousPage,
hasNextPage,
bookmarkCount,
annotationCount,
excerptCount,
startNavigation,
handleShowResults,
handleClose,
handlePrevious,
handleNext,
};
}