Something went wrong. Try again.
Original-language reader for Kant, Aristotle & more: select any passage to gloss, translate, or hear it read aloud, with exact citations.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240import { 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<HTMLElement | null>,): ReadingNav { const [currentOffset, setCurrentOffset] = useState(0) const positionsRef = useRef<BlockPos[]>([]) 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<HTMLElement>("[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<HTMLElement>(".browse-bar") ?? document.querySelector<HTMLElement>(".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<HTMLElement>("[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}