import classNames from 'classnames'; import throttle from 'lodash.throttle'; import { useCallback, useEffect, useRef, useState } from 'react'; const SELECTOR = [1, 2, 3, 4, 5, 6].map((n) => `main h${n}`).join(', '); const HIGHLIGHT_CLASS = 'highlight'; const onClient = typeof document !== 'undefined'; type Heading = { id: string; title: string; level: number; }; type Props = { headings: Heading[]; activeId?: string; highlight?: () => void; }; /** * This renders an item in the table of contents list. * scrollIntoView is used to ensure that when a user clicks on an item, it will smoothly scroll. */ const Headings = ({ headings, activeId, highlight }: Props) => ( ); function getHeaders(): HTMLHeadingElement[] { const headers = Array.from(document.querySelectorAll(SELECTOR)).filter((e) => { return !e.classList.contains('title'); }); return headers as HTMLHeadingElement[]; } function useHeaders() { if (!onClient) return { activeId: '', headings: [] }; const onScreen = useRef>(new Set()); const [activeId, setActiveId] = useState(); const headingsSet = useRef>(new Set()); const highlight = useCallback(() => { const current = [...onScreen.current]; const highlighted = current.reduce((a, b) => { if (a) return a; if (b.classList.contains('highlight')) return b.id; return null; }, null as string | null); const active = [...onScreen.current].sort((a, b) => a.offsetTop - b.offsetTop)[0]; if (highlighted || active) setActiveId(highlighted || active.id); }, []); const { observer } = useIntersectionObserver(highlight, onScreen.current); const [elements, setElements] = useState([]); const render = throttle(() => setElements(getHeaders()), 500); useEffect(() => { // We have to look at the document changes for reloads/mutations const main = document.querySelector('main'); const mutations = new MutationObserver(render); // Fire when added to the dom render(); if (main) { mutations.observe(main, { attributes: true, childList: true, subtree: true }); } return () => mutations.disconnect(); }, []); useEffect(() => { // Re-observe all elements when the observer changes Array.from(elements).map((e) => observer.current?.observe(e)); }, [observer]); elements.forEach((e) => { if (headingsSet.current.has(e)) return; observer.current?.observe(e); headingsSet.current.add(e); }); const headings = elements.map((heading) => { const { innerText: title, id } = heading; return { title, id, level: Number(heading.tagName.slice(1)) }; }); return { activeId, highlight, headings }; } const useIntersectionObserver = ( highlight: () => void, onScreen: Set, ) => { const observer = useRef(null); if (!onClient) return { observer }; useEffect(() => { const callback: IntersectionObserverCallback = (entries) => { entries.forEach((entry) => { onScreen[entry.isIntersecting ? 'add' : 'delete']( entry.target as HTMLHeadingElement, ); }); highlight(); }; const o = new IntersectionObserver(callback); observer.current = o; return () => o.disconnect(); }, [highlight, onScreen]); return { observer }; }; export const TableOfContents = () => { const { activeId, headings, highlight } = useHeaders(); if (headings.length <= 1) return