Преглед изворни кода

feat(编辑器): 优化块渲染性能与错误处理,增强索引传递机制

- 重构 BlockCanvas 组件为 React.memo,合并 TOC 检测与标题索引查询为单一 useMemo
- 优化 TOC 占位符显示逻辑,仅在第一个标题块上方显示(而非每个标题块)
- 增强 BlockRenderer 组件,新增 index 和 blockCount 属性用于块位置追踪
- 更新 HeadingBlock、ParagraphBlock、ImageBlock 组件接收索引参数,移除本地索引查询
- 改进 BlockEditor 文档加载逻辑,添加竞态条件防护与缺失文档 ID 的错误提示
- 优化 handleSave 回调,添加只读模式检查防止不必要的保存操作
Zhang Yice пре 1 месец
родитељ
комит
f03e7d2c67

+ 25 - 14
src/components/Editor/BlockCanvas.tsx

@@ -31,22 +31,31 @@ export interface BlockCanvasProps {
31 31
 /**
32 32
  * BlockCanvas - 块画布
33 33
  * 
34
- * 渲染所有blocks,按block_order排序显示
34
+ * 在第一个标题块上方显示TOC占位符(如果当前没有TOC块)
35 35
  * 在每个标题块上方显示TOC占位符(如果当前没有TOC块)
36 36
  */
37
-export const BlockCanvas: React.FC<BlockCanvasProps> = ({
37
+export const BlockCanvas = React.memo(function BlockCanvas({
38 38
   blocks,
39 39
   readOnly = false,
40
-}) => {
41
-  // 按block_order排序
42
-  const sortedBlocks = React.useMemo(() => {
43
-    return [...blocks].sort((a, b) => a.block_order - b.block_order);
44
-  }, [blocks]);
40
+}: BlockCanvasProps) {
41
+  const { sortedBlocks, hasTOC, firstHeadingIndex } = React.useMemo(() => {
42
+    const sorted = [...blocks].sort((a, b) => a.block_order - b.block_order);
43
+    let tocFound = false;
44
+    let headingIndex = -1;
45
+
46
+    sorted.forEach((block, index) => {
47
+      if (block.type === 'toc') tocFound = true;
48
+      if (headingIndex === -1 && block.type === 'heading') {
49
+        headingIndex = index;
50
+      }
51
+    });
45 52
 
46
-  // 检查是否已存在TOC块
47
-  const hasTOC = React.useMemo(() => {
48
-    return sortedBlocks.some(block => block.type === 'toc');
49
-  }, [sortedBlocks]);
53
+    return {
54
+      sortedBlocks: sorted,
55
+      hasTOC: tocFound,
56
+      firstHeadingIndex: headingIndex,
57
+    };
58
+  }, [blocks]);
50 59
 
51 60
   // 空状态
52 61
   if (sortedBlocks.length === 0) {
@@ -60,14 +69,14 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
60 69
   return (
61 70
     <div className="block-canvas" data-testid="block-canvas">
62 71
       {sortedBlocks.map((block, index) => {
63
-        const isHeading = block.type === 'heading';
64 72
         const prevBlock = index > 0 ? sortedBlocks[index - 1] : null;
65 73
         
66 74
         // 在标题上方显示TOC占位符的条件:
67 75
         // 1. 当前文档没有TOC块
68 76
         // 2. 当前块是标题块
69 77
         // 3. 不是只读模式
70
-        const showPlaceholderBefore = !hasTOC && isHeading && !readOnly;
78
+        const showPlaceholderBefore =
79
+          !hasTOC && index === firstHeadingIndex && !readOnly;
71 80
         
72 81
         return (
73 82
           <React.Fragment key={block.id}>
@@ -82,6 +91,8 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
82 91
             {/* 渲染当前块 */}
83 92
             <BlockRenderer
84 93
               block={block}
94
+              index={index}
95
+              blockCount={sortedBlocks.length}
85 96
               readOnly={readOnly}
86 97
             />
87 98
           </React.Fragment>
@@ -89,6 +100,6 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
89 100
       })}
90 101
     </div>
91 102
   );
92
-};
103
+});
93 104
 
94 105
 export default BlockCanvas;

+ 24 - 2
src/components/Editor/BlockEditor.tsx

@@ -75,15 +75,26 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
75 75
 
76 76
   // ── Load document ──────────────────────────────────────────────────────────
77 77
   useEffect(() => {
78
+    let isActive = true;
79
+
78 80
     if (documentId) {
79 81
       loadDocument(documentId).catch((err) => {
80
-        message.error('加载文档失败: ' + (err.message || '未知错误'));
82
+        if (!isActive) return;
83
+
84
+        const detail = err instanceof Error ? err.message : '未知错误';
85
+        message.error('加载文档失败: ' + detail);
81 86
       });
82 87
     }
88
+
89
+    return () => {
90
+      isActive = false;
91
+    };
83 92
   }, [documentId, loadDocument]);
84 93
 
85 94
   // ── Save handler ───────────────────────────────────────────────────────────
86 95
   const handleSave = useCallback(async () => {
96
+    if (readOnly) return;
97
+
87 98
     try {
88 99
       await saveDocument();
89 100
       message.success('文档已保存');
@@ -91,7 +102,7 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
91 102
       const detail = error instanceof Error ? error.message : '未知错误';
92 103
       message.error('保存失败: ' + detail);
93 104
     }
94
-  }, [saveDocument]);
105
+  }, [readOnly, saveDocument]);
95 106
 
96 107
   // ── Render ─────────────────────────────────────────────────────────────────
97 108
 
@@ -118,6 +129,17 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
118 129
     );
