Browse Source

feat(编辑器): 优化表格选区交互与工具栏层级管理

- 重构表格单元格选区机制,支持拖拽多选和shift点击扩展选择
- 实现视觉单元格位置追踪,正确处理跨行跨列合并单元格的选择
- 优化工具栏z-index层级(1000→1002)和位置定位,防止被表格遮挡
- 移除表格块的hover工具栏显示,统一采用左侧浮动工具按钮交互
- 调整编辑器wrapper的隔离上下文和连接间隙宽度(42px→12px),改进鼠标交互区域
- 增强拖拽选择时的user-select禁用,提升选区拖拽的稳定性
- 优化表格调整大小hook与工具栏逻辑,支持多选状态下的批量操作
Zhang Yice 1 month ago
parent
commit
d259eb21d0

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

@@ -5,6 +5,7 @@
5
 .rich-text-editor-wrapper {
5
 .rich-text-editor-wrapper {
6
   position: relative;
6
   position: relative;
7
   width: 100%;
7
   width: 100%;
8
+  isolation: isolate;
8
 }
9
 }
9
 
10
 
10
 /* 连接正文与左侧工具按钮,避免鼠标经过间隙时触发 hover 离开 */
11
 /* 连接正文与左侧工具按钮,避免鼠标经过间隙时触发 hover 离开 */
@@ -12,8 +13,8 @@
12
   content: '';
13
   content: '';
13
   position: absolute;
14
   position: absolute;
14
   top: 0;
15
   top: 0;
15
-  left: -42px;
16
-  width: 42px;
16
+  left: -12px;
17
+  width: 12px;
17
   height: 100%;
18
   height: 100%;
18
   pointer-events: auto;
19
   pointer-events: auto;
19
 }
20
 }

+ 4 - 4
src/components/Editor/RichTextEditor/RichTextEditor.tsx

@@ -132,7 +132,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
132
   hasContent,
132
   hasContent,
133
 }) => {
133
 }) => {
134
   const editorRef = useRef<HTMLDivElement>(null);
134
   const editorRef = useRef<HTMLDivElement>(null);
135
-  const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -34 });
135
+  const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: -40 });
136
   const isComposingRef = useRef(false);
136
   const isComposingRef = useRef(false);
137
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
137
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
138
   const formatFrameRef = useRef<number | null>(null);
138
   const formatFrameRef = useRef<number | null>(null);
@@ -290,7 +290,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
290
       
290
       
291
       setToolbarPosition({
291
       setToolbarPosition({
292
         top: 0,
292
         top: 0,
293
-        left: -34,
293
+        left: -40,
294
       });
294
       });
295
       
295
       
296
       return;
296
       return;
@@ -308,7 +308,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
308
 
308
 
309
     setToolbarPosition({
309
     setToolbarPosition({
310
       top: 0,
310
       top: 0,
311
-      left: -34,
311
+      left: -40,
312
     });
312
     });
313
     
313
     
314
   }, [readOnly, tableContext]);
314
   }, [readOnly, tableContext]);
@@ -323,7 +323,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
323
       
323
       
324
       setToolbarPosition({
324
       setToolbarPosition({
325
         top: 0,
325
         top: 0,
326
-        left: -34,
326
+        left: -40,
327
       });
327
       });
328
     }, 50);
328
     }, 50);
329
   }, [readOnly, tableContext]);
329
   }, [readOnly, tableContext]);

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

@@ -4,7 +4,7 @@
4
 
4
 
5
 .rich-text-toolbar {
5
 .rich-text-toolbar {
6
   position: absolute;
6
   position: absolute;
7
-  z-index: 1000;
7
+  z-index: 1002;
8
   width: 208px;
8
   width: 208px;
9
   top: 0;
9
   top: 0;
10
   padding: 7px 7px 6px;
10
   padding: 7px 7px 6px;
@@ -46,6 +46,8 @@
46
 }
46
 }
47
 
47
 
48
 .toolbar-launch-button {
48
 .toolbar-launch-button {
49
+  position: relative;
50
+  z-index: 1;
49
   display: inline-flex;
51
   display: inline-flex;
50
   align-items: center;
52
   align-items: center;
51
   justify-content: center;
53
   justify-content: center;

+ 5 - 13
src/components/Editor/blocks/TableBlock.css

@@ -8,19 +8,6 @@
8
   overflow: visible;
8
   overflow: visible;
9
 }
9
 }
10
 
10
 
11
-.table-block-wrapper .block-toolbar-launcher {
12
-  position: absolute;
13
-  top: 8px;
14
-  left: -38px;
15
-  z-index: 11;
16
-  opacity: 0;
17
-  transition: opacity 0.16s ease;
18
-}
19
-
20
-.table-block-wrapper:hover .block-toolbar-launcher {
21
-  opacity: 1;
22
-}
23
-
24
 .table-container {
11
 .table-container {
25
   overflow: visible; /* 改为visible以显示拖拽线 */
12
   overflow: visible; /* 改为visible以显示拖拽线 */
26
   margin: 8px 0;
13
   margin: 8px 0;
@@ -40,6 +27,11 @@
40
   user-select: none;
27
   user-select: none;
41
 }
28
 }
42
 
29
 
30
+.table-block.table-selecting,
31
+.table-block.table-selecting * {
32
+  user-select: none;
33
+}
34
+
43
 .table-block th,
35
 .table-block th,
