diff --git a/packages/core/src/enhancers/pagination/chapters/index.ts b/packages/core/src/enhancers/pagination/chapters/index.ts index 035a6808..f1bebea8 100644 --- a/packages/core/src/enhancers/pagination/chapters/index.ts +++ b/packages/core/src/enhancers/pagination/chapters/index.ts @@ -1,4 +1,8 @@ export { resolveChapterInfoFromVisibleNode } from "./node" export { buildTocCandidatesBySpineHref, buildTocIndex } from "./shared" -export { buildChaptersInfo, buildStaticChaptersInfo } from "./static" +export { + buildChaptersInfo, + createStaticChaptersResolver, + type StaticChaptersResolver, +} from "./static" export type { ChapterInfo, TocCandidatesBySpineHref, TocIndex } from "./types" diff --git a/packages/core/src/enhancers/pagination/chapters/shared.ts b/packages/core/src/enhancers/pagination/chapters/shared.ts index 2899bc0f..c2f41f65 100644 --- a/packages/core/src/enhancers/pagination/chapters/shared.ts +++ b/packages/core/src/enhancers/pagination/chapters/shared.ts @@ -52,7 +52,14 @@ const flattenToc = ( }) } -const getSpineItemIndexByHref = (manifest: Manifest) => { +/** + * Map every spine href to the index of its first occurrence. + * + * Building this once lets callers resolve a spine item index by href in O(1) + * instead of scanning `manifest.spineItems` per lookup. First-occurrence wins, + * which matches `Array.prototype.findIndex` semantics for duplicate hrefs. + */ +export const getSpineItemIndexByHref = (manifest: Manifest) => { const indexByHref = new Map() manifest.spineItems.forEach((item, index) => { diff --git a/packages/core/src/enhancers/pagination/chapters/static.ts b/packages/core/src/enhancers/pagination/chapters/static.ts index 13cecad0..4c32a7cb 100644 --- a/packages/core/src/enhancers/pagination/chapters/static.ts +++ b/packages/core/src/enhancers/pagination/chapters/static.ts @@ -2,6 +2,7 @@ import type { Manifest } from "@prose-reader/shared" import { buildChapterInfoFromChain, buildTocIndex, + getSpineItemIndexByHref, isPossibleTocItemCandidateForHref, stripAnchor, } from "./shared" @@ -26,17 +27,15 @@ const shouldSkipAnchorSubChapter = ({ const findChapterChainByHref = ({ href, tocIndex, - manifest, + spineItemIndexByHref, }: { href: string tocIndex: FlatTocEntry[] - manifest: Manifest + spineItemIndexByHref: Map }): TocPathEntry[] | undefined => { const hrefWithoutAnchor = stripAnchor(href) const hrefHasAnchor = href.includes(`#`) - const spineItemIndex = manifest.spineItems.findIndex( - (item) => item.href === hrefWithoutAnchor, - ) + const spineItemIndex = spineItemIndexByHref.get(hrefWithoutAnchor) ?? -1 let bestChain: TocPathEntry[] | undefined @@ -66,37 +65,67 @@ export const buildChaptersInfo = ( manifest: Manifest, ): ChapterInfo | undefined => { const tocIndex = buildTocIndex(tocItem, manifest) - const chapterChain = findChapterChainByHref({ href, tocIndex, manifest }) + const spineItemIndexByHref = getSpineItemIndexByHref(manifest) + const chapterChain = findChapterChainByHref({ + href, + tocIndex, + spineItemIndexByHref, + }) return chapterChain ? buildChapterInfoFromChain(chapterChain) : undefined } -const buildChapterInfoFromSpineItem = ( - manifest: Manifest, - tocIndex: TocIndex, - item: Manifest[`spineItems`][number], -) => { - const { href } = item - - const chapterChain = findChapterChainByHref({ href, tocIndex, manifest }) - - return chapterChain ? buildChapterInfoFromChain(chapterChain) : undefined +export type StaticChaptersResolver = { + /** + * Fallback chapter info for a spine item, resolved from its own href against + * the TOC. Returns `undefined` for spine items not present in the TOC. + */ + get: (spineItemId: string) => ChapterInfo | undefined } -export const buildStaticChaptersInfo = ( +/** + * Lazily resolve the static (href-based) fallback chapter info per spine item. + * + * This fallback is only read for the few spine items actually displayed (see + * `mapChapterInfo`), yet the previous implementation eagerly resolved it for + * *every* spine item at book open — an O(spineItems × tocEntries) pass whose + * result was mostly thrown away on large books. Resolving on demand and caching + * per id makes the cost proportional to the items the reader visits, and each + * resolution avoids an O(spineItems) `findIndex` by reusing a prebuilt + * href → index map. + */ +export const createStaticChaptersResolver = ( manifest: Manifest, tocIndex: TocIndex, -): { [key: string]: ChapterInfo | undefined } => { - if (!manifest) return {} - - const chaptersInfo = manifest.spineItems.reduce( - (acc, item) => { - acc[item.id] = buildChapterInfoFromSpineItem(manifest, tocIndex, item) - - return acc +): StaticChaptersResolver => { + const spineItemIndexByHref = getSpineItemIndexByHref(manifest) + + // Last write wins, matching the previous `record[item.id] = …` assignment + // semantics when several spine items share an id. + const hrefBySpineItemId = new Map() + manifest.spineItems.forEach((item) => { + hrefBySpineItemId.set(item.id, item.href) + }) + + const cache = new Map() + + return { + get: (spineItemId) => { + const cached = cache.get(spineItemId) + if (cached !== undefined || cache.has(spineItemId)) return cached + + const href = hrefBySpineItemId.get(spineItemId) + const chapterChain = + href !== undefined + ? findChapterChainByHref({ href, tocIndex, spineItemIndexByHref }) + : undefined + const chapterInfo = chapterChain + ? buildChapterInfoFromChain(chapterChain) + : undefined + + cache.set(spineItemId, chapterInfo) + + return chapterInfo }, - {} as { [key: string]: ChapterInfo | undefined }, - ) - - return chaptersInfo + } } diff --git a/packages/core/src/enhancers/pagination/trackPaginationInfo.ts b/packages/core/src/enhancers/pagination/trackPaginationInfo.ts index e2453239..21024d74 100644 --- a/packages/core/src/enhancers/pagination/trackPaginationInfo.ts +++ b/packages/core/src/enhancers/pagination/trackPaginationInfo.ts @@ -15,17 +15,18 @@ import { Pages, type PagesState } from "../../spine/Pages" import type { SpineItem } from "../../spineItem/SpineItem" import type { LayoutEnhancerOutput } from "../layout/layoutEnhancer" import { - buildStaticChaptersInfo, buildTocCandidatesBySpineHref, buildTocIndex, + createStaticChaptersResolver, resolveChapterInfoFromVisibleNode, + type StaticChaptersResolver, type TocCandidatesBySpineHref, } from "./chapters" import { getPercentageEstimate } from "./progression" type ChaptersData = { tocCandidatesBySpineHref: TocCandidatesBySpineHref - chaptersInfo: ReturnType + chaptersInfo: StaticChaptersResolver } type ChapterPaginationInfo = Pick< @@ -97,12 +98,14 @@ const mapChapterInfo = ({ return { beginChapterInfo: beginChapterInfoFromVisibleNode ?? - (beginItem ? chaptersData.chaptersInfo[beginItem.item.id] : undefined), + (beginItem + ? chaptersData.chaptersInfo.get(beginItem.item.id) + : undefined), beginSpineItemReadingDirection: beginItem?.readingDirection, beginAbsolutePageIndex: beginPageEntry?.absolutePageIndex, endChapterInfo: endChapterInfoFromVisibleNode ?? - (endItem ? chaptersData.chaptersInfo[endItem.item.id] : undefined), + (endItem ? chaptersData.chaptersInfo.get(endItem.item.id) : undefined), endSpineItemReadingDirection: endItem?.readingDirection, endAbsolutePageIndex: endPageEntry?.absolutePageIndex, } @@ -186,7 +189,7 @@ const observeChaptersData = (reader: Reader & LayoutEnhancerOutput) => return { tocCandidatesBySpineHref, - chaptersInfo: buildStaticChaptersInfo(manifest, tocIndex), + chaptersInfo: createStaticChaptersResolver(manifest, tocIndex), } }), )