Something went wrong. Try again.
The Cavu Reader
Something went wrong. Try again.
23 kB · 559 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560/** * Paginated reading mode. A sliding window of spine documents — previous, * current, next — renders as adjacent CSS-column segments on one * continuous column grid, and turning a page translates the strip. The * view is addressed as (document, column offset), so a two-up spread can * straddle a chunk edge: a chapter opening at the top of the next chunk * lands on the facing page exactly as it would mid-chunk, and crossing * the edge is an ordinary animated turn — the URL follows with a replace * navigation (the route's onCenterChange) instead of remounting the * reader. Because each segment paginates independently, window shifts * never refragment the text under the reader; the strip just repositions * by whole columns. */import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'import type { ReactNode } from 'react'import { getBookDocument } from '#/lib/bundle'import { parseAtUri } from '#/lib/atproto'import { chapterPagesOf, getPages } from '#/lib/book'import type { BookDocument, SpineEntry } from '#/lib/book'import { Blocks, DocCover } from '#/components/Blocks'import type { BookContext } from '#/components/Blocks'import { DEFAULT_TYPESET, typesetAttrs } from '#/lib/typeset'import { ComicPageColumn } from '#/components/ComicReader'import { useSwipe } from '#/lib/useSwipe'import { useTwoUp } from '#/lib/twoUp'
const COLUMN_GAP = 72/** Window shifts wait out the turn animation (0.22s) plus a beat. */const RECENTER_DELAY_MS = 260
/** * Map a 0..1 chunk fraction to the column whose span contains it. Floor, * not round — landing a hair early never skips unread text — with a * half-permille allowance so a fraction that round-tripped through the * stored permille grammar (see lib/positionFragment) still lands on the * exact column it was written from. */export const pageAtFraction = (fraction: number, count: number): number => Math.min(count - 1, Math.max(0, Math.floor(fraction * count + count / 2000)))
interface Geometry { colWidth: number cols: number height: number}
interface PaginatedReaderProps { spine: SpineEntry[] /** Chunk the route points at; changes either from our own URL sync (ignored) or an external jump like a TOC link (jumped to). */ docUri: string book: BookContext docDid: string /** The route-loaded document, so SSR and the first paint carry content. */ initialDoc?: BookDocument /** Alt text for a document-carried cover image (the book's coverAlt). */ coverAlt?: string /** Rendered after the last chunk (the follow prompt / finished card). */ endSlot?: ReactNode /** Land on the last view after measuring (legacy ?at=end links). */ startAtEnd: boolean /** Land at this fraction through the initial chunk (resuming a stored position). */ startAtFraction?: number /** Reports the chunk under the view's leading column (for the URL sync). */ onCenterChange: (entry: SpineEntry) => void /** * Chunk-relative position, top-of-view grammar: everything before the * view has been read, the view itself hasn't. `finished` is explicit — * a forward turn onto a view containing the chunk's last column — * since merely landing there (resume, back-arrival) must not durably * mark the chunk read. */ onPositionChange: (index: number, fraction: number, finished?: boolean) => void /** Reports the chapter the current view sits in (last H1/H2 reached). */ onChapterChange?: (title: string | undefined) => void}
export function PaginatedReader({ spine, docUri, book, docDid, initialDoc, coverAlt, endSlot, startAtEnd, startAtFraction, onCenterChange, onPositionChange, onChapterChange,}: PaginatedReaderProps) { // The author's typesetting, as attributes on every .prose segment. It // feeds the column measurement (the chapter drop is a fraction of page // height), so it must be stable across renders like `book` itself. const typeset = useMemo( () => typesetAttrs(book.typeset ?? DEFAULT_TYPESET), [book.typeset], ) const viewportRef = useRef<HTMLDivElement>(null) const swayRef = useRef<HTMLDivElement>(null) const stripRef = useRef<HTMLDivElement>(null) const segmentEls = useRef(new Map<string, HTMLElement>())
const [initialIndex] = useState(() => Math.max(0, spine.findIndex((e) => e.document === docUri))) const [docs, setDocs] = useState<Record<string, BookDocument>>(() => initialDoc ? { [docUri]: initialDoc } : {}, ) // The view: leading column `col` of document `doc` (a spine index). // count distinguishes successive same-direction turns so the sway // animation retriggers (see the sway-a/sway-b classes). const [view, setView] = useState({ doc: initialIndex, col: 0, dir: 0, count: 0 }) // First mounted spine index; the window is [windowStart, windowStart+3). // Recentered around the view a beat *after* a crossing, so the turn // animates on a stable strip. const [windowStart, setWindowStart] = useState(Math.max(0, initialIndex - 1)) const [geometry, setGeometry] = useState<Geometry>() // Measured column count per mounted segment, by document URI. const [colCounts, setColCounts] = useState<Record<string, number>>({})
// On wide viewports a view is two facing columns; turns advance both. const cols = useTwoUp() ? 2 : 1
// Live mirrors for event handlers and timers. const viewRef = useRef(view) viewRef.current = view const countsRef = useRef(colCounts) countsRef.current = colCounts const colsRef = useRef(cols) colsRef.current = cols const onCenterChangeRef = useRef(onCenterChange) onCenterChangeRef.current = onCenterChange const onPositionChangeRef = useRef(onPositionChange) onPositionChangeRef.current = onPositionChange const startAtEndRef = useRef(startAtEnd) startAtEndRef.current = startAtEnd const startAtFractionRef = useRef(startAtFraction) startAtFractionRef.current = startAtFraction
// Consumed on every measure until the reader turns a page (see go): // early measures see transient layouts — the hydration render is // single-column before useTwoUp reports wide, and web fonts reflow the // columns — so the resume target is re-aimed on each reflow. const pendingEndRef = useRef(startAtEnd) const pendingFractionRef = useRef(startAtFraction) const selfSyncedUri = useRef(docUri) // Heading strip-columns for the chapter tracker, measured once per // layout instead of re-queried on every page turn. const headingsRef = useRef<{ col: number; title: string | undefined }[] | null>(null) // The last measured geometry and counts, so a reflow can tell a resize // (the text refragmented; a raw column index now holds different // words) from mere content growth (columns appended; the index still // holds the same words). const prevMeasureRef = useRef<{ geometry: Geometry; counts: Record<string, number> }>(undefined) // What the last commit painted, so only actual turns animate: window // recenters and count changes move the transform under the same view, // and animating that would slide the page for no reason. const lastPaintRef = useRef({ offset: 0, count: 0 })
const windowEntries = useMemo( () => spine.slice(windowStart, windowStart + 3), [spine, windowStart], ) const lastIndex = spine.length - 1
// Fetch every document in the window (getBookDocument dedupes and caches). useEffect(() => { let cancelled = false for (const entry of windowEntries) { if (docs[entry.document]) continue getBookDocument(entry.document, book).then( (envelope) => { if (!cancelled) setDocs((d) => ({ ...d, [entry.document]: envelope.value })) }, () => {}, ) } return () => { cancelled = true } }, [windowStart, spine]) // eslint-disable-line react-hooks/exhaustive-deps
// External navigation (TOC link, chapter buttons) while already mounted. useEffect(() => { if (docUri === selfSyncedUri.current) return const index = spine.findIndex((e) => e.document === docUri) if (index === -1) return selfSyncedUri.current = docUri pendingEndRef.current = startAtEndRef.current pendingFractionRef.current = startAtFractionRef.current setWindowStart(Math.max(0, index - 1)) setView({ doc: index, col: 0, dir: 0, count: 0 }) }, [docUri, spine])
const measure = useCallback(() => { const sway = swayRef.current const strip = stripRef.current if (!sway || !strip) return const viewW = sway.clientWidth const height = sway.clientHeight if (viewW <= 0 || height <= 0) return const nCols = colsRef.current const colWidth = (viewW - (nCols - 1) * COLUMN_GAP) / nCols // Page height for the chapter-drop inset (see .chapter-title styles). strip.style.setProperty('--page-h', `${height}px`) // Measuring pass: pin each segment to the view's geometry so its // content runs out into overflow columns, then count them. for (const el of segmentEls.current.values()) { el.style.columnWidth = `${colWidth}px` el.style.columnCount = String(nCols) el.style.width = `${viewW}px` } const counts: Record<string, number> = {} for (const [uri, el] of segmentEls.current) { counts[uri] = Math.max(1, Math.round((el.scrollWidth + COLUMN_GAP) / (colWidth + COLUMN_GAP))) } // Final pass: size each segment to exactly its columns — same column // width, so nothing refragments — and the flex gap (one column gap) // keeps every segment on one continuous column grid, letting a // two-up view straddle a chunk edge. for (const [uri, el] of segmentEls.current) { el.style.columnCount = String(counts[uri]) el.style.width = `${counts[uri] * (colWidth + COLUMN_GAP) - COLUMN_GAP}px` } headingsRef.current = null const nextGeometry: Geometry = { colWidth, cols: nCols, height } const prev = prevMeasureRef.current prevMeasureRef.current = { geometry: nextGeometry, counts } setGeometry((g) => g && g.colWidth === colWidth && g.cols === nCols && g.height === height ? g : nextGeometry, ) setColCounts((c) => { const uris = Object.keys(counts) return uris.length === Object.keys(c).length && uris.every((u) => c[u] === counts[u]) ? c : counts }) const jumpEnd = pendingEndRef.current const jumpFraction = pendingFractionRef.current setView((v) => { const uri = spine[v.doc]?.document const n = uri ? counts[uri] : undefined if (!n) return v if (jumpEnd) { const col = Math.max(0, n - nCols) return col === v.col && v.dir === 0 ? v : { ...v, col, dir: 0 } } if (jumpFraction !== undefined) { const col = pageAtFraction(jumpFraction, n) return col === v.col && v.dir === 0 ? v : { ...v, col, dir: 0 } } // After interaction: a geometry change (resize, rotate, two-up // flip) refragments the text, so carry the reading *position* // across — the raw column index would land on different words, or // past the chunk's end when the count shrank. Content growth keeps // the raw index. Repositioning is not a turn: dir resets so no // sway or slide plays. const reflowed = prev && (prev.geometry.cols !== nCols || Math.abs(prev.geometry.colWidth - colWidth) > 0.5 || Math.abs(prev.geometry.height - height) > 0.5) const nOld = prev?.counts[uri] let col = v.col if (reflowed && nOld && nOld !== n) col = Math.floor((col / nOld) * n) col = Math.min(n - 1, Math.max(0, col)) if (col === v.col && !reflowed) return v return { ...v, col, dir: reflowed ? 0 : v.dir } }) }, [spine])
useLayoutEffect(() => { measure() }, [measure, docs, windowStart, cols])
useEffect(() => { // Web fonts reflow the columns; re-measure once they settle. document.fonts?.ready.then(measure) const observer = new ResizeObserver(measure) if (swayRef.current) observer.observe(swayRef.current) return () => observer.disconnect() }, [measure])
// Recenter the window around the view once the turn animation is done: // shifting content and transform together is visually a no-op (the // strip repositions by exactly the dropped or added columns), but it // must not race the 0.22s slide. const shiftingRef = useRef(false) useEffect(() => { const want = Math.max(0, view.doc - 1) if (want === windowStart) return const timer = window.setTimeout(() => { shiftingRef.current = true setWindowStart(Math.max(0, viewRef.current.doc - 1)) }, RECENTER_DELAY_MS) return () => window.clearTimeout(timer) }, [view.doc, windowStart]) useEffect(() => { shiftingRef.current = false })
const go = useCallback( (delta: number) => { // The reader took over; a still-pending resume target must not // snap them back on the next measure. pendingEndRef.current = false pendingFractionRef.current = undefined const nCols = colsRef.current const counts = countsRef.current const v = viewRef.current const countAt = (i: number) => { const uri = spine[i]?.document return uri ? counts[uri] : undefined } let d = v.doc let c = v.col + delta * nCols while (c < 0) { const nPrev = d > 0 ? countAt(d - 1) : undefined if (nPrev === undefined) { // Book start (or an unmeasured neighbor): clamp. c = 0 break } d -= 1 c += nPrev } for (let n = countAt(d); n !== undefined && c >= n; n = countAt(d)) { if (d >= lastIndex) { // Turning forward past the book's end is the definitive finish // signal — it covers a last view that was landed on rather // than turned onto (resumed near the end) and then read. const last = Math.max(0, n - nCols) if (delta > 0) onPositionChangeRef.current(d, n > 0 ? last / n : 0, true) c = last break } const next = countAt(d + 1) if (next === undefined) { // The neighbor isn't measured yet (rapid turns outpacing the // window); hold at this chunk's last view until it is. c = Math.max(0, n - nCols) break } c -= n d += 1 } if (d === v.doc && c === v.col) return if (d !== v.doc) { const entry = spine[d] selfSyncedUri.current = entry.document onCenterChangeRef.current(entry) } setView({ doc: d, col: c, dir: delta, count: v.count + 1 }) }, [spine, lastIndex], )
useSwipe(viewportRef, go)
useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented || e.metaKey || e.ctrlKey || e.altKey) return // Typing in a form field (highlight notes, suggestion box) must // never turn the page. if ( e.target instanceof HTMLElement && (e.target.isContentEditable || e.target.closest('input, textarea, select')) ) { return } switch (e.key) { case 'ArrowRight': case 'PageDown': case ' ': e.preventDefault() go(1) break case 'ArrowLeft': case 'PageUp': e.preventDefault() go(-1) break } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [go])
// Columns of mounted segments before this spine index, strip-relative. const colsBefore = (docIndex: number) => { let sum = 0 for (let i = windowStart; i < docIndex && i < spine.length; i++) { sum += colCounts[spine[i].document] ?? 0 } return sum }
// Report only once columns are measured and the document is real: the // pre-measure state is one placeholder column at index 0, which would // momentarily reset stored progress to the chunk's top. const currentUri = spine[view.doc]?.document const currentCount = currentUri ? colCounts[currentUri] : undefined const currentLoaded = Boolean(currentUri && docs[currentUri]) useEffect(() => { if (!geometry || !currentUri || !currentCount || !currentLoaded) return const finished = view.dir > 0 && view.col + geometry.cols >= currentCount onPositionChange(view.doc, view.col / currentCount, finished) }, [view, geometry, currentUri, currentCount, currentLoaded, onPositionChange])
// The chapter under the current view: the last H1/H2 whose column has // come into view, across every mounted segment (headings from a // previous chunk count until the current chunk's first heading // arrives). Positions are measured relative to each segment (rect // deltas), so a mid-animation translate can't skew them — and stay // valid across page turns, so they're cached until the next measure. useEffect(() => { if (!onChapterChange || !geometry) return const stride = geometry.colWidth + COLUMN_GAP let headings = headingsRef.current if (!headings) { headings = [] for (let i = windowStart; i < windowStart + 3 && i < spine.length; i++) { const el = segmentEls.current.get(spine[i].document) if (!el) continue const segLeft = el.getBoundingClientRect().left const base = colsBefore(i) for (const h of el.querySelectorAll<HTMLElement>('h1, h2')) { headings.push({ col: base + Math.round((h.getBoundingClientRect().left - segLeft) / stride), title: h.textContent ?? undefined, }) } } headings.sort((a, b) => a.col - b.col) headingsRef.current = headings } const viewEnd = colsBefore(view.doc) + view.col + geometry.cols let chapter: string | undefined for (const heading of headings) { if (heading.col < viewEnd) chapter = heading.title else break } onChapterChange(chapter) // eslint-disable-next-line react-hooks/exhaustive-deps }, [view, geometry, colCounts, windowStart, spine, onChapterChange])
const setSegmentRef = useCallback((uri: string, el: HTMLElement | null) => { if (el) segmentEls.current.set(uri, el) else segmentEls.current.delete(uri) }, [])
const stride = geometry ? geometry.colWidth + COLUMN_GAP : 0 const offset = (colsBefore(view.doc) + view.col) * stride // Only actual turns animate: a recenter or count change repositions // the strip under the same view (dropped columns and transform move // together), and a resume jump or resize re-aim would otherwise slide // thousands of pixels on its first paint. const newTurn = view.count !== lastPaintRef.current.count const repositioned = offset !== lastPaintRef.current.offset && !newTurn useEffect(() => { lastPaintRef.current = { offset, count: view.count } })
// The sway lives on a wrapper so its keyframed transform composes with // the strip's inline translate; alternating a/b names retriggers it on // consecutive same-direction turns. const sway = view.dir === 0 ? '' : ` ${view.dir > 0 ? 'turn-forward' : 'turn-back'} sway-${view.count % 2 ? 'a' : 'b'}`
const atBookStart = view.doc === 0 && view.col === 0 const atBookEnd = view.doc === lastIndex && currentCount !== undefined && view.col + cols >= currentCount
return ( <div className="paginated" ref={viewportRef}> <div className={`paginated-sway${sway}`} ref={swayRef}> <div className="paginated-strip" ref={stripRef} style={{ gap: COLUMN_GAP, transform: `translateX(${-offset}px)`, transition: view.dir === 0 || shiftingRef.current || repositioned ? 'none' : undefined, visibility: geometry === undefined ? 'hidden' : undefined, }} > {windowEntries.map((entry, i) => { const doc = docs[entry.document] const pages = doc ? getPages(doc) : undefined // Chapter DOM ids are composited with the document rkey: // page ids are only unique within a document (chunk-local in // new books), and several documents mount at once here. const docRkey = parseAtUri(entry.document).rkey return ( <div className="paginated-content prose" key={entry.document} ref={(el) => setSegmentRef(entry.document, el)} {...typeset} style={{ ...typeset.style, columnGap: COLUMN_GAP }} > {doc ? ( pages ? ( // A comic chunk adjoining text: its pages flow // through the columns; crossing into it hands the // route to ComicReader. <ComicPageColumn docDid={docDid} docRkey={docRkey} hosted={book.hosted} pages={pages.pages} /> ) : ( <> {/* One container per doc: highlight anchors offset into it. */} <div data-doc={entry.document}> <DocCover alt={coverAlt} doc={doc} docDid={docDid} hosted={book.hosted} /> {chapterPagesOf(doc).map((page, j) => ( <section className="chapter" id={page.id ? `${docRkey}--${page.id}` : undefined} key={page.id ?? j} > <Blocks blocks={page.blocks} book={book} docDid={docDid} /> </section> ))} </div> {windowStart + i === lastIndex && endSlot} </> ) ) : ( <p aria-label="Loading" className="section-loading"> ~ ~ ~ </p> )} </div> ) })} </div> </div> <button aria-label="Previous page" className="page-zone page-zone-prev" disabled={atBookStart} onClick={() => go(-1)} type="button" /> <button aria-label="Next page" className="page-zone page-zone-next" disabled={atBookEnd} onClick={() => go(1)} type="button" /> </div> )}