'use client'; import { useEffect, useRef, useState } from 'react'; import { FiList } from 'react-icons/fi'; interface TocItem { id: string; text: string; depth: number; } export function PostToc({ onLinkClick, contentKey, showTitle = true, className }: { onLinkClick?: () => void; contentKey?: string; showTitle?: boolean; className?: string; }) { const [items, setItems] = useState([]); const [activeId, setActiveId] = useState(null); const listRef = useRef(null); const itemRefs = useRef>({}); const [indicator, setIndicator] = useState({ top: 0, opacity: 0 }); useEffect(() => { // Clear items immediately when content changes setItems([]); setActiveId(null); itemRefs.current = {}; const containerSelector = contentKey ? `[data-toc-content="${contentKey}"]` : '[data-toc-content]'; const container = document.querySelector(containerSelector); if (!container) { return undefined; } let observer: IntersectionObserver | null = null; let rafId1: number; let rafId2: number; // Use double requestAnimationFrame to ensure DOM has been painted // This is more reliable than setTimeout for DOM updates rafId1 = requestAnimationFrame(() => { rafId2 = requestAnimationFrame(() => { const headings = Array.from( container.querySelectorAll('h2, h3') ); const mapped = headings .filter((el) => el.id) .map((el) => ({ id: el.id, text: el.innerText, depth: el.tagName === 'H3' ? 3 : 2 })); setItems(mapped); observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { if (entry.isIntersecting) { const id = (entry.target as HTMLElement).id; if (id) { setActiveId(id); } } }); }, { // Trigger when heading is in upper 40% of viewport rootMargin: '0px 0px -60% 0px', threshold: 0.1 } ); headings.forEach((el) => observer?.observe(el)); }); }); return () => { cancelAnimationFrame(rafId1); cancelAnimationFrame(rafId2); if (observer) { observer.disconnect(); } }; }, [contentKey]); useEffect(() => { if (!activeId || !listRef.current) { setIndicator({ top: 0, opacity: 0 }); return; } const activeEl = itemRefs.current[activeId]; if (!activeEl) return; const listTop = listRef.current.getBoundingClientRect().top; const { top, height } = activeEl.getBoundingClientRect(); setIndicator({ top: top - listTop + height / 2, opacity: 1 }); }, [activeId, items.length]); const handleClick = (id: string) => (event: React.MouseEvent) => { event.preventDefault(); const el = document.getElementById(id); if (!el) return; el.scrollIntoView({ behavior: 'smooth', block: 'start' }); // Temporary highlight el.classList.add('toc-target-highlight'); setTimeout(() => { el.classList.remove('toc-target-highlight'); }, 700); // Update hash without instant jump if (history.replaceState) { const url = new URL(window.location.href); url.hash = id; history.replaceState(null, '', url.toString()); } // Trigger callback if provided (e.g. to close mobile menu) if (onLinkClick) { onLinkClick(); } }; if (items.length === 0) return null; return ( ); }