tahamajs/IE / Projects /front /src /components /article /TableOfContents.tsx
tahamajs's picture
download
raw
3.68 kB
// src/components/article/TableOfContents.tsx
import { useEffect, useState, useRef } from "react";
interface Heading {
id: string;
text: string;
level: number;
yOffset: number;
}
interface TableOfContentsProps {
/** The HTML string of the article body (to parse headings). If not provided, uses `containerRef`. */
bodyHtml?: string;
/** A ref to the container element where headings are rendered. */
containerRef?: React.RefObject<HTMLElement>;
onNavigate?: () => void;
}
export function TableOfContents({ bodyHtml, containerRef, onNavigate }: TableOfContentsProps) {
const [headings, setHeadings] = useState<Heading[]>([]);
const [activeId, setActiveId] = useState<string>("");
const observerRef = useRef<IntersectionObserver | null>(null);
// Extract headings from HTML string or from the DOM
useEffect(() => {
if (bodyHtml) {
// Parse from HTML string
const parser = new DOMParser();
const doc = parser.parseFromString(bodyHtml, "text/html");
const elements = doc.querySelectorAll("h1, h2, h3, h4, h5, h6");
const extracted = Array.from(elements).map((el, idx) => ({
id: el.id || `heading-${idx}`,
text: el.textContent?.trim() || "",
level: parseInt(el.tagName[1], 10),
yOffset: 0,
}));
setHeadings(extracted);
} else if (containerRef?.current) {
// Extract from live DOM
const elements = containerRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6");
const extracted = Array.from(elements).map((el, idx) => ({
id: el.id || `heading-${idx}`,
text: el.textContent?.trim() || "",
level: parseInt(el.tagName[1], 10),
yOffset: (el as HTMLElement).offsetTop,
}));
setHeadings(extracted);
}
}, [bodyHtml, containerRef]);
// Set up intersection observer to track active heading
useEffect(() => {
if (!containerRef?.current) return;
const elements = containerRef.current.querySelectorAll("h1, h2, h3, h4, h5, h6");
if (elements.length === 0) return;
observerRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
});
},
{ rootMargin: "-20% 0px -60% 0px", threshold: 0.5 }
);
elements.forEach((el) => observerRef.current?.observe(el));
return () => observerRef.current?.disconnect();
}, [containerRef]);
const scrollToHeading = (id: string) => {
const element = document.getElementById(id);
if (element) {
const offset = 80; // adjust for sticky header
const y = element.getBoundingClientRect().top + window.scrollY - offset;
window.scrollTo({ top: y, behavior: "smooth" });
onNavigate?.();
}
};
if (headings.length === 0) return null;
return (
<nav className="hidden lg:block w-64 shrink-0">
<div className="sticky top-24">
<h4 className="text-sm font-semibold mb-2">Contents</h4>
<ul className="space-y-1 text-sm">
{headings.map((heading) => (
<li key={heading.id}>
<button
onClick={() => scrollToHeading(heading.id)}
className={`text-left hover:text-primary transition ${
activeId === heading.id
? "text-primary font-medium"
: "text-gray-600 dark:text-gray-400"
}`}
style={{ paddingLeft: `${(heading.level - 1) * 0.75}rem` }}
>
{heading.text}
</button>
</li>
))}
</ul>
</div>
</nav>
);
}

Xet Storage Details

Size:
3.68 kB
·
Xet hash:
d8359499a102dae6033893fd0b1b9cde6f62d8cb157263fbedbae659d06c3c58

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.