Parcourir la source

feat(编辑器): 优化富文本工具栏稳定性与选区验证机制

- 修复requestAnimationFrame未正确清理导致的内存泄漏问题
- 增强选区验证逻辑,确保工具栏仅在有效编辑器范围内显示
- 改进选区恢复机制,添加编辑器容器边界检查防止跨编辑器干扰
- 优化内容格式按钮布局,采用固定六列网格避免工具栏溢出
- 重构hasFormat函数,改进选区遍历效率与范围检测准确性
- 移除未使用的UnorderedListOutlined导入与过期工具函数
Zhang Yice il y a 1 mois
Parent
commit
b909181f04

+ 21 - 2
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -135,6 +135,15 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
135 135
   const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -34 });
136 136
   const isComposingRef = useRef(false);
137 137
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
138
+  const formatFrameRef = useRef<number | null>(null);
139
+
140
+  useEffect(() => {
141
+    return () => {
142
+      if (formatFrameRef.current !== null) {
143
+        cancelAnimationFrame(formatFrameRef.current);
144
+      }
145
+    };
146
+  }, []);
138 147
 
139 148
   // ── 初始化内容 ─────────────────────────────────────────────────────────────
140 149
   useEffect(() => {
@@ -183,10 +192,15 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
183 192
   // ── 处理格式变更(工具栏修改) ──────────────────────────────────────────────
184 193
   const handleFormatChange = useCallback(() => {
185 194
     if (!editorRef.current || !onChange) return;
195
+
196
+    if (formatFrameRef.current !== null) {
197
+      cancelAnimationFrame(formatFrameRef.current);
198
+    }
186 199
     
187 200
     // 使用requestAnimationFrame或setTimeout等待DOM更新
188 201
     // 这样可以确保工具栏的DOM修改已经完成
189
-    requestAnimationFrame(() => {
202
+    formatFrameRef.current = requestAnimationFrame(() => {
203
+      formatFrameRef.current = null;
190 204
       if (!editorRef.current || !onChange) return;
191 205
       
192 206
       // 标记正在格式化
@@ -283,7 +297,12 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
283 297
     }
284 298
     
285 299
     // 富文本模式:只在有选中文本时显示工具栏
286
-    if (!selection || selection.isCollapsed) {
300
+    if (!selection || selection.isCollapsed || !editorRef.current) {
301
+      return;
302
+    }
303
+
304
+    const range = selection.getRangeAt(0);
305
+    if (!editorRef.current.contains(range.commonAncestorContainer)) {
287 306
       return;
288 307
     }
289 308
 

+ 9 - 0
src/components/Editor/RichTextEditor/RichTextToolbar.css

@@ -79,6 +79,15 @@
79 79
   gap: 2px;
80 80
 }
81 81
 
82
+/* 内容格式按钮较多,固定六列,避免标题和列表按钮超出工具栏。 */
83
+.rich-text-toolbar.expanded .toolbar-row.toolbar-row-content {
84
+  display: grid;
85
+  grid-template-columns: repeat(6, 28px);
86
+  grid-auto-rows: 28px;
87
+  justify-content: start;
88
+  gap: 2px;
89
+}
90
+
82 91
 .toolbar-row-primary,
83 92
 .toolbar-row-secondary,
84 93
 .toolbar-row-format {

+ 124 - 94
src/components/Editor/RichTextEditor/RichTextToolbar.tsx

@@ -18,7 +18,6 @@ import {
18 18
   FontSizeOutlined,
19 19
   FontColorsOutlined,
20 20
   OrderedListOutlined,
21
-  UnorderedListOutlined,
22 21
   AlignLeftOutlined,
23 22
   AlignCenterOutlined,
24 23
   AlignRightOutlined,
@@ -102,6 +101,18 @@ function restoreSelection(saved: SavedRange | null): boolean {
102 101
         !document.body.contains(saved.endContainer)) {
103 102
       return false;
104 103
     }
104
+
105
+    const startElement = saved.startContainer.nodeType === Node.TEXT_NODE
106
+      ? saved.startContainer.parentElement
107
+      : saved.startContainer as HTMLElement;
108
+    const endElement = saved.endContainer.nodeType === Node.TEXT_NODE
109
+      ? saved.endContainer.parentElement
110
+      : saved.endContainer as HTMLElement;
111
+    const startEditor = startElement?.closest('.rich-text-editor');
112
+    const endEditor = endElement?.closest('.rich-text-editor');
113
+    if (!startEditor || startEditor !== endEditor) {
114
+      return false;
115
+    }
105 116
     
106 117
     const range = document.createRange();
107 118
     range.setStart(saved.startContainer, saved.startOffset);
@@ -120,13 +131,28 @@ function restoreSelection(saved: SavedRange | null): boolean {
120 131
   }
121 132
 }
122 133
 
123
-function restoreSavedOrCurrentSelection(saved: SavedRange | null): boolean {
134
+function restoreSavedOrCurrentSelection(saved: SavedRange | null, editor?: HTMLElement | null): boolean {
124 135
   if (restoreSelection(saved)) {
125
-    return true;
136
+    if (!editor) return true;
137
+    const selection = window.getSelection();
138
+    const container = selection?.rangeCount ? selection.getRangeAt(0).commonAncestorContainer : null;
139
+    const element = container?.nodeType === Node.TEXT_NODE
140
+      ? container.parentElement
141
+      : container as HTMLElement | null;
142
+    return element?.closest('.rich-text-editor') === editor;
126 143
   }
127 144
 
128 145
   const selection = window.getSelection();
129
-  return !!selection && selection.rangeCount > 0 && !selection.isCollapsed;
146
+  if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
147
+    return false;
148
+  }
149
+
150
+  const container = selection.getRangeAt(0).commonAncestorContainer;
151
+  const element = container.nodeType === Node.TEXT_NODE
152
+    ? container.parentElement
153
+    : container as HTMLElement;
154
+  const selectedEditor = element?.closest('.rich-text-editor');
155
+  return !!selectedEditor && (!editor || selectedEditor === editor);
130 156
 }
131 157
 
132 158
 // ══════════════════════════════════════════════════════════════════════════════
@@ -187,54 +213,49 @@ function hasFormat(tagName?: string, styleCheck?: (el: HTMLElement) => boolean):
187 213
   if (!selection || selection.rangeCount === 0) return false;
188 214
   
189 215
   const range = selection.getRangeAt(0);
190
-  const container = range.commonAncestorContainer;
191
-  const parentElement = container.nodeType === Node.TEXT_NODE 
192
-    ? container.parentElement 
193
-    : container as HTMLElement;
194
-  
195
-  if (!parentElement) return false;
196
-  
197
-  // 向上遍历检查
198
-  let element: HTMLElement | null = parentElement;
199
-  while (element && element !== document.body) {
200
-    // 检查标签名
201
-    if (tagName && element.tagName.toUpperCase() === tagName.toUpperCase()) {
202
-      return true;
203
-    }
204
-    
205
-    // 检查样式
206
-    if (styleCheck && styleCheck(element)) {
207
-      return true;
216
+  const matchesElement = (element: HTMLElement | null): boolean => {
217
+    let current = element;
218
+    while (current && current !== document.body) {
219
+      if (tagName && current.tagName.toUpperCase() === tagName.toUpperCase()) {
220
+        return true;
221
+      }
222
+      if (styleCheck && styleCheck(current)) {
223
+        return true;
224
+      }
225
+      current = current.parentElement;
208 226
     }
209
-    
210
-    element = element.parentElement;
211
-  }
212
-  
213
-  return false;
214
-}
215
-
216
-type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
217
-type ContentFormat = 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${HeadingLevel}`;
218
-
219
-function getTextWithLineBreaks(node: Node): string {
220
-  if (node.nodeType === Node.TEXT_NODE) {
221
-    return node.textContent || '';
222
-  }
227
+    return false;
228
+  };
223 229
 
224
-  if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) {
225
-    return '';
230
+  if (range.collapsed) {
231
+    const container = range.startContainer;
232
+    const element = container.nodeType === Node.TEXT_NODE
233
+      ? container.parentElement
234
+      : container as HTMLElement;
235
+    return matchesElement(element);
226 236
   }
227 237
 
228
-  const element = node as HTMLElement;
229
-  if (element.tagName === 'BR') {
230
-    return '\n';
238
+  const walker = document.createTreeWalker(
239
+    range.commonAncestorContainer,
240
+    NodeFilter.SHOW_TEXT,
241
+  );
242
+  let textNode: Node | null = walker.nextNode();
243
+  while (textNode) {
244
+    if (range.intersectsNode(textNode)) {
245
+      if (matchesElement(textNode.parentElement)) return true;
246
+    }
247
+    textNode = walker.nextNode();
231 248
   }
232 249
 
233
-  const text = Array.from(node.childNodes).map(getTextWithLineBreaks).join('');
234
-  const blockTags = new Set(['DIV', 'P', 'LI', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
235
-  return element.tagName && blockTags.has(element.tagName) ? `${text}\n` : text;
250
+  const ancestor = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
251
+    ? range.commonAncestorContainer.parentElement
252
+    : range.commonAncestorContainer as HTMLElement;
253
+  return matchesElement(ancestor);
236 254
 }
237 255
 
256
+type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6;
257
+type ContentFormat = 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-${HeadingLevel}`;
258
+
238 259
 // ══════════════════════════════════════════════════════════════════════════════
239 260
 // Component
240 261
 // ══════════════════════════════════════════════════════════════════════════════
@@ -257,7 +278,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
257 278
   const [colorPickerOpen, setColorPickerOpen] = useState(false);
258 279
   
259 280
   // 对齐样式状态
260
-  const [textAlign, setTextAlign] = useState<'left' | 'center' | 'right' | 'justify'>('center');
281
+  const [textAlign, setTextAlign] = useState<'left' | 'center' | 'right' | 'justify'>('left');
261 282
   const [verticalAlign, setVerticalAlign] = useState<'top' | 'middle' | 'bottom'>('middle');
262 283
   const [isBold, setIsBold] = useState<boolean>(false);
263 284
   const [isItalic, setIsItalic] = useState<boolean>(false);
@@ -267,6 +288,10 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
267 288
   // 判断是否处于表格编辑模式
268 289
   const isTableMode = !!tableContext;
269 290
 
291
+  const getEditorElement = useCallback(() => (
292
+    toolbarRef.current?.parentElement?.querySelector<HTMLElement>('.rich-text-editor') ?? null
293
+  ), []);
294
+
270 295
   // 预设颜色
271 296
   const presetColors = [
272 297
     '#000000', '#FFFFFF', '#FF0000', '#00FF00',
@@ -280,7 +305,15 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
280 305
     let canceled = false;
281 306
 
282 307
     if (!isTableMode) {
283
-      savedRangeRef.current = saveSelection();
308
+      const selection = window.getSelection();
309
+      const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
310
+      const container = range?.commonAncestorContainer;
311
+      const element = container?.nodeType === Node.TEXT_NODE
312
+        ? container.parentElement
313
+        : container as HTMLElement | null;
314
+      if (element?.closest('.rich-text-editor') === getEditorElement()) {
315
+        savedRangeRef.current = saveSelection();
316
+      }
284 317
     }
285 318
 
286 319
     queueMicrotask(() => {
@@ -350,7 +383,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
350 383
     return () => {
351 384
       canceled = true;
352 385
     };
353
-  }, [isTableMode, tableContext, currentAlign]);
386
+  }, [getEditorElement, isTableMode, tableContext, currentAlign]);
354 387
 
355 388
   useEffect(() => {
356 389
     if (isTableMode) return;
@@ -365,14 +398,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
365 398
         ? container.parentElement
366 399
         : container as HTMLElement;
367 400
 
368
-      if (element?.closest('.rich-text-editor')) {
401
+      if (element?.closest('.rich-text-editor') === getEditorElement()) {
369 402
         savedRangeRef.current = saveSelection();
370 403
       }
371 404
     };
372 405
 
373 406
     document.addEventListener('selectionchange', handleSelectionChange);
374 407
     return () => document.removeEventListener('selectionchange', handleSelectionChange);
375
-  }, [isTableMode]);
408
+  }, [getEditorElement, isTableMode]);
376 409
 
377 410
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
378 411
   useEffect(() => {
@@ -381,7 +414,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
381 414
       const isToolbarPopup = target.closest('.ant-select-dropdown, .ant-popover, .ant-dropdown');
382 415
 
383 416
       if (toolbarRef.current && !toolbarRef.current.contains(target) && !isToolbarPopup) {
384
-        const editor = document.querySelector('.rich-text-editor');
417
+        const editor = getEditorElement();
385 418
         if (!editor?.contains(target)) {
386 419
           setIsExpanded(false);
387 420
           onClose();
@@ -391,7 +424,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
391 424
 
392 425
     document.addEventListener('mousedown', handleClickOutside);
393 426
     return () => document.removeEventListener('mousedown', handleClickOutside);
394
-  }, [onClose]);
427
+  }, [getEditorElement, onClose]);
395 428
 
396 429
   // ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
397 430
   const toggleFormat = useCallback((tagName: string) => {
@@ -410,7 +443,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
410 443
     }
411 444
     
412 445
     // 富文本模式
413
-    const restored = restoreSavedOrCurrentSelection(savedRangeRef.current);
446
+    const restored = restoreSavedOrCurrentSelection(savedRangeRef.current, getEditorElement());
414 447
     if (!restored) {
415 448
       return;
416 449
     }
@@ -425,7 +458,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
425 458
     
426 459
     savedRangeRef.current = saveSelection();
427 460
     onFormat();
428
-  }, [isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
461
+  }, [getEditorElement, isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
429 462
 
430 463
   // ── 处理字号变化 ───────────────────────────────────────────────────────────
431 464
   const handleFontSizeChange = useCallback((value: number | null) => {
@@ -440,7 +473,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
440 473
     }
441 474
     
442 475
     // 富文本模式
443
-    if (!restoreSavedOrCurrentSelection(savedRangeRef.current)) {
476
+    if (!restoreSavedOrCurrentSelection(savedRangeRef.current, getEditorElement())) {
444 477
       return;
445 478
     }
446 479
     
@@ -456,7 +489,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
456 489
     
457 490
     savedRangeRef.current = saveSelection();
458 491
     onFormat();
459
-  }, [isTableMode, tableContext, onFormat]);
492
+  }, [getEditorElement, isTableMode, tableContext, onFormat]);
460 493
 
461 494
   // ── 处理颜色变化 ───────────────────────────────────────────────────────────
462 495
   const handleColorChange = useCallback((color: string) => {
@@ -471,7 +504,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
471 504
     }
472 505
     
473 506
     // 富文本模式
474
-    const restored = restoreSavedOrCurrentSelection(savedRangeRef.current);
507
+    const restored = restoreSavedOrCurrentSelection(savedRangeRef.current, getEditorElement());
475 508
     
476 509
     if (!restored) {
477 510
       setTimeout(() => setColorPickerOpen(false), 100);
@@ -506,7 +539,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
506 539
     onFormat();
507 540
     
508 541
     setTimeout(() => setColorPickerOpen(false), 100);
509
-  }, [isTableMode, tableContext, onFormat]);
542
+  }, [getEditorElement, isTableMode, tableContext, onFormat]);
510 543
 
511 544
   // ── 处理对齐方式 ──────────────────────────────────────────────────────────
512 545
   const handleTextAlignChange = useCallback((align: 'left' | 'center' | 'right' | 'justify') => {
@@ -535,41 +568,20 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
535 568
 
536 569
   // ── 处理内容格式 ──────────────────────────────────────────────────────────
537 570
   const handleContentFormatChange = useCallback((format: ContentFormat) => {
538
-    if (isTableMode || !restoreSavedOrCurrentSelection(savedRangeRef.current)) {
539
-      return;
540
-    }
541
-
542
-    const selection = window.getSelection();
543
-    if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
571
+    if (isTableMode) {
544 572
       return;
545 573
     }
546 574
 
547
-    if (format !== 'ordered-list') {
575
+    // 对于列表格式,直接调用回调,不需要选中文本
576
+    if (format === 'ordered-list') {
548 577
       onContentFormatChange?.(format);
578
+      onFormat();
549 579
       return;
550
-    } else {
551
-      const range = selection.getRangeAt(0);
552
-      const selectedContent = range.cloneContents();
553
-      const selectedText = getTextWithLineBreaks(selectedContent).replace(/\n+$/, '');
554
-      const lines = selectedText.split(/\r?\n/);
555
-      let itemNumber = 0;
556
-      const numberedText = lines.map((line) => {
557
-        if (!line.trim()) return '';
558
-        itemNumber += 1;
559
-        return `${itemNumber}. ${line.replace(/^\s*\d+\.\s+/, '')}`;
560
-      }).join('\n');
561
-
562
-      range.deleteContents();
563
-      const textNode = document.createTextNode(numberedText);
564
-      range.insertNode(textNode);
565
-      range.selectNodeContents(textNode);
566
-      selection.removeAllRanges();
567
-      selection.addRange(range);
568 580
     }
569 581
 
570
-    savedRangeRef.current = saveSelection();
582
+    // 对于段落和标题格式,也直接调用回调
583
+    onContentFormatChange?.(format);
571 584
     onFormat();
572
-    requestAnimationFrame(() => onContentFormatChange?.('ordered-list'));
573 585
   }, [isTableMode, onContentFormatChange, onFormat]);
574 586
 
575 587
   // ── 检查格式状态 ───────────────────────────────────────────────────────────
@@ -588,7 +600,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
588 600
 
589 601
   const handleDelete = useCallback(() => {
590 602
     if (isTableMode) return;
591
-    if (restoreSavedOrCurrentSelection(savedRangeRef.current)) {
603
+    if (restoreSavedOrCurrentSelection(savedRangeRef.current, getEditorElement())) {
592 604
       const selection = window.getSelection();
593 605
       if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
594 606
         document.execCommand('delete', false);
@@ -597,22 +609,38 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
597 609
       }
598 610
     }
599 611
     onDelete?.();
600
-  }, [isTableMode, onDelete, onFormat]);
612
+  }, [getEditorElement, isTableMode, onDelete, onFormat]);
601 613
 
602 614
   // ── 阻止工具栏点击导致失焦 ─────────────────────────────────────────────────
603 615
   const handleMouseDown = (e: React.MouseEvent) => {
604 616
     if (!isTableMode) {
605
-      savedRangeRef.current = saveSelection();
617
+      const editor = getEditorElement();
618
+      const selection = window.getSelection();
619
+      const container = selection?.rangeCount ? selection.getRangeAt(0).commonAncestorContainer : null;
620
+      const element = container?.nodeType === Node.TEXT_NODE
621
+        ? container.parentElement
622
+        : container as HTMLElement | null;
623
+      if (element?.closest('.rich-text-editor') === editor) {
624
+        savedRangeRef.current = saveSelection();
625
+      }
606 626
     }
607 627
     e.preventDefault();
608 628
   };
609 629
 
610 630
   const handleLauncherClick = useCallback(() => {
611 631
     if (!isTableMode) {
612
-      savedRangeRef.current = saveSelection();
632
+      const editor = getEditorElement();
633
+      const selection = window.getSelection();
634
+      const container = selection?.rangeCount ? selection.getRangeAt(0).commonAncestorContainer : null;
635
+      const element = container?.nodeType === Node.TEXT_NODE
636
+        ? container.parentElement
637
+        : container as HTMLElement | null;
638
+      if (element?.closest('.rich-text-editor') === editor) {
639
+        savedRangeRef.current = saveSelection();
640
+      }
613 641
     }
614 642
     setIsExpanded(true);
615
-  }, [isTableMode]);
643
+  }, [getEditorElement, isTableMode]);
616 644
 
617 645
   // ── 渲染 ───────────────────────────────────────────────────────────────────
618 646
 
@@ -682,9 +710,6 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
682 710
             <ToolbarButton title="有序列表" active={currentContentFormat === 'ordered-list'} onClick={() => handleContentFormatChange('ordered-list')}>
683 711
               <OrderedListOutlined />
684 712
             </ToolbarButton>
685
-            <ToolbarButton title="无序列表" active={currentContentFormat === 'unordered-list'} onClick={() => handleContentFormatChange('unordered-list')}>
686
-              <UnorderedListOutlined />
687
-            </ToolbarButton>
688 713
           </div>
689 714
         </>
690 715
       )}
