"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Typography } from "poyraz-ui/atoms"; import { parseMarkdownHeadings } from "@/lib/markdown-headings"; type ArticleTocProps = { markdown: string; title: string; onNavigate?: () => void; }; export function ArticleToc({ markdown, title, onNavigate }: ArticleTocProps) { const headings = useMemo(() => parseMarkdownHeadings(markdown), [markdown]); const [activeId, setActiveId] = useState(""); const rafRef = useRef(0); const handleClick = useCallback( (id: string) => { const target = document.getElementById(id); if (!target) return; target.scrollIntoView({ behavior: "smooth", block: "start" }); setActiveId(id); onNavigate?.(); }, [onNavigate], ); useEffect(() => { if (headings.length === 0) return; const headingElements = headings .map((heading) => ({ id: heading.id, element: document.getElementById(heading.id), })) .filter((item): item is { id: string; element: HTMLElement } => Boolean(item.element), ); if (headingElements.length === 0) return; const updateActive = () => { let current = headingElements[0].id; for (const item of headingElements) { if (item.element.getBoundingClientRect().top <= 120) { current = item.id; continue; } break; } setActiveId(current); }; const observer = new IntersectionObserver( () => { cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(updateActive); }, { rootMargin: "-120px 0px -65% 0px", threshold: [0, 1], }, ); for (const item of headingElements) { observer.observe(item.element); } const timer = window.setTimeout(updateActive, 200); return () => { observer.disconnect(); cancelAnimationFrame(rafRef.current); window.clearTimeout(timer); }; }, [headings]); if (headings.length < 2) return null; return ( ); }