'use client'; import { useState, useEffect, useCallback } from 'react'; import { cn } from '@/lib/utils'; import type { TocItem } from './article-stream'; interface TableOfContentsProps { headings: TocItem[]; } export function TableOfContents({ headings }: TableOfContentsProps) { const [activeId, setActiveId] = useState(''); const updateActiveHeading = useCallback(() => { if (headings.length === 0) return; const headingElements = headings .map((h) => ({ id: h.id, element: document.getElementById(h.id), })) .filter((h) => h.element !== null); if (headingElements.length === 0) return; // Find the heading that's closest to the top of the viewport but still visible or just passed const scrollPosition = window.scrollY + 120; // Account for sticky header let currentHeading = headingElements[0].id; for (const { id, element } of headingElements) { if (element) { const elementTop = element.offsetTop; if (elementTop <= scrollPosition) { currentHeading = id; } else { break; } } } setActiveId(currentHeading); }, [headings]); useEffect(() => { // Initial update updateActiveHeading(); // Throttled scroll handler let ticking = false; const handleScroll = () => { if (!ticking) { window.requestAnimationFrame(() => { updateActiveHeading(); ticking = false; }); ticking = true; } }; window.addEventListener('scroll', handleScroll, { passive: true }); return () => { window.removeEventListener('scroll', handleScroll); }; }, [updateActiveHeading]); const handleClick = (e: React.MouseEvent, id: string) => { e.preventDefault(); const element = document.getElementById(id); if (element) { const yOffset = -100; const y = element.getBoundingClientRect().top + window.pageYOffset + yOffset; window.scrollTo({ top: y, behavior: 'smooth' }); setActiveId(id); } }; if (headings.length === 0) { return null; } return ( ); }