Files
readest/apps/readest-app/src/app/reader/hooks/useSearchNav.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

138 lines
5.0 KiB
TypeScript

import { useCallback, useMemo } from 'react';
import { useSidebarStore } from '@/store/sidebarStore';
import { useReaderStore } from '@/store/readerStore';
import { useBookProgress } from '@/store/readerProgressStore';
import { createCfiLocationMatcher } from '@/utils/cfi';
import { flattenSearchResults } from '../components/sidebar/SearchResultsNav';
export function useSearchNav(bookKey: string) {
const getView = useReaderStore((s) => s.getView);
const { setSideBarVisible } = useSidebarStore();
const { getSearchNavState, setSearchResultIndex, clearSearch } = useSidebarStore();
const searchNavState = getSearchNavState(bookKey);
const { searchTerm, searchResults, searchResultIndex, searchProgress } = searchNavState;
// 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;
}, [progress]);
// Flatten search results for navigation
const flattenedResults = useMemo(() => {
if (!searchResults) return [];
return flattenSearchResults(searchResults);
}, [searchResults]);
const totalResults = flattenedResults.length;
const hasSearchResults = searchResults && totalResults > 0;
const showSearchNav = hasSearchResults;
// Get current section label
const currentSection = useMemo(() => {
if (!flattenedResults.length || searchResultIndex >= flattenedResults.length) return '';
return flattenedResults[searchResultIndex]?.sectionLabel || '';
}, [flattenedResults, searchResultIndex]);
// 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 && matches(result.cfi)) {
if (firstIndex === -1) firstIndex = i;
lastIndex = i;
}
}
if (firstIndex !== -1) {
setTimeout(() => setSearchResultIndex(bookKey, firstIndex), 0);
}
return { firstIndex, lastIndex };
}, [flattenedResults, currentLocation, bookKey, setSearchResultIndex]);
// Navigate to a specific search result
const navigateToResult = useCallback(
(index: number) => {
if (!flattenedResults.length) return;
if (index < 0 || index >= flattenedResults.length) return;
const result = flattenedResults[index];
if (result) {
setSearchResultIndex(bookKey, index);
getView(bookKey)?.goTo(result.cfi);
}
},
[bookKey, flattenedResults, setSearchResultIndex, getView],
);
const handleShowResults = useCallback(() => {
setSideBarVisible(true);
}, [setSideBarVisible]);
const handleCloseSearch = useCallback(() => {
clearSearch(bookKey);
getView(bookKey)?.clearSearch();
}, [clearSearch, bookKey, getView]);
// Navigate to the previous page with results (last result before current page)
const handlePreviousResult = useCallback(() => {
const { firstIndex } = currentPageResults;
if (firstIndex > 0) {
// Navigate to the result just before the first result on current page
navigateToResult(firstIndex - 1);
} else if (firstIndex === -1 && searchResultIndex > 0) {
// No results on current page, just go to previous result
navigateToResult(searchResultIndex - 1);
}
}, [currentPageResults, searchResultIndex, navigateToResult]);
// Navigate to the next page with results (first result after current page)
const handleNextResult = useCallback(() => {
const { lastIndex } = currentPageResults;
if (lastIndex >= 0 && lastIndex < totalResults - 1) {
// Navigate to the result just after the last result on current page
navigateToResult(lastIndex + 1);
} else if (lastIndex === -1 && searchResultIndex < totalResults - 1) {
// No results on current page, just go to next result
navigateToResult(searchResultIndex + 1);
}
}, [currentPageResults, totalResults, searchResultIndex, navigateToResult]);
// Check if there are results before/after the current page
const hasPreviousPage =
currentPageResults.firstIndex > 0 ||
(currentPageResults.firstIndex === -1 && searchResultIndex > 0);
const hasNextPage =
(currentPageResults.lastIndex >= 0 && currentPageResults.lastIndex < totalResults - 1) ||
(currentPageResults.lastIndex === -1 && searchResultIndex < totalResults - 1);
return {
searchTerm,
searchProgress,
currentSection,
searchResultIndex,
totalResults,
showSearchNav,
hasPreviousPage,
hasNextPage,
handleShowResults,
handleCloseSearch,
handlePreviousResult,
handleNextResult,
};
}