File size: 7,716 Bytes
20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 0094189 20354ec 0094189 76fc93a 0094189 76fc93a 0094189 76fc93a 0094189 76fc93a 0094189 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 76fc93a 20354ec 9b7d64e 20354ec 9b7d64e 20354ec 9b7d64e 20354ec 9b7d64e 20354ec 9b7d64e 76fc93a 20354ec 76fc93a 20354ec 9b7d64e 20354ec 9b7d64e 20354ec 9b7d64e 20354ec 9b7d64e 20354ec 9b7d64e 20354ec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 | 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>
);
}
|