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