73 lines
2.0 KiB
TypeScript
73 lines
2.0 KiB
TypeScript
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;
|
||
}
|