deerflow-code/frontend-web/src/open-canvas/hooks/useDocumentOutline.ts
2026-09-07 18:24:55 +08:00

73 lines
2.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<OutlineHeading[]>([]);
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<typeof setTimeout> | undefined;
const onUpdate = () => {
clearTimeout(timer);
timer = setTimeout(compute, DEBOUNCE_MS);
};
editor.on("update", onUpdate);
return () => {
clearTimeout(timer);
editor.off("update", onUpdate);
};
}, [editor]);
return headings;
}