import { useCallback, useEffect, useRef, useState, type RefObject } from "react" import { anchorIndexAt } from "@/lib/model/locus" import type { DocumentModel } from "@/lib/schemas/model" import { selectOffsets } from "@/read/dom-offsets" /** * Navigation state for the reading view (§2). Tracks the current locus from the * scroll position, drives the citation readout + page-rail indicator, and keeps * `?loc` / `?sel` in the URL — `replaceState` on scroll/select (no history * entry), `pushState` on deliberate jumps (rail / TOC / Go To), so Back returns * to jumps, not every scroll (§2 "URL state"). */ /** * The "current reading line" sits this fraction down the viewport — it drives * the citation readout + browse-bar indicator + facsimile sync from the scroll * position. Kept at 20%, but floored below the sticky chrome (header + browse * bar) so the tracked line never hides behind it (`chromeBottom()`). Locus / * heading jumps land at `--jump-offset-nav` (also chrome-aware), and * `goToAnchor`/`goToHeading` set the offset directly so the readout agrees with * where a jump lands. */ const TRIGGER_RATIO = 0.2 interface BlockPos { offset: number top: number } export interface ReadingNav { currentOffset: number currentAnchorIndex: number /** Id of the nearest preceding locus anchor — drives the facsimile pane (§4). */ currentAnchorId: string | null goToAnchor: (anchorId: string) => void goToHeading: (headingId: string, anchorId: string | null) => void setSelectionLocus: (offset: number, sel: { start: number; end: number } | null) => void clearSelection: () => void } function writeUrl( patch: Partial<{ loc: string | null; sel: string | null }>, push: boolean, ): void { const url = new URL(window.location.href) if ("loc" in patch) { if (patch.loc == null) url.searchParams.delete("loc") else url.searchParams.set("loc", patch.loc) } if ("sel" in patch) { if (patch.sel == null) url.searchParams.delete("sel") else url.searchParams.set("sel", patch.sel) } const next = url.pathname + url.search + url.hash if (push) window.history.pushState(null, "", next) else window.history.replaceState(null, "", next) } export function useReadingNav( model: DocumentModel, rootRef: RefObject, ): ReadingNav { const [currentOffset, setCurrentOffset] = useState(0) const positionsRef = useRef([]) const restoredRef = useRef(false) const anchorIdAt = useCallback( (offset: number): string | null => { const idx = anchorIndexAt(model, offset) return idx >= 0 ? (model.anchors[idx]?.id ?? null) : null }, [model], ) // Recompute block positions (document-relative tops) — stable across scroll, // so we only rebuild on mount / resize / font load. const recompute = useCallback(() => { const root = rootRef.current if (!root) return const els = root.querySelectorAll("[data-block-start]") const positions: BlockPos[] = [] for (const el of els) { const offset = Number(el.dataset.blockStart) if (Number.isNaN(offset)) continue positions.push({ offset, top: el.getBoundingClientRect().top + window.scrollY }) } positions.sort((a, b) => a.top - b.top) positionsRef.current = positions }, [rootRef]) // Scroll tracking → currentOffset + `?loc` (replaceState on change only). useEffect(() => { recompute() let frame = 0 let lastAnchorId: string | null = null // Viewport-relative bottom of the sticky chrome — the browse bar sits below // the header, so its bottom edge is the whole chrome; fall back to the // header alone when no bar renders (e.g. a text with no tiers). const chromeBottom = (): number => { const el = document.querySelector(".browse-bar") ?? document.querySelector(".reader-head") return el ? el.getBoundingClientRect().bottom : 0 } const onScroll = (): void => { if (frame) return frame = window.requestAnimationFrame(() => { frame = 0 const positions = positionsRef.current if (positions.length === 0) return // The "current line" never hides behind the sticky chrome (header + // browse bar): on tall viewports it rests at 20vh; on short ones (or // with a 4-tier bar) it drops to just below the chrome's bottom edge. const triggerY = window.scrollY + Math.max(window.innerHeight * TRIGGER_RATIO, chromeBottom() + 8) let offset = positions[0]!.offset for (const p of positions) { if (p.top <= triggerY) offset = p.offset else break } setCurrentOffset(offset) const anchorId = anchorIdAt(offset) if (anchorId !== lastAnchorId) { lastAnchorId = anchorId writeUrl({ loc: anchorId }, false) } }) } const onResize = (): void => { recompute() onScroll() } window.addEventListener("scroll", onScroll, { passive: true }) window.addEventListener("resize", onResize) // Fonts shift layout; rebuild once they settle. document.fonts?.ready.then(recompute).catch(() => undefined) onScroll() return () => { window.removeEventListener("scroll", onScroll) window.removeEventListener("resize", onResize) if (frame) window.cancelAnimationFrame(frame) } }, [recompute, anchorIdAt]) // One-shot restore of `?loc` (scroll) + `?sel` (re-select) once laid out. useEffect(() => { if (restoredRef.current) return restoredRef.current = true const root = rootRef.current if (!root) return const params = new URLSearchParams(window.location.search) const loc = params.get("loc") const sel = params.get("sel") const locAnchor = loc ? model.anchors.find((a) => a.id === loc) : undefined window.requestAnimationFrame(() => { if (locAnchor) { document.getElementById(`loc-${locAnchor.id}`)?.scrollIntoView({ block: "start" }) setCurrentOffset(locAnchor.offset) } if (sel && locAnchor) { const m = /^(\d+)-(\d+)$/.exec(sel) if (m) { const start = locAnchor.offset + Number(m[1]) const end = locAnchor.offset + Number(m[2]) if (selectOffsets(root, start, end)) { offsetToElement(root, start)?.scrollIntoView({ block: "center" }) } } } }) }, [model, rootRef]) const goToAnchor = useCallback( (anchorId: string) => { const anchor = model.anchors.find((a) => a.id === anchorId) if (!anchor) return document.getElementById(`loc-${anchorId}`)?.scrollIntoView({ block: "start" }) setCurrentOffset(anchor.offset) writeUrl({ loc: anchorId, sel: null }, true) }, [model], ) const goToHeading = useCallback( (headingId: string, anchorId: string | null) => { document.getElementById(headingId)?.scrollIntoView({ block: "start" }) writeUrl({ loc: anchorId, sel: null }, true) }, [], ) const setSelectionLocus = useCallback( (offset: number, sel: { start: number; end: number } | null) => { setCurrentOffset(offset) const anchorId = anchorIdAt(offset) if (sel && anchorId) { const anchor = model.anchors.find((a) => a.id === anchorId) const base = anchor?.offset ?? 0 writeUrl({ loc: anchorId, sel: `${String(sel.start - base)}-${String(sel.end - base)}` }, false) } else { writeUrl({ loc: anchorId, sel: null }, false) } }, [model, anchorIdAt], ) const clearSelection = useCallback(() => { writeUrl({ sel: null }, false) }, []) return { currentOffset, currentAnchorIndex: anchorIndexAt(model, currentOffset), currentAnchorId: anchorIdAt(currentOffset), goToAnchor, goToHeading, setSelectionLocus, clearSelection, } } /** Element owning an offset (for centering a restored selection). */ function offsetToElement(root: HTMLElement, offset: number): HTMLElement | null { const runs = root.querySelectorAll("[data-start]") for (const run of runs) { const base = Number(run.dataset.start) if (Number.isNaN(base)) continue const len = run.textContent?.length ?? 0 if (offset >= base && offset <= base + len) return run } return null }