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 ;
return (
);
};