119 130
   }
120 131
 
132
+  if (!documentId) {
133
+    return (
134
+      <div className="block-editor-error">
135
+        <div className="error-content">
136
+          <h3>无法打开文档</h3>
137
+          <p>缺少文档 ID</p>
138
+        </div>
139
+      </div>
140
+    );
141
+  }
142
+
121 143
   return (
122 144
     <div className="block-editor" data-testid="block-editor">
123 145
       {/* 工具栏 */}

+ 30 - 3
src/components/Editor/BlockRenderer.tsx

@@ -21,6 +21,10 @@ import { TOCBlock } from './blocks/TOCBlock';
21 21
 export interface BlockRendererProps {
22 22
   /** 文档块 */
23 23
   block: DocumentBlock;
24
+  /** 块在画布排序结果中的位置 */
25
+  index?: number;
26
+  /** 画布中的块总数 */
27
+  blockCount?: number;
24 28
   /** 是否只读 */
25 29
   readOnly?: boolean;
26 30
 }
@@ -36,20 +40,43 @@ export interface BlockRendererProps {
36 40
  */
37 41
 export const BlockRenderer = React.memo(function BlockRenderer({
38 42
   block,
43
+  index,
44
+  blockCount,
39 45
   readOnly = false,
40 46
 }: BlockRendererProps) {
41 47
   switch (block.type) {
42 48
     case 'heading':
43
-      return <HeadingBlock block={block} readOnly={readOnly} />;
49
+      return (
50
+        <HeadingBlock
51
+          block={block}
52
+          index={index ?? 0}
53
+          blockCount={blockCount ?? 0}
54
+          readOnly={readOnly}
55
+        />
56
+      );
44 57
     
45 58
     case 'paragraph':
46
-      return <ParagraphBlock block={block} readOnly={readOnly} />;
59
+      return (
60
+        <ParagraphBlock
61
+          block={block}
62
+          index={index ?? 0}
63
+          blockCount={blockCount ?? 0}
64
+          readOnly={readOnly}
65
+        />
66
+      );
47 67
     
48 68
     case 'table':
49 69
       return <TableBlock block={block} readOnly={readOnly} />;
50 70
     
51 71
     case 'image':
52
-      return <ImageBlock block={block} readOnly={readOnly} />;
72
+      return (
73
+        <ImageBlock
74
+          block={block}
75
+          index={index ?? 0}
76
+          blockCount={blockCount ?? 0}
77
+          readOnly={readOnly}
78
+        />
79
+      );
53 80
     
54 81
     case 'toc':
55 82
       return <TOCBlock block={block} readOnly={true} />;

+ 6 - 7
src/components/Editor/blocks/HeadingBlock.tsx

@@ -15,6 +15,8 @@ import './HeadingBlock.css';
15 15
 
16 16
 export interface HeadingBlockProps {
17 17
   block: HeadingBlockType;
18
+  index: number;
19
+  blockCount: number;
18 20
   readOnly?: boolean;
19 21
 }
20 22
 
@@ -23,17 +25,14 @@ export interface HeadingBlockProps {
23 25
  */
24 26
 export const HeadingBlock: React.FC<HeadingBlockProps> = ({ 
25 27
   block, 
28
+  index,
29
+  blockCount,
26 30
   readOnly,
27 31
 }) => {
28 32
   const updateBlock = useEditorStore((state) => state.updateBlock);
29 33
   const saveBlock = useEditorStore((state) => state.saveBlock);
30 34
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
31 35
   const addBlock = useEditorStore((state) => state.addBlock);
32
-  const currentIndex = useEditorStore((state) =>
33
-    state.blocks.findIndex((currentBlock) => currentBlock.id === block.id)
34
-  );
35
-  const blockCount = useEditorStore((state) => state.blocks.length);
36
-
37 36
   // 解析样式
38 37
   const blockStyle = resolveBlockStyle(block.word_style, block.style);
39 38
   
@@ -43,8 +42,8 @@ export const HeadingBlock: React.FC<HeadingBlockProps> = ({
43 42
   }
44 43
 
45 44
   // 查找当前块的位置
46
-  const isFirst = currentIndex === 0;
47
-  const isLast = currentIndex === blockCount - 1;
45
+  const isFirst = index === 0;
46
+  const isLast = index === blockCount - 1;
48 47
 
49 48
   // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
50 49
   const handleChange = useCallback(

+ 10 - 7
src/components/Editor/blocks/ImageBlock.tsx

@@ -19,20 +19,23 @@ import './ImageBlock.css';
19 19
 
20 20
 export interface ImageBlockProps {
21 21
   block: ImageBlockType;
22
+  index: number;
23
+  blockCount: number;
22 24
   readOnly?: boolean;
23 25
 }
24 26
 
25 27
 /**
26 28
  * ImageBlock - 图片块(完整实现)
27 29
  */
28
-export const ImageBlock: React.FC<ImageBlockProps> = ({ block, readOnly }) => {
30
+export const ImageBlock: React.FC<ImageBlockProps> = ({
31
+  block,
32
+  index,
33
+  blockCount,
34
+  readOnly,
35
+}) => {
29 36
   const updateBlock = useEditorStore((state) => state.updateBlock);
30 37
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
31 38
   const addBlock = useEditorStore((state) => state.addBlock);
32
-  const currentIndex = useEditorStore((state) =>
33
-    state.blocks.findIndex((currentBlock) => currentBlock.id === block.id)
34
-  );
35
-  const blockCount = useEditorStore((state) => state.blocks.length);
36 39
   const [isHovered, setIsHovered] = useState(false);
37 40
   const [isResizing, setIsResizing] = useState(false);
38 41
   const imageRef = useRef<HTMLImageElement>(null);
@@ -44,8 +47,8 @@ export const ImageBlock: React.FC<ImageBlockProps> = ({ block, readOnly }) => {
44 47
   });
45 48
 
46 49
   // 查找当前块的位置
47
-  const isFirst = currentIndex === 0;
48
-  const isLast = currentIndex === blockCount - 1;
50
+  const isFirst = index === 0;
51
+  const isLast = index === blockCount - 1;
49 52
 
50 53
   // 处理图片上传
51 54
   const handleUpload = useCallback(

+ 6 - 7
src/components/Editor/blocks/ParagraphBlock.tsx

@@ -15,6 +15,8 @@ import './ParagraphBlock.css';
15 15
 
16 16
 export interface ParagraphBlockProps {
17 17
   block: ParagraphBlockType;
18
+  index: number;
19
+  blockCount: number;
18 20
   readOnly?: boolean;
19 21
 }
20 22
 
@@ -23,17 +25,14 @@ export interface ParagraphBlockProps {
23 25
  */
24 26
 export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({ 
25 27
   block, 
28
+  index,
29
+  blockCount,
26 30
   readOnly,
27 31
 }) => {
28 32
   const updateBlock = useEditorStore((state) => state.updateBlock);
29 33
   const saveBlock = useEditorStore((state) => state.saveBlock);
30 34
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
31 35
   const addBlock = useEditorStore((state) => state.addBlock);
32
-  const currentIndex = useEditorStore((state) =>
33
-    state.blocks.findIndex((currentBlock) => currentBlock.id === block.id)
34
-  );
35
-  const blockCount = useEditorStore((state) => state.blocks.length);
36
-
37 36
   // 解析样式
38 37
   const blockStyle = resolveBlockStyle(block.word_style, block.style);
39 38
   
@@ -42,8 +41,8 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
42 41
     blockStyle.textAlign = block.style.align;
43 42
   }
44 43
 
45
-  const isFirst = currentIndex === 0;
46
-  const isLast = currentIndex === blockCount - 1;
44
+  const isFirst = index === 0;
45
+  const isLast = index === blockCount - 1;
47 46
 
48 47
   // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
49 48
   const handleChange = useCallback(