feat: add BlogToc component to parse markdown headings and provide scroll-spy navigation
This commit is contained in:
+30
-17
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user