"use client"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; import type { TocHeading } from "@/lib/toc"; export interface DocsTocProps { headings: TocHeading[]; } interface SvgState { path: string; width: number; height: number; } interface ThumbState { top: number; height: number; visible: boolean; } // useLayoutEffect on the server warns; swap to useEffect during SSR. const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; // Right-rail TOC. Hidden below xl (1280px) because the main column // already fills most of the viewport at laptop widths. // // This is a 1:1 port of fumadocs-ui's "clerk" TOC variant // (@fumadocs/ui/dist/components/toc/clerk.js): persistent gray vertical // lines per item, diagonal SVG connectors at depth changes, and a // violet thumb that slides behind an SVG mask. The mask is the union // of every item's vertical line segment, so the violet pill paints // only along the line path of the active heading. // // The active line follows the shell accent token while inactive // connectors use the shared border token so the TOC tracks theme changes. function getItemOffset(depth: number): number { if (depth <= 2) return 14; if (depth === 3) return 26; return 36; } function getLineOffset(depth: number): number { return depth >= 3 ? 10 : 0; } export function DocsToc({ headings }: DocsTocProps) { const [activeSlug, setActiveSlug] = useState( headings[0]?.slug ?? null, ); const [svg, setSvg] = useState(null); const [thumb, setThumb] = useState({ top: 0, height: 0, visible: false, }); const containerRef = useRef(null); const linkRefs = useRef>(new Map()); // Scroll-spy: pick the last heading whose top has crossed the // trigger line (~25% from viewport top), with an explicit // scrollMax override so the last heading is active when the user // hits the bottom of the page. IntersectionObserver alone doesn't // cover this case — once the last heading has scrolled past the // trigger band, no entry fires "intersecting" so the previous // active stays selected even though the user has clearly arrived // at the last section. useEffect(() => { if (headings.length === 0) return; // The actual scroll happens on `.docs-content-wrapper` (canonical // page architecture: body has `overflow: hidden`). Fall back to // window for safety on routes that don't wrap content this way. const scrollEl = document.querySelector(".docs-content-wrapper") ?? null; const update = () => { const triggerY = window.innerHeight * 0.25; let activeIdx = 0; for (let i = 0; i < headings.length; i++) { const el = document.getElementById(headings[i].slug); if (!el) continue; if (el.getBoundingClientRect().top <= triggerY) { activeIdx = i; } } if (scrollEl) { const atBottom = scrollEl.scrollTop + scrollEl.clientHeight >= scrollEl.scrollHeight - 4; if (atBottom) activeIdx = headings.length - 1; } else { const docEl = document.documentElement; const atBottom = window.scrollY + window.innerHeight >= docEl.scrollHeight - 4; if (atBottom) activeIdx = headings.length - 1; } setActiveSlug(headings[activeIdx]?.slug ?? null); }; update(); const target: HTMLElement | Window = scrollEl ?? window; target.addEventListener("scroll", update, { passive: true }); window.addEventListener("resize", update); return () => { target.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; }, [headings]); // Build the SVG mask path by tracing each item's vertical line // segment. Mirrors clerk.js `onResize`. useIsoLayoutEffect(() => { const container = containerRef.current; if (!container) return; const onResize = () => { if (container.clientHeight === 0) return; let w = 0; let h = 0; const d: string[] = []; for (let i = 0; i < headings.length; i++) { const element = linkRefs.current.get(headings[i].slug); if (!element) continue; const styles = getComputedStyle(element); const offset = getLineOffset(headings[i].depth) + 1; const top = element.offsetTop + parseFloat(styles.paddingTop); const bottom = element.offsetTop + element.clientHeight - parseFloat(styles.paddingBottom); w = Math.max(offset, w); h = Math.max(h, bottom); d.push(`${i === 0 ? "M" : "L"}${offset} ${top}`); d.push(`L${offset} ${bottom}`); } setSvg({ path: d.join(" "), width: w + 1, height: h }); }; onResize(); const observer = new ResizeObserver(onResize); observer.observe(container); return () => observer.disconnect(); }, [headings]); // Track the active item's geometry so the thumb (the violet pill // behind the mask) animates between segments. useIsoLayoutEffect(() => { if (!activeSlug) { setThumb((s) => ({ ...s, visible: false })); return; } const container = containerRef.current; const link = linkRefs.current.get(activeSlug); if (!container || !link) return; const measure = () => { setThumb({ top: link.offsetTop, height: link.clientHeight, visible: true, }); }; measure(); const ro = new ResizeObserver(measure); ro.observe(container); if (typeof document !== "undefined" && document.fonts?.ready) { document.fonts.ready.then(measure).catch(() => {}); } return () => ro.disconnect(); }, [activeSlug, headings]); if (headings.length === 0) return null; const maskUrl = svg ? `url("data:image/svg+xml,${encodeURIComponent( ``, )}")` : undefined; return ( ); }