Spaces:
Sleeping
Sleeping
| 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; | |
| } | |
| /** | |
| * Resolve ProseMirror position to the closest heading DOM element. | |
| */ | |
| 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)); | |
| // 60ms debounce: short enough that headings typed by any collaborator | |
| // (local or remote via Yjs) appear in the TOC with no perceptible lag | |
| // while still coalescing bursts of per-keystroke updates. | |
| const debouncedUpdate = () => { | |
| clearTimeout(debounceRef.current); | |
| debounceRef.current = window.setTimeout( | |
| () => setHeadings(extractHeadings(editor)), | |
| 60, | |
| ); | |
| }; | |
| editor.on("update", debouncedUpdate); | |
| // Yjs remote transactions also fire "transaction" - listen to both so | |
| // the TOC keeps pace with collaborators editing in other browsers. | |
| editor.on("transaction", debouncedUpdate); | |
| return () => { | |
| editor.off("update", debouncedUpdate); | |
| editor.off("transaction", debouncedUpdate); | |
| clearTimeout(debounceRef.current); | |
| }; | |
| }, [editor]); | |
| // Scroll-spy via IntersectionObserver - no per-frame scroll work. | |
| // | |
| // Semantics: active heading = the *topmost visible heading inside the scroll | |
| // container's reading zone* (below the SCROLL_OFFSET_PX top margin). | |
| // This is intentionally different from the publisher-side TOC which uses a | |
| // "last heading scrolled past the offset line" rule (see | |
| // `backend/src/publisher/html-renderer.ts`). Rationale: | |
| // - Editor: users navigate non-linearly (click TOC, jump, split view). The | |
| // topmost-visible rule reacts instantly when a heading enters the | |
| // reading zone, so the sidebar always reflects what the user is looking | |
| // at. | |
| // - Publisher: readers scroll linearly through the article. The | |
| // last-scrolled-past rule matches classic docs UX (MDN, Docusaurus) and | |
| // keeps the active entry stable while reading body text between | |
| // headings. | |
| // Both use IntersectionObserver as the primitive; only the selection logic | |
| // differs. Do not "unify" the two without considering the UX implications. | |
| 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> | |
| ); | |
| } | |