on a28cef3 help sign in
urbit.org/components/TableOfContents.js
111 lines 3.3 kB history raw
1import { useState, useEffect, useRef } from "react";
3export const TableOfContents = () => {
4 const { nestedHeadings } = useHeadingsData();
5 const [activeId, setActiveId] = useState();
6 useIntersectionObserver(setActiveId);
7 return (
8 <nav
9 className="sticky min-h-0 overflow-y-scroll w-52 flex-shrink-0 pb-24 hidden lg:block"
10 style={{ top: "8rem", height: "calc(100vh - 16rem)" }}
11 >
12 <Headings headings={nestedHeadings} activeId={activeId} />
13 </nav>
14 );
15};
17const getNestedHeadings = (headingElements) => {
18 const nestedHeadings = [];
20 headingElements.forEach((heading, index) => {
21 const { innerText: title, id } = heading;
23 if (heading.nodeName === "H2") {
24 nestedHeadings.push({ id, title, items: [] });
25 } else if (heading.nodeName === "H3" && nestedHeadings.length > 0) {
26 nestedHeadings[nestedHeadings.length - 1].items.push({
27 id,
28 title,
29 });
30 }
31 });
33 return nestedHeadings;
34};
36const useHeadingsData = () => {
37 const [nestedHeadings, setNestedHeadings] = useState([]);
39 useEffect(() => {
40 const headingElements = Array.from(document.querySelectorAll("h2, h3"));
42 const newNestedHeadings = getNestedHeadings(headingElements);
43 setNestedHeadings(newNestedHeadings);
44 }, []);
46 return { nestedHeadings };
47};
49const Headings = ({ headings, activeId }) => (
50 <ul>
51 {headings.map((heading) => (
52 <li key={heading.id}>
53 <a className="font-semibold" href={`#${heading.id}`}>
54 {heading.title}
55 </a>
56 {heading.items.length > 0 && (
57 <ul className="pl-2">
58 {heading.items.map((child) => (
59 <li
60 key={child.id}
61 className={child.id === activeId ? "text-green" : "text-gray"}
62 >
63 <a href={`#${child.id}`}>{child.title}</a>
64 </li>
65 ))}
66 </ul>
67 )}
68 </li>
69 ))}
70 </ul>
71);
73const useIntersectionObserver = (setActiveId) => {
74 const headingElementsRef = useRef({});
75 useEffect(() => {
76 const callback = (headings) => {
77 headingElementsRef.current = headings.reduce((map, headingElement) => {
78 map[headingElement.target.id] = headingElement;
79 return map;
80 }, headingElementsRef.current);
82 const visibleHeadings = [];
83 Object.keys(headingElementsRef.current).forEach((key) => {
84 const headingElement = headingElementsRef.current[key];
85 if (headingElement.isIntersecting) visibleHeadings.push(headingElement);
86 });
88 const getIndexFromId = (id) =>
89 headingElements.findIndex((heading) => heading.id === id);
91 if (visibleHeadings.length === 1) {
92 setActiveId(visibleHeadings[0].target.id);
93 } else if (visibleHeadings.length > 1) {
94 const sortedVisibleHeadings = visibleHeadings.sort(
95 (a, b) => getIndexFromId(a.target.id) > getIndexFromId(b.target.id)
96 );
97 setActiveId(sortedVisibleHeadings[0].target.id);
98 }
99 };
101 const observer = new IntersectionObserver(callback, {
102 rootMargin: "0px 0px -50% 0px",
103 });
105 const headingElements = Array.from(document.querySelectorAll("h2, h3"));
107 headingElements.forEach((element) => observer.observe(element));
109 return () => observer.disconnect();
110 }, [setActiveId]);
111};