jevstrudel.git / website / src / components / RightSidebar / TableOfContents.tsx
TableOfContents.tsx91 lines · 2.9 KB · raw
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;