tfrere's picture
tfrere HF Staff
feat(frontend): editor refresh (embed studio, comment popover, shiki, top bar, hooks, styles)
76fc93a
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>
);
}