/** * DocumentOutline.tsx - 文档大纲组件 * * 显示文档的层级结构,支持: * - 展开/折叠子标题 * - 点击跳转到对应位置 * - 高亮当前激活的标题 * * @module components/Editor */ import React, { useState, useMemo, useCallback } from 'react'; import { Tree } from 'antd'; import type { DataNode } from 'antd/es/tree'; import { FileTextOutlined, CaretDownOutlined, CaretRightOutlined, } from '@ant-design/icons'; import type { HeadingBlock } from '../../types/editor'; import { useEditorStore } from '../../stores/editorStore'; import { useShallow } from 'zustand/react/shallow'; import './DocumentOutline.css'; import { getHeadingNumberMap } from '../../utils/headingNumbering'; // ══════════════════════════════════════════════════════════════════════════════ // Types // ══════════════════════════════════════════════════════════════════════════════ interface OutlineNode extends DataNode { key: string; title: string; level: number; blockId: string; children?: OutlineNode[]; } // ══════════════════════════════════════════════════════════════════════════════ // Component Props // ══════════════════════════════════════════════════════════════════════════════ export interface DocumentOutlineProps { /** 是否显示大纲 */ visible?: boolean; /** 关闭回调 */ onClose?: () => void; } // ══════════════════════════════════════════════════════════════════════════════ // Helper Functions // ══════════════════════════════════════════════════════════════════════════════ /** * 构建大纲树结构 */ function buildOutlineTree(headings: HeadingBlock[]): OutlineNode[] { const root: OutlineNode[] = []; const stack: OutlineNode[] = []; headings.forEach((heading) => { const content = typeof heading.content === 'string' ? heading.content : heading.content.map(seg => seg.text).join(''); const node: OutlineNode = { key: heading.id, title: content || `标题 ${heading.level}`, level: heading.level, blockId: heading.id, children: [], }; // 找到合适的父节点 while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { stack.pop(); } if (stack.length === 0) { // 顶层节点 root.push(node); } else { // 子节点 const parent = stack[stack.length - 1]; if (!parent.children) { parent.children = []; } parent.children.push(node); } stack.push(node); }); return root; } function addHeadingNumbers( node: OutlineNode, numberMap: Map, ): OutlineNode { return { ...node, title: `${numberMap.get(node.blockId) || ''} ${node.title}`.trim(), children: node.children?.map((child) => addHeadingNumbers(child, numberMap)), }; } // ══════════════════════════════════════════════════════════════════════════════ // Component // ══════════════════════════════════════════════════════════════════════════════ /** * DocumentOutline - 文档大纲 */ export const DocumentOutline: React.FC = ({ visible = true, }) => { const headings = useEditorStore( useShallow((state) => state.blocks .filter((block): block is HeadingBlock => block.type === 'heading') .sort((left, right) => left.block_order - right.block_order) ) ); const selectedBlockId = useEditorStore((state) => state.selectedBlockId); const [expandedKeys, setExpandedKeys] = useState([]); const [autoExpandParent, setAutoExpandParent] = useState(true); // 构建树结构 const treeData = useMemo(() => { const numberMap = getHeadingNumberMap(headings); return buildOutlineTree(headings).map((node) => addHeadingNumbers(node, numberMap)); }, [headings]); // 获取所有节点的key const allKeys = useMemo(() => { const keys: string[] = []; const traverse = (nodes: OutlineNode[]) => { nodes.forEach(node => { keys.push(node.key); if (node.children) { traverse(node.children); } }); }; traverse(treeData); return keys; }, [treeData]); // 处理节点点击 const handleSelect = useCallback((selectedKeys: React.Key[]) => { if (selectedKeys.length === 0) return; const blockId = selectedKeys[0] as string; const element = document.querySelector(`[data-block-id="${blockId}"]`); if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 可以添加高亮效果 element.classList.add('block-highlight'); setTimeout(() => { element.classList.remove('block-highlight'); }, 2000); } }, []); // 处理展开/折叠 const handleExpand = useCallback((expandedKeys: React.Key[]) => { setExpandedKeys(expandedKeys); setAutoExpandParent(false); }, []); // 全部展开 const handleExpandAll = useCallback(() => { setExpandedKeys(allKeys); }, [allKeys]); // 全部折叠 const handleCollapseAll = useCallback(() => { setExpandedKeys([]); }, []); // 自定义标题渲染 const titleRender = useCallback((node: DataNode) => { const outlineNode = node as OutlineNode; const levelClass = `outline-title-level-${outlineNode.level}`; return ( {outlineNode.title} ); }, []); if (!visible) return null; if (headings.length === 0) { return (
文档大纲

暂无标题

添加标题后将显示文档结构

); } return (
{/* 头部 */}
文档结构图
{/* 树形结构 */}
expanded ? : } treeData={treeData} selectedKeys={selectedBlockId ? [selectedBlockId] : []} expandedKeys={expandedKeys} autoExpandParent={autoExpandParent} onSelect={handleSelect} onExpand={handleExpand} titleRender={titleRender} />
); }; export default DocumentOutline;