import classNames from "classnames"; import React, { useEffect, useRef, useState } from "react"; import { UnfoldMore, UnfoldLess, Dismiss, Restore } from "./toc-buttons.js"; enum State { Normal, Expanded, Collapsed, } export function TOC({ postSelector, headingSelector, }: { postSelector?: string; headingSelector?: string; }) { postSelector = postSelector || ".e-content.entry-content"; headingSelector = headingSelector || "h2,h3,h4,h5,h6"; const { headings } = useHeadingsData(postSelector, headingSelector); const { inViewId } = useInViewId(postSelector, headingSelector); const [expansion, setExpansion] = useState(State.Normal); const scrollRef = useRef<HTMLDivElement>(null); function scroll(to: number) { scrollRef.current?.scroll({ top: to - 75, behavior: "smooth", }); } const dismissIfExpanded = () => { if (expansion === State.Expanded) expand(); }; const expand = () => setExpansion(State.Expanded); const normal = () => setExpansion(State.Normal); const collapse = () => setExpansion(State.Collapsed); return ( <nav aria-label="Table of Contents"> {expansion != State.Collapsed && ( <div className="controls"> {expansion == State.Normal ? ( <UnfoldMore onClick={expand} /> ) : ( <UnfoldLess onClick={normal} /> )} <Dismiss onClick={collapse} /> </div> )} <div ref={scrollRef} className={classNames("outer-scroll", { expanded: expansion == State.Expanded, collapsed: expansion == State.Collapsed, normal: expansion == State.Normal, })} > {expansion == State.Collapsed ? ( <Restore onClick={normal} /> ) : ( <> <div role="heading" aria-level={6}> In this post: </div> <ul> {headings.map((h) => ( <li key={h.id}> <H entry={h} inView={inViewId} scroll={scroll} onClick={dismissIfExpanded} /> </li> ))} </ul> </> )} </div> </nav> ); } function H({ entry, inView, scroll, onClick, }: { entry: HEntry; inView: string | undefined; scroll: (to: number) => void; onClick: () => void; }) { const aRef = useRef<HTMLAnchorElement>(null); useEffect(() => { if (inView == entry.id && aRef.current) { scroll(aRef.current.offsetTop); } }, [inView]); return ( <> <a href={`#${entry.id}`} className={classNames("h", entry.id === inView ? "active" : undefined)} ref={aRef} onClick={() => { onClick(); }} > {entry.text} </a> {entry.items && ( <ul> {entry.items.map((h) => ( <li key={h.id}> <H entry={h} inView={inView} scroll={scroll} onClick={onClick} /> </li> ))} </ul> )} </> ); } function useInViewId(postSelector: string, headingSelector: string) { const [inViewId, setInViewId] = useState<string | undefined>(); useEffect(() => { const inViewSet = new Map<string, HTMLElement>(); const callback: IntersectionObserverCallback = (changes) => { for (const change of changes) { change.isIntersecting ? inViewSet.set(change.target.id, change.target as HTMLElement) : inViewSet.delete(change.target.id); } const inView = Array.from(inViewSet.entries()) .map(([id, el]) => [id, el.offsetTop] as const) .filter(([id, _]) => !!id); if (inView.length > 0) { setInViewId( inView.reduce((acc, next) => (next[1] < acc[1] ? next : acc))[0] ); } }; const observer = new IntersectionObserver(callback, { rootMargin: "0px 0px -20% 0px", }); for (const el of document .querySelector(postSelector)! .querySelectorAll(headingSelector)) { observer.observe(el); } return () => observer.disconnect(); }, []); return { inViewId }; } interface HEntry { text: string; id: string; level: number; items?: HEntry[]; } function getNestedHeadings(headings: readonly HTMLHeadingElement[]): HEntry[] { const sentinel: HEntry = { text: "", id: "", level: 0 }; const traversalStack: HEntry[] = [sentinel]; for (const h of headings) { const hLevel = level(h); for ( let last = traversalStack[traversalStack.length - 1]; hLevel <= last.level; traversalStack.pop(), last = traversalStack[traversalStack.length - 1] ) {} const last = traversalStack[traversalStack.length - 1]; last.items = last.items || []; last.items.push({ text: h.textContent || "", id: h.id, level: hLevel, }); traversalStack.push(last.items[last.items.length - 1]); } return sentinel.items || []; } function level(e: HTMLHeadingElement): number { return parseInt(e.tagName[1]); } function useHeadingsData(postSelector: string, headingSelector: string) { const [headings, setHeadings] = useState<HEntry[]>([]); useEffect(() => { const hs = getNestedHeadings( Array.from( document .querySelector(postSelector)! .querySelectorAll<HTMLHeadingElement>(headingSelector) ) ); setHeadings(hs); }, []); return { headings }; }