Browse Source

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

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

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

@@ -20,6 +20,8 @@
20
 }
20
 }
21
 
21
 
22
 .rich-text-editor {
22
 .rich-text-editor {
23
+  position: relative;
24
+  z-index: 0;
23
   width: 100%;
25
   width: 100%;
24
   outline: none;
26
   outline: none;
25
   white-space: pre-wrap;
27
   white-space: pre-wrap;
@@ -27,6 +29,7 @@
27
   word-break: break-word;
29
   word-break: break-word;
28
   min-height: 1.5em;
30
   min-height: 1.5em;
29
   transition: all 0.2s ease;
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
       endRow: number;
84
       endRow: number;
85
       endCol: number;
85
       endCol: number;
86
     } | null;
86
     } | null;
87
+    selectedVisualRange?: {
88
+      rowStart: number;
89
+      rowEnd: number;
90
+      colStart: number;
91
+      colEnd: number;
92
+    } | null;
87
     onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
93
     onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
88
   };
94
   };
89
   /** 是否显示左侧格式工具按钮 */
95
   /** 是否显示左侧格式工具按钮 */
@@ -131,18 +137,90 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
131
   tableContext,
137
   tableContext,
132
   hasContent,
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
   const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -40 });
146
   const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -40 });
136
   const isComposingRef = useRef(false);
147
   const isComposingRef = useRef(false);
137
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
148
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
138
   const formatFrameRef = useRef<number | null>(null);
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
   useEffect(() => {
157
   useEffect(() => {
141
     return () => {
158
     return () => {
142
       if (formatFrameRef.current !== null) {
159
       if (formatFrameRef.current !== null) {
143
         cancelAnimationFrame(formatFrameRef.current);
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
     if (!selection || selection.isCollapsed || !editorRef.current) {
378
     if (!selection || selection.isCollapsed || !editorRef.current) {
379
+      setHasTextSelection(false);
301
       return;
380
       return;
302
     }
381
     }
303
 
382
 
@@ -310,12 +389,17 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
310
       top: 0,
389
       top: 0,
311
       left: -40,
390
       left: -40,
312
     });
391
     });
392
+    setHasTextSelection(true);
313
     
393
     
314
   }, [readOnly, tableContext]);
394
   }, [readOnly, tableContext]);
315
 
395
 
316
   // ── 处理聚焦(表格模式显示工具栏) ─────────────────────────────────────────────
396
   // ── 处理聚焦(表格模式显示工具栏) ─────────────────────────────────────────────
317
   const handleFocus = useCallback(() => {
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
     setTimeout(() => {
405
     setTimeout(() => {
@@ -355,24 +439,11 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
355
 
439
 
356
   return (
440
   return (
357
     <div className="rich-text-editor-wrapper">
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
       <div
443
       <div
375
-        ref={editorRef}
444
+        onMouseEnter={() => handleEditorHoverChange(true)}
445
+        onMouseLeave={() => handleEditorHoverChange(false)}
446
+        ref={handleEditorRef}
376
         className={`rich-text-editor ${readOnly ? 'read-only' : ''}`}
447
         className={`rich-text-editor ${readOnly ? 'read-only' : ''}`}
377
         contentEditable={!readOnly}
448
         contentEditable={!readOnly}
378
         suppressContentEditableWarning
449
         suppressContentEditableWarning
@@ -387,6 +458,24 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
387
         data-placeholder={placeholder}
458
         data-placeholder={placeholder}
388
         style={baseStyle}
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
     </div>
479
     </div>
391
   );
480
   );
392
 };
481
 };

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

@@ -3,8 +3,8 @@
3
  */
3
  */
4
 
4
 
5
 .rich-text-toolbar {
5
 .rich-text-toolbar {
6
-  position: absolute;
7
-  z-index: 1002;
6
+  position: fixed;
7
+  z-index: 1100;
8
   width: 208px;
8
   width: 208px;
9
   top: 0;
9
   top: 0;
10
   padding: 7px 7px 6px;
10
   padding: 7px 7px 6px;
@@ -19,7 +19,7 @@
19
   transition: opacity 0.2s ease;
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
   opacity: 1;
23
   opacity: 1;
24
   pointer-events: auto;
24
   pointer-events: auto;
25
 }
25
 }
@@ -32,10 +32,39 @@
32
 }
32
 }
33
 
33
 
34
 .rich-text-toolbar.expanded {
34
 .rich-text-toolbar.expanded {
35
+  position: relative;
35
   width: 208px;
36
   width: 208px;
36
   padding: 7px 5px;
37
   padding: 7px 5px;
37
   border-radius: 5px;
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
   box-shadow: 0 8px 24px rgba(31, 38, 49, 0.16), 0 2px 6px rgba(31, 38, 49, 0.08);
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
 .rich-text-toolbar.expanded .toolbar-row-content {
70
 .rich-text-toolbar.expanded .toolbar-row-content {
@@ -378,13 +407,23 @@
378
 }
407
 }
379
 
408
 
380
 .rich-text-alignment-menu .ant-dropdown-menu {
409
 .rich-text-alignment-menu .ant-dropdown-menu {
410
+  position: relative;
411
+  z-index: 1;
381
   width: 218px;
412
   width: 218px;
413
+  background: #fff;
382
   padding: 7px 0;
414
   padding: 7px 0;
383
   border: 1px solid #dfe3e8;
415
   border: 1px solid #dfe3e8;
384
   border-radius: 5px;
416
   border-radius: 5px;
385
   box-shadow: 0 8px 24px rgba(31, 38, 49, 0.16), 0 2px 6px rgba(31, 38, 49, 0.08);
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
 .rich-text-alignment-menu .ant-dropdown-menu-item {
427
 .rich-text-alignment-menu .ant-dropdown-menu-item {
389
   min-height: 30px;
428
   min-height: 30px;
390
   margin: 1px 5px;
429
   margin: 1px 5px;
@@ -405,6 +444,18 @@
405
   font-size: 15px;
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
 @keyframes toolbar-fade-in {
459
 @keyframes toolbar-fade-in {
409
   from {
460
   from {
410
     opacity: 0;
461
     opacity: 0;

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

@@ -9,6 +9,7 @@
9
  */
9
  */
10
 
10
 
11
 import React, { useEffect, useRef, useState, useCallback } from 'react';
11
 import React, { useEffect, useRef, useState, useCallback } from 'react';
12
+import { createPortal } from 'react-dom';
12
 import { Tooltip, InputNumber, Popover, Dropdown } from 'antd';
13
 import { Tooltip, InputNumber, Popover, Dropdown } from 'antd';
13
 import {
14
 import {
14
   BoldOutlined,
15
   BoldOutlined,
@@ -27,6 +28,7 @@ import {
27
   VerticalAlignBottomOutlined,
28
   VerticalAlignBottomOutlined,
28
 } from '@ant-design/icons';
29
 } from '@ant-design/icons';
29
 import type { TableBlock, TableCell as TableCellType } from '../../../types/editor';
30
 import type { TableBlock, TableCell as TableCellType } from '../../../types/editor';
31
+import { getTableCellsForVisualBounds, getTableVisualCellPositions } from '../../../utils/blockOperations';
30
 import './RichTextToolbar.css';
32
 import './RichTextToolbar.css';
31
 
33
 
32
 // ══════════════════════════════════════════════════════════════════════════════
34
 // ══════════════════════════════════════════════════════════════════════════════
@@ -36,6 +38,12 @@ import './RichTextToolbar.css';
36
 export interface RichTextToolbarProps {
38
 export interface RichTextToolbarProps {
37
   /** 工具栏位置 */
39
   /** 工具栏位置 */
38
   position: { top: number; left: number };
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
   onClose: () => void;
48
   onClose: () => void;
41
   /** 格式变更回调 */
49
   /** 格式变更回调 */
@@ -60,6 +68,12 @@ export interface RichTextToolbarProps {
60
       endRow: number;
68
       endRow: number;
61
       endCol: number;
69
       endCol: number;
62
     } | null;
70
     } | null;
71
+    selectedVisualRange?: {
72
+      rowStart: number;
73
+      rowEnd: number;
74
+      colStart: number;
75
+      colEnd: number;
76
+    } | null;
63
     onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
77
     onStyleChange: (styleUpdates: Partial<TableCellType['style']>) => void;
64
   };
78
   };
65
 }
79
 }
@@ -155,6 +169,21 @@ function restoreSavedOrCurrentSelection(saved: SavedRange | null, editor?: HTMLE
155
   return !!selectedEditor && (!editor || selectedEditor === editor);
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
 // Helper: 格式化应用
188
 // Helper: 格式化应用
160
 // ══════════════════════════════════════════════════════════════════════════════
189
 // ══════════════════════════════════════════════════════════════════════════════
@@ -262,6 +291,9 @@ type ContentFormat = 'paragraph' | 'ordered-list' | 'unordered-list' | `heading-
262
 
291
 
263
 export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
292
 export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
264
   position,
293
   position,
294
+  editorElement,
295
+  visible = false,
296
+  onHoverChange,
265
   onClose,
297
   onClose,
266
   onFormat,
298
   onFormat,
267
   currentContentFormat,
299
   currentContentFormat,
@@ -288,9 +320,31 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
288
   // 判断是否处于表格编辑模式
320
   // 判断是否处于表格编辑模式
289
   const isTableMode = !!tableContext;
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
   const getEditorElement = useCallback(() => (
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
   const presetColors = [
350
   const presetColors = [
@@ -321,16 +375,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
321
 
375
 
322
     if (isTableMode && tableContext) {
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
     } else {
387
     } else {
336
       // 如果提供了 currentAlign,使用它
388
       // 如果提供了 currentAlign,使用它
@@ -383,7 +435,20 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
383
     return () => {
435
     return () => {
384
       canceled = true;
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
   useEffect(() => {
453
   useEffect(() => {
389
     if (isTableMode) return;
454
     if (isTableMode) return;
@@ -407,37 +472,22 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
407
     return () => document.removeEventListener('selectionchange', handleSelectionChange);
472
     return () => document.removeEventListener('selectionchange', handleSelectionChange);
408
   }, [getEditorElement, isTableMode]);
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
   // ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
475
   // ── 切换格式 (Bold, Italic, Underline) ─────────────────────────────────────
430
   const toggleFormat = useCallback((tagName: string) => {
476
   const toggleFormat = useCallback((tagName: string) => {
431
     if (isTableMode && tableContext) {
477
     if (isTableMode && tableContext) {
432
       // 表格模式:切换单元格样式
478
       // 表格模式:切换单元格样式
433
       const styleKey = tagName === 'strong' ? 'bold' : tagName === 'em' ? 'italic' : 'underline';
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
       tableContext.onStyleChange({ [styleKey]: !currentValue });
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
       return;
492
       return;
443
     }
493
     }
@@ -458,7 +508,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
458
     
508
     
459
     savedRangeRef.current = saveSelection();
509
     savedRangeRef.current = saveSelection();
460
     onFormat();
510
     onFormat();
461
-  }, [getEditorElement, isTableMode, tableContext, isBold, isItalic, isUnderline, onFormat]);
511
+  }, [getEditorElement, isTableMode, tableContext, allSelectedCellsHaveBold, allSelectedCellsHaveItalic, allSelectedCellsHaveUnderline, onFormat]);
462
 
512
 
463
   // ── 处理字号变化 ───────────────────────────────────────────────────────────
513
   // ── 处理字号变化 ───────────────────────────────────────────────────────────
464
   const handleFontSizeChange = useCallback((value: number | null) => {
514
   const handleFontSizeChange = useCallback((value: number | null) => {
@@ -497,7 +547,10 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
497
     
547
     
498
     if (isTableMode && tableContext) {
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
       tableContext.onStyleChange({ color: hexColor });
554
       tableContext.onStyleChange({ color: hexColor });
502
       setTimeout(() => setColorPickerOpen(false), 100);
555
       setTimeout(() => setColorPickerOpen(false), 100);
503
       return;
556
       return;
@@ -627,7 +680,7 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
627
     e.preventDefault();
680
     e.preventDefault();
628
   };
681
   };
629
 
682
 
630
-  const handleLauncherClick = useCallback(() => {
683
+  const handleLauncherMouseDown = useCallback(() => {
631
     if (!isTableMode) {
684
     if (!isTableMode) {
632
       const editor = getEditorElement();
685
       const editor = getEditorElement();
633
       const selection = window.getSelection();
686
       const selection = window.getSelection();
@@ -639,7 +692,6 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
639
         savedRangeRef.current = saveSelection();
692
         savedRangeRef.current = saveSelection();
640
       }
693
       }
641
     }
694
     }
642
-    setIsExpanded(true);
643
   }, [getEditorElement, isTableMode]);
695
   }, [getEditorElement, isTableMode]);
644
 
696
 
645
   // ── 渲染 ───────────────────────────────────────────────────────────────────
697
   // ── 渲染 ───────────────────────────────────────────────────────────────────
@@ -677,20 +729,12 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
677
     </div>
729
     </div>
678
   );
730
   );
679
 
731
 
680
-  return (
732
+  const expandedToolbar = (
681
     <div
733
     <div
682
       ref={toolbarRef}
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
       onMouseDown={handleMouseDown}
736
       onMouseDown={handleMouseDown}
689
     >
737
     >
690
-      {!isExpanded ? (
691
-        <ToolbarLauncher onClick={handleLauncherClick} />
692
-      ) : (
693
-        <>
694
       {!isTableMode && (
738
       {!isTableMode && (
695
         <>
739
         <>
696
           <div className="toolbar-row toolbar-row-primary toolbar-row-content">
740
           <div className="toolbar-row toolbar-row-primary toolbar-row-content">
@@ -738,7 +782,14 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
738
             prefix={<FontSizeOutlined />}
782
             prefix={<FontSizeOutlined />}
739
           />
783
           />
740
         </Tooltip>
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
           <button type="button" className="toolbar-icon-button" title="文字颜色">
793
           <button type="button" className="toolbar-icon-button" title="文字颜色">
743
             <FontColorsOutlined style={{ color: currentColor }} />
794
             <FontColorsOutlined style={{ color: currentColor }} />
744
           </button>
795
           </button>
@@ -753,7 +804,9 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
753
             items: alignmentMenuItems,
804
             items: alignmentMenuItems,
754
             selectedKeys: [textAlign],
805
             selectedKeys: [textAlign],
755
           }}
806
           }}
756
-          trigger={['click']}
807
+          trigger={['hover']}
808
+          mouseEnterDelay={0}
809
+          mouseLeaveDelay={0.15}
757
           placement="bottomLeft"
810
           placement="bottomLeft"
758
           overlayClassName="rich-text-alignment-menu"
811
           overlayClassName="rich-text-alignment-menu"
759
         >
812
         >
@@ -781,10 +834,40 @@ export const RichTextToolbar: React.FC<RichTextToolbarProps> = ({
781
       <div className="toolbar-row toolbar-row-actions toolbar-row-bottom">
834
       <div className="toolbar-row toolbar-row-actions toolbar-row-bottom">
782
         <ToolbarButton title="删除" onClick={handleDelete}><DeleteOutlined /></ToolbarButton>
835
         <ToolbarButton title="删除" onClick={handleDelete}><DeleteOutlined /></ToolbarButton>
783
       </div>
836
       </div>
784
-        </>
785
-      )}
786
     </div>
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
 interface ToolbarButtonProps {
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
   return (
900
   return (
818
     <button
901
     <button
819
       type="button"
902
       type="button"
820
       className="toolbar-launch-button"
903
       className="toolbar-launch-button"
821
       aria-label="打开格式工具栏"
904
       aria-label="打开格式工具栏"
822
-      aria-expanded="false"
905
+      aria-expanded={expanded}
823
       title="打开格式工具栏"
906
       title="打开格式工具栏"
824
       onClick={onClick}
907
       onClick={onClick}
825
     >
908
     >

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

@@ -4,11 +4,12 @@
4
  * @module components/Editor/blocks
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
 import type { TableBlock as TableBlockType, TableCell as TableCellType } from '../../../types/editor';
8
 import type { TableBlock as TableBlockType, TableCell as TableCellType } from '../../../types/editor';
9
 import { useEditorStore } from '../../../stores/editorStore';
9
 import { useEditorStore } from '../../../stores/editorStore';
10
 import { TableCell } from './TableCell';
10
 import { TableCell } from './TableCell';
11
 import { TableToolbar } from './TableToolbar';
11
 import { TableToolbar } from './TableToolbar';
12
+import { TableStylePanel } from './TableStylePanel';
12
 import { TableResizeHandle } from './TableResizeHandle';
13
 import { TableResizeHandle } from './TableResizeHandle';
13
 import { useTableResize } from '../../../hooks/useTableResize';
14
 import { useTableResize } from '../../../hooks/useTableResize';
14
 import { getTableVisualCellPositions } from '../../../utils/blockOperations';
15
 import { getTableVisualCellPositions } from '../../../utils/blockOperations';
@@ -57,14 +58,20 @@ export const TableBlock: React.FC<TableBlockProps> = ({
57
   const isSelectingRef = useRef(false);
58
   const isSelectingRef = useRef(false);
58
   const didDragSelectRef = useRef(false);
59
   const didDragSelectRef = useRef(false);
59
   const [isSelecting, setIsSelecting] = useState(false);
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
   // 使用表格调整大小Hook
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
   const handleColumnResize = useCallback(
77
   const handleColumnResize = useCallback(
@@ -147,22 +154,23 @@ export const TableBlock: React.FC<TableBlockProps> = ({
147
     (styleUpdates: Partial<TableCellType['style']>) => {
154
     (styleUpdates: Partial<TableCellType['style']>) => {
148
       if (!selectedCell) return;
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
       const newRows = block.content.rows.map((row, rowIdx) => {
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
         return {
163
         return {
159
           ...row,
164
           ...row,
160
           cells: row.cells.map((cell, colIdx) => {
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
             return {
175
             return {
168
               ...cell,
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
   // 处理单元格点击(支持Shift多选)
196
   // 处理单元格点击(支持Shift多选)
@@ -327,16 +335,17 @@ export const TableBlock: React.FC<TableBlockProps> = ({
327
   const colWidths = block.metadata.col_widths;
335
   const colWidths = block.metadata.col_widths;
328
   
336
   
329
   // 如果没有metadata.col_widths,从content.col_widths推算百分比
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
   return (
350
   return (
342
     <div className="table-block-wrapper" data-block-id={block.id}>
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
       {resizeState?.isResizing && currentSizeTooltip && (
377
       {resizeState?.isResizing && currentSizeTooltip && (
360
         <div
378
         <div
@@ -444,6 +462,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
444
                       onMouseEnter={handleCellMouseEnter}
462
                       onMouseEnter={handleCellMouseEnter}
445
                       tableBlock={block}
463
                       tableBlock={block}
446
                       selectedRange={selectedRange}
464
                       selectedRange={selectedRange}
465
+                      selectedVisualRange={selectedVisualRange}
447
                       onStyleChange={handleCellStyleChange}
466
                       onStyleChange={handleCellStyleChange}
448
                     />
467
                     />
449
                   );
468
                   );

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

@@ -6,12 +6,13 @@
6
  * @module components/Editor/blocks
6
  * @module components/Editor/blocks
7
  */
7
  */
8
 
8
 
9
-import React, { useCallback } from 'react';
9
+import React, { useCallback, useMemo } from 'react';
10
 import { Button, Space, ColorPicker, InputNumber } from 'antd';
10
 import { Button, Space, ColorPicker, InputNumber } from 'antd';
11
 import type { Color } from 'antd/es/color-picker';
11
 import type { Color } from 'antd/es/color-picker';
12
 import { BorderOutlined } from '@ant-design/icons';
12
 import { BorderOutlined } from '@ant-design/icons';
13
 import type { TableBlock } from '../../../types/editor';
13
 import type { TableBlock } from '../../../types/editor';
14
 import { useEditorStore } from '../../../stores/editorStore';
14
 import { useEditorStore } from '../../../stores/editorStore';
15
+import { getTableVisualCellPositions } from '../../../utils/blockOperations';
15
 
16
 
16
 // ══════════════════════════════════════════════════════════════════════════════
17
 // ══════════════════════════════════════════════════════════════════════════════
17
 // Component Props
18
 // Component Props
@@ -29,6 +30,12 @@ export interface TableBorderControlProps {
29
     endRow: number;
30
     endRow: number;
30
     endCol: number;
31
     endCol: number;
31
   } | null;
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
 export const TableBorderControl: React.FC<TableBorderControlProps> = ({
52
 export const TableBorderControl: React.FC<TableBorderControlProps> = ({
46
   block,
53
   block,
47
   selectedCell,
54
   selectedCell,
48
-  selectedRange,
55
+  selectedVisualRange,
49
 }) => {
56
 }) => {
50
   const updateBlock = useEditorStore((state) => state.updateBlock);
57
   const updateBlock = useEditorStore((state) => state.updateBlock);
58
+  const visualCellPositions = useMemo(
59
+    () => getTableVisualCellPositions(block),
60
+    [block],
61
+  );
51
   
62
   
52
   // 获取当前选中单元格的边框样式
63
   // 获取当前选中单元格的边框样式
53
   const currentCell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
64
   const currentCell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
@@ -58,22 +69,23 @@ export const TableBorderControl: React.FC<TableBorderControlProps> = ({
58
     border_width?: number;
69
     border_width?: number;
59
     border_color?: string;
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
     const newRows = block.content.rows.map((row, rowIdx) => {
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
       return {
78
       return {
70
         ...row,
79
         ...row,
71
         cells: row.cells.map((cell, colIdx) => {
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
           return {
90
           return {
79
             ...cell,
91
             ...cell,
@@ -92,7 +104,7 @@ export const TableBorderControl: React.FC<TableBorderControlProps> = ({
92
         rows: newRows,
104
         rows: newRows,
93
       },
105
       },
94
     });
106
     });
95
-  }, [block, selectedCell, selectedRange, updateBlock]);
107
+  }, [block, selectedCell, selectedVisualRange, updateBlock, visualCellPositions]);
96
 
108
 
97
   // 设置边框宽度
109
   // 设置边框宽度
98
   const setBorderWidth = useCallback((border_width: number | null) => {
110
   const setBorderWidth = useCallback((border_width: number | null) => {
@@ -103,7 +115,7 @@ export const TableBorderControl: React.FC<TableBorderControlProps> = ({
103
 
115
 
104
   // 设置边框颜色
116
   // 设置边框颜色
105
   const setBorderColor = useCallback((color: Color) => {
117
   const setBorderColor = useCallback((color: Color) => {
106
-    const hexColor = color.toHexString().replace('#', '');
118
+    const hexColor = color.toHexString().replace(/^#/, '').toUpperCase();
107
     applyBorderStyle({ border_color: hexColor });
119
     applyBorderStyle({ border_color: hexColor });
108
   }, [applyBorderStyle]);
120
   }, [applyBorderStyle]);
109
 
121
 

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

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

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

@@ -6,7 +6,7 @@
6
  * @module components/Editor/blocks
6
  * @module components/Editor/blocks
7
  */
7
  */
8
 
8
 
9
-import React, { useCallback } from 'react';
9
+import React, { useCallback, useMemo } from 'react';
10
 import { Button, Space, ColorPicker, InputNumber, Select, Divider } from 'antd';
10
 import { Button, Space, ColorPicker, InputNumber, Select, Divider } from 'antd';
11
 import type { Color } from 'antd/es/color-picker';
11
 import type { Color } from 'antd/es/color-picker';
12
 import {
12
 import {
@@ -24,6 +24,10 @@ import {
24
 } from '@ant-design/icons';
24
 } from '@ant-design/icons';
25
 import type { TableBlock, TableCell } from '../../../types/editor';
25
 import type { TableBlock, TableCell } from '../../../types/editor';
26
 import { useEditorStore } from '../../../stores/editorStore';
26
 import { useEditorStore } from '../../../stores/editorStore';
27
+import {
28
+  getTableCellsForVisualBounds,
29
+  getTableVisualCellPositions,
30
+} from '../../../utils/blockOperations';
27
 import { TableBorderControl } from './TableBorderControl';
31
 import { TableBorderControl } from './TableBorderControl';
28
 import './TableStylePanel.css';
32
 import './TableStylePanel.css';
29
 
33
 
@@ -43,6 +47,12 @@ export interface TableStylePanelProps {
43
     endRow: number;
47
     endRow: number;
44
     endCol: number;
48
     endCol: number;
45
   } | null;
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
   block,
73
   block,
64
   selectedCell,
74
   selectedCell,
65
   selectedRange,
75
   selectedRange,
76
+  selectedVisualRange,
66
 }) => {
77
 }) => {
67
   const updateBlock = useEditorStore((state) => state.updateBlock);
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
   const applyStyle = useCallback((styleUpdates: Partial<TableCell['style']>) => {
108
   const applyStyle = useCallback((styleUpdates: Partial<TableCell['style']>) => {
75
     const newRows = block.content.rows.map((row, rowIdx) => {
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
       return {
110
       return {
84
         ...row,
111
         ...row,
85
         cells: row.cells.map((cell, colIdx) => {
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
           return {
122
           return {
93
             ...cell,
123
             ...cell,
@@ -106,7 +136,7 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
106
         rows: newRows,
136
         rows: newRows,
107
       },
137
       },
108
     });
138
     });
109
-  }, [block, selectedCell, selectedRange, updateBlock]);
139
+  }, [block, styleRange, updateBlock, visualCellPositions]);
110
 
140
 
111
   // 字体样式切换
141
   // 字体样式切换
112
   const toggleBold = useCallback(() => {
142
   const toggleBold = useCallback(() => {
@@ -140,7 +170,7 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
140
 
170
 
141
   // 字体颜色
171
   // 字体颜色
142
   const setFontColor = useCallback((color: Color) => {
172
   const setFontColor = useCallback((color: Color) => {
143
-    const hexColor = color.toHexString().replace('#', '');
173
+    const hexColor = color.toHexString().replace(/^#/, '').toUpperCase();
144
     applyStyle({ color: hexColor });
174
     applyStyle({ color: hexColor });
145
   }, [applyStyle]);
175
   }, [applyStyle]);
146
 
176
 
@@ -283,6 +313,7 @@ export const TableStylePanel: React.FC<TableStylePanelProps> = ({
283
           block={block}
313
           block={block}
284
           selectedCell={selectedCell}
314
           selectedCell={selectedCell}
285
           selectedRange={selectedRange}
315
           selectedRange={selectedRange}
316
+          selectedVisualRange={selectedVisualRange}
286
         />
317
         />
287
       </Space>
318
       </Space>
288
     </div>
319
     </div>

+ 68 - 100
src/utils/blockOperations.ts

@@ -172,6 +172,28 @@ export function getTableVisualCellPositions(table: TableBlock): Map<string, Tabl
172
   return positions;
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
 function createHiddenCell(colIndex: number): TableCell {
197
 function createHiddenCell(colIndex: number): TableCell {
176
   return {
198
   return {
177
     text: '',
199
     text: '',
@@ -1109,116 +1131,62 @@ export function splitCell(
1109
   assertTableIndex(rowIndex, table.content.rows.length, '行');
1131
   assertTableIndex(rowIndex, table.content.rows.length, '行');
1110
   assertTableIndex(colIndex, table.metadata.cols, '列');
1132
   assertTableIndex(colIndex, table.metadata.cols, '列');
1111
   const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
1133
   const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
1112
-  
1134
+
1113
   if (!targetCell) {
1135
   if (!targetCell) {
1114
     throw new Error('单元格不存在');
1136
     throw new Error('单元格不存在');
1115
   }
1137
   }
1116
-  
1138
+
1117
   // 如果单元格没有合并,无需拆分
1139
   // 如果单元格没有合并,无需拆分
1118
   if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) {
1140
   if (targetCell.rowspan <= 1 && targetCell.colspan <= 1) {
1119
     throw new Error('此单元格未合并,无需拆分');
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
   return {
1191
   return {
1224
     ...table,
1192
     ...table,