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>
95 lines
3.9 KiB
TypeScript
95 lines
3.9 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
buildAnnotationIndex,
|
|
selectLocationAnnotations,
|
|
} from '@/app/reader/utils/annotationIndex';
|
|
import { uniqueId } from '@/utils/misc';
|
|
import { BookNote } from '@/types/book';
|
|
|
|
describe('buildAnnotationIndex / selectLocationAnnotations', () => {
|
|
const note = (overrides: Partial<BookNote>): BookNote => ({
|
|
id: uniqueId(),
|
|
type: 'annotation',
|
|
cfi: 'epubcfi(/6/6!/4/2/2,/1:0,/1:10)',
|
|
note: '',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
...overrides,
|
|
});
|
|
|
|
// A relocate always reports a single-section location (foliate joins the
|
|
// section base CFI with the in-document range), so the spine prefix keys
|
|
// the bucket. `epubcfi(/6/6)` covers everything anchored in section /6/6.
|
|
const location = 'epubcfi(/6/6)';
|
|
|
|
it('buckets a styled annotation under its spine prefix', () => {
|
|
const highlight = note({ style: 'highlight', color: 'yellow' });
|
|
const { bySection, globals } = buildAnnotationIndex([highlight]);
|
|
expect(bySection.get('/6/6')).toEqual([highlight]);
|
|
expect(globals).toEqual([]);
|
|
});
|
|
|
|
it('buckets a note-only annotation (no style) so its bubble survives relocate', () => {
|
|
// Regression: notes added via the Notebook flow carry a `note` but no
|
|
// `style`/`color`. They must still be tracked so the note bubble is
|
|
// re-applied on page turns, not just at section first-render.
|
|
const noteOnly = note({ note: 'a margin note' });
|
|
const { bySection } = buildAnnotationIndex([noteOnly]);
|
|
expect(bySection.get('/6/6')).toEqual([noteOnly]);
|
|
});
|
|
|
|
it('splits styled global highlights into their own array', () => {
|
|
const global = note({ style: 'highlight', color: 'yellow', text: 'x', global: true });
|
|
const { bySection, globals } = buildAnnotationIndex([global]);
|
|
expect(globals).toEqual([global]);
|
|
expect(bySection.size).toBe(0);
|
|
});
|
|
|
|
it('skips deleted, non-annotation, and empty (no style, no note) entries', () => {
|
|
const deleted = note({ style: 'highlight', deletedAt: 9 });
|
|
const bookmark = note({ type: 'bookmark' });
|
|
const empty = note({ note: ' ' });
|
|
const { bySection, globals } = buildAnnotationIndex([deleted, bookmark, empty]);
|
|
expect(bySection.size).toBe(0);
|
|
expect(globals).toEqual([]);
|
|
});
|
|
|
|
it('classifies a styled annotation into annotations only', () => {
|
|
const highlight = note({ style: 'highlight', color: 'yellow' });
|
|
const index = buildAnnotationIndex([highlight]);
|
|
const { annotations, notes } = selectLocationAnnotations(index, location);
|
|
expect(annotations).toEqual([highlight]);
|
|
expect(notes).toEqual([]);
|
|
});
|
|
|
|
it('classifies a note-only annotation into notes only', () => {
|
|
const noteOnly = note({ note: 'hello' });
|
|
const index = buildAnnotationIndex([noteOnly]);
|
|
const { annotations, notes } = selectLocationAnnotations(index, location);
|
|
expect(annotations).toEqual([]);
|
|
expect(notes).toEqual([noteOnly]);
|
|
});
|
|
|
|
it('classifies a styled+noted annotation into both lists', () => {
|
|
const both = note({ style: 'highlight', color: 'yellow', note: 'hi' });
|
|
const index = buildAnnotationIndex([both]);
|
|
const { annotations, notes } = selectLocationAnnotations(index, location);
|
|
expect(annotations).toEqual([both]);
|
|
expect(notes).toEqual([both]);
|
|
});
|
|
|
|
it('excludes annotations anchored in a different section', () => {
|
|
const other = note({ cfi: 'epubcfi(/6/8!/4/2/2,/1:0,/1:10)', style: 'highlight' });
|
|
const index = buildAnnotationIndex([other]);
|
|
const { annotations, notes } = selectLocationAnnotations(index, location);
|
|
expect(annotations).toEqual([]);
|
|
expect(notes).toEqual([]);
|
|
});
|
|
|
|
it('returns empty lists for a null/empty location', () => {
|
|
const index = buildAnnotationIndex([note({ style: 'highlight' })]);
|
|
expect(selectLocationAnnotations(index, null)).toEqual({ annotations: [], notes: [] });
|
|
expect(selectLocationAnnotations(index, '')).toEqual({ annotations: [], notes: [] });
|
|
});
|
|
});
|