DocumentOutline.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. /**
  2. * DocumentOutline.tsx - 文档大纲组件
  3. *
  4. * 显示文档的层级结构,支持:
  5. * - 展开/折叠子标题
  6. * - 点击跳转到对应位置
  7. * - 高亮当前激活的标题
  8. *
  9. * @module components/Editor
  10. */
  11. import React, { useState, useMemo, useCallback } from 'react';
  12. import { Tree } from 'antd';
  13. import type { DataNode } from 'antd/es/tree';
  14. import {
  15. FileTextOutlined,
  16. CaretDownOutlined,
  17. CaretRightOutlined,
  18. } from '@ant-design/icons';
  19. import type { HeadingBlock } from '../../types/editor';
  20. import { useEditorStore } from '../../stores/editorStore';
  21. import { useShallow } from 'zustand/react/shallow';
  22. import './DocumentOutline.css';
  23. import { getHeadingNumberMap } from '../../utils/headingNumbering';
  24. // ══════════════════════════════════════════════════════════════════════════════
  25. // Types
  26. // ══════════════════════════════════════════════════════════════════════════════
  27. interface OutlineNode extends DataNode {
  28. key: string;
  29. title: string;
  30. level: number;
  31. blockId: string;
  32. children?: OutlineNode[];
  33. }
  34. // ══════════════════════════════════════════════════════════════════════════════
  35. // Component Props
  36. // ══════════════════════════════════════════════════════════════════════════════
  37. export interface DocumentOutlineProps {
  38. /** 是否显示大纲 */
  39. visible?: boolean;
  40. /** 关闭回调 */
  41. onClose?: () => void;
  42. }
  43. // ══════════════════════════════════════════════════════════════════════════════
  44. // Helper Functions
  45. // ══════════════════════════════════════════════════════════════════════════════
  46. /**
  47. * 构建大纲树结构
  48. */
  49. function buildOutlineTree(headings: HeadingBlock[]): OutlineNode[] {
  50. const root: OutlineNode[] = [];
  51. const stack: OutlineNode[] = [];
  52. headings.forEach((heading) => {
  53. const content = typeof heading.content === 'string'
  54. ? heading.content
  55. : heading.content.map(seg => seg.text).join('');
  56. const node: OutlineNode = {
  57. key: heading.id,
  58. title: content || `标题 ${heading.level}`,
  59. level: heading.level,
  60. blockId: heading.id,
  61. children: [],
  62. };
  63. // 找到合适的父节点
  64. while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
  65. stack.pop();
  66. }
  67. if (stack.length === 0) {
  68. // 顶层节点
  69. root.push(node);
  70. } else {
  71. // 子节点
  72. const parent = stack[stack.length - 1];
  73. if (!parent.children) {
  74. parent.children = [];
  75. }
  76. parent.children.push(node);
  77. }
  78. stack.push(node);
  79. });
  80. return root;
  81. }
  82. function addHeadingNumbers(
  83. node: OutlineNode,
  84. numberMap: Map<string, string>,
  85. ): OutlineNode {
  86. return {
  87. ...node,
  88. title: `${numberMap.get(node.blockId) || ''} ${node.title}`.trim(),
  89. children: node.children?.map((child) => addHeadingNumbers(child, numberMap)),
  90. };
  91. }
  92. // ══════════════════════════════════════════════════════════════════════════════
  93. // Component
  94. // ══════════════════════════════════════════════════════════════════════════════
  95. /**
  96. * DocumentOutline - 文档大纲
  97. */
  98. export const DocumentOutline: React.FC<DocumentOutlineProps> = ({
  99. visible = true,
  100. }) => {
  101. const headings = useEditorStore(
  102. useShallow((state) =>
  103. state.blocks
  104. .filter((block): block is HeadingBlock => block.type === 'heading')
  105. .sort((left, right) => left.block_order - right.block_order)
  106. )
  107. );
  108. const selectedBlockId = useEditorStore((state) => state.selectedBlockId);
  109. const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
  110. const [autoExpandParent, setAutoExpandParent] = useState(true);
  111. // 构建树结构
  112. const treeData = useMemo(() => {
  113. const numberMap = getHeadingNumberMap(headings);
  114. return buildOutlineTree(headings).map((node) => addHeadingNumbers(node, numberMap));
  115. }, [headings]);
  116. // 获取所有节点的key
  117. const allKeys = useMemo(() => {
  118. const keys: string[] = [];
  119. const traverse = (nodes: OutlineNode[]) => {
  120. nodes.forEach(node => {
  121. keys.push(node.key);
  122. if (node.children) {
  123. traverse(node.children);
  124. }
  125. });
  126. };
  127. traverse(treeData);
  128. return keys;
  129. }, [treeData]);
  130. // 处理节点点击
  131. const handleSelect = useCallback((selectedKeys: React.Key[]) => {
  132. if (selectedKeys.length === 0) return;
  133. const blockId = selectedKeys[0] as string;
  134. const element = document.querySelector(`[data-block-id="${blockId}"]`);
  135. if (element) {
  136. element.scrollIntoView({ behavior: 'smooth', block: 'center' });
  137. // 可以添加高亮效果
  138. element.classList.add('block-highlight');
  139. setTimeout(() => {
  140. element.classList.remove('block-highlight');
  141. }, 2000);
  142. }
  143. }, []);
  144. // 处理展开/折叠
  145. const handleExpand = useCallback((expandedKeys: React.Key[]) => {
  146. setExpandedKeys(expandedKeys);
  147. setAutoExpandParent(false);
  148. }, []);
  149. // 全部展开
  150. const handleExpandAll = useCallback(() => {
  151. setExpandedKeys(allKeys);
  152. }, [allKeys]);
  153. // 全部折叠
  154. const handleCollapseAll = useCallback(() => {
  155. setExpandedKeys([]);
  156. }, []);
  157. // 自定义标题渲染
  158. const titleRender = useCallback((node: DataNode) => {
  159. const outlineNode = node as OutlineNode;
  160. const levelClass = `outline-title-level-${outlineNode.level}`;
  161. return (
  162. <span className={`outline-title ${levelClass}`}>
  163. {outlineNode.title}
  164. </span>
  165. );
  166. }, []);
  167. if (!visible) return null;
  168. if (headings.length === 0) {
  169. return (
  170. <div className="document-outline empty">
  171. <div className="outline-header">
  172. <FileTextOutlined />
  173. <span>文档大纲</span>
  174. </div>
  175. <div className="outline-empty">
  176. <p>暂无标题</p>
  177. <p className="outline-hint">添加标题后将显示文档结构</p>
  178. </div>
  179. </div>
  180. );
  181. }
  182. return (
  183. <div className="document-outline">
  184. {/* 头部 */}
  185. <div className="outline-header">
  186. <div className="outline-header-left">
  187. <FileTextOutlined />
  188. <span>文档结构图</span>
  189. </div>
  190. <div className="outline-header-actions">
  191. <button
  192. className="outline-action-btn"
  193. onClick={handleExpandAll}
  194. title="全部展开"
  195. >
  196. +
  197. </button>
  198. <button
  199. className="outline-action-btn"
  200. onClick={handleCollapseAll}
  201. title="全部折叠"
  202. >
  203. </button>
  204. </div>
  205. </div>
  206. {/* 树形结构 */}
  207. <div className="outline-content">
  208. <Tree
  209. showLine={false}
  210. showIcon={false}
  211. switcherIcon={({ expanded }) =>
  212. expanded ? <CaretDownOutlined /> : <CaretRightOutlined />
  213. }
  214. treeData={treeData}
  215. selectedKeys={selectedBlockId ? [selectedBlockId] : []}
  216. expandedKeys={expandedKeys}
  217. autoExpandParent={autoExpandParent}
  218. onSelect={handleSelect}
  219. onExpand={handleExpand}
  220. titleRender={titleRender}
  221. />
  222. </div>
  223. </div>
  224. );
  225. };
  226. export default DocumentOutline;