import { useEffect, useState } from "react"; import type { Editor } from "@tiptap/react"; /** 文档目录中的一个标题项 */ export interface OutlineHeading { /** React key + scroll-spy 标识,按 ProseMirror 位置生成 */ id: string; /** 标题层级,仅保留 1~3 */ level: 1 | 2 | 3; /** 标题纯文本 */ text: string; /** 标题节点在 ProseMirror 文档中的位置,用于解析 DOM 元素 */ pos: number; } /** 仅纳入目录的最大标题层级(H1~H3) */ const MAX_LEVEL = 3; /** 内容变化后重建目录的防抖间隔(ms) */ const DEBOUNCE_MS = 200; /** * 从 Tiptap 编辑器实时提取 H1~H3 标题,构成文档目录。 * * 首次挂载立即构建一次;之后监听 `editor.on('update')`,按 DEBOUNCE_MS 防抖重建, * 避免逐字输入时频繁全量遍历。 */ export function useDocumentOutline(editor: Editor | null): OutlineHeading[] { const [headings, setHeadings] = useState([]); useEffect(() => { if (!editor) { setHeadings([]); return; } const compute = () => { const out: OutlineHeading[] = []; editor.state.doc.descendants((node, pos) => { if (node.type.name !== "heading") return true; const level = node.attrs.level as number; if (level >= 1 && level <= MAX_LEVEL) { out.push({ id: `heading-${pos}`, level: level as 1 | 2 | 3, text: node.textContent.trim() || "(无标题)", pos, }); } return true; }); setHeadings(out); }; // 首次立即构建 compute(); // 内容变化时防抖重建 let timer: ReturnType | undefined; const onUpdate = () => { clearTimeout(timer); timer = setTimeout(compute, DEBOUNCE_MS); }; editor.on("update", onUpdate); return () => { clearTimeout(timer); editor.off("update", onUpdate); }; }, [editor]); return headings; }