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