"use client"; import * as React from "react"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; interface TocItem { id: string; text: string; level: 2 | 3; } /** * "On this page" table of contents. Reads h2/h3 headings from the rendered * `[data-docs-article]` element and highlights the section currently in view. */ export function DocsToc({ className }: { className?: string }) { const pathname = usePathname(); const [items, setItems] = React.useState([]); const [active, setActive] = React.useState(null); React.useEffect(() => { // The article is rendered by a sibling server component; scan it once the frame has painted // so the DOM is complete, and subscribe to scroll position through an IntersectionObserver. let observer: IntersectionObserver | null = null; const frame = window.requestAnimationFrame(() => { const article = document.querySelector("[data-docs-article]"); const headings = article ? Array.from(article.querySelectorAll("h2[id], h3[id]")) : []; const next: TocItem[] = headings.map((h) => ({ id: h.id, text: (h.textContent ?? "").replace(/#\s*$/, "").trim(), level: h.tagName === "H2" ? 2 : 3, })); setItems(next); setActive(next[0]?.id ?? null); if (!headings.length) return; const visible = new Map(); observer = new IntersectionObserver( (entries) => { for (const e of entries) { if (e.isIntersecting) visible.set(e.target.id, e.boundingClientRect.top); else visible.delete(e.target.id); } if (visible.size) { const top = [...visible.entries()].sort((a, b) => a[1] - b[1])[0]![0]; setActive(top); } else { // Nothing intersecting: pick the last heading above the viewport. const above = headings.filter((h) => h.getBoundingClientRect().top < 120); if (above.length) setActive(above[above.length - 1]!.id); } }, { rootMargin: "-80px 0px -60% 0px", threshold: [0, 1] }, ); headings.forEach((h) => observer!.observe(h)); }); return () => { window.cancelAnimationFrame(frame); observer?.disconnect(); }; }, [pathname]); if (items.length < 2) return null; return ( ); }