import { useCallback, useEffect, useMemo, useRef, useState, type RefCallback } from "react"; import { pageIndexFromHash } from "./readerPageRoute"; import { createReaderPageRegistry } from "./readerPageRegistry"; import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel"; import { createPageVisibilityObserver, scrollToPage } from "./readerScroll"; import { usePanelState } from "./usePanelState"; import { useReaderScrollAnchor } from "./useReaderScrollAnchor"; import { useReaderHashSync } from "./useReaderHashSync"; import { useReaderKeyboardNav } from "./useReaderKeyboardNav"; export interface SetPageOptions { behavior?: ScrollBehavior; } interface UseReaderRuntimeOptions { pageCount: number; leftPanelBreakpoint?: number; rightPanelBreakpoint?: number; panelStateStorageKey?: string; initialPanelState?: { leftPanelOpen: boolean; rightPanelOpen: boolean; }; } export function useReaderRuntime({ pageCount, leftPanelBreakpoint, rightPanelBreakpoint = 1000, panelStateStorageKey, initialPanelState, }: UseReaderRuntimeOptions) { const normalizedPageCount = normalizeReaderPageCount(pageCount); const stageRef = useRef(null); const [pageRegistrationVersion, setPageRegistrationVersion] = useState(0); const pageRegistry = useRef> | null>(null); if (!pageRegistry.current) { pageRegistry.current = createReaderPageRegistry(setPageRegistrationVersion); } const pageRefs = useMemo(() => ({ get current() { return pageRegistry.current?.refs ?? []; }, }), []) as { current: Array }; const [currentPageIndex, setCurrentPageIndex] = useState(() => { if (typeof window === "undefined") return 0; const fromHash = pageIndexFromHash(window.location.hash, normalizedPageCount); return fromHash ?? 0; }); const currentPageIndexRef = useRef(currentPageIndex); currentPageIndexRef.current = currentPageIndex; const hasRestoredInitialRouteRef = useRef(false); const { pendingScrollTargetRef, armPendingScrollTarget, clearPendingScrollTarget } = useReaderScrollAnchor({ stageRef, pageRefs, currentPageIndexRef }); const { leftPanelOpen, rightPanelOpen, setRightPanelOpen, toggleLeftPanel, toggleRightPanel } = usePanelState({ leftPanelBreakpoint, rightPanelBreakpoint, panelStateStorageKey, initialPanelState, }); // Trim the registry + clamp current page when the page count shrinks. useEffect(() => { pageRegistry.current?.trim(normalizedPageCount); setCurrentPageIndex((idx) => clampReaderPageIndex(idx, normalizedPageCount)); }, [normalizedPageCount]); // Drive currentPageIndex from visible pages. Suppress intermediates while a // programmatic scroll is in flight. useEffect(() => { const stage = stageRef.current; if (!stage) return undefined; const observer = createPageVisibilityObserver(stage, (pageIndex) => { if (pendingScrollTargetRef.current !== null) { if (pageIndex !== pendingScrollTargetRef.current) return; clearPendingScrollTarget(); } setCurrentPageIndex((prev) => (prev === pageIndex ? prev : pageIndex)); }); if (!observer) return undefined; pageRegistry.current?.refs.forEach((el) => el && observer.observe(el)); return () => observer.disconnect(); }, [clearPendingScrollTarget, normalizedPageCount, pageRegistrationVersion, pendingScrollTargetRef]); // Honor a routed page once after the initial page refs are available. Later // registrations happen after document refreshes and must not reset the // reader's position to the top of the current page. useEffect(() => { if (hasRestoredInitialRouteRef.current) return undefined; const frame = window.requestAnimationFrame(() => { const refs = pageRegistry.current?.refs ?? []; const idx = currentPageIndexRef.current; if (!refs[idx]) return; hasRestoredInitialRouteRef.current = true; if (idx === 0) return; armPendingScrollTarget(idx); scrollToPage(refs, idx, "instant", stageRef.current); }); return () => window.cancelAnimationFrame(frame); }, [armPendingScrollTarget, pageRegistrationVersion]); const setPage = useCallback( (pageIndex: number, options: SetPageOptions = {}) => { const refs = pageRegistry.current?.refs ?? []; const target = clampReaderPageIndex(pageIndex, normalizedPageCount); armPendingScrollTarget(target); setCurrentPageIndex(target); scrollToPage(refs, target, options.behavior ?? "smooth", stageRef.current); }, [armPendingScrollTarget, normalizedPageCount], ); const nextPage = useCallback(() => { setPage(currentPageIndexRef.current + 1); }, [setPage]); const prevPage = useCallback(() => { setPage(currentPageIndexRef.current - 1); }, [setPage]); useReaderHashSync({ stageRef, pageRefs, currentPageIndex, currentPageIndexRef, normalizedPageCount, setCurrentPageIndex, armPendingScrollTarget, }); useReaderKeyboardNav({ nextPage, prevPage, setPage, normalizedPageCount }); const registerPage = useCallback<(pageIndex: number) => RefCallback>( (pageIndex) => pageRegistry.current?.registerPage(pageIndex) ?? (() => undefined), [], ); const progressPercent = normalizedPageCount <= 1 ? 100 : ((currentPageIndex + 1) / normalizedPageCount) * 100; return { stageRef, currentPageIndex, currentPageLabel: formatReaderPageNumber(currentPageIndex + 1), totalPageLabel: formatReaderPageNumber(normalizedPageCount), progressPercent, leftPanelOpen, rightPanelOpen, setRightPanelOpen, registerPage, setPage, toggleLeftPanel, toggleRightPanel, }; }