| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259 |
- /**
- * 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<string, string>,
- ): 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<DocumentOutlineProps> = ({
- 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<React.Key[]>([]);
- 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 (
- <span className={`outline-title ${levelClass}`}>
- {outlineNode.title}
- </span>
- );
- }, []);
- if (!visible) return null;
- if (headings.length === 0) {
- return (
- <div className="document-outline empty">
- <div className="outline-header">
- <FileTextOutlined />
- <span>文档大纲</span>
- </div>
- <div className="outline-empty">
- <p>暂无标题</p>
- <p className="outline-hint">添加标题后将显示文档结构</p>
- </div>
- </div>
- );
- }
- return (
- <div className="document-outline">
- {/* 头部 */}
- <div className="outline-header">
- <div className="outline-header-left">
- <FileTextOutlined />
- <span>文档结构图</span>
- </div>
- <div className="outline-header-actions">
- <button
- className="outline-action-btn"
- onClick={handleExpandAll}
- title="全部展开"
- >
- +
- </button>
- <button
- className="outline-action-btn"
- onClick={handleCollapseAll}
- title="全部折叠"
- >
- −
- </button>
- </div>
- </div>
- {/* 树形结构 */}
- <div className="outline-content">
- <Tree
- showLine={false}
- showIcon={false}
- switcherIcon={({ expanded }) =>
- expanded ? <CaretDownOutlined /> : <CaretRightOutlined />
- }
- treeData={treeData}
- selectedKeys={selectedBlockId ? [selectedBlockId] : []}
- expandedKeys={expandedKeys}
- autoExpandParent={autoExpandParent}
- onSelect={handleSelect}
- onExpand={handleExpand}
- titleRender={titleRender}
- />
- </div>
- </div>
- );
- };
- export default DocumentOutline;
|