Files
readest/apps/readest-app/src/utils/cfi.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

166 lines
5.8 KiB
TypeScript

import * as CFI from 'foliate-js/epubcfi.js';
const unwrapCfi = (cfi: string): string => {
const match = cfi.match(/^epubcfi\((.+)\)$/);
return match ? match[1]! : cfi;
};
export function isCfiInLocation(cfi: string, location: string | null | undefined): boolean {
if (!cfi || !location) return false;
if (cfi === location) return true;
if (cfi && unwrapCfi(cfi).startsWith(unwrapCfi(location))) return true;
const start = CFI.collapse(location);
const end = CFI.collapse(location, true);
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;
}
}
/**
* 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.
*/
export function findNearestCfi(
sortedCfis: string[],
location: string | null | undefined,
): string | null {
if (!location || sortedCfis.length === 0) return null;
const target = CFI.collapse(location);
let lo = 0;
let hi = sortedCfis.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (CFI.compare(sortedCfis[mid]!, target) <= 0) {
lo = mid + 1;
} else {
hi = mid;
}
}
// lo is the first index where cfi > target
// The nearest item at or before target is lo - 1
if (lo === 0) return sortedCfis[0]!;
if (lo >= sortedCfis.length) return sortedCfis[sortedCfis.length - 1]!;
return sortedCfis[lo - 1]!;
}
/**
* Detect a degenerate range CFI whose start or end path is empty — e.g.
* `epubcfi(/6/24!/4,,/20/1:58)`. These were produced by the cfi-inert skip-link
* bug (fixed in foliate 569cc06): the visible-range start/end anchored on an
* injected a11y skip-link, foliate dropped that inert step, and the range
* silently collapsed to the section boundary. Such a CFI resolves to a
* section-spanning range that navigates to the wrong end of the section, so a
* synced/saved location matching this shape should be discarded rather than
* trusted. Well-formed point and range CFIs return false.
*/
export function isMalformedLocationCfi(cfi: string): boolean {
try {
const parts = CFI.parse(cfi);
if (!parts.parent) return false;
const isEmptyPath = (segment: unknown): boolean =>
Array.isArray(segment) &&
segment.every((group) => Array.isArray(group) && group.length === 0);
return isEmptyPath(parts.start) || isEmptyPath(parts.end);
} catch {
return false;
}
}
export function getIndexFromCfi(cfi: string): number | null {
try {
const parts = CFI.parse(cfi);
return CFI.fake.toIndex((parts.parent ?? parts).shift());
} catch {
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);
}