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;
|
|
}
|