| import { useState, useEffect, useCallback, useMemo, useRef } from "react"; |
| import type { Editor } from "@tiptap/core"; |
|
|
| const SCROLL_OFFSET_PX = 100; |
|
|
| interface TocItem { |
| id: string; |
| text: string; |
| level: number; |
| pos: number; |
| } |
|
|
| interface TocNode extends TocItem { |
| children: TocNode[]; |
| } |
|
|
| function extractHeadings(editor: Editor): TocItem[] { |
| const items: TocItem[] = []; |
| editor.state.doc.descendants((node, pos) => { |
| if (node.type.name === "heading") { |
| const level = node.attrs.level as number; |
| const text = node.textContent; |
| if (text.trim()) { |
| items.push({ id: `heading-${pos}`, text, level, pos }); |
| } |
| } |
| }); |
| return items; |
| } |
|
|
| function buildTree(headings: TocItem[]): TocNode[] { |
| const root: TocNode[] = []; |
| const stack: TocNode[] = []; |
|
|
| for (const h of headings) { |
| const node: TocNode = { ...h, children: [] }; |
|
|
| while (stack.length > 0 && stack[stack.length - 1].level >= h.level) { |
| stack.pop(); |
| } |
|
|
| if (stack.length === 0) { |
| root.push(node); |
| } else { |
| stack[stack.length - 1].children.push(node); |
| } |
|
|
| stack.push(node); |
| } |
|
|
| return root; |
| } |
|
|
| |
| |
| |
| function resolveHeadingElement(editor: Editor, pos: number): HTMLElement | null { |
| try { |
| const dom = editor.view.domAtPos(pos + 1); |
| let el: HTMLElement | null = |
| dom.node instanceof HTMLElement ? dom.node : dom.node.parentElement; |
| while (el && !/^H[1-6]$/i.test(el.tagName)) { |
| el = el.parentElement; |
| } |
| return el; |
| } catch { |
| return null; |
| } |
| } |
|
|
| interface TableOfContentsProps { |
| editor: Editor | null; |
| scrollContainer?: HTMLElement | null; |
| autoCollapse?: boolean; |
| onNavigate?: () => void; |
| } |
|
|
| export function TableOfContents({ editor, scrollContainer, autoCollapse = false, onNavigate }: TableOfContentsProps) { |
| const [headings, setHeadings] = useState<TocItem[]>([]); |
| const [activePos, setActivePos] = useState<number | null>(null); |
|
|
| const debounceRef = useRef(0); |
|
|
| useEffect(() => { |
| if (!editor) return; |
| setHeadings(extractHeadings(editor)); |
| |
| |
| |
| const debouncedUpdate = () => { |
| clearTimeout(debounceRef.current); |
| debounceRef.current = window.setTimeout( |
| () => setHeadings(extractHeadings(editor)), |
| 60, |
| ); |
| }; |
| editor.on("update", debouncedUpdate); |
| |
| |
| editor.on("transaction", debouncedUpdate); |
| return () => { |
| editor.off("update", debouncedUpdate); |
| editor.off("transaction", debouncedUpdate); |
| clearTimeout(debounceRef.current); |
| }; |
| }, [editor]); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| useEffect(() => { |
| if (!editor || headings.length === 0) return; |
|
|
| const root = scrollContainer ?? null; |
| const visibleSet = new Map<Element, number>(); |
|
|
| const observer = new IntersectionObserver( |
| (entries) => { |
| for (const entry of entries) { |
| if (entry.isIntersecting) { |
| visibleSet.set(entry.target, headings.findIndex( |
| (h) => resolveHeadingElement(editor, h.pos) === entry.target, |
| )); |
| } else { |
| visibleSet.delete(entry.target); |
| } |
| } |
| let bestIdx = -1; |
| for (const idx of visibleSet.values()) { |
| if (idx >= 0 && (bestIdx < 0 || idx < bestIdx)) bestIdx = idx; |
| } |
| if (visibleSet.size > 0) { |
| setActivePos(bestIdx >= 0 ? headings[bestIdx].pos : null); |
| } |
| }, |
| { |
| root, |
| rootMargin: `-${SCROLL_OFFSET_PX}px 0px 0px 0px`, |
| threshold: 0, |
| }, |
| ); |
|
|
| const elements: HTMLElement[] = []; |
| for (const h of headings) { |
| const el = resolveHeadingElement(editor, h.pos); |
| if (el) { |
| observer.observe(el); |
| elements.push(el); |
| } |
| } |
|
|
| return () => observer.disconnect(); |
| }, [editor, headings, scrollContainer]); |
|
|
| const tree = useMemo(() => buildTree(headings), [headings]); |
|
|
| const expandedPositions = useMemo(() => { |
| const set = new Set<number>(); |
| if (activePos == null) return set; |
|
|
| const walk = (nodes: TocNode[], ancestors: number[]): boolean => { |
| for (const node of nodes) { |
| if (node.pos === activePos) { |
| ancestors.forEach((p) => set.add(p)); |
| set.add(node.pos); |
| return true; |
| } |
| if (node.children.length > 0 && walk(node.children, [...ancestors, node.pos])) { |
| return true; |
| } |
| } |
| return false; |
| }; |
| walk(tree, []); |
| return set; |
| }, [tree, activePos]); |
|
|
| const scrollTo = useCallback((pos: number) => { |
| if (!editor) return; |
| const el = resolveHeadingElement(editor, pos); |
| if (el) { |
| el.scrollIntoView({ behavior: "smooth", block: "start" }); |
| } |
| editor.chain().focus().setTextSelection(pos).run(); |
| onNavigate?.(); |
| }, [editor, onNavigate]); |
|
|
| if (!editor || headings.length === 0) { |
| return ( |
| <div className="table-of-contents"> |
| <div className="toc-title">Table of contents</div> |
| <div className="toc-empty">Headings will appear here as you write.</div> |
| </div> |
| ); |
| } |
|
|
| const renderList = (nodes: TocNode[], isRoot: boolean) => ( |
| <ul> |
| {nodes.map((node) => { |
| const isExpanded = expandedPositions.has(node.pos); |
| const hasChildren = node.children.length > 0; |
| |
| return ( |
| <li key={node.id}> |
| <a |
| className={activePos === node.pos ? "active" : undefined} |
| onClick={(e) => { |
| e.preventDefault(); |
| scrollTo(node.pos); |
| }} |
| > |
| {node.text} |
| </a> |
| {hasChildren && ( |
| <div |
| className="toc-children" |
| style={autoCollapse ? { |
| gridTemplateRows: isExpanded ? "1fr" : "0fr", |
| opacity: isExpanded ? 1 : 0, |
| } : { |
| gridTemplateRows: "1fr", |
| opacity: 1, |
| }} |
| > |
| {renderList(node.children, false)} |
| </div> |
| )} |
| </li> |
| ); |
| })} |
| </ul> |
| ); |
|
|
| return ( |
| <div className="table-of-contents"> |
| <div className="toc-title">Table of contents</div> |
| <nav> |
| {renderList(tree, true)} |
| </nav> |
| </div> |
| ); |
| } |
|
|