@@ -732,8 +757,13 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
732 757
           placement="bottomLeft"
733 758
           overlayClassName="rich-text-alignment-menu"
734 759
         >
735
-          <button type="button" className="toolbar-action-button" title={`缩进和对齐(${textAlign})`}>
736
-            <AlignLeftOutlined /><span>缩进和对齐</span><DownOutlined />
760
+          <button type="button" className="toolbar-action-button" title="对齐方式">
761
+            {textAlign === 'left' && <AlignLeftOutlined />}
762
+            {textAlign === 'center' && <AlignCenterOutlined />}
763
+            {textAlign === 'right' && <AlignRightOutlined />}
764
+            {textAlign === 'justify' && <AlignRightOutlined />}
765
+            <span>对齐</span>
766
+            <DownOutlined />
737 767
           </button>
738 768
         </Dropdown>
739 769
       </div>

+ 46 - 4
src/components/Editor/blocks/BlockMenu.tsx

@@ -62,6 +62,13 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
62 62
   onInsertTable,
63 63
 }) => {
64 64
   const uploadRef = React.useRef<HTMLDivElement>(null);
65
+  const isMountedRef = React.useRef(true);
66
+
67
+  React.useEffect(() => {
68
+    return () => {
69
+      isMountedRef.current = false;
70
+    };
71
+  }, []);
65 72
 
66 73
   /**
67 74
    * 处理图片上传
@@ -74,10 +81,17 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
74 81
       return false;
75 82
     }
76 83
 
77
-    // 文件类型检查
78
-    const isImage = file.type.startsWith('image/');
84
+    // 只允许浏览器可安全解码的常见栅格图片,避免将 SVG 等可执行载荷写入文档。
85
+    const allowedImageTypes = new Set([
86
+      'image/jpeg',
87
+      'image/png',
88
+      'image/gif',
89
+      'image/webp',
90
+      'image/bmp',
91
+    ]);
92
+    const isImage = allowedImageTypes.has(file.type.toLowerCase());
79 93
     if (!isImage) {
80
-      message.error('只能上传图片文件');
94
+      message.error('只支持 JPG、PNG、GIF、WebP 或 BMP 图片');
81 95
       return false;
82 96
     }
83 97
 
@@ -85,26 +99,54 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
85 99
     const reader = new FileReader();
86 100
     reader.onload = (e) => {
87 101
       const dataUrl = e.target?.result as string;
102
+      if (!isMountedRef.current || typeof dataUrl !== 'string') return;
88 103
 
89 104
       // 使用Image对象获取图片尺寸
90 105
       const img = new Image();
91 106
       img.onload = () => {
107
+        if (!isMountedRef.current) return;
108
+
109
+        const maxDimension = 10000;
110
+        const maxPixels = 40_000_000;
111
+        if (
112
+          !Number.isFinite(img.width) ||
113
+          !Number.isFinite(img.height) ||
114
+          img.width <= 0 ||
115
+          img.height <= 0 ||
116
+          img.width > maxDimension ||
117
+          img.height > maxDimension ||
118
+          img.width * img.height > maxPixels
119
+        ) {
120
+          message.error('图片尺寸过大,请选择较小的图片');
121
+          return;
122
+        }
123
+
92 124
         // 计算适合的显示尺寸(默认最大宽度15cm)
93 125
         const maxWidthCm = 15;
94 126
         const aspectRatio = img.height / img.width;
95 127
         const widthCm = Math.min(maxWidthCm, img.width / 37.795); // 37.795 px ≈ 1cm
96 128
         const heightCm = widthCm * aspectRatio;
129
+        const safeFileName = Array.from(file.name)
130
+          .filter((character) => {
131
+            const codePoint = character.codePointAt(0) ?? 0;
132
+            return codePoint > 31 && codePoint !== 127;
133
+          })
134
+          .join('')
135
+          .trim()
136
+          .slice(0, 200) || '未命名图片';
97 137
 
98 138
         // 调用插入图片回调
99
-        onInsertImage?.(dataUrl, file.name, parseFloat(widthCm.toFixed(2)), parseFloat(heightCm.toFixed(2)));
139
+        onInsertImage?.(dataUrl, safeFileName, parseFloat(widthCm.toFixed(2)), parseFloat(heightCm.toFixed(2)));
100 140
         message.success('图片已插入');
101 141
       };
102 142
       img.onerror = () => {
143
+        if (!isMountedRef.current) return;
103 144
         message.error('图片加载失败');
104 145
       };
105 146
       img.src = dataUrl;
106 147
     };
107 148
     reader.onerror = () => {
149
+      if (!isMountedRef.current) return;
108 150
       message.error('图片读取失败');
109 151
     };
110 152
     reader.readAsDataURL(file);