ソースを参照

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

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

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

@@ -135,6 +135,15 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
135
   const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -34 });
135
   const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -34 });
136
   const isComposingRef = useRef(false);
136
   const isComposingRef = useRef(false);
137
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
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
   useEffect(() => {
149
   useEffect(() => {
@@ -183,10 +192,15 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
183
   // ── 处理格式变更(工具栏修改) ──────────────────────────────────────────────
192
   // ── 处理格式变更(工具栏修改) ──────────────────────────────────────────────
184
   const handleFormatChange = useCallback(() => {
193
   const handleFormatChange = useCallback(() => {
185
     if (!editorRef.current || !onChange) return;
194
     if (!editorRef.current || !onChange) return;
195
+
196
+    if (formatFrameRef.current !== null) {
197
+      cancelAnimationFrame(formatFrameRef.current);
198
+    }
186
     
199
     
187
     // 使用requestAnimationFrame或setTimeout等待DOM更新
200
     // 使用requestAnimationFrame或setTimeout等待DOM更新
188
     // 这样可以确保工具栏的DOM修改已经完成
201
     // 这样可以确保工具栏的DOM修改已经完成
189
-    requestAnimationFrame(() => {
202
+    formatFrameRef.current = requestAnimationFrame(() => {
203
+      formatFrameRef.current = null;
190
       if (!editorRef.current || !onChange) return;
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
       return;
306
       return;
288
     }
307
     }
289
 
308
 

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

@@ -79,6 +79,15 @@
79
   gap: 2px;
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
 .toolbar-row-primary,
91
 .toolbar-row-primary,
83
 .toolbar-row-secondary,
92
 .toolbar-row-secondary,
84
 .toolbar-row-format {
93
 .toolbar-row-format {

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

@@ -18,7 +18,6 @@ import {
18
   FontSizeOutlined,
18
   FontSizeOutlined,
19
   FontColorsOutlined,
19
   FontColorsOutlined,
20
   OrderedListOutlined,
20
   OrderedListOutlined,
21
-  UnorderedListOutlined,
22
   AlignLeftOutlined,
21
   AlignLeftOutlined,
23
   AlignCenterOutlined,
22
   AlignCenterOutlined,
24
   AlignRightOutlined,
23
   AlignRightOutlined,
@@ -102,6 +101,18 @@ function restoreSelection(saved: SavedRange | null): boolean {
102
         !document.body.contains(saved.endContainer)) {
101
         !document.body.contains(saved.endContainer)) {
103
       return false;
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
     const range = document.createRange();
117
     const range = document.createRange();
107
     range.setStart(saved.startContainer, saved.startOffset);
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
   if (restoreSelection(saved)) {
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
   const selection = window.getSelection();
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
   if (!selection || selection.rangeCount === 0) return false;
213
   if (!selection || selection.rangeCount === 0) return false;
188
   
214
   
189
   const range = selection.getRangeAt(0);
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
 // Component
260
 // Component
240
 // ══════════════════════════════════════════════════════════════════════════════
261
 // ══════════════════════════════════════════════════════════════════════════════
@@ -257,7 +278,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
257
   const [colorPickerOpen, setColorPickerOpen] = useState(false);
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
   const [verticalAlign, setVerticalAlign] = useState<'top' | 'middle' | 'bottom'>('middle');
282
   const [verticalAlign, setVerticalAlign] = useState<'top' | 'middle' | 'bottom'>('middle');
262
   const [isBold, setIsBold] = useState<boolean>(false);
283
   const [isBold, setIsBold] = useState<boolean>(false);
263
   const [isItalic, setIsItalic] = useState<boolean>(false);
284
   const [isItalic, setIsItalic] = useState<boolean>(false);
@@ -267,6 +288,10 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
267
   // 判断是否处于表格编辑模式
288
   // 判断是否处于表格编辑模式
268
   const isTableMode = !!tableContext;
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
   const presetColors = [
296
   const presetColors = [
272
     '#000000', '#FFFFFF', '#FF0000', '#00FF00',
297
     '#000000', '#FFFFFF', '#FF0000', '#00FF00',
@@ -280,7 +305,15 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
280
     let canceled = false;
305
     let canceled = false;
281
 
306
 
282
     if (!isTableMode) {
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
     queueMicrotask(() => {
319
     queueMicrotask(() => {
@@ -350,7 +383,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
350
     return () => {
383
     return () => {
351
       canceled = true;
384
       canceled = true;
352
     };
385
     };
353
-  }, [isTableMode, tableContext, currentAlign]);
386
+  }, [getEditorElement, isTableMode, tableContext, currentAlign]);
354
 
387
 
355
   useEffect(() => {
388
   useEffect(() => {
356
     if (isTableMode) return;
389
     if (isTableMode) return;
@@ -365,14 +398,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
365
         ? container.parentElement
398
         ? container.parentElement
366
         : container as HTMLElement;
399
         : container as HTMLElement;
367
 
400
 
368
-      if (element?.closest('.rich-text-editor')) {
401
+      if (element?.closest('.rich-text-editor') === getEditorElement()) {
369
         savedRangeRef.current = saveSelection();
402
         savedRangeRef.current = saveSelection();
370
       }
403
       }
371
     };
404
     };
372
 
405
 
373
     document.addEventListener('selectionchange', handleSelectionChange);
406
     document.addEventListener('selectionchange', handleSelectionChange);
374
     return () => document.removeEventListener('selectionchange', handleSelectionChange);
407
     return () => document.removeEventListener('selectionchange', handleSelectionChange);
375
-  }, [isTableMode]);
408
+  }, [getEditorElement, isTableMode]);
376
 
409
 
377
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
410
   // ── 点击外部关闭 ───────────────────────────────────────────────────────────
378
   useEffect(() => {
411
   useEffect(() => {
@@ -381,7 +414,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
381
       const isToolbarPopup = target.closest('.ant-select-dropdown, .ant-popover, .ant-dropdown');
414
       const isToolbarPopup = target.closest('.ant-select-dropdown, .ant-popover, .ant-dropdown');
382
 
415
 
383
       if (toolbarRef.current && !toolbarRef.current.contains(target) && !isToolbarPopup) {
416
       if (toolbarRef.current && !toolbarRef.current.contains(target) && !isToolbarPopup) {
384
-        const editor = document.querySelector('.rich-text-editor');
417
+        const editor = getEditorElement();
385
         if (!editor?.contains(target)) {
418
         if (!editor?.contains(target)) {
386
           setIsExpanded(false);
419
           setIsExpanded(false);
387
           onClose();
420
           onClose();
@@ -391,7 +424,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
391
 
424
 
392
     document.addEventListener('mousedown', handleClickOutside);
425
     document.addEventListener('mousedown', handleClickOutside);
393
     return () => document.removeEventListener('mousedown', handleClickOutside);
426
     return () => document.removeEventListener('mousedown', handleClickOutside);
394
-  }, [onClose]);
427
+  }, [getEditorElement, onClose]);
395
 
428
 
396
   // ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
429
   // ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
397
   const toggleFormat = useCallback((tagName: string) => {
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
     if (!restored) {
447
     if (!restored) {
415
       return;
448
       return;
416
     }
449
     }
@@ -425,7 +458,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
425
     
458
     
426
     savedRangeRef.current = saveSelection();
459
     savedRangeRef.current = saveSelection();
427
     onFormat();
460
     onFormat();
428
-  }, [isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
461
+  }, [getEditorElement, isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
429
 
462
 
430
   // ── 处理字号变化 ───────────────────────────────────────────────────────────
463
   // ── 处理字号变化 ───────────────────────────────────────────────────────────
431
   const handleFontSizeChange = useCallback((value: number | null) => {
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
       return;
477
       return;
445
     }
478
     }
446
     
479
     
@@ -456,7 +489,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
456
     
489
     
457
     savedRangeRef.current = saveSelection();
490
     savedRangeRef.current = saveSelection();
458
     onFormat();
491
     onFormat();
459
-  }, [isTableMode, tableContext, onFormat]);
492
+  }, [getEditorElement, isTableMode, tableContext, onFormat]);
460
 
493
 
461
   // ── 处理颜色变化 ───────────────────────────────────────────────────────────
494
   // ── 处理颜色变化 ───────────────────────────────────────────────────────────
462
   const handleColorChange = useCallback((color: string) => {
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
     if (!restored) {
509
     if (!restored) {
477
       setTimeout(() => setColorPickerOpen(false), 100);
510
       setTimeout(() => setColorPickerOpen(false), 100);
@@ -506,7 +539,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
506
     onFormat();
539
     onFormat();
507
     
540
     
508
     setTimeout(() => setColorPickerOpen(false), 100);
541
     setTimeout(() => setColorPickerOpen(false), 100);
509
-  }, [isTableMode, tableContext, onFormat]);
542
+  }, [getEditorElement, isTableMode, tableContext, onFormat]);
510
 
543
 
511
   // ── 处理对齐方式 ──────────────────────────────────────────────────────────
544
   // ── 处理对齐方式 ──────────────────────────────────────────────────────────
512
   const handleTextAlignChange = useCallback((align: 'left' | 'center' | 'right' | 'justify') => {
545
   const handleTextAlignChange = useCallback((align: 'left' | 'center' | 'right' | 'justify') => {
@@ -535,41 +568,20 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
535
 
568
 
536
   // ── 处理内容格式 ──────────────────────────────────────────────────────────
569
   // ── 处理内容格式 ──────────────────────────────────────────────────────────
537
   const handleContentFormatChange = useCallback((format: ContentFormat) => {
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
       return;
572
       return;
545
     }
573
     }
546
 
574
 
547
-    if (format !== 'ordered-list') {
575
+    // 对于列表格式,直接调用回调,不需要选中文本
576
+    if (format === 'ordered-list') {
548
       onContentFormatChange?.(format);
577
       onContentFormatChange?.(format);
578
+      onFormat();
549
       return;
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
     onFormat();
584
     onFormat();
572
-    requestAnimationFrame(() => onContentFormatChange?.('ordered-list'));
573
   }, [isTableMode, onContentFormatChange, onFormat]);
585
   }, [isTableMode, onContentFormatChange, onFormat]);
574
 
586
 
575
   // ── 检查格式状态 ───────────────────────────────────────────────────────────
587
   // ── 检查格式状态 ───────────────────────────────────────────────────────────
@@ -588,7 +600,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
588
 
600
 
589
   const handleDelete = useCallback(() => {
601
   const handleDelete = useCallback(() => {
590
     if (isTableMode) return;
602
     if (isTableMode) return;
591
-    if (restoreSavedOrCurrentSelection(savedRangeRef.current)) {
603
+    if (restoreSavedOrCurrentSelection(savedRangeRef.current, getEditorElement())) {
592
       const selection = window.getSelection();
604
       const selection = window.getSelection();
593
       if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
605
       if (selection && selection.rangeCount > 0 && !selection.isCollapsed) {
594
         document.execCommand('delete', false);
606
         document.execCommand('delete', false);
@@ -597,22 +609,38 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
597
       }
609
       }
598
     }
610
     }
599
     onDelete?.();
611
     onDelete?.();
600
-  }, [isTableMode, onDelete, onFormat]);
612
+  }, [getEditorElement, isTableMode, onDelete, onFormat]);
601
 
613
 
602
   // ── 阻止工具栏点击导致失焦 ─────────────────────────────────────────────────
614
   // ── 阻止工具栏点击导致失焦 ─────────────────────────────────────────────────
603
   const handleMouseDown = (e: React.MouseEvent) => {
615
   const handleMouseDown = (e: React.MouseEvent) => {
604
     if (!isTableMode) {
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
     e.preventDefault();
627
     e.preventDefault();
608
   };
628
   };
609
 
629
 
610
   const handleLauncherClick = useCallback(() => {
630
   const handleLauncherClick = useCallback(() => {
611
     if (!isTableMode) {
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
     setIsExpanded(true);
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
             <ToolbarButton title="有序列表" active={currentContentFormat === 'ordered-list'} onClick={() => handleContentFormatChange('ordered-list')}>
710
             <ToolbarButton title="有序列表" active={currentContentFormat === 'ordered-list'} onClick={() => handleContentFormatChange('ordered-list')}>
683
               <OrderedListOutlined />
711
               <OrderedListOutlined />
684
             </ToolbarButton>
712
             </ToolbarButton>
685
-            <ToolbarButton title="无序列表" active={currentContentFormat === 'unordered-list'} onClick={() => handleContentFormatChange('unordered-list')}>
686
-              <UnorderedListOutlined />
687
-            </ToolbarButton>
688
           </div>
713
           </div>
689
         </>
714
         </>
690
       )}
715
       )}
@@ -732,8 +757,13 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
732
           placement="bottomLeft"
757
           placement="bottomLeft"
733
           overlayClassName="rich-text-alignment-menu"
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
           </button>
767
           </button>
738
         </Dropdown>
768
         </Dropdown>
739
       </div>
769
       </div>

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

@@ -62,6 +62,13 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
62
   onInsertTable,
62
   onInsertTable,
63
 }) => {
63
 }) => {
64
   const uploadRef = React.useRef<HTMLDivElement>(null);
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
       return false;
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
     if (!isImage) {
93
     if (!isImage) {
80
-      message.error('只能上传图片文件');
94
+      message.error('只支持 JPG、PNG、GIF、WebP 或 BMP 图片');
81
       return false;
95
       return false;
82
     }
96
     }
83
 
97
 
@@ -85,26 +99,54 @@ export const BlockMenu: React.FC<BlockMenuProps> = ({
85
     const reader = new FileReader();
99
     const reader = new FileReader();
86
     reader.onload = (e) => {
100
     reader.onload = (e) => {
87
       const dataUrl = e.target?.result as string;
101
       const dataUrl = e.target?.result as string;
102
+      if (!isMountedRef.current || typeof dataUrl !== 'string') return;
88
 
103
 
89
       // 使用Image对象获取图片尺寸
104
       // 使用Image对象获取图片尺寸
90
       const img = new Image();
105
       const img = new Image();
91
       img.onload = () => {
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
         // 计算适合的显示尺寸(默认最大宽度15cm)
124
         // 计算适合的显示尺寸(默认最大宽度15cm)
93
         const maxWidthCm = 15;
125
         const maxWidthCm = 15;
94
         const aspectRatio = img.height / img.width;
126
         const aspectRatio = img.height / img.width;
95
         const widthCm = Math.min(maxWidthCm, img.width / 37.795); // 37.795 px ≈ 1cm
127
         const widthCm = Math.min(maxWidthCm, img.width / 37.795); // 37.795 px ≈ 1cm
96
         const heightCm = widthCm * aspectRatio;
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
         message.success('图片已插入');
140
         message.success('图片已插入');
101
       };
141
       };
102
       img.onerror = () => {
142
       img.onerror = () => {
143
+        if (!isMountedRef.current) return;
103
         message.error('图片加载失败');
144
         message.error('图片加载失败');
104
       };
145
       };
105
       img.src = dataUrl;
146
       img.src = dataUrl;
106
     };
147
     };
107
     reader.onerror = () => {
148
     reader.onerror = () => {
149
+      if (!isMountedRef.current) return;
108
       message.error('图片读取失败');
150
       message.error('图片读取失败');
109
     };
151
     };
110
     reader.readAsDataURL(file);
152
     reader.readAsDataURL(file);