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) {
const headings = useMemo(() => parseHeadings(markdown), [markdown]);
const [activeId, setActiveId] = useState("");
const observerRef = useRef<IntersectionObserver | null>(null);
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;
observerRef.current = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ root: null, rootMargin: "0px 0px -60% 0px", threshold: 0.1 },
);
const OFFSET = 120;
const elements = headings
.map((h) => document.getElementById(h.id))
.filter(Boolean) as Element[];
const updateActive = () => {
let current = "";
for (const el of elements) {
observerRef.current.observe(el);
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;
}
}
if (current) {
setActiveId(current);
}
};
const onScroll = () => {
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateActive);
};
window.addEventListener("scroll", onScroll, { passive: true });
const timer = setTimeout(updateActive, 300);
return () => {
observerRef.current?.disconnect();
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(rafRef.current);
clearTimeout(timer);
};
}, [headings]);