44
 .table-block td {
36
 .table-block td {
45
   border: 1px solid #d9d9d9;
37
   border: 1px solid #d9d9d9;

+ 178 - 16
src/components/Editor/blocks/TableBlock.tsx

@@ -4,14 +4,13 @@
4
  * @module components/Editor/blocks
4
  * @module components/Editor/blocks
5
  */
5
  */
6
 
6
 
7
-import React, { useState, useCallback, useRef } from 'react';
7
+import React, { useState, useCallback, useRef, useEffect } 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 { TableResizeHandle } from './TableResizeHandle';
12
 import { TableResizeHandle } from './TableResizeHandle';
13
 import { useTableResize } from '../../../hooks/useTableResize';
13
 import { useTableResize } from '../../../hooks/useTableResize';
14
-import { ToolbarLauncher } from '../RichTextEditor/RichTextToolbar';
15
 import './TableBlock.css';
14
 import './TableBlock.css';
16
 
15
 
17
 // ══════════════════════════════════════════════════════════════════════════════
16
 // ══════════════════════════════════════════════════════════════════════════════
@@ -23,6 +22,53 @@ export interface TableBlockProps {
23
   readOnly?: boolean;
22
   readOnly?: boolean;
24
 }
23
 }
25
 
24
 
25
+interface VisualCellPosition {
26
+  rowStart: number;
27
+  rowEnd: number;
28
+  colStart: number;
29
+  colEnd: number;
30
+}
31
+
32
+type VisualCellPositions = Map<string, VisualCellPosition>;
33
+
34
+const getCellKey = (rowIndex: number, colIndex: number) => `${rowIndex}-${colIndex}`;
35
+
36
+const buildVisualCellPositions = (rows: TableBlockType['content']['rows']): VisualCellPositions => {
37
+  const occupied: boolean[][] = [];
38
+  const positions: VisualCellPositions = new Map();
39
+
40
+  rows.forEach((row, rowIndex) => {
41
+    if (!occupied[rowIndex]) occupied[rowIndex] = [];
42
+    let visualCol = 0;
43
+
44
+    row.cells.forEach((cell, colIndex) => {
45
+      if (cell.rowspan === 0 || cell.colspan === 0) return;
46
+
47
+      while (occupied[rowIndex][visualCol]) visualCol += 1;
48
+
49
+      const rowSpan = Math.max(cell.rowspan || 1, 1);
50
+      const colSpan = Math.max(cell.colspan || 1, 1);
51
+      const position = {
52
+        rowStart: rowIndex,
53
+        rowEnd: rowIndex + rowSpan - 1,
54
+        colStart: visualCol,
55
+        colEnd: visualCol + colSpan - 1,
56
+      };
57
+      positions.set(getCellKey(rowIndex, colIndex), position);
58
+
59
+      for (let occupiedRow = rowIndex; occupiedRow <= position.rowEnd; occupiedRow += 1) {
60
+        if (!occupied[occupiedRow]) occupied[occupiedRow] = [];
61
+        for (let occupiedCol = position.colStart; occupiedCol <= position.colEnd; occupiedCol += 1) {
62
+          occupied[occupiedRow][occupiedCol] = true;
63
+        }
64
+      }
65
+      visualCol = position.colEnd + 1;
66
+    });
67
+  });
68
+
69
+  return positions;
70
+};
71
+
26
 /**
72
 /**
27
  * TableBlock - 表格块(完整实现)
73
  * TableBlock - 表格块(完整实现)
28
  */
74
  */
@@ -39,9 +85,16 @@ export const TableBlock: React.FC<TableBlockProps> = ({
39
     endRow: number;
85
     endRow: number;
40
     endCol: number;
86
     endCol: number;
41
   } | null>(null);
87
   } | null>(null);
88
+  const [selectedVisualRange, setSelectedVisualRange] = useState<VisualCellPosition | null>(null);
42
   
89
   
43
   const tableRef = useRef<HTMLTableElement>(null);
90
   const tableRef = useRef<HTMLTableElement>(null);
44
   const containerRef = useRef<HTMLDivElement>(null);
91
   const containerRef = useRef<HTMLDivElement>(null);
92
+  const selectionAnchorRef = useRef<{ row: number; col: number } | null>(null);
93
+  const selectionAnchorVisualRef = useRef<VisualCellPosition | null>(null);
94
+  const isSelectingRef = useRef(false);
95
+  const didDragSelectRef = useRef(false);
96
+  const [isSelecting, setIsSelecting] = useState(false);
97
+  const visualCellPositions = buildVisualCellPositions(block.content.rows);
45
 
98
 
46
   // ══════════════════════════════════════════════════════════════════════════════
99
   // ══════════════════════════════════════════════════════════════════════════════
47
   // 使用表格调整大小Hook
100
   // 使用表格调整大小Hook
@@ -171,6 +224,11 @@ export const TableBlock: React.FC<TableBlockProps> = ({
171
 
224
 
172
   // 处理单元格点击(支持Shift多选)
225
   // 处理单元格点击(支持Shift多选)
173
   const handleCellClick = useCallback((rowIndex: number, colIndex: number, shiftKey: boolean) => {
226
   const handleCellClick = useCallback((rowIndex: number, colIndex: number, shiftKey: boolean) => {
227
+    if (didDragSelectRef.current) {
228
+      didDragSelectRef.current = false;
229
+      return;
230
+    }
231
+
174
     if (shiftKey && selectedCell) {
232
     if (shiftKey && selectedCell) {
175
       // Shift+点击:选择范围
233
       // Shift+点击:选择范围
176
       const startRow = Math.min(selectedCell.row, rowIndex);
234
       const startRow = Math.min(selectedCell.row, rowIndex);
@@ -179,12 +237,114 @@ export const TableBlock: React.FC<TableBlockProps> = ({
179
       const endCol = Math.max(selectedCell.col, colIndex);
237
       const endCol = Math.max(selectedCell.col, colIndex);
180
       
238
       
181
       setSelectedRange({ startRow, startCol, endRow, endCol });
239
       setSelectedRange({ startRow, startCol, endRow, endCol });
240
+      const anchorPosition = visualCellPositions.get(getCellKey(selectedCell.row, selectedCell.col));
241
+      const targetPosition = visualCellPositions.get(getCellKey(rowIndex, colIndex));
242
+      if (anchorPosition && targetPosition) {
243
+        setSelectedVisualRange({
244
+          rowStart: Math.min(anchorPosition.rowStart, targetPosition.rowStart),
245
+          rowEnd: Math.max(anchorPosition.rowEnd, targetPosition.rowEnd),
246
+          colStart: Math.min(anchorPosition.colStart, targetPosition.colStart),
247
+          colEnd: Math.max(anchorPosition.colEnd, targetPosition.colEnd),
248
+        });
249
+      }
182
     } else {
250
     } else {
183
       // 普通点击:选择单个单元格
251
       // 普通点击:选择单个单元格
184
       setSelectedCell({ row: rowIndex, col: colIndex });
252
       setSelectedCell({ row: rowIndex, col: colIndex });
185
       setSelectedRange(null);
253
       setSelectedRange(null);
254
+      setSelectedVisualRange(null);
255
+    }
256
+  }, [selectedCell, visualCellPositions]);
257
+
258
+  const updateSelectedRange = useCallback((rowIndex: number, colIndex: number) => {
259
+    const anchor = selectionAnchorRef.current;
260
+    if (!anchor) return;
261
+
262
+    const startRow = Math.min(anchor.row, rowIndex);
263
+    const endRow = Math.max(anchor.row, rowIndex);
264
+    const startCol = Math.min(anchor.col, colIndex);
265
+    const endCol = Math.max(anchor.col, colIndex);
266
+    setSelectedCell({ row: rowIndex, col: colIndex });
267
+    setSelectedRange({ startRow, startCol, endRow, endCol });
268
+
269
+    const anchorPosition = selectionAnchorVisualRef.current;
270
+    const targetPosition = visualCellPositions.get(getCellKey(rowIndex, colIndex));
271
+    if (anchorPosition && targetPosition) {
272
+      setSelectedVisualRange({
273
+        rowStart: Math.min(anchorPosition.rowStart, targetPosition.rowStart),
274
+        rowEnd: Math.max(anchorPosition.rowEnd, targetPosition.rowEnd),
275
+        colStart: Math.min(anchorPosition.colStart, targetPosition.colStart),
276
+        colEnd: Math.max(anchorPosition.colEnd, targetPosition.colEnd),
277
+      });
278
+    }
279
+  }, [visualCellPositions]);
280
+
281
+  const handleCellMouseDown = useCallback((
282
+    rowIndex: number,
283
+    colIndex: number,
284
+    event: React.MouseEvent<HTMLTableCellElement>,
285
+  ) => {
286
+    if (readOnly || event.button !== 0) return;
287
+
288
+    const isEditorTarget = !!(event.target as HTMLElement).closest('.rich-text-editor');
289
+    if (!isEditorTarget) {
290
+      event.preventDefault();
291
+    }
292
+    selectionAnchorRef.current = { row: rowIndex, col: colIndex };
293
+    selectionAnchorVisualRef.current = visualCellPositions.get(getCellKey(rowIndex, colIndex)) || null;
294
+    isSelectingRef.current = true;
295
+    setIsSelecting(true);
296
+
297
+    if (event.shiftKey && selectedCell) {
298
+      selectionAnchorRef.current = selectedCell;
299
+      updateSelectedRange(rowIndex, colIndex);
300
+    } else {
301
+      setSelectedCell({ row: rowIndex, col: colIndex });
302
+      setSelectedRange(null);
303
+      setSelectedVisualRange(null);
304
+    }
305
+  }, [readOnly, selectedCell, updateSelectedRange, visualCellPositions]);
306
+
307
+  const handleCellMouseEnter = useCallback((rowIndex: number, colIndex: number) => {
308
+    if (isSelectingRef.current) {
309
+      const anchor = selectionAnchorRef.current;
310
+      if (anchor && (anchor.row !== rowIndex || anchor.col !== colIndex)) {
311
+        didDragSelectRef.current = true;
312
+      }
313
+      updateSelectedRange(rowIndex, colIndex);
314
+    }
315
+  }, [updateSelectedRange]);
316
+
317
+  const handleTableMouseMove = useCallback((event: React.MouseEvent<HTMLTableElement>) => {
318
+    if (!isSelectingRef.current) return;
319
+
320
+    const target = event.target as HTMLElement;
321
+    const cellElement = target.closest<HTMLTableCellElement>('td[data-row][data-col]');
322
+    if (!cellElement || !tableRef.current?.contains(cellElement)) return;
323
+
324
+    const rowIndex = Number(cellElement.dataset.row);
325
+    const colIndex = Number(cellElement.dataset.col);
326
+    if (!Number.isInteger(rowIndex) || !Number.isInteger(colIndex)) return;
327
+
328
+    const anchor = selectionAnchorRef.current;
329
+    if (anchor && (anchor.row !== rowIndex || anchor.col !== colIndex)) {
330
+      didDragSelectRef.current = true;
331
+      event.preventDefault();
332
+      window.getSelection()?.removeAllRanges();
186
     }
333
     }
187
-  }, [selectedCell]);
334
+    updateSelectedRange(rowIndex, colIndex);
335
+  }, [updateSelectedRange]);
336
+
337
+  useEffect(() => {
338
+    const handleMouseUp = () => {
339
+      isSelectingRef.current = false;
340
+      setIsSelecting(false);
341
+      selectionAnchorRef.current = null;
342
+      selectionAnchorVisualRef.current = null;
343
+    };
344
+
345
+    document.addEventListener('mouseup', handleMouseUp);
346
+    return () => document.removeEventListener('mouseup', handleMouseUp);
347
+  }, []);
188
 
348
 
189
   // ══════════════════════════════════════════════════════════════════════════════
349
   // ══════════════════════════════════════════════════════════════════════════════
190
   // 渲染
350
   // 渲染
@@ -209,17 +369,14 @@ export const TableBlock: React.FC<TableBlockProps> = ({
209
     : block.content.col_widths 
369
     : block.content.col_widths 
210
       ? (() => {
370
       ? (() => {
211
           const totalPt = block.content.col_widths.reduce((sum: number, w: number) => sum + w, 0);
371
           const totalPt = block.content.col_widths.reduce((sum: number, w: number) => sum + w, 0);
212
-          return block.content.col_widths.map((w: number) => (w / totalPt) * 100);
372
+          return totalPt > 0
373
+            ? block.content.col_widths.map((w: number) => (w / totalPt) * 100)
374
+            : block.content.col_widths.map(() => 100 / block.content.col_widths!.length);
213
         })()
375
         })()
214
-      : [];
376
+      : Array(Math.max(block.metadata.cols, 1)).fill(100 / Math.max(block.metadata.cols, 1));
215
 
377
 
216
   return (
378
   return (
217
     <div className="table-block-wrapper" data-block-id={block.id}>
379
     <div className="table-block-wrapper" data-block-id={block.id}>
218
-      {!readOnly && block.content.rows.length > 0 && (
219
-        <div className="block-toolbar-launcher">
220
-          <ToolbarLauncher onClick={() => setSelectedCell({ row: 0, col: 0 })} />
221
-        </div>
222
-      )}
223
       {/* 表格工具栏 */}
380
       {/* 表格工具栏 */}
224
       {!readOnly && selectedCell && (
381
       {!readOnly && selectedCell && (
225
         <TableToolbar
382
         <TableToolbar
@@ -229,6 +386,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
229
           onClose={() => {
386
           onClose={() => {
230
             setSelectedCell(null);
387
             setSelectedCell(null);
231
             setSelectedRange(null);
388
             setSelectedRange(null);
389
+            setSelectedVisualRange(null);
232
           }}
390
           }}
233
         />
391
         />
234
       )}
392
       )}
@@ -275,7 +433,8 @@ export const TableBlock: React.FC<TableBlockProps> = ({
275
 
433
 
276
         <table
434
         <table
277
           ref={tableRef}
435
           ref={tableRef}
278
-          className={`table-block ${resizeState?.isResizing ? 'table-resizing' : ''}`}
436
+          onMouseMove={handleTableMouseMove}
437
+          className={`table-block${resizeState?.isResizing ? ' table-resizing' : ''}${isSelecting ? ' table-selecting' : ''}`}
279
           style={{
438
           style={{
280
             width: tableWidthStyle,
439
             width: tableWidthStyle,
281
             tableLayout: 'fixed',
440
             tableLayout: 'fixed',
@@ -296,11 +455,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
296
               >
455
               >
297
                 {row.cells.map((cell, colIndex) => {
456
                 {row.cells.map((cell, colIndex) => {
298
                   // 检查单元格是否在选择范围内
457
                   // 检查单元格是否在选择范围内
299
-                  const isInRange = selectedRange
300
-                    ? rowIndex >= selectedRange.startRow &&
301
-                      rowIndex <= selectedRange.endRow &&
302
-                      colIndex >= selectedRange.startCol &&
303
-                      colIndex <= selectedRange.endCol
458
+                  const cellPosition = visualCellPositions.get(getCellKey(rowIndex, colIndex));
459
+                  const isInRange = selectedVisualRange && cellPosition
460
+                    ? cellPosition.rowStart <= selectedVisualRange.rowEnd &&
461
+                      cellPosition.rowEnd >= selectedVisualRange.rowStart &&
462
+                      cellPosition.colStart <= selectedVisualRange.colEnd &&
463
+                      cellPosition.colEnd >= selectedVisualRange.colStart
304
                     : false;
464
                     : false;
305
 
465
 
306
                   return (
466
                   return (
@@ -316,6 +476,8 @@ export const TableBlock: React.FC<TableBlockProps> = ({
316
                       }
476
                       }
317
                       onChange={handleCellChange}
477
                       onChange={handleCellChange}
318
                       onClick={handleCellClick}
478
                       onClick={handleCellClick}
479
+                      onMouseDown={handleCellMouseDown}
480
+                      onMouseEnter={handleCellMouseEnter}
319
                       tableBlock={block}
481
                       tableBlock={block}
320
                       selectedRange={selectedRange}
482
                       selectedRange={selectedRange}
321
                       onStyleChange={handleCellStyleChange}
483
                       onStyleChange={handleCellStyleChange}

+ 1 - 2
src/components/Editor/blocks/TableCell.css

@@ -17,8 +17,7 @@
17
 
17
 
18
 .table-cell.selected {
18
 .table-cell.selected {
19
   background-color: rgba(24, 144, 255, 0.1);
19
   background-color: rgba(24, 144, 255, 0.1);
20
-  border-color: #1890ff;
21
-  box-shadow: inset 0 0 0 1px #1890ff;
20
+  box-shadow: inset 0 0 0 2px #1890ff;
22
 }
21
 }
23
 
22
 
24
 /* 单元格内的富文本编辑器 */
23
 /* 单元格内的富文本编辑器 */

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

@@ -29,6 +29,10 @@ export interface TableCellProps {
29
   onChange?: (rowIndex: number, colIndex: number, updates: Partial<TableCellType>) => void;
29
   onChange?: (rowIndex: number, colIndex: number, updates: Partial<TableCellType>) => void;
30
   /** 单元格点击回调 */
30
   /** 单元格点击回调 */
31
   onClick?: (rowIndex: number, colIndex: number, shiftKey: boolean) => void;
31
   onClick?: (rowIndex: number, colIndex: number, shiftKey: boolean) => void;
32
+  /** 鼠标按下回调(用于拖动选择单元格) */
33
+  onMouseDown?: (rowIndex: number, colIndex: number, event: React.MouseEvent<HTMLTableCellElement>) => void;
34
+  /** 鼠标进入回调(用于拖动选择单元格) */
35
+  onMouseEnter?: (rowIndex: number, colIndex: number) => void;
32
   /** 表格块(用于传递给RichTextEditor) */
36
   /** 表格块(用于传递给RichTextEditor) */
33
   tableBlock?: TableBlock;
37
   tableBlock?: TableBlock;
34
   /** 选中的范围(用于传递给RichTextEditor) */
38
   /** 选中的范围(用于传递给RichTextEditor) */
@@ -63,6 +67,8 @@ export const TableCell: React.FC<TableCellProps> = ({
63
   isSelected = false,
67
   isSelected = false,
64
   onChange,
68
   onChange,
65
   onClick,
69
   onClick,
70
+  onMouseDown,
71
+  onMouseEnter,
66
   tableBlock,
72
   tableBlock,
67
   selectedRange,
73
   selectedRange,
68
   onStyleChange,
74
   onStyleChange,
@@ -110,6 +116,8 @@ export const TableCell: React.FC<TableCellProps> = ({
110
       colSpan={cell.colspan}
116
       colSpan={cell.colspan}
111
       style={cellStyle}
117
       style={cellStyle}
112
       onClick={handleClick}
118
       onClick={handleClick}
119
+      onMouseDown={(event) => onMouseDown?.(rowIndex, colIndex, event)}
120
+      onMouseEnter={() => onMouseEnter?.(rowIndex, colIndex)}
113
       data-row={rowIndex}
121
       data-row={rowIndex}
114
       data-col={colIndex}
122
       data-col={colIndex}
115
     >
123
     >

+ 26 - 3
src/components/Editor/blocks/TableToolbar.tsx

@@ -71,6 +71,26 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
71
   const updateBlock = useEditorStore((state) => state.updateBlock);
71
   const updateBlock = useEditorStore((state) => state.updateBlock);
72
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
72
   const deleteBlock = useEditorStore((state) => state.deleteBlock);
73
 
73
 
74
+  const rowCount = block.content.rows.length;
75
+  const columnCount = block.metadata.cols;
76
+  const hasValidSelectedCell = Number.isInteger(selectedCell.row)
77
+    && Number.isInteger(selectedCell.col)
78
+    && selectedCell.row >= 0
79
+    && selectedCell.row < rowCount
80
+    && selectedCell.col >= 0
81
+    && selectedCell.col < columnCount
82
+    && !!block.content.rows[selectedCell.row]?.cells[selectedCell.col];
83
+  const hasValidRange = !!selectedRange
84
+    && selectedRange.startRow >= 0
85
+    && selectedRange.startCol >= 0
86
+    && selectedRange.endRow >= selectedRange.startRow
87
+    && selectedRange.endCol >= selectedRange.startCol
88
+    && selectedRange.endRow < rowCount
89
+    && selectedRange.endCol < columnCount
90
+    && block.content.rows.every((row) => row.cells.length > selectedRange.endCol);
91
+  const isMultiCellRange = hasValidRange
92
+    && (selectedRange.endRow > selectedRange.startRow || selectedRange.endCol > selectedRange.startCol);
93
+
74
   // 插入行
94
   // 插入行
75
   const handleInsertRow = useCallback(() => {
95
   const handleInsertRow = useCallback(() => {
76
     try {
96
     try {
@@ -181,9 +201,10 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
181
 
201
 
182
   // 检查当前单元格是否已合并
202
   // 检查当前单元格是否已合并
183
   const isMergedCell = useCallback(() => {
203
   const isMergedCell = useCallback(() => {
204
+    if (!hasValidSelectedCell) return false;
184
     const cell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
205
     const cell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
185
     return cell && (cell.rowspan > 1 || cell.colspan > 1);
206
     return cell && (cell.rowspan > 1 || cell.colspan > 1);
186
-  }, [block, selectedCell]);
207
+  }, [block, hasValidSelectedCell, selectedCell]);
187
 
208
 
188
   return (
209
   return (
189
     <div className="table-toolbar">
210
     <div className="table-toolbar">
@@ -223,8 +244,8 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
223
           size="small"
244
           size="small"
224
           icon={<MergeCellsOutlined />}
245
           icon={<MergeCellsOutlined />}
225
           onClick={handleMergeCells}
246
           onClick={handleMergeCells}
226
-          disabled={!selectedRange}
227
-          title={selectedRange ? '合并选中的单元格' : '请先按住Shift选择范围'}
247
+          disabled={!isMultiCellRange}
248
+          title={isMultiCellRange ? '合并选中的单元格' : '请先按住Shift选择多个单元格'}
228
         >
249
         >
229
           合并单元格
250
           合并单元格
230
         </Button>
251
         </Button>
@@ -255,6 +276,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
255
             size="small"
276
             size="small"
256
             icon={<MinusOutlined />}
277
             icon={<MinusOutlined />}
257
             danger
278
             danger
279
+            disabled={!hasValidSelectedCell || rowCount <= 1}
258
             title="删除当前行"
280
             title="删除当前行"
259
           >
281
           >
260
             删除行
282
             删除行
@@ -273,6 +295,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
273
             size="small"
295
             size="small"
274
             icon={<MinusOutlined />}
296
             icon={<MinusOutlined />}
275
             danger
297
             danger
298
+            disabled={!hasValidSelectedCell || columnCount <= 1}
276
             title="删除当前列"
299
             title="删除当前列"
277
           >
300
           >
278
             删除列
301
             删除列

+ 19 - 7
src/components/Editor/blocks/TableWidthControl.tsx

@@ -43,11 +43,24 @@ export const TableWidthControl: React.FC<TableWidthControlProps> = ({ block }) =
43
 
43
 
44
   // 应用宽度变更
44
   // 应用宽度变更
45
   const applyWidthChange = useCallback(() => {
45
   const applyWidthChange = useCallback(() => {
46
+    const safeWidth = Number.isFinite(width) && width > 0 ? width : 100;
47
+    const sourceColWidths = block.metadata.col_widths.length > 0
48
+      ? block.metadata.col_widths
49
+      : Array(Math.max(block.metadata.cols, 1)).fill(100 / Math.max(block.metadata.cols, 1));
50
+    const totalPercent = sourceColWidths.reduce(
51
+      (sum, value) => sum + (Number.isFinite(value) && value > 0 ? value : 0),
52
+      0,
53
+    );
54
+    const normalizedColWidths = totalPercent > 0
55
+      ? sourceColWidths.map((value) => (Number.isFinite(value) && value > 0 ? value : 0) * 100 / totalPercent)
56
+      : Array(sourceColWidths.length).fill(100 / sourceColWidths.length);
57
+
46
     // 更新metadata
58
     // 更新metadata
47
     const newMetadata = {
59
     const newMetadata = {
48
       ...block.metadata,
60
       ...block.metadata,
49
-      table_width: width,
61
+      table_width: safeWidth,
50
       table_width_unit: unit,
62
       table_width_unit: unit,
63
+      col_widths: normalizedColWidths,
51
     };
64
     };
52
     
65
     
53
     // 重新计算content.col_widths(pt单位)
66
     // 重新计算content.col_widths(pt单位)
@@ -56,19 +69,18 @@ export const TableWidthControl: React.FC<TableWidthControlProps> = ({ block }) =
56
     
69
     
57
     let tableActualWidthPt: number;
70
     let tableActualWidthPt: number;
58
     if (unit === 'percent') {
71
     if (unit === 'percent') {
59
-      tableActualWidthPt = pageWidthPt * (width / 100);
72
+      tableActualWidthPt = pageWidthPt * (safeWidth / 100);
60
     } else if (unit === 'cm') {
73
     } else if (unit === 'cm') {
61
-      tableActualWidthPt = width * 28.35; // 1cm = 28.35pt
74
+      tableActualWidthPt = safeWidth * 28.35; // 1cm = 28.35pt
62
     } else if (unit === 'inch') {
75
     } else if (unit === 'inch') {
63
-      tableActualWidthPt = width * 72; // 1inch = 72pt
76
+      tableActualWidthPt = safeWidth * 72; // 1inch = 72pt
64
     } else {
77
     } else {
65
       tableActualWidthPt = pageWidthPt * (width / 100);
78
       tableActualWidthPt = pageWidthPt * (width / 100);
66
     }
79
     }
67
     
80
     
68
     // 根据metadata.col_widths(百分比)重新计算content.col_widths(pt)
81
     // 根据metadata.col_widths(百分比)重新计算content.col_widths(pt)
69
-    const totalPercent = block.metadata.col_widths.reduce((sum, value) => sum + value, 0);
70
-    const newColWidthsPt = block.metadata.col_widths.map(
71
-      (percent) => tableActualWidthPt * percent / totalPercent
82
+    const newColWidthsPt = normalizedColWidths.map(
83
+      (percent) => tableActualWidthPt * percent / 100
72
     );
84
     );
73
     
85
     
74
     updateBlock(block.id, {
86
     updateBlock(block.id, {

+ 11 - 0
src/hooks/useTableResize.ts

@@ -258,6 +258,13 @@ export function useTableResize(options: UseTableResizeOptions): UseTableResizeRe
258
         // 列宽调整
258
         // 列宽调整
259
         // ──────────────────────────────────────────────────────────────────────
259
         // ──────────────────────────────────────────────────────────────────────
260
         const tableWidthPx = table.offsetWidth;
260
         const tableWidthPx = table.offsetWidth;
261
+        if (tableWidthPx <= 0) {
262
+          setResizeState(null);
263
+          setResizeLinePosition(null);
264
+          document.body.style.cursor = '';
265
+          document.body.style.userSelect = '';
266
+          return;
267
+        }
261
         const deltaPercent = (finalOffset / tableWidthPx) * 100;
268
         const deltaPercent = (finalOffset / tableWidthPx) * 100;
262
 
269
 
263
         const newColWidths = [...colWidths];
270
         const newColWidths = [...colWidths];
@@ -352,8 +359,12 @@ export function useTableResize(options: UseTableResizeOptions): UseTableResizeRe
352
         }
359
         }
353
         window.removeEventListener('mousemove', handleResizeMove);
360
         window.removeEventListener('mousemove', handleResizeMove);
354
         window.removeEventListener('mouseup', handleResizeEnd);
361
         window.removeEventListener('mouseup', handleResizeEnd);
362
+        document.body.style.cursor = '';
363
+        document.body.style.userSelect = '';
355
       };
364
       };
356
     }
365
     }
366
+
367
+    return undefined;
357
   }, [resizeState, tableRef, hoverState, handleResizeStart, handleResizeMove, handleResizeEnd]);
368
   }, [resizeState, tableRef, hoverState, handleResizeStart, handleResizeMove, handleResizeEnd]);
358
 
369
 
359
   /**
370
   /**

+ 32 - 0
src/utils/blockOperations.ts

@@ -98,6 +98,30 @@ export function rebalanceBlockOrders(blocks: DocumentBlock[]): DocumentBlock[] {
98
 // Table Operations
98
 // Table Operations
99
 // ══════════════════════════════════════════════════════════════════════════════
99
 // ══════════════════════════════════════════════════════════════════════════════
100
 
100
 
101
+function assertTableIndex(index: number, length: number, label: string): void {
102
+  if (!Number.isInteger(index) || index < 0 || index >= length) {
103
+    throw new Error(`${label}索引无效`);
104
+  }
105
+}
106
+
107
+function assertTableRange(
108
+  table: TableBlock,
109
+  startRow: number,
110
+  startCol: number,
111
+  endRow: number,
112
+  endCol: number,
113
+): void {
114
+  assertTableIndex(startRow, table.content.rows.length, '起始行');
115
+  assertTableIndex(endRow, table.content.rows.length, '结束行');
116
+  if (startRow > endRow) throw new Error('行范围无效');
117
+  assertTableIndex(startCol, table.metadata.cols, '起始列');
118
+  assertTableIndex(endCol, table.metadata.cols, '结束列');
119
+  if (startCol > endCol) throw new Error('列范围无效');
120
+  if (table.content.rows.some((row) => row.cells.length <= endCol)) {
121
+    throw new Error('表格单元格结构无效');
122
+  }
123
+}
124
+
101
 /**
125
 /**
102
  * 将 text 字段从富文本数组转换为纯字符串
126
  * 将 text 字段从富文本数组转换为纯字符串
103
  * 
127
  * 
@@ -379,6 +403,7 @@ export function createEmptyRow(cols: number, colWidths?: number[], height?: numb
379
  * ```
403
  * ```
380
  */
404
  */
381
 export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
405
 export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
406
+  assertTableIndex(afterRow, table.content.rows.length, '行');
382
   // 使用合理的默认行高
407
   // 使用合理的默认行高
383
   const defaultHeight = 58; // 默认行高58磅
408
   const defaultHeight = 58; // 默认行高58磅
384
   
409
   
@@ -431,6 +456,7 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
431
  * ```
456
  * ```
432
  */
457
  */
433
 export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
458
 export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
459
+  assertTableIndex(afterCol, table.metadata.cols, '列');
434
   // 计算新列的宽度
460
   // 计算新列的宽度
435
   // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值
461
   // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值
436
   let newColWidth = 100; // 默认列宽100磅
462
   let newColWidth = 100; // 默认列宽100磅
@@ -502,6 +528,7 @@ export function insertTableColumn(table: TableBlock, afterCol: number): TableBlo
502
  * @returns 新的表格块
528
  * @returns 新的表格块
503
  */
529
  */
504
 export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock {
530
 export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock {
531
+  assertTableIndex(rowIndex, table.content.rows.length, '行');
505
   if (table.content.rows.length <= 1) {
532
   if (table.content.rows.length <= 1) {
506
     throw new Error('表格至少需要一行');
533
     throw new Error('表格至少需要一行');
507
   }
534
   }
@@ -530,6 +557,7 @@ export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock
530
  * @returns 新的表格块
557
  * @returns 新的表格块
531
  */
558
  */
532
 export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock {
559
 export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock {
560
+  assertTableIndex(colIndex, table.metadata.cols, '列');
533
   if (table.metadata.cols <= 1) {
561
   if (table.metadata.cols <= 1) {
534
     throw new Error('表格至少需要一列');
562
     throw new Error('表格至少需要一列');
535
   }
563
   }
@@ -607,6 +635,8 @@ export function mergeCells(
607
   endRow: number,
635
   endRow: number,
608
   endCol: number
636
   endCol: number
609
 ): TableBlock {
637
 ): TableBlock {
638
+  assertTableRange(table, startRow, startCol, endRow, endCol);
639
+
610
   // 计算合并范围
640
   // 计算合并范围
611
   const rowSpan = endRow - startRow + 1;
641
   const rowSpan = endRow - startRow + 1;
612
   const colSpan = endCol - startCol + 1;
642
   const colSpan = endCol - startCol + 1;
@@ -732,6 +762,8 @@ export function splitCell(
732
   rowIndex: number,
762
   rowIndex: number,
733
   colIndex: number
763
   colIndex: number
734
 ): TableBlock {
764
 ): TableBlock {
765
+  assertTableIndex(rowIndex, table.content.rows.length, '行');
766
+  assertTableIndex(colIndex, table.metadata.cols, '列');
735
   const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
767
   const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
736
   
768
   
737
   if (!targetCell) {
769
   if (!targetCell) {

+ 28 - 10
src/utils/tableUtils.ts

@@ -19,9 +19,16 @@ export function percentToPtWidths(
19
   tableWidthPercent: number = 100,
19
   tableWidthPercent: number = 100,
20
   pageWidthPt: number = 478 // A4纸宽度595pt - 左右边距各71pt ≈ 453pt
20
   pageWidthPt: number = 478 // A4纸宽度595pt - 左右边距各71pt ≈ 453pt
21
 ): number[] {
21
 ): number[] {
22
-  const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
23
-  return percentWidths.map(percent => 
24
-    Math.round((tableActualWidthPt * percent / 100) * 10) / 10
22
+  const safePageWidthPt = Number.isFinite(pageWidthPt) && pageWidthPt > 0 ? pageWidthPt : 478;
23
+  const safeTableWidthPercent = Number.isFinite(tableWidthPercent) && tableWidthPercent > 0
24
+    ? tableWidthPercent
25
+    : 100;
26
+  const tableActualWidthPt = safePageWidthPt * (safeTableWidthPercent / 100);
27
+  const validWidths = percentWidths.map((percent) => Number.isFinite(percent) && percent > 0 ? percent : 0);
28
+  const totalPercent = validWidths.reduce((sum, percent) => sum + percent, 0);
29
+  const fallbackPercent = validWidths.length > 0 ? 100 / validWidths.length : 0;
30
+  return validWidths.map((percent) =>
31
+    Math.round((tableActualWidthPt * (totalPercent > 0 ? percent / totalPercent : fallbackPercent / 100)) * 10) / 10
25
   );
32
   );
26
 }
33
 }
27
 
34
 
@@ -32,10 +39,13 @@ export function percentToPtWidths(
32
  * @returns 百分比宽度数组
39
  * @returns 百分比宽度数组
33
  */
40
  */
34
 export function ptToPercentWidths(ptWidths: number[]): number[] {
41
 export function ptToPercentWidths(ptWidths: number[]): number[] {
35
-  const totalPt = ptWidths.reduce((sum, w) => sum + w, 0);
36
-  if (totalPt === 0) return ptWidths.map(() => 0);
42
+  const validWidths = ptWidths.map((width) => Number.isFinite(width) && width > 0 ? width : 0);
43
+  const totalPt = validWidths.reduce((sum, width) => sum + width, 0);
44
+  if (totalPt === 0) {
45
+    return validWidths.length > 0 ? validWidths.map(() => 100 / validWidths.length) : [];
46
+  }
37
   
47
   
38
-  const percentWidths = ptWidths.map(w => (w / totalPt) * 100);
48
+  const percentWidths = validWidths.map(width => (width / totalPt) * 100);
39
   
49
   
40
   // 归一化,确保总和为100%
50
   // 归一化,确保总和为100%
41
   const totalPercent = percentWidths.reduce((sum, w) => sum + w, 0);
51
   const totalPercent = percentWidths.reduce((sum, w) => sum + w, 0);
@@ -77,15 +87,18 @@ export function ptToPixel(pt: number): number {
77
  * @returns 归一化后的百分比数组
87
  * @returns 归一化后的百分比数组
78
  */
88
  */
79
 export function normalizePercents(percentWidths: number[]): number[] {
89
 export function normalizePercents(percentWidths: number[]): number[] {
80
-  const total = percentWidths.reduce((sum, w) => sum + w, 0);
81
-  if (total === 0) return percentWidths;
90
+  const validWidths = percentWidths.map((width) => Number.isFinite(width) && width > 0 ? width : 0);
91
+  const total = validWidths.reduce((sum, width) => sum + width, 0);
92
+  if (total === 0) {
93
+    return validWidths.length > 0 ? validWidths.map(() => 100 / validWidths.length) : [];
94
+  }
82
   
95
   
83
   if (Math.abs(total - 100) < 0.1) {
96
   if (Math.abs(total - 100) < 0.1) {
84
-    return percentWidths; // 已经接近100%
97
+    return validWidths; // 已经接近100%
85
   }
98
   }
86
   
99
   
87
   const factor = 100 / total;
100
   const factor = 100 / total;
88
-  return percentWidths.map(w => Math.round(w * factor * 100) / 100);
101
+  return validWidths.map(w => Math.round(w * factor * 100) / 100);
89
 }
102
 }
90
 
103
 
91
 /**
104
 /**
@@ -102,6 +115,11 @@ export function validateTableData(
102
   content: TableContent;
115
   content: TableContent;
103
   metadata: Pick<TableBlock['metadata'], 'cols' | 'col_widths' | 'table_width'>;
116
   metadata: Pick<TableBlock['metadata'], 'cols' | 'col_widths' | 'table_width'>;
104
 } {
117
 } {
118
+  const safeColumnCount = Number.isInteger(metadata.cols) && metadata.cols > 0
119
+    ? metadata.cols
120
+    : Math.max(content.rows.reduce((max, row) => Math.max(max, row.cells.length), 0), 1);
121
+  metadata.cols = safeColumnCount;
122
+
105
   // 确保metadata.col_widths存在且长度正确
123
   // 确保metadata.col_widths存在且长度正确
106
   if (!metadata.col_widths || metadata.col_widths.length !== metadata.cols) {
124
   if (!metadata.col_widths || metadata.col_widths.length !== metadata.cols) {
107
     // 平均分配
125
     // 平均分配