1import { useEffect, useRef, useState } from 'react'; 2 3export type HeadingEntry = { 4 id: string; 5 element: HTMLHeadingElement; 6}; 7 8/** 9 * Retrieve all headings matching the selector within the document. 10 * This will search for all elements matching the heading tags, and generate a selector string. 11 * The string can be used to properly initialize the intersection observer. 12 * Currently, only headings with either an `[id="<id>"]` or `[data-id="<id>"]` are supported. 13 */ 14export function useHeadingsObserver(tags = 'h2,h3') { 15 const observerRef = useRef<IntersectionObserver>(); 16 const [headings, setHeadings] = useState<HeadingEntry[]>([]); 17 const [activeId, setActiveId] = useState<string>(); 18 19 useEffect( 20 function didMount() { 21 const headings = Array.from(document.querySelectorAll<HTMLHeadingElement>(tags)).map( 22 element => ({ element, id: getHeadingId(element) }) 23 ); 24 25 function onObserve(entries: IntersectionObserverEntry[]) { 26 const entry = getActiveEntry(entries); 27 if (entry) { 28 setActiveId(getHeadingId(entry.target as HTMLHeadingElement)); 29 } 30 } 31 32 observerRef.current = new IntersectionObserver(onObserve, { 33 // TODO(cedric): make sure these margins are tweaked properly with the new heading components 34 rootMargin: '-25% 0px -50% 0px', 35 }); 36 37 setHeadings(headings); 38 headings.forEach(heading => observerRef.current?.observe(heading.element)); 39 40 return function didUnmount() { 41 observerRef.current?.disconnect(); 42 }; 43 }, 44 [tags] 45 ); 46 47 return { headings, activeId }; 48} 49 50/** 51 * Get the unique identifier of the heading element. 52 * This could either be `[id="<id>"]` or `[data-id="<id>"]`. 53 */ 54function getHeadingId(heading: HTMLHeadingElement): string { 55 return heading.id || heading.dataset.id || ''; 56} 57 58/** 59 * Find the most probable observer entry that should be considered "active". 60 * If there are more than one, the upper heading (by offsetTop) is returned. 61 */ 62function getActiveEntry(entries: IntersectionObserverEntry[]): IntersectionObserverEntry | null { 63 const visible = entries.filter(entry => entry.isIntersecting); 64 65 if (visible.length <= 0) { 66 return null; 67 } else if (visible.length === 1) { 68 return visible[0]; 69 } 70 71 const sorted = visible.sort( 72 (a, b) => 73 (a.target as HTMLHeadingElement).offsetTop - (b.target as HTMLHeadingElement).offsetTop 74 ); 75 76 return sorted[0]; 77} 78