import { useEffect, useRef, useState } from 'react'; export type HeadingEntry = { id: string; element: HTMLHeadingElement; }; /** * Retrieve all headings matching the selector within the document. * This will search for all elements matching the heading tags, and generate a selector string. * The string can be used to properly initialize the intersection observer. * Currently, only headings with either an `[id=""]` or `[data-id=""]` are supported. */ export function useHeadingsObserver(tags = 'h2,h3') { const observerRef = useRef(); const [headings, setHeadings] = useState([]); const [activeId, setActiveId] = useState(); useEffect( function didMount() { const headings = Array.from(document.querySelectorAll(tags)).map( element => ({ element, id: getHeadingId(element) }) ); function onObserve(entries: IntersectionObserverEntry[]) { const entry = getActiveEntry(entries); if (entry) { setActiveId(getHeadingId(entry.target as HTMLHeadingElement)); } } observerRef.current = new IntersectionObserver(onObserve, { // TODO(cedric): make sure these margins are tweaked properly with the new heading components rootMargin: '-25% 0px -50% 0px', }); setHeadings(headings); headings.forEach(heading => observerRef.current?.observe(heading.element)); return function didUnmount() { observerRef.current?.disconnect(); }; }, [tags] ); return { headings, activeId }; } /** * Get the unique identifier of the heading element. * This could either be `[id=""]` or `[data-id=""]`. */ function getHeadingId(heading: HTMLHeadingElement): string { return heading.id || heading.dataset.id || ''; } /** * Find the most probable observer entry that should be considered "active". * If there are more than one, the upper heading (by offsetTop) is returned. */ function getActiveEntry(entries: IntersectionObserverEntry[]): IntersectionObserverEntry | null { const visible = entries.filter(entry => entry.isIntersecting); if (visible.length <= 0) { return null; } else if (visible.length === 1) { return visible[0]; } const sorted = visible.sort( (a, b) => (a.target as HTMLHeadingElement).offsetTop - (b.target as HTMLHeadingElement).offsetTop ); return sorted[0]; }