Przeglądaj źródła

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

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

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

@@ -31,22 +31,31 @@ export interface BlockCanvasProps {
31
 /**
31
 /**
32
  * BlockCanvas - 块画布
32
  * BlockCanvas - 块画布
33
  * 
33
  * 
34
- * 渲染所有blocks,按block_order排序显示
34
+ * 在第一个标题块上方显示TOC占位符(如果当前没有TOC块)
35
  * 在每个标题块上方显示TOC占位符(如果当前没有TOC块)
35
  * 在每个标题块上方显示TOC占位符(如果当前没有TOC块)
36
  */
36
  */
37
-export const BlockCanvas: React.FC<BlockCanvasProps> = ({
37
+export const BlockCanvas = React.memo(function BlockCanvas({
38
   blocks,
38
   blocks,
39
   readOnly = false,
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
   if (sortedBlocks.length === 0) {
61
   if (sortedBlocks.length === 0) {
@@ -60,14 +69,14 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
60
   return (
69
   return (
61
     <div className="block-canvas" data-testid="block-canvas">
70
     <div className="block-canvas" data-testid="block-canvas">
62
       {sortedBlocks.map((block, index) => {
71
       {sortedBlocks.map((block, index) => {
63
-        const isHeading = block.type === 'heading';
64
         const prevBlock = index > 0 ? sortedBlocks[index - 1] : null;
72
         const prevBlock = index > 0 ? sortedBlocks[index - 1] : null;
65
         
73
         
66
         // 在标题上方显示TOC占位符的条件:
74
         // 在标题上方显示TOC占位符的条件:
67
         // 1. 当前文档没有TOC块
75
         // 1. 当前文档没有TOC块
68
         // 2. 当前块是标题块
76
         // 2. 当前块是标题块
69
         // 3. 不是只读模式
77
         // 3. 不是只读模式
70
-        const showPlaceholderBefore = !hasTOC && isHeading && !readOnly;
78
+        const showPlaceholderBefore =
79
+          !hasTOC && index === firstHeadingIndex && !readOnly;
71
         
80
         
72
         return (
81
         return (
73
           <React.Fragment key={block.id}>
82
           <React.Fragment key={block.id}>
@@ -82,6 +91,8 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
82
             {/* 渲染当前块 */}
91
             {/* 渲染当前块 */}
83
             <BlockRenderer
92
             <BlockRenderer
84
               block={block}
93
               block={block}
94
+              index={index}
95
+              blockCount={sortedBlocks.length}
85
               readOnly={readOnly}
96
               readOnly={readOnly}
86
             />
97
             />
87
           </React.Fragment>
98
           </React.Fragment>
@@ -89,6 +100,6 @@ export const BlockCanvas: React.FC<BlockCanvasProps> = ({
89
       })}
100
       })}
90
     </div>
101
     </div>
91
   );
102
   );
92
-};
103
+});
93
 
104
 
94
 export default BlockCanvas;
105
 export default BlockCanvas;

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

@@ -75,15 +75,26 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
75
 
75
 
76
   // ── Load document ──────────────────────────────────────────────────────────
76
   // ── Load document ──────────────────────────────────────────────────────────
77
   useEffect(() => {
77
   useEffect(() => {
78
+    let isActive = true;
79
+
78
     if (documentId) {
80
     if (documentId) {
79
       loadDocument(documentId).catch((err) => {
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
   }, [documentId, loadDocument]);
92
   }, [documentId, loadDocument]);
84
 
93
 
85
   // ── Save handler ───────────────────────────────────────────────────────────
94
   // ── Save handler ───────────────────────────────────────────────────────────
86
   const handleSave = useCallback(async () => {
95
   const handleSave = useCallback(async () => {
96
+    if (readOnly) return;
97
+
87
     try {
98
     try {
88
       await saveDocument();
99
       await saveDocument();
89
       message.success('文档已保存');
100
       message.success('文档已保存');
@@ -91,7 +102,7 @@ export const BlockEditor: React.FC<BlockEditorProps> = ({
91
       const detail = error instanceof Error ? error.message : '未知错误';
102
       const detail = error instanceof Error ? error.message : '未知错误';
92
       message.error('保存失败: ' + detail);
103
       message.error('保存失败: ' + detail);
93
     }
104
     }
94
-  }, [saveDocument]);
105
+  }, [readOnly, saveDocument]);
95
 
106
 
96
   // ── Render ─────────────────────────────────────────────────────────────────
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
   return (
143
   return (
122
     <div className="block-editor" data-testid="block-editor">
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
 export interface BlockRendererProps {
21
 export interface BlockRendererProps {
22
   /** 文档块 */
22
   /** 文档块 */
23
   block: DocumentBlock;
23
   block: DocumentBlock;
24
+  /** 块在画布排序结果中的位置 */
25
+  index?: number;
26
+  /** 画布中的块总数 */
27
+  blockCount?: number;
24
   /** 是否只读 */
28
   /** 是否只读 */
25
   readOnly?: boolean;
29
   readOnly?: boolean;
26
 }
30
 }
@@ -36,20 +40,43 @@ export interface BlockRendererProps {
36
  */
40
  */
37
 export const BlockRenderer = React.memo(function BlockRenderer({
41
 export const BlockRenderer = React.memo(function BlockRenderer({
38
   block,
42
   block,
43
+  index,
44
+  blockCount,
39
   readOnly = false,
45
   readOnly = false,
40
 }: BlockRendererProps) {
46
 }: BlockRendererProps) {
41
   switch (block.type) {
47
   switch (block.type) {
42
     case 'heading':
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
     case 'paragraph':
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
     case 'table':
68
     case 'table':
49
       return <TableBlock block={block} readOnly={readOnly} />;
69
       return <TableBlock block={block} readOnly={readOnly} />;
50
     
70
     
51
     case 'image':
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
     case 'toc':
81
     case 'toc':
55
       return <TOCBlock block={block} readOnly={true} />;
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
 export interface HeadingBlockProps {
16
 export interface HeadingBlockProps {
17
   block: HeadingBlockType;
17
   block: HeadingBlockType;
18
+  index: number;
19
+  blockCount: number;
18
   readOnly?: boolean;
20
   readOnly?: boolean;
19
 }
21
 }
20
 
22
 
@@ -23,17 +25,14 @@ export interface HeadingBlockProps {
23
  */
25
  */
24
 export const HeadingBlock: React.FC<HeadingBlockProps> = ({ 
26
 export const HeadingBlock: React.FC<HeadingBlockProps> = ({ 
25
   block, 
27
   block, 
28
+  index,
29
+  blockCount,
26
   readOnly,
30
   readOnly,
27
 }) => {
31
 }) => {
28
   const updateBlock = useEditorStore((state) => state.updateBlock);
32
   const updateBlock = useEditorStore((state) => state.updateBlock);
29
   const saveBlock = useEditorStore((state) => state.saveBlock);
33
   const saveBlock = useEditorStore((state) => state.saveBlock);
30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
34
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
31
   const addBlock = useEditorStore((state) => state.addBlock);
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
   const blockStyle = resolveBlockStyle(block.word_style, block.style);
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
   // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
48
   // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
50
   const handleChange = useCallback(
49
   const handleChange = useCallback(

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

@@ -19,20 +19,23 @@ import './ImageBlock.css';
19
 
19
 
20
 export interface ImageBlockProps {
20
 export interface ImageBlockProps {
21
   block: ImageBlockType;
21
   block: ImageBlockType;
22
+  index: number;
23
+  blockCount: number;
22
   readOnly?: boolean;
24
   readOnly?: boolean;
23
 }
25
 }
24
 
26
 
25
 /**
27
 /**
26
  * ImageBlock - 图片块(完整实现)
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
   const updateBlock = useEditorStore((state) => state.updateBlock);
36
   const updateBlock = useEditorStore((state) => state.updateBlock);
30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
37
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
31
   const addBlock = useEditorStore((state) => state.addBlock);
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
   const [isHovered, setIsHovered] = useState(false);
39
   const [isHovered, setIsHovered] = useState(false);
37
   const [isResizing, setIsResizing] = useState(false);
40
   const [isResizing, setIsResizing] = useState(false);
38
   const imageRef = useRef<HTMLImageElement>(null);
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
   const handleUpload = useCallback(
54
   const handleUpload = useCallback(

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

@@ -15,6 +15,8 @@ import './ParagraphBlock.css';
15
 
15
 
16
 export interface ParagraphBlockProps {
16
 export interface ParagraphBlockProps {
17
   block: ParagraphBlockType;
17
   block: ParagraphBlockType;
18
+  index: number;
19
+  blockCount: number;
18
   readOnly?: boolean;
20
   readOnly?: boolean;
19
 }
21
 }
20
 
22
 
@@ -23,17 +25,14 @@ export interface ParagraphBlockProps {
23
  */
25
  */
24
 export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({ 
26
 export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({ 
25
   block, 
27
   block, 
28
+  index,
29
+  blockCount,
26
   readOnly,
30
   readOnly,
27
 }) => {
31
 }) => {
28
   const updateBlock = useEditorStore((state) => state.updateBlock);
32
   const updateBlock = useEditorStore((state) => state.updateBlock);
29
   const saveBlock = useEditorStore((state) => state.saveBlock);
33
   const saveBlock = useEditorStore((state) => state.saveBlock);
30
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
34
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
31
   const addBlock = useEditorStore((state) => state.addBlock);
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
   const blockStyle = resolveBlockStyle(block.word_style, block.style);
37
   const blockStyle = resolveBlockStyle(block.word_style, block.style);
39
   
38
   
@@ -42,8 +41,8 @@ export const ParagraphBlock: React.FC<ParagraphBlockProps> = ({
42
     blockStyle.textAlign = block.style.align;
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
   // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
47
   // 处理内容变更 - 只需调用updateBlock,store会自动处理保存
49
   const handleChange = useCallback(
48
   const handleChange = useCallback(