91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
import { unescape } from 'html-escaper';
|
|
import type { MarkdownHeading } from 'astro';
|
|
import type { FC } from 'react';
|
|
import { useState, useEffect, useRef } from 'react';
|
|
|
|
type ItemOffsets = {
|
|
id: string;
|
|
topOffset: number;
|
|
};
|
|
|
|
const TableOfContents: FC<{ headings: MarkdownHeading[]; currentPage: string }> = ({ headings = [], currentPage }) => {
|
|
const toc = useRef<any>();
|
|
const onThisPageID = 'on-this-page-heading';
|
|
const itemOffsets = useRef<ItemOffsets[]>([]);
|
|
const [currentID, setCurrentID] = useState('overview');
|
|
useEffect(() => {
|
|
const getItemOffsets = () => {
|
|
const titles = document.querySelectorAll('article :is(h1, h2, h3, h4)');
|
|
itemOffsets.current = Array.from(titles).map((title) => ({
|
|
id: title.id,
|
|
topOffset: title.getBoundingClientRect().top + window.scrollY,
|
|
}));
|
|
};
|
|
|
|
getItemOffsets();
|
|
window.addEventListener('resize', getItemOffsets);
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', getItemOffsets);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!toc.current) return;
|
|
|
|
const setCurrent: IntersectionObserverCallback = (entries) => {
|
|
for (const entry of entries) {
|
|
if (entry.isIntersecting) {
|
|
const { id } = entry.target;
|
|
if (id === onThisPageID) continue;
|
|
setCurrentID(entry.target.id);
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
const observerOptions: IntersectionObserverInit = {
|
|
// Negative top margin accounts for `scroll-margin`.
|
|
// Negative bottom margin means heading needs to be towards top of viewport to trigger intersection.
|
|
rootMargin: '-100px 0% -66%',
|
|
threshold: 1,
|
|
};
|
|
|
|
const headingsObserver = new IntersectionObserver(setCurrent, observerOptions);
|
|
|
|
// Observe all the headings in the main page content.
|
|
document.querySelectorAll('article :is(h1,h2,h3)').forEach((h) => headingsObserver.observe(h));
|
|
|
|
// Stop observing when the component is unmounted.
|
|
return () => headingsObserver.disconnect();
|
|
}, [toc.current]);
|
|
|
|
const onLinkClick = (e: any) => {
|
|
setCurrentID(e.target.getAttribute('href').replace('#', ''));
|
|
};
|
|
const minDepth = 1;
|
|
|
|
return (
|
|
<>
|
|
<ul ref={toc}>
|
|
{headings
|
|
.filter(({ depth }) => depth >= minDepth && depth < 4)
|
|
.map((heading, i) => (
|
|
<li className={`w-full`} key={i}>
|
|
<a
|
|
href={`${currentPage}/#${heading.slug}`}
|
|
onClick={onLinkClick}
|
|
className={`py-0.5 block cursor-pointer w-full border-l-4 border-lineHighlight hover:bg-lineHighlight ${
|
|
['pl-4', 'pl-9', 'pl-12'][heading.depth - minDepth]
|
|
} ${currentID === heading.slug ? 'bg-lineHighlight' : ''}`.trim()}
|
|
>
|
|
{unescape(heading.text)}
|
|
</a>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default TableOfContents;
|