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([]); const [activePos, setActivePos] = useState(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(); 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(); 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 (
Table of contents
Headings will appear here as you write.
); } const renderList = (nodes: TocNode[], isRoot: boolean) => ( ); return (
Table of contents
); }