feat: add BlogToc component to parse markdown headings and provide scroll-spy navigation

This commit is contained in:
Poyraz Avsever
2026-04-10 10:18:07 +03:00
parent 62e024c0a7
commit 34f9ff2531
+30 -17
View File
@@ -42,40 +42,53 @@ type BlogTocProps = {
export function BlogToc({ markdown, onNavigate }: BlogTocProps) { export function BlogToc({ markdown, onNavigate }: BlogTocProps) {
const headings = useMemo(() => parseHeadings(markdown), [markdown]); const headings = useMemo(() => parseHeadings(markdown), [markdown]);
const [activeId, setActiveId] = useState(""); const [activeId, setActiveId] = useState("");
const observerRef = useRef<IntersectionObserver | null>(null); const rafRef = useRef(0);
const handleClick = useCallback((id: string) => { const handleClick = useCallback((id: string) => {
const target = document.getElementById(id); const target = document.getElementById(id);
if (!target) return; if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "start" }); target.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(id);
onNavigate?.(); onNavigate?.();
}, [onNavigate]); }, [onNavigate]);
useEffect(() => { useEffect(() => {
if (headings.length === 0) return; if (headings.length === 0) return;
observerRef.current = new IntersectionObserver( const OFFSET = 120;
(entries) => {
for (const entry of entries) { const updateActive = () => {
if (entry.isIntersecting) { let current = "";
setActiveId(entry.target.id);
} for (const heading of headings) {
const el = document.getElementById(heading.id);
if (!el) continue;
const top = el.getBoundingClientRect().top;
if (top <= OFFSET) {
current = heading.id;
} }
}, }
{ root: null, rootMargin: "0px 0px -60% 0px", threshold: 0.1 },
);
const elements = headings if (current) {
.map((h) => document.getElementById(h.id)) setActiveId(current);
.filter(Boolean) as Element[]; }
};
for (const el of elements) { const onScroll = () => {
observerRef.current.observe(el); cancelAnimationFrame(rafRef.current);
} rafRef.current = requestAnimationFrame(updateActive);
};
window.addEventListener("scroll", onScroll, { passive: true });
const timer = setTimeout(updateActive, 300);
return () => { return () => {
observerRef.current?.disconnect(); window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
clearTimeout(timer);
}; };
}, [headings]); }, [headings]);