forked from akai/readest
7f57af8f90
* 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>
187 lines
6.4 KiB
TypeScript
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,
|
|
};
|
|
}
|