Parcourir la source

feat(编辑器): 优化富文本工具栏显隐逻辑与分层管理

- 重构工具栏可见性控制,基于编辑器悬停、焦点、文本选中等多个状态管理
- 增强编辑器与工具栏交互,添加悬停延迟防抖逻辑避免闪烁
- 调整工具栏 DOM 位置至编辑内容之后,防止 contenteditable 重绘覆盖
- 改进编辑器层级管理,添加 position/z-index/isolation 样式确保正确堆叠顺序
- 扩展 RichTextEditor 组件 props,支持表格选区可视范围传递
- 优化编辑器焦点与文本选中状态追踪,完善点击外部区域的失焦处理
- 增加编辑器元素引用传递给工具栏,便于位置计算与交互判断
Zhang Yice il y a 1 mois
Parent
commit
f2ab485b27

+ 3 - 0
src/components/Editor/RichTextEditor/RichTextEditor.css

@@ -20,6 +20,8 @@
20 20
 }
21 21
 
22 22
 .rich-text-editor {
23
+  position: relative;
24
+  z-index: 0;
23 25
   width: 100%;
24 26
   outline: none;
25 27
   white-space: pre-wrap;
@@ -27,6 +29,7 @@
27 29
   word-break: break-word;
28 30
   min-height: 1.5em;
29 31
   transition: all 0.2s ease;
32
+  isolation: isolate;
30 33
 }
31 34
 
32 35
 /* 可编辑状态 */

+ 107 - 18
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -84,6 +84,12 @@ export interface RichTextEditorProps {
84 84
       endRow: number;
85 85
       endCol: number;
86 86
     } | null;
87
+    selectedVisualRange?: {
88
+      rowStart: number;
89
+      rowEnd: number;
90
+      colStart: number;
91
+      colEnd: number;
92
+    } | null;
87 93
     onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
88 94
   };
89 95
   /** 是否显示左侧格式工具按钮 */
@@ -131,18 +137,90 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
131 137
   tableContext,
132 138
   hasContent,
133 139
 }) => {
134
-  const editorRef = useRef<HTMLDivElement>(null);
140
+  const editorRef = useRef<HTMLDivElement | null>(null);
141
+  const [editorElement, setEditorElement] = useState<HTMLDivElement | null>(null);
142
+  const [isEditorHovered, setIsEditorHovered] = useState(false);
143
+  const [isToolbarHovered, setIsToolbarHovered] = useState(false);
144
+  const [isEditorFocused, setIsEditorFocused] = useState(false);
145
+  const [hasTextSelection, setHasTextSelection] = useState(false);
135 146
   const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -40 });
136 147
   const isComposingRef = useRef(false);
137 148
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
138 149
   const formatFrameRef = useRef<number | null>(null);
150
+  const toolbarHoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
151
+
152
+  const handleEditorRef = useCallback((element: HTMLDivElement | null) => {
153
+    editorRef.current = element;
154
+    setEditorElement(element);
155
+  }, []);
139 156
 
140 157
   useEffect(() => {
141 158
     return () => {
142 159
       if (formatFrameRef.current !== null) {
143 160
         cancelAnimationFrame(formatFrameRef.current);
144 161
       }
162
+      if (toolbarHoverTimeoutRef.current !== null) {
163
+        clearTimeout(toolbarHoverTimeoutRef.current);
164
+      }
165
+    };
166
+  }, []);
167
+
168
+  useEffect(() => {
169
+    if (readOnly) return;
170
+
171
+    const handleDocumentMouseDown = (event: MouseEvent) => {
172
+      const target = event.target;
173
+      if (!(target instanceof Element)) return;
174
+
175
+      const isEditorClick = editorRef.current?.contains(target) ?? false;
176
+      const isToolbarClick = Boolean(target.closest(
177
+        '.rich-text-toolbar, .rich-text-toolbar-dropdown, .ant-popover, .ant-dropdown, .ant-select-dropdown',
178
+      ));
179
+
180
+      if (!isEditorClick && !isToolbarClick) {
181
+        setIsEditorFocused(false);
182
+        setHasTextSelection(false);
183
+      }
145 184
     };
185
+
186
+    document.addEventListener('mousedown', handleDocumentMouseDown);
187
+    return () => document.removeEventListener('mousedown', handleDocumentMouseDown);
188
+  }, [readOnly]);
189
+
190
+  const handleToolbarHoverChange = useCallback((hovered: boolean) => {
191
+    if (toolbarHoverTimeoutRef.current !== null) {
192
+      clearTimeout(toolbarHoverTimeoutRef.current);
193
+      toolbarHoverTimeoutRef.current = null;
194
+    }
195
+
196
+    if (hovered) {
197
+      setIsEditorHovered(false);
198
+      setIsToolbarHovered(true);
199
+      return;
200
+    }
201
+
202
+    toolbarHoverTimeoutRef.current = setTimeout(() => {
203
+      setIsToolbarHovered(false);
204
+      toolbarHoverTimeoutRef.current = null;
205
+    }, 300);
206
+  }, []);
207
+
208
+  const handleEditorHoverChange = useCallback((hovered: boolean) => {
209
+    if (toolbarHoverTimeoutRef.current !== null) {
210
+      clearTimeout(toolbarHoverTimeoutRef.current);
211
+      toolbarHoverTimeoutRef.current = null;
212
+    }
213
+
214
+    if (hovered) {
215
+      setIsEditorHovered(true);
216
+      return;
217
+    }
218
+
219
+    toolbarHoverTimeoutRef.current = setTimeout(() => {
220
+      setIsEditorHovered(false);
221
+      setIsToolbarHovered(false);
222
+      toolbarHoverTimeoutRef.current = null;
223
+    }, 300);
146 224
   }, []);
147 225
 
148 226
   // ── 初始化内容 ─────────────────────────────────────────────────────────────
@@ -298,6 +376,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
298 376
     
299 377
     // 富文本模式:只在有选中文本时显示工具栏
300 378
     if (!selection || selection.isCollapsed || !editorRef.current) {
379
+      setHasTextSelection(false);
301 380
       return;
302 381
     }
303 382
 
@@ -310,12 +389,17 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
310 389
       top: 0,
311 390
       left: -40,
312 391
     });
392
+    setHasTextSelection(true);
313 393
     
314 394
   }, [readOnly, tableContext]);
315 395
 
316 396
   // ── 处理聚焦(表格模式显示工具栏) ─────────────────────────────────────────────
317 397
   const handleFocus = useCallback(() => {
318
-    if (readOnly || !tableContext) return;
398
+    if (readOnly) return;
399
+
400
+    setIsEditorFocused(true);
401
+
402
+    if (!tableContext) return;
319 403
     
320 404
     // 延迟一下,确保光标已经定位
321 405
     setTimeout(() => {
@@ -355,24 +439,11 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
355 439
 
356 440
   return (
357 441
     <div className="rich-text-editor-wrapper">
358
-      {/* 浮动工具栏 */}
359
-      {!readOnly && hasContent !== false && (
360
-        <RichTextToolbar
361
-          position={toolbarPosition}
362
-          onClose={() => undefined}
363
-          onFormat={handleFormatChange}
364
-          currentContentFormat={currentContentFormat}
365
-          onContentFormatChange={onContentFormatChange}
366
-          onDelete={onDelete}
367
-          onAlignChange={onAlignChange}
368
-          currentAlign={currentAlign}
369
-          tableContext={tableContext}
370
-        />
371
-      )}
372
-
373 442
       {/* 可编辑区域 */}
374 443
       <div
375
-        ref={editorRef}
444
+        onMouseEnter={() => handleEditorHoverChange(true)}
445
+        onMouseLeave={() => handleEditorHoverChange(false)}
446
+        ref={handleEditorRef}
376 447
         className={`rich-text-editor ${readOnly ? 'read-only' : ''}`}
377 448
         contentEditable={!readOnly}
378 449
         suppressContentEditableWarning
@@ -387,6 +458,24 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
387 458
         data-placeholder={placeholder}
388 459
         style={baseStyle}
389 460
       />
461
+
462
+      {/* 浮动工具栏放在编辑内容之后,避免 contenteditable 重绘时覆盖面板。 */}
463
+      {!readOnly && hasContent !== false && (
464
+        <RichTextToolbar
465
+          position={toolbarPosition}
466
+          editorElement={editorElement}
467
+          visible={isEditorHovered || isToolbarHovered || isEditorFocused || hasTextSelection}
468
+          onHoverChange={handleToolbarHoverChange}
469
+          onClose={() => setHasTextSelection(false)}
470
+          onFormat={handleFormatChange}
471
+          currentContentFormat={currentContentFormat}
472
+          onContentFormatChange={onContentFormatChange}
473
+          onDelete={onDelete}
474
+          onAlignChange={onAlignChange}
475
+          currentAlign={currentAlign}
476
+          tableContext={tableContext}
477
+        />
478
+      )}
390 479
     </div>
391 480
   );
392 481
 };

+ 54 - 3
src/components/Editor/RichTextEditor/RichTextToolbar.css

@@ -3,8 +3,8 @@
3 3
  */
4 4
 
5 5
 .rich-text-toolbar {
6
-  position: absolute;
7
-  z-index: 1002;
6
+  position: fixed;
7
+  z-index: 1100;
8 8
   width: 208px;
9 9
   top: 0;
10 10
   padding: 7px 7px 6px;
@@ -19,7 +19,7 @@
19 19
   transition: opacity 0.2s ease;
20 20
 }
21 21
 
22
-.rich-text-editor-wrapper:hover .rich-text-toolbar {
22
+.rich-text-toolbar.visible {
23 23
   opacity: 1;
24 24
   pointer-events: auto;
25 25
 }
@@ -32,10 +32,39 @@
32 32
 }
33 33
 
34 34
 .rich-text-toolbar.expanded {
35
+  position: relative;
35 36
   width: 208px;
36 37
   padding: 7px 5px;
37 38
   border-radius: 5px;
39
+  isolation: isolate;
40
+  z-index: 10001;
41
+  contain: paint;
42
+  background-color: #ffffff !important;
43
+  background-image: none;
44
+  mix-blend-mode: normal;
38 45
   box-shadow: 0 8px 24px rgba(31, 38, 49, 0.16), 0 2px 6px rgba(31, 38, 49, 0.08);
46
+  opacity: 1 !important;
47
+  pointer-events: auto;
48
+}
49
+
50
+.rich-text-toolbar-dropdown {
51
+  z-index: 1100;
52
+}
53
+
54
+.rich-text-toolbar.expanded::before {
55
+  content: '';
56
+  position: absolute;
57
+  z-index: 0;
58
+  inset: 0;
59
+  display: block;
60
+  background: #ffffff;
61
+  border-radius: inherit;
62
+  pointer-events: none;
63
+}
64
+
65
+.rich-text-toolbar.expanded > * {
66
+  position: relative;
67
+  z-index: 1;
39 68
 }
40 69
 
41 70
 .rich-text-toolbar.expanded .toolbar-row-content {
@@ -378,13 +407,23 @@
378 407
 }
379 408
 
380 409
 .rich-text-alignment-menu .ant-dropdown-menu {
410
+  position: relative;
411
+  z-index: 1;
381 412
   width: 218px;
413
+  background: #fff;
382 414
   padding: 7px 0;
383 415
   border: 1px solid #dfe3e8;
384 416
   border-radius: 5px;
385 417
   box-shadow: 0 8px 24px rgba(31, 38, 49, 0.16), 0 2px 6px rgba(31, 38, 49, 0.08);
386 418
 }
387 419
 
420
+/* 提升 Dropdown 外层容器,而不是只提升菜单内部,避免被主格式面板覆盖。 */
421
+.rich-text-alignment-menu,
422
+.rich-text-alignment-menu.ant-dropdown,
423
+.rich-text-alignment-menu.ant-dropdown-placement-bottomLeft {
424
+  z-index: 10002 !important;
425
+}
426
+
388 427
 .rich-text-alignment-menu .ant-dropdown-menu-item {
389 428
   min-height: 30px;
390 429
   margin: 1px 5px;
@@ -405,6 +444,18 @@
405 444
   font-size: 15px;
406 445
 }
407 446
 
447
+/* Popover 的层级需要设置在外层容器,避免颜色面板被格式工具栏覆盖。 */
448
+.rich-text-color-picker,
449
+.rich-text-color-picker.ant-popover {
450
+  z-index: 10003 !important;
451
+}
452
+
453
+.rich-text-color-picker .ant-popover-inner {
454
+  position: relative;
455
+  z-index: 1;
456
+  background: #fff;
457
+}
458
+
408 459
 @keyframes toolbar-fade-in {
409 460
   from {
410 461
     opacity: 0;

+ 139 - 56
src/components/Editor/RichTextEditor/RichTextToolbar.tsx

@@ -9,6 +9,7 @@
9 9
  */
10 10
 
11 11
 import React, { useEffect, useRef, useState, useCallback } from 'react';
12
+import { createPortal } from 'react-dom';
12 13
 import { Tooltip, InputNumber, Popover, Dropdown } from 'antd';
13 14
 import {
14 15
   BoldOutlined,
@@ -27,6 +28,7 @@ import {
27 28
   VerticalAlignBottomOutlined,
28 29
 } from '@ant-design/icons';
29 30
 import type { TableBlock, TableCell as TableCellType } from '../../../types/editor';
31
+import { getTableCellsForVisualBounds, getTableVisualCellPositions } from '../../../utils/blockOperations';
30 32
 import './RichTextToolbar.css';
31 33
 
32 34
 // ══════════════════════════════════════════════════════════════════════════════
@@ -36,6 +38,12 @@ import './RichTextToolbar.css';
36 38
 export interface RichTextToolbarProps {
37 39
   /** 工具栏位置 */
38 40
   position: { top: number; left: number };
41
+  /** 编辑器元素,用于计算脱离 contenteditable 后的固定定位 */
42
+  editorElement?: HTMLElement | null;
43
+  /** 收起状态下是否显示启动按钮 */
44
+  visible?: boolean;
45
+  /** 工具栏触发器悬停状态变化 */
46
+  onHoverChange?: (hovered: boolean) => void;
39 47
   /** 关闭回调 */
40 48
   onClose: () => void;
41 49
   /** 格式变更回调 */
@@ -60,6 +68,12 @@ export interface RichTextToolbarProps {
60 68
       endRow: number;
61 69
       endCol: number;
62 70
     } | null;
71
+    selectedVisualRange?: {
72
+      rowStart: number;
73
+      rowEnd: number;
74
+      colStart: number;
75
+      colEnd: number;
76
+    } | null;
63 77
     onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
64 78
   };
65 79
 }
@@ -155,6 +169,21 @@ function restoreSavedOrCurrentSelection(saved: SavedRange | null, editor?: HTMLE
155 169
   return !!selectedEditor && (!editor || selectedEditor === editor);
156 170
 }
157 171
 
172
+function normalizeHexColor(color: string | undefined, fallback = '#000000'): string {
173
+  const normalized = (color || '').trim().replace(/^#/, '');
174
+  return /^[0-9a-f]{3,8}$/i.test(normalized) ? `#${normalized}` : fallback;
175
+}
176
+
177
+function getCommonCellStyleValue<Key extends keyof TableCellType['style']>(
178
+  cells: TableCellType[],
179
+  key: Key,
180
+): TableCellType['style'][Key] | undefined {
181
+  if (cells.length === 0) return undefined;
182
+
183
+  const firstValue = cells[0].style?.[key];
184
+  return cells.every((cell) => cell.style?.[key] === firstValue) ? firstValue : undefined;
185
+}
186
+
158 187
 // ══════════════════════════════════════════════════════════════════════════════
159 188
 // Helper: 格式化应用
160 189
 // ══════════════════════════════════════════════════════════════════════════════
@@ -262,6 +291,9 @@ type ContentFormat = 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-
262 291
 
263 292
 export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
264 293
   position,
294
+  editorElement,
295
+  visible = false,
296
+  onHoverChange,
265 297
   onClose,
266 298
   onFormat,
267 299
   currentContentFormat,
@@ -288,9 +320,31 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
288 320
   // 判断是否处于表格编辑模式
289 321
   const isTableMode = !!tableContext;
290 322
 
323
+  const selectedTableCells = isTableMode && tableContext
324
+    ? (() => {
325
+        const positions = getTableVisualCellPositions(tableContext.block);
326
+        const selectedPosition = positions.get(
327
+          `${tableContext.selectedCell.row}-${tableContext.selectedCell.col}`,
328
+        );
329
+        const bounds = tableContext.selectedVisualRange || selectedPosition;
330
+        return bounds ? getTableCellsForVisualBounds(tableContext.block, bounds) : [];
331
+      })()
332
+    : [];
333
+  const hasSelectedTableCells = selectedTableCells.length > 0;
334
+  const allSelectedCellsHave = (key: 'bold' | 'italic' | 'underline') => (
335
+    hasSelectedTableCells && selectedTableCells.every((cell) => cell.style?.[key] === true)
336
+  );
337
+  const allSelectedCellsHaveBold = allSelectedCellsHave('bold');
338
+  const allSelectedCellsHaveItalic = allSelectedCellsHave('italic');
339
+  const allSelectedCellsHaveUnderline = allSelectedCellsHave('underline');
340
+  const commonFontSize = getCommonCellStyleValue(selectedTableCells, 'font_size');
341
+  const commonColor = getCommonCellStyleValue(selectedTableCells, 'color');
342
+  const commonTextAlign = getCommonCellStyleValue(selectedTableCells, 'align');
343
+  const commonVerticalAlign = getCommonCellStyleValue(selectedTableCells, 'valign');
344
+
291 345
   const getEditorElement = useCallback(() => (
292
-    toolbarRef.current?.parentElement?.querySelector<HTMLElement>('.rich-text-editor') ?? null
293
-  ), []);
346
+    editorElement ?? null
347
+  ), [editorElement]);
294 348
 
295 349
   // 预设颜色
296 350
   const presetColors = [
@@ -321,16 +375,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
321 375
 
322 376
     if (isTableMode && tableContext) {
323 377
       // 表格模式:加载当前单元格的样式
324
-      const currentCell = tableContext.block.content.rows[tableContext.selectedCell.row]?.cells[tableContext.selectedCell.col];
325
-      if (currentCell) {
326
-        const style = currentCell.style || {};
327
-        setFontSize(style.font_size || 12);
328
-        setCurrentColor(style.color ? `#${style.color}` : '#000000');
329
-        setTextAlign(style.align || 'center');
330
-        setVerticalAlign(style.valign || 'middle');
331
-        setIsBold(!!style.bold);
332
-        setIsItalic(!!style.italic);
333
-        setIsUnderline(!!style.underline);
378
+      if (hasSelectedTableCells) {
379
+        setFontSize(commonFontSize || 12);
380
+        setCurrentColor(normalizeHexColor(commonColor));
381
+        setTextAlign(commonTextAlign || 'center');
382
+        setVerticalAlign(commonVerticalAlign || 'middle');
383
+        setIsBold(allSelectedCellsHaveBold);
384
+        setIsItalic(allSelectedCellsHaveItalic);
385
+        setIsUnderline(allSelectedCellsHaveUnderline);
334 386
       }
335 387
     } else {
336 388
       // 如果提供了 currentAlign,使用它
@@ -383,7 +435,20 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
383 435
     return () => {
384 436
       canceled = true;
385 437
     };
386
-  }, [getEditorElement, isTableMode, tableContext, currentAlign]);
438
+  }, [
439
+    getEditorElement,
440
+    isTableMode,
441
+    tableContext,
442
+    currentAlign,
443
+    hasSelectedTableCells,
444
+    commonFontSize,
445
+    commonColor,
446
+    commonTextAlign,
447
+    commonVerticalAlign,
448
+    allSelectedCellsHaveBold,
449
+    allSelectedCellsHaveItalic,
450
+    allSelectedCellsHaveUnderline,
451
+  ]);
387 452
 
388 453
   useEffect(() => {
389 454
     if (isTableMode) return;
@@ -407,37 +472,22 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
407 472
     return () => document.removeEventListener('selectionchange', handleSelectionChange);
408 473
   }, [getEditorElement, isTableMode]);
409 474
 
410
-  // ── 点击外部关闭 ───────────────────────────────────────────────────────────
411
-  useEffect(() => {
412
-    const handleClickOutside = (e: MouseEvent) => {
413
-      const target = e.target as Element;
414
-      const isToolbarPopup = target.closest('.ant-select-dropdown, .ant-popover, .ant-dropdown');
415
-
416
-      if (toolbarRef.current && !toolbarRef.current.contains(target) && !isToolbarPopup) {
417
-        const editor = getEditorElement();
418
-        if (!editor?.contains(target)) {
419
-          setIsExpanded(false);
420
-          onClose();
421
-        }
422
-      }
423
-    };
424
-
425
-    document.addEventListener('mousedown', handleClickOutside);
426
-    return () => document.removeEventListener('mousedown', handleClickOutside);
427
-  }, [getEditorElement, onClose]);
428
-
429 475
   // ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
430 476
   const toggleFormat = useCallback((tagName: string) => {
431 477
     if (isTableMode && tableContext) {
432 478
       // 表格模式:切换单元格样式
433 479
       const styleKey = tagName === 'strong' ? 'bold' : tagName === 'em' ? 'italic' : 'underline';
434
-      const currentValue = styleKey === 'bold' ? isBold : styleKey === 'italic' ? isItalic : isUnderline;
480
+      const currentValue = styleKey === 'bold'
481
+        ? allSelectedCellsHaveBold
482
+        : styleKey === 'italic'
483
+          ? allSelectedCellsHaveItalic
484
+          : allSelectedCellsHaveUnderline;
435 485
       
436 486
       tableContext.onStyleChange({ [styleKey]: !currentValue });
437 487
       
438
-      if (styleKey === 'bold') setIsBold(!isBold);
439
-      else if (styleKey === 'italic') setIsItalic(!isItalic);
440
-      else setIsUnderline(!isUnderline);
488
+      if (styleKey === 'bold') setIsBold(!currentValue);
489
+      else if (styleKey === 'italic') setIsItalic(!currentValue);
490
+      else setIsUnderline(!currentValue);
441 491
       
442 492
       return;
443 493
     }
@@ -458,7 +508,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
458 508
     
459 509
     savedRangeRef.current = saveSelection();
460 510
     onFormat();
461
-  }, [getEditorElement, isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
511
+  }, [getEditorElement, isTableMode, tableContext, allSelectedCellsHaveBold, allSelectedCellsHaveItalic, allSelectedCellsHaveUnderline, onFormat]);
462 512
 
463 513
   // ── 处理字号变化 ───────────────────────────────────────────────────────────
464 514
   const handleFontSizeChange = useCallback((value: number | null) => {
@@ -497,7 +547,10 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
497 547
     
498 548
     if (isTableMode && tableContext) {
499 549
       // 表格模式
500
-      const hexColor = color.replace('#', '');
550
+      const hexColor = color.trim().replace(/^#/, '').toUpperCase();
551
+      if (!/^[0-9A-F]{3,8}$/.test(hexColor)) {
552
+        return;
553
+      }
501 554
       tableContext.onStyleChange({ color: hexColor });
502 555
       setTimeout(() => setColorPickerOpen(false), 100);
503 556
       return;
@@ -627,7 +680,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
627 680
     e.preventDefault();
628 681
   };
629 682
 
630
-  const handleLauncherClick = useCallback(() => {
683
+  const handleLauncherMouseDown = useCallback(() => {
631 684
     if (!isTableMode) {
632 685
       const editor = getEditorElement();
633 686
       const selection = window.getSelection();
@@ -639,7 +692,6 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
639 692
         savedRangeRef.current = saveSelection();
640 693
       }
641 694
     }
642
-    setIsExpanded(true);
643 695
   }, [getEditorElement, isTableMode]);
644 696
 
645 697
   // ── 渲染 ───────────────────────────────────────────────────────────────────
@@ -677,20 +729,12 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
677 729
     </div>
678 730
   );
679 731
 
680
-  return (
732
+  const expandedToolbar = (
681 733
     <div
682 734
       ref={toolbarRef}
683
-      className={`rich-text-toolbar${isExpanded ? ' expanded' : ' collapsed'}`}
684
-      style={{
685
-        top: `${position.top + (isExpanded ? 34 : 0)}px`,
686
-        left: `${position.left}px`,
687
-      }}
735
+      className="rich-text-toolbar expanded"
688 736
       onMouseDown={handleMouseDown}
689 737
     >
690
-      {!isExpanded ? (
691
-        <ToolbarLauncher onClick={handleLauncherClick} />
692
-      ) : (
693
-        <>
694 738
       {!isTableMode && (
695 739
         <>
696 740
           <div className="toolbar-row toolbar-row-primary toolbar-row-content">
@@ -738,7 +782,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
738 782
             prefix={<FontSizeOutlined />}
739 783
           />
740 784
         </Tooltip>
741
-        <Popover open={colorPickerOpen} onOpenChange={setColorPickerOpen} content={colorContent} trigger="click" placement="bottom">
785
+        <Popover
786
+          open={colorPickerOpen}
787
+          onOpenChange={setColorPickerOpen}
788
+          content={colorContent}
789
+          trigger="click"
790
+          placement="bottom"
791
+          overlayClassName="rich-text-color-picker"
792
+        >
742 793
           <button type="button" className="toolbar-icon-button" title="文字颜色">
743 794
             <FontColorsOutlined style={{ color: currentColor }} />
744 795
           </button>
@@ -753,7 +804,9 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
753 804
             items: alignmentMenuItems,
754 805
             selectedKeys: [textAlign],
755 806
           }}
756
-          trigger={['click']}
807
+          trigger={['hover']}
808
+          mouseEnterDelay={0}
809
+          mouseLeaveDelay={0.15}
757 810
           placement="bottomLeft"
758 811
           overlayClassName="rich-text-alignment-menu"
759 812
         >
@@ -781,10 +834,40 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
781 834
       <div className="toolbar-row toolbar-row-actions toolbar-row-bottom">
782 835
         <ToolbarButton title="删除" onClick={handleDelete}><DeleteOutlined /></ToolbarButton>
783 836
       </div>
784
-        </>
785
-      )}
786 837
     </div>
787 838
   );
839
+
840
+  const toolbar = (
841
+    <Dropdown
842
+      open={isExpanded}
843
+      onOpenChange={(open) => {
844
+        setIsExpanded(open);
845
+        if (!open) {
846
+          setColorPickerOpen(false);
847
+          onClose();
848
+        }
849
+      }}
850
+      trigger={['click']}
851
+      placement="bottomLeft"
852
+      overlayClassName="rich-text-toolbar-dropdown"
853
+      popupRender={() => expandedToolbar}
854
+    >
855
+      <div
856
+        className={`rich-text-toolbar collapsed${visible ? ' visible' : ''}`}
857
+        style={{
858
+          top: `${(editorElement?.getBoundingClientRect().top ?? 0) + position.top}px`,
859
+          left: `${(editorElement?.getBoundingClientRect().left ?? 0) + position.left}px`,
860
+        }}
861
+        onMouseDown={handleLauncherMouseDown}
862
+        onMouseEnter={() => onHoverChange?.(true)}
863
+        onMouseLeave={() => onHoverChange?.(false)}
864
+      >
865
+        <ToolbarLauncher expanded={isExpanded} />
866
+      </div>
867
+    </Dropdown>
868
+  );
869
+
870
+  return typeof document === 'undefined' ? toolbar : createPortal(toolbar, document.body);
788 871
 };
789 872
 
790 873
 interface ToolbarButtonProps {
@@ -813,13 +896,13 @@ function ToolbarButton({ title, active = false, disabled = false, onClick, child
813 896
   );
814 897
 }
815 898
 
816
-export function ToolbarLauncher({ onClick }: { onClick?: () => void }) {
899
+export function ToolbarLauncher({ onClick, expanded = false }: { onClick?: () => void; expanded?: boolean }) {
817 900
   return (
818 901
     <button
819 902
       type="button"
820 903
       className="toolbar-launch-button"
821 904
       aria-label="打开格式工具栏"
822
-      aria-expanded="false"
905
+      aria-expanded={expanded}
823 906
       title="打开格式工具栏"
824 907
       onClick={onClick}
825 908
     >

+ 45 - 26
src/components/Editor/blocks/TableBlock.tsx

@@ -4,11 +4,12 @@
4 4
  * @module components/Editor/blocks
5 5
  */
6 6
 
7
-import React, { useState, useCallback, useRef, useEffect } from 'react';
7
+import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
8 8
 import type { TableBlock as TableBlockType, TableCell as TableCellType } from '../../../types/editor';
9 9
 import { useEditorStore } from '../../../stores/editorStore';
10 10
 import { TableCell } from './TableCell';
11 11
 import { TableToolbar } from './TableToolbar';
12
+import { TableStylePanel } from './TableStylePanel';
12 13
 import { TableResizeHandle } from './TableResizeHandle';
13 14
 import { useTableResize } from '../../../hooks/useTableResize';
14 15
 import { getTableVisualCellPositions } from '../../../utils/blockOperations';
@@ -57,14 +58,20 @@ export const TableBlock: React.FC<TableBlockProps> = ({
57 58
   const isSelectingRef = useRef(false);
58 59
   const didDragSelectRef = useRef(false);
59 60
   const [isSelecting, setIsSelecting] = useState(false);
60
-  const visualCellPositions = getTableVisualCellPositions(block);
61
+  const visualCellPositions = useMemo(
62
+    () => getTableVisualCellPositions(block),
63
+    [block],
64
+  );
61 65
 
62 66
   // ══════════════════════════════════════════════════════════════════════════════
63 67
   // 使用表格调整大小Hook
64 68
   // ══════════════════════════════════════════════════════════════════════════════
65 69
 
66 70
   // 准备行高数据
67
-  const rowHeights = block.content.rows.map(row => row.height || 20);
71
+  const rowHeights = useMemo(
72
+    () => block.content.rows.map((row) => row.height || 20),
73
+    [block.content.rows],
74
+  );
68 75
 
69 76
   // 列宽调整回调
70 77
   const handleColumnResize = useCallback(
@@ -147,22 +154,23 @@ export const TableBlock: React.FC<TableBlockProps> = ({
147 154
     (styleUpdates: Partial<TableCellType['style']>) => {
148 155
       if (!selectedCell) return;
149 156
 
157
+      const styleRange = selectedVisualRange || (() => {
158
+        const position = visualCellPositions.get(getCellKey(selectedCell.row, selectedCell.col));
159
+        return position || null;
160
+      })();
161
+
150 162
       const newRows = block.content.rows.map((row, rowIdx) => {
151
-        // 检查是否在选择范围内
152
-        const isInRange = selectedRange
153
-          ? rowIdx >= selectedRange.startRow && rowIdx <= selectedRange.endRow
154
-          : rowIdx === selectedCell.row;
155
-        
156
-        if (!isInRange) return row;
157
-        
158 163
         return {
159 164
           ...row,
160 165
           cells: row.cells.map((cell, colIdx) => {
161
-            const isInRangeCol = selectedRange
162
-              ? colIdx >= selectedRange.startCol && colIdx <= selectedRange.endCol
163
-              : colIdx === selectedCell.col;
164
-            
165
-            if (!isInRangeCol) return cell;
166
+            const cellPosition = visualCellPositions.get(getCellKey(rowIdx, colIdx));
167
+            const isInRange = !!styleRange && !!cellPosition
168
+              && cellPosition.rowStart <= styleRange.rowEnd
169
+              && cellPosition.rowEnd >= styleRange.rowStart
170
+              && cellPosition.colStart <= styleRange.colEnd
171
+              && cellPosition.colEnd >= styleRange.colStart;
172
+
173
+            if (!isInRange) return cell;
166 174
             
167 175
             return {
168 176
               ...cell,
@@ -182,7 +190,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
182 190
         },
183 191
       });
184 192
     },
185
-    [block, selectedCell, selectedRange, updateBlock]
193
+    [block, selectedCell, selectedVisualRange, updateBlock, visualCellPositions]
186 194
   );
187 195
 
188 196
   // 处理单元格点击(支持Shift多选)
@@ -327,16 +335,17 @@ export const TableBlock: React.FC<TableBlockProps> = ({
327 335
   const colWidths = block.metadata.col_widths;
328 336
   
329 337
   // 如果没有metadata.col_widths,从content.col_widths推算百分比
330
-  const effectiveColWidths = colWidths && colWidths.length > 0 
331
-    ? colWidths 
332
-    : block.content.col_widths 
333
-      ? (() => {
334
-          const totalPt = block.content.col_widths.reduce((sum: number, w: number) => sum + w, 0);
335
-          return totalPt > 0
336
-            ? block.content.col_widths.map((w: number) => (w / totalPt) * 100)
337
-            : block.content.col_widths.map(() => 100 / block.content.col_widths!.length);
338
-        })()
339
-      : Array(Math.max(block.metadata.cols, 1)).fill(100 / Math.max(block.metadata.cols, 1));
338
+  const effectiveColWidths = useMemo(() => {
339
+    if (colWidths && colWidths.length > 0) return colWidths;
340
+    if (block.content.col_widths) {
341
+      const totalPt = block.content.col_widths.reduce((sum, width) => sum + width, 0);
342
+      return totalPt > 0
343
+        ? block.content.col_widths.map((width) => (width / totalPt) * 100)
344
+        : block.content.col_widths.map(() => 100 / block.content.col_widths!.length);
345
+    }
346
+    const columnCount = Math.max(block.metadata.cols, 1);
347
+    return Array(columnCount).fill(100 / columnCount);
348
+  }, [block.content.col_widths, block.metadata.cols, colWidths]);
340 349
 
341 350
   return (
342 351
     <div className="table-block-wrapper" data-block-id={block.id}>
@@ -355,6 +364,15 @@ export const TableBlock: React.FC<TableBlockProps> = ({
355 364
         />
356 365
       )}
357 366
 
367
+      {!readOnly && selectedCell && (
368
+        <TableStylePanel
369
+          block={block}
370
+          selectedCell={selectedCell}
371
+          selectedRange={selectedRange}
372
+          selectedVisualRange={selectedVisualRange}
373
+        />
374
+      )}
375
+
358 376
       {/* 拖动尺寸提示 */}
359 377
       {resizeState?.isResizing && currentSizeTooltip && (
360 378
         <div
@@ -444,6 +462,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
444 462
                       onMouseEnter={handleCellMouseEnter}
445 463
                       tableBlock={block}
446 464
                       selectedRange={selectedRange}
465
+                      selectedVisualRange={selectedVisualRange}
447 466
                       onStyleChange={handleCellStyleChange}
448 467
                     />
449 468
                   );

+ 28 - 16
src/components/Editor/blocks/TableBorderControl.tsx

@@ -6,12 +6,13 @@
6 6
  * @module components/Editor/blocks
7 7
  */
8 8
 
9
-import React, { useCallback } from 'react';
9
+import React, { useCallback, useMemo } from 'react';
10 10
 import { Button, Space, ColorPicker, InputNumber } from 'antd';
11 11
 import type { Color } from 'antd/es/color-picker';
12 12
 import { BorderOutlined } from '@ant-design/icons';
13 13
 import type { TableBlock } from '../../../types/editor';
14 14
 import { useEditorStore } from '../../../stores/editorStore';
15
+import { getTableVisualCellPositions } from '../../../utils/blockOperations';
15 16
 
16 17
 // ══════════════════════════════════════════════════════════════════════════════
17 18
 // Component Props
@@ -29,6 +30,12 @@ export interface TableBorderControlProps {
29 30
     endRow: number;
30 31
     endCol: number;
31 32
   } | null;
33
+  selectedVisualRange?: {
34
+    rowStart: number;
35
+    rowEnd: number;
36
+    colStart: number;
37
+    colEnd: number;
38
+  } | null;
32 39
 }
33 40
 
34 41
 // ══════════════════════════════════════════════════════════════════════════════
@@ -45,9 +52,13 @@ export interface TableBorderControlProps {
45 52
 export const TableBorderControl: React.FC<TableBorderControlProps> = ({
46 53
   block,
47 54
   selectedCell,
48
-  selectedRange,
55
+  selectedVisualRange,
49 56
 }) => {
50 57
   const updateBlock = useEditorStore((state) => state.updateBlock);
58
+  const visualCellPositions = useMemo(
59
+    () => getTableVisualCellPositions(block),
60
+    [block],
61
+  );
51 62
   
52 63
   // 获取当前选中单元格的边框样式
53 64
   const currentCell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
@@ -58,22 +69,23 @@ export const TableBorderControl: React.FC<TableBorderControlProps> = ({
58 69
     border_width?: number;
59 70
     border_color?: string;
60 71
   }) => {
72
+    const styleRange = selectedVisualRange || (() => {
73
+      const position = visualCellPositions.get(`${selectedCell.row}-${selectedCell.col}`);
74
+      return position || null;
75
+    })();
76
+
61 77
     const newRows = block.content.rows.map((row, rowIdx) => {
62
-      // 检查是否在选择范围内
63
-      const isInRange = selectedRange
64
-        ? rowIdx >= selectedRange.startRow && rowIdx <= selectedRange.endRow
65
-        : rowIdx === selectedCell.row;
66
-      
67
-      if (!isInRange) return row;
68
-      
69 78
       return {
70 79
         ...row,
71 80
         cells: row.cells.map((cell, colIdx) => {
72
-          const isInRangeCol = selectedRange
73
-            ? colIdx >= selectedRange.startCol && colIdx <= selectedRange.endCol
74
-            : colIdx === selectedCell.col;
75
-          
76
-          if (!isInRangeCol) return cell;
81
+          const cellPosition = visualCellPositions.get(`${rowIdx}-${colIdx}`);
82
+          const isInRange = !!styleRange && !!cellPosition
83
+            && cellPosition.rowStart <= styleRange.rowEnd
84
+            && cellPosition.rowEnd >= styleRange.rowStart
85
+            && cellPosition.colStart <= styleRange.colEnd
86
+            && cellPosition.colEnd >= styleRange.colStart;
87
+
88
+          if (!isInRange) return cell;
77 89
           
78 90
           return {
79 91
             ...cell,
@@ -92,7 +104,7 @@ export const TableBorderControl: React.FC<TableBorderControlProps> = ({
92 104
         rows: newRows,
93 105
       },
94 106
     });
95
-  }, [block, selectedCell, selectedRange, updateBlock]);
107
+  }, [block, selectedCell, selectedVisualRange, updateBlock, visualCellPositions]);
96 108
 
97 109
   // 设置边框宽度
98 110
   const setBorderWidth = useCallback((border_width: number | null) => {
@@ -103,7 +115,7 @@ export const TableBorderControl: React.FC<TableBorderControlProps> = ({
103 115
 
104 116
   // 设置边框颜色
105 117
   const setBorderColor = useCallback((color: Color) => {
106
-    const hexColor = color.toHexString().replace('#', '');
118
+    const hexColor = color.toHexString().replace(/^#/, '').toUpperCase();
107 119
     applyBorderStyle({ border_color: hexColor });
108 120
   }, [applyBorderStyle]);
109 121
 

+ 8 - 0
src/components/Editor/blocks/TableCell.tsx

@@ -42,6 +42,12 @@ export interface TableCellProps {
42 42
     endRow: number;
43 43
     endCol: number;
44 44
   } | null;
45
+  selectedVisualRange?: {
46
+    rowStart: number;
47
+    rowEnd: number;
48
+    colStart: number;
49
+    colEnd: number;
50
+  } | null;
45 51
   /** 样式变更回调(用于传递给RichTextEditor) */
46 52
   onStyleChange?: (styleUpdates: Partial<TableCellType['style']>) => void;
47 53
 }
@@ -71,6 +77,7 @@ export const TableCell: React.FC<TableCellProps> = ({
71 77
   onMouseEnter,
72 78
   tableBlock,
73 79
   selectedRange,
80
+  selectedVisualRange,
74 81
   onStyleChange,
75 82
 }) => {
76 83
   // 解析样式
@@ -137,6 +144,7 @@ export const TableCell: React.FC<TableCellProps> = ({
137 144
           block: tableBlock,
138 145
           selectedCell: { row: rowIndex, col: colIndex },
139 146
           selectedRange: selectedRange,
147
+          selectedVisualRange: selectedVisualRange,
140 148
           onStyleChange: onStyleChange,
141 149
         } : undefined}
142 150
       />

+ 50 - 19
src/components/Editor/blocks/TableStylePanel.tsx

@@ -6,7 +6,7 @@
6 6
  * @module components/Editor/blocks
7 7
  */
8 8
 
9
-import React, { useCallback } from 'react';
9
+import React, { useCallback, useMemo } from 'react';
10 10
 import { Button, Space, ColorPicker, InputNumber, Select, Divider } from 'antd';
11 11
 import type { Color } from 'antd/es/color-picker';
12 12
 import {
@@ -24,6 +24,10 @@ import {
24 24
 } from '@ant-design/icons';
25 25
 import type { TableBlock, TableCell } from '../../../types/editor';
26 26
 import { useEditorStore } from '../../../stores/editorStore';
27
+import {
28
+  getTableCellsForVisualBounds,
29
+  getTableVisualCellPositions,
30
+} from '../../../utils/blockOperations';
27 31
 import { TableBorderControl } from './TableBorderControl';
28 32
 import './TableStylePanel.css';
29 33
 
@@ -43,6 +47,12 @@ export interface TableStylePanelProps {
43 47
     endRow: number;
44 48
     endCol: number;
45 49
   } | null;
50
+  selectedVisualRange?: {
51
+    rowStart: number;
52
+    rowEnd: number;
53
+    colStart: number;
54
+    colEnd: number;
55
+  } | null;
46 56
 }
47 57
 
48 58
 // ══════════════════════════════════════════════════════════════════════════════
@@ -63,31 +73,51 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
63 73
   block,
64 74
   selectedCell,
65 75
   selectedRange,
76
+  selectedVisualRange,
66 77
 }) => {
67 78
   const updateBlock = useEditorStore((state) => state.updateBlock);
68
-  
69
-  // 获取当前选中单元格的样式
70
-  const currentCell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
71
-  const currentStyle = currentCell?.style || {};
79
+  const visualCellPositions = useMemo(
80
+    () => getTableVisualCellPositions(block),
81
+    [block],
82
+  );
83
+  const selectedPosition = visualCellPositions.get(`${selectedCell.row}-${selectedCell.col}`);
84
+  const styleRange = selectedVisualRange || selectedPosition;
85
+  const selectedCells = styleRange
86
+    ? getTableCellsForVisualBounds(block, styleRange)
87
+    : [];
88
+  const getCommonStyleValue = <Key extends keyof TableCell['style']>(key: Key) => {
89
+    if (selectedCells.length === 0) return undefined;
90
+    const firstValue = selectedCells[0].style?.[key];
91
+    return selectedCells.every((cell) => cell.style?.[key] === firstValue) ? firstValue : undefined;
92
+  };
93
+  const commonBold = selectedCells.length > 0 && selectedCells.every((cell) => cell.style?.bold === true);
94
+  const commonItalic = selectedCells.length > 0 && selectedCells.every((cell) => cell.style?.italic === true);
95
+  const commonUnderline = selectedCells.length > 0 && selectedCells.every((cell) => cell.style?.underline === true);
96
+  const currentStyle = {
97
+    bold: commonBold,
98
+    italic: commonItalic,
99
+    underline: commonUnderline,
100
+    align: getCommonStyleValue('align'),
101
+    valign: getCommonStyleValue('valign'),
102
+    font_size: getCommonStyleValue('font_size'),
103
+    font_name: getCommonStyleValue('font_name'),
104
+    color: getCommonStyleValue('color'),
105
+  };
72 106
 
73 107
   // 应用样式到选中的单元格或范围
74 108
   const applyStyle = useCallback((styleUpdates: Partial<TableCell['style']>) => {
75 109
     const newRows = block.content.rows.map((row, rowIdx) => {
76
-      // 检查是否在选择范围内
77
-      const isInRange = selectedRange
78
-        ? rowIdx >= selectedRange.startRow && rowIdx <= selectedRange.endRow
79
-        : rowIdx === selectedCell.row;
80
-      
81
-      if (!isInRange) return row;
82
-      
83 110
       return {
84 111
         ...row,
85 112
         cells: row.cells.map((cell, colIdx) => {
86
-          const isInRangeCol = selectedRange
87
-            ? colIdx >= selectedRange.startCol && colIdx <= selectedRange.endCol
88
-            : colIdx === selectedCell.col;
89
-          
90
-          if (!isInRangeCol) return cell;
113
+          const cellPosition = visualCellPositions.get(`${rowIdx}-${colIdx}`);
114
+          const isInRange = !!styleRange && !!cellPosition
115
+            && cellPosition.rowStart <= styleRange.rowEnd
116
+            && cellPosition.rowEnd >= styleRange.rowStart
117
+            && cellPosition.colStart <= styleRange.colEnd
118
+            && cellPosition.colEnd >= styleRange.colStart;
119
+
120
+          if (!isInRange) return cell;
91 121
           
92 122
           return {
93 123
             ...cell,
@@ -106,7 +136,7 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
106 136
         rows: newRows,
107 137
       },
108 138
     });
109
-  }, [block, selectedCell, selectedRange, updateBlock]);
139
+  }, [block, styleRange, updateBlock, visualCellPositions]);
110 140
 
111 141
   // 字体样式切换
112 142
   const toggleBold = useCallback(() => {
@@ -140,7 +170,7 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
140 170
 
141 171
   // 字体颜色
142 172
   const setFontColor = useCallback((color: Color) => {
143
-    const hexColor = color.toHexString().replace('#', '');
173
+    const hexColor = color.toHexString().replace(/^#/, '').toUpperCase();
144 174
     applyStyle({ color: hexColor });
145 175
   }, [applyStyle]);
146 176
 
@@ -283,6 +313,7 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
283 313
           block={block}
284 314
           selectedCell={selectedCell}
285 315
           selectedRange={selectedRange}
316
+          selectedVisualRange={selectedVisualRange}
286 317
         />
287 318
       </Space>
288 319
     </div>

+ 68 - 100
src/utils/blockOperations.ts

@@ -172,6 +172,28 @@ export function getTableVisualCellPositions(table: TableBlock): Map<string, Tabl
172 172
   return positions;
173 173
 }
174 174
 
175
+export function getTableCellsForVisualBounds(
176
+  table: TableBlock,
177
+  bounds: TableVisualCellPosition,
178
+): TableCell[] {
179
+  const positions = getTableVisualCellPositions(table);
180
+  const cells: TableCell[] = [];
181
+
182
+  for (const [key, position] of positions.entries()) {
183
+    const intersects = position.rowStart <= bounds.rowEnd
184
+      && position.rowEnd >= bounds.rowStart
185
+      && position.colStart <= bounds.colEnd
186
+      && position.colEnd >= bounds.colStart;
187
+    if (!intersects) continue;
188
+
189
+    const [rowIndex, cellIndex] = key.split('-').map(Number);
190
+    const cell = table.content.rows[rowIndex]?.cells[cellIndex];
191
+    if (cell) cells.push(cell);
192
+  }
193
+
194
+  return cells;
195
+}
196
+
175 197
 function createHiddenCell(colIndex: number): TableCell {
176 198
   return {
177 199
     text: '',
@@ -1109,116 +1131,62 @@ export function splitCell(
1109 1131
   assertTableIndex(rowIndex, table.content.rows.length, '行');
1110 1132
   assertTableIndex(colIndex, table.metadata.cols, '列');
1111 1133
   const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
1112
-  
1134
+
1113 1135
   if (!targetCell) {
1114 1136
     throw new Error('单元格不存在');
1115 1137
   }
1116
-  
1138
+
1117 1139
   // 如果单元格没有合并,无需拆分
1118 1140
   if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) {
1119 1141
     throw new Error('此单元格未合并,无需拆分');
1120 1142
   }
1121
-  
1122
-  const rowspan = targetCell.rowspan || 1;
1123
-  const colspan = targetCell.colspan || 1;
1124
-  
1125
-  const rows = table.content.rows.map((row, rowIdx) => {
1126
-    const cells = [...row.cells];
1127
-    
1128
-    // 处理主单元格所在的行
1129
-    if (rowIdx === rowIndex) {
1130
-      // 1. 修改主单元格,清除合并标记
1131
-      cells[colIndex] = {
1132
-        ...cells[colIndex],
1133
-        rowspan: 1,
1134
-        colspan: 1,
1135
-        col_index: colIndex + 1,
1136
-        word_style: cells[colIndex].word_style || 'Normal',
1137
-      };
1138
-      
1139
-      // 2. 如果有横向合并(colspan>1),在主单元格后面插入新的空单元格
1140
-      if (colspan > 1) {
1141
-        const newCells: TableCell[] = [];
1142
-        for (let i = 0; i < colspan - 1; i++) {
1143
-          newCells.push({
1144
-            text: '',
1145
-            rowspan: 1,
1146
-            colspan: 1,
1147
-            col_index: colIndex + 2 + i, // col_index从1开始
1148
-            style: {},
1149
-            word_style: 'Normal',
1150
-            width: cells[colIndex].width || 100,
1151
-          });
1152
-        }
1153
-        // 在主单元格后插入新单元格
1154
-        cells.splice(colIndex + 1, 0, ...newCells);
1155
-        
1156
-        // 更新后续单元格的 col_index
1157
-        for (let i = colIndex + colspan; i < cells.length; i++) {
1158
-          cells[i] = {
1159
-            ...cells[i],
1160
-            col_index: (cells[i].col_index || 0) + (colspan - 1),
1161
-          };
1162
-        }
1163
-      }
1164
-      
1165
-      return { cells, height: row.height };
1143
+
1144
+  const positions = getTableVisualCellPositions(table);
1145
+  const targetPosition = positions.get(`${rowIndex}-${colIndex}`);
1146
+  if (!targetPosition) {
1147
+    throw new Error('合并单元格位置无效');
1148
+  }
1149
+
1150
+  const positionedCells: PositionedTableCell[] = [];
1151
+  for (const [key, position] of positions.entries()) {
1152
+    const [currentRow, currentCol] = key.split('-').map(Number);
1153
+    const cell = table.content.rows[currentRow]?.cells[currentCol];
1154
+    if (!cell) continue;
1155
+
1156
+    if (currentRow !== rowIndex || currentCol !== colIndex) {
1157
+      positionedCells.push({ cell, position });
1158
+      continue;
1166 1159
     }
1167
-    
1168
-    // 处理被纵向合并影响的其他行
1169
-    if (rowIdx > rowIndex && rowIdx < rowIndex + rowspan) {
1170
-      // 查找并恢复被隐藏的单元格
1171
-      let foundHiddenCell = false;
1172
-      const newCells = cells.map((cell, cellIdx) => {
1173
-        // 找到对应列位置的被隐藏单元格
1174
-        if (cellIdx === colIndex && (cell.rowspan === 0 || cell.colspan === 0)) {
1175
-          foundHiddenCell = true;
1176
-          
1177
-          // 恢复为单个正常单元格
1178
-          return {
1179
-            text: '',
1180
-            rowspan: 1,
1181
-            colspan: 1,
1182
-            col_index: colIndex + 1,
1183
-            style: {},
1184
-            word_style: 'Normal',
1185
-            width: cell.width || 100,
1186
-          } as TableCell;
1187
-        }
1188
-        return cell;
1189
-      });
1190
-      
1191
-      // 如果主单元格有横向合并,需要插入额外的单元格
1192
-      if (foundHiddenCell && colspan > 1) {
1193
-        const additionalCells: TableCell[] = [];
1194
-        for (let i = 0; i < colspan - 1; i++) {
1195
-          additionalCells.push({
1196
-            text: '',
1197
-            rowspan: 1,
1198
-            colspan: 1,
1199
-            col_index: colIndex + 2 + i,
1200
-            style: {},
1201
-            word_style: 'Normal',
1202
-            width: newCells[colIndex].width || 100,
1203
-          });
1204
-        }
1205
-        newCells.splice(colIndex + 1, 0, ...additionalCells);
1206
-        
1207
-        // 更新后续单元格的 col_index
1208
-        for (let i = colIndex + colspan; i < newCells.length; i++) {
1209
-          newCells[i] = {
1210
-            ...newCells[i],
1211
-            col_index: (newCells[i].col_index || 0) + (colspan - 1),
1212
-          };
1213
-        }
1160
+
1161
+    for (let splitRow = targetPosition.rowStart; splitRow <= targetPosition.rowEnd; splitRow += 1) {
1162
+      for (let splitCol = targetPosition.colStart; splitCol <= targetPosition.colEnd; splitCol += 1) {
1163
+        const isPrimary = splitRow === targetPosition.rowStart && splitCol === targetPosition.colStart;
1164
+        positionedCells.push({
1165
+          cell: isPrimary
1166
+            ? {
1167
+                ...cell,
1168
+                rowspan: 1,
1169
+                colspan: 1,
1170
+                col_index: splitCol + 1,
1171
+                word_style: cell.word_style || 'Normal',
1172
+              }
1173
+            : createEmptyCell(splitCol + 1, cell.width || 100),
1174
+          position: {
1175
+            rowStart: splitRow,
1176
+            rowEnd: splitRow,
1177
+            colStart: splitCol,
1178
+            colEnd: splitCol,
1179
+          },
1180
+        });
1214 1181
       }
1215
-      
1216
-      return { cells: newCells, height: row.height };
1217 1182
     }
1218
-    
1219
-    // 其他行不受影响
1220
-    return { cells, height: row.height };
1221
-  });
1183
+  }
1184
+
1185
+  const rows = rebuildTableRows(
1186
+    positionedCells,
1187
+    table.content.rows.map((row) => row.height),
1188
+    table.metadata.cols,
1189
+  );
1222 1190
   
1223 1191
   return {
1224 1192
     ...table,