1import { unescape } from 'html-escaper'; 2import type { MarkdownHeading } from 'astro'; 3import type { FC } from 'react'; 4import { useState, useEffect, useRef } from 'react'; 5 6type ItemOffsets = { 7 id: string; 8 topOffset: number; 9}; 10 11const TableOfContents: FC<{ headings: MarkdownHeading[]; currentPage: string }> = ({ headings = [], currentPage }) => { 12 const toc = useRef<any>(); 13 const onThisPageID = 'on-this-page-heading'; 14 const itemOffsets = useRef<ItemOffsets[]>([]); 15 const [currentID, setCurrentID] = useState('overview'); 16 useEffect(() => { 17 const getItemOffsets = () => { 18 const titles = document.querySelectorAll('article :is(h1, h2, h3, h4)'); 19 itemOffsets.current = Array.from(titles).map((title) => ({ 20 id: title.id, 21 topOffset: title.getBoundingClientRect().top + window.scrollY, 22 })); 23 }; 24 25 getItemOffsets(); 26 window.addEventListener('resize', getItemOffsets); 27 28 return () => { 29 window.removeEventListener('resize', getItemOffsets); 30 }; 31 }, []); 32 33 useEffect(() => { 34 if (!toc.current) return; 35 36 const setCurrent: IntersectionObserverCallback = (entries) => { 37 for (const entry of entries) { 38 if (entry.isIntersecting) { 39 const { id } = entry.target; 40 if (id === onThisPageID) continue; 41 setCurrentID(entry.target.id); 42 break; 43 } 44 } 45 }; 46 47 const observerOptions: IntersectionObserverInit = { 48 // Negative top margin accounts for `scroll-margin`. 49 // Negative bottom margin means heading needs to be towards top of viewport to trigger intersection. 50 rootMargin: '-100px 0% -66%', 51 threshold: 1, 52 }; 53 54 const headingsObserver = new IntersectionObserver(setCurrent, observerOptions); 55 56 // Observe all the headings in the main page content. 57 document.querySelectorAll('article :is(h1,h2,h3)').forEach((h) => headingsObserver.observe(h)); 58 59 // Stop observing when the component is unmounted. 60 return () => headingsObserver.disconnect(); 61 }, [toc.current]); 62 63 const onLinkClick = (e: any) => { 64 setCurrentID(e.target.getAttribute('href').replace('#', '')); 65 }; 66 const minDepth = 1; 67 68 return ( 69 <> 70 <ul ref={toc}> 71 {headings 72 .filter(({ depth }) => depth >= minDepth && depth < 4) 73 .map((heading, i) => ( 74 <li className={`w-full`} key={i}> 75 <a 76 href={`${currentPage}/#${heading.slug}`} 77 onClick={onLinkClick} 78 className={`py-0.5 block cursor-pointer w-full border-l-4 border-lineHighlight hover:bg-lineHighlight ${ 79 ['pl-4', 'pl-9', 'pl-12'][heading.depth - minDepth] 80 } ${currentID === heading.slug ? 'bg-lineHighlight' : ''}`.trim()} 81 > 82 {unescape(heading.text)} 83 </a> 84 </li> 85 ))} 86 </ul> 87 </> 88 ); 89}; 90 91export default TableOfContents;