| // 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.