"use client"; import { useState, useCallback, useEffect, useRef } from "react"; import { ListIcon } from "lucide-react"; import { cn } from "../node/utils"; import { ScrollTo } from "./ScrollTo"; import { TocItem } from "../node/types"; interface TocProps { tocs: TocItem[]; } export default function Toc({ tocs }: TocProps) { const [activeId, setActiveId] = useState(null); const clickedIdRef = useRef(null); const clickTimerRef = useRef>(null); const activeIdRef = useRef(null); useEffect(() => { activeIdRef.current = activeId; }, [activeId]); // Single source of truth for the anchor offset — used by BOTH the scroll // observer and the TOC click scroll so they can never disagree. Reads the // CSS scroll-margin-top (prose-headings:scroll-mt-4 desktop / // scroll-mt-16 mobile) so a change in one place updates both, and the // heading lands exactly where the browser would put a native anchor jump. const getAnchorOffset = useCallback((): number => { if (typeof window === "undefined") return 100; const first = tocs[0] ? document.getElementById(tocs[0].href.slice(1)) : null; const margin = first ? parseFloat(getComputedStyle(first).scrollMarginTop) : 0; return margin || 100; }, [tocs]); useEffect(() => { // Desktop-only TOC — on mobile this island is display:none but still // mounted; letting its observer run would hijack the URL hash while the // mobile bar owns TOC behavior there. if (typeof window === "undefined" || window.innerWidth < 1024) return; if (!tocs.length) return; const isDesktop = window.innerWidth >= 1024; const container = isDesktop ? document.getElementById("scroll-container") : null; const scrollTarget = container || window; const offset = getAnchorOffset(); const handleScroll = () => { if (clickedIdRef.current) return; let currentId: string | null = null; for (const toc of tocs) { const id = toc.href.slice(1); const el = document.getElementById(id); if (!el) continue; const top = container ? el.offsetTop - container.scrollTop : el.getBoundingClientRect().top; if (top <= offset) { currentId = id; } else { break; } } if (currentId !== activeIdRef.current) { setActiveId(currentId); history.replaceState(null, "", currentId ? `#${currentId}` : "#top"); } }; handleScroll(); // set initial active on mount let throttleTimer: ReturnType | null = null; const listener = tocs.length > 30 ? () => { if (throttleTimer) return; throttleTimer = setTimeout(() => { throttleTimer = null; handleScroll(); }, 50); } : handleScroll; scrollTarget.addEventListener("scroll", listener, { passive: true }); return () => { scrollTarget.removeEventListener("scroll", listener); if (throttleTimer) clearTimeout(throttleTimer); }; }, [tocs, getAnchorOffset]); const handleLinkClick = useCallback((id: string) => { clickedIdRef.current = id; setActiveId(id); history.replaceState(null, "", `#${id}`); if (clickTimerRef.current) clearTimeout(clickTimerRef.current); clickTimerRef.current = setTimeout(() => { clickedIdRef.current = null; }, 1000); }, []); const handleScrollToTop = useCallback(() => { clickedIdRef.current = "__top__"; setActiveId(null); history.replaceState(null, "", "#top"); if (clickTimerRef.current) clearTimeout(clickTimerRef.current); clickTimerRef.current = setTimeout(() => { clickedIdRef.current = null; }, 1000); }, []); useEffect(() => { return () => { if (clickTimerRef.current) clearTimeout(clickTimerRef.current); }; }, []); const activeItemRef = useRef(null); useEffect(() => { if (activeId && activeItemRef.current && !clickedIdRef.current) { activeItemRef.current.scrollIntoView({ block: "nearest", behavior: "smooth", }); } }, [activeId]); if (!tocs.length) return null; return (

On this page

{tocs.map(({ href, level, text }) => { const id = href.slice(1); const isActive = activeId === id; const levelPadding = (level - 2) * 16; return (
{ e.preventDefault(); handleLinkClick(id); const el = document.getElementById(id); if (el) { // Manual scroll with the same offset the observer uses — // scrollIntoView ignores scroll-margin-top on iOS Safari // window-scroll, landing the heading under the sticky // mobile bar. const offset = getAnchorOffset(); const scroller = window.innerWidth >= 1024 ? document.getElementById("scroll-container") : null; const elTop = el.getBoundingClientRect().top; if (scroller) { // Container scroll space: element offset within the // container minus the anchor offset (the container // itself may sit below the viewport top, e.g. under // the navbar). scroller.scrollTo({ top: elTop - scroller.getBoundingClientRect().top + scroller.scrollTop - offset, behavior: "smooth", }); } else { window.scrollTo({ top: elTop + window.scrollY - offset, behavior: "smooth", }); } } }} className={cn( "flex flex-1 items-center py-2 transition-all duration-200", isActive ? "text-primary font-medium" : "text-base-content/60 hover:text-base-content" )} style={{ paddingLeft: `${levelPadding + 6}px` }} > {text}
); })}
); }