Connor McCutcheon
/ Music
TableOfContents.tsx
tsx
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;
No comments yet.