Bladeren bron

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

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

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

@@ -5,6 +5,7 @@
5 5
 .rich-text-editor-wrapper {
6 6
   position: relative;
7 7
   width: 100%;
8
+  isolation: isolate;
8 9
 }
9 10
 
10 11
 /* 连接正文与左侧工具按钮,避免鼠标经过间隙时触发 hover 离开 */
@@ -12,8 +13,8 @@
12 13
   content: '';
13 14
   position: absolute;
14 15
   top: 0;
15
-  left: -42px;
16
-  width: 42px;
16
+  left: -12px;
17
+  width: 12px;
17 18
   height: 100%;
18 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 132
   hasContent,
133 133
 }) => {
134 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 136
   const isComposingRef = useRef(false);
137 137
   const isFormattingRef = useRef(false); // 标记正在格式化,避免被value更新覆盖
138 138
   const formatFrameRef = useRef<number | null>(null);
@@ -290,7 +290,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
290 290
       
291 291
       setToolbarPosition({
292 292
         top: 0,
293
-        left: -34,
293
+        left: -40,
294 294
       });
295 295
       
296 296
       return;
@@ -308,7 +308,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
308 308
 
309 309
     setToolbarPosition({
310 310
       top: 0,
311
-      left: -34,
311
+      left: -40,
312 312
     });
313 313
     
314 314
   }, [readOnly, tableContext]);
@@ -323,7 +323,7 @@ export const RichTextEditor: React.FC<RichTextEditorProps> = ({
323 323
       
324 324
       setToolbarPosition({
325 325
         top: 0,
326
-        left: -34,
326
+        left: -40,
327 327
       });
328 328
     }, 50);
329 329
   }, [readOnly, tableContext]);

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

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

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

@@ -8,19 +8,6 @@
8 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 11
 .table-container {
25 12
   overflow: visible; /* 改为visible以显示拖拽线 */
26 13
   margin: 8px 0;
@@ -40,6 +27,11 @@
40 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 35
 .table-block th,
44 36
 .table-block td {
45 37
   border: 1px solid #d9d9d9;

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

@@ -4,14 +4,13 @@
4 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 8
 import type { TableBlock as TableBlockType, TableCell as TableCellType } from '../../../types/editor';
9 9
 import { useEditorStore } from '../../../stores/editorStore';
10 10
 import { TableCell } from './TableCell';
11 11
 import { TableToolbar } from './TableToolbar';
12 12
 import { TableResizeHandle } from './TableResizeHandle';
13 13
 import { useTableResize } from '../../../hooks/useTableResize';
14
-import { ToolbarLauncher } from '../RichTextEditor/RichTextToolbar';
15 14
 import './TableBlock.css';
16 15
 
17 16
 // ══════════════════════════════════════════════════════════════════════════════
@@ -23,6 +22,53 @@ export interface TableBlockProps {
23 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 73
  * TableBlock - 表格块(完整实现)
28 74
  */
@@ -39,9 +85,16 @@ export const TableBlock: React.FC<TableBlockProps> = ({
39 85
     endRow: number;
40 86
     endCol: number;
41 87
   } | null>(null);
88
+  const [selectedVisualRange, setSelectedVisualRange] = useState<VisualCellPosition | null>(null);
42 89
   
43 90
   const tableRef = useRef<HTMLTableElement>(null);
44 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 100
   // 使用表格调整大小Hook
@@ -171,6 +224,11 @@ export const TableBlock: React.FC<TableBlockProps> = ({
171 224
 
172 225
   // 处理单元格点击(支持Shift多选)
173 226
   const handleCellClick = useCallback((rowIndex: number, colIndex: number, shiftKey: boolean) => {
227
+    if (didDragSelectRef.current) {
228
+      didDragSelectRef.current = false;
229
+      return;
230
+    }
231
+
174 232
     if (shiftKey && selectedCell) {
175 233
       // Shift+点击:选择范围
176 234
       const startRow = Math.min(selectedCell.row, rowIndex);
@@ -179,12 +237,114 @@ export const TableBlock: React.FC<TableBlockProps> = ({
179 237
       const endCol = Math.max(selectedCell.col, colIndex);
180 238
       
181 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 250
     } else {
183 251
       // 普通点击:选择单个单元格
184 252
       setSelectedCell({ row: rowIndex, col: colIndex });
185 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 369
     : block.content.col_widths 
210 370
       ? (() => {
211 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 378
   return (
217 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 381
       {!readOnly && selectedCell && (
225 382
         <TableToolbar
@@ -229,6 +386,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
229 386
           onClose={() => {
230 387
             setSelectedCell(null);
231 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 434
         <table
277 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 438
           style={{
280 439
             width: tableWidthStyle,
281 440
             tableLayout: 'fixed',
@@ -296,11 +455,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
296 455
               >
297 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 464
                     : false;
305 465
 
306 466
                   return (
@@ -316,6 +476,8 @@ export const TableBlock: React.FC<TableBlockProps> = ({
316 476
                       }
317 477
                       onChange={handleCellChange}
318 478
                       onClick={handleCellClick}
479
+                      onMouseDown={handleCellMouseDown}
480
+                      onMouseEnter={handleCellMouseEnter}
319 481
                       tableBlock={block}
320 482
                       selectedRange={selectedRange}
321 483
                       onStyleChange={handleCellStyleChange}

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

@@ -17,8 +17,7 @@
17 17
 
18 18
 .table-cell.selected {
19 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 29
   onChange?: (rowIndex: number, colIndex: number, updates: Partial<TableCellType>) => void;
30 30
   /** 单元格点击回调 */
31 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 36
   /** 表格块(用于传递给RichTextEditor) */
33 37
   tableBlock?: TableBlock;
34 38
   /** 选中的范围(用于传递给RichTextEditor) */
@@ -63,6 +67,8 @@ export const TableCell: React.FC<TableCellProps> = ({
63 67
   isSelected = false,
64 68
   onChange,
65 69
   onClick,
70
+  onMouseDown,
71
+  onMouseEnter,
66 72
   tableBlock,
67 73
   selectedRange,
68 74
   onStyleChange,
@@ -110,6 +116,8 @@ export const TableCell: React.FC<TableCellProps> = ({
110 116
       colSpan={cell.colspan}
111 117
       style={cellStyle}
112 118
       onClick={handleClick}
119
+      onMouseDown={(event) => onMouseDown?.(rowIndex, colIndex, event)}
120
+      onMouseEnter={() => onMouseEnter?.(rowIndex, colIndex)}
113 121
       data-row={rowIndex}
114 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 71
   const updateBlock = useEditorStore((state) => state.updateBlock);
72 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 95
   const handleInsertRow = useCallback(() => {
76 96
     try {
@@ -181,9 +201,10 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
181 201
 
182 202
   // 检查当前单元格是否已合并
183 203
   const isMergedCell = useCallback(() => {
204
+    if (!hasValidSelectedCell) return false;
184 205
     const cell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
185 206
     return cell && (cell.rowspan > 1 || cell.colspan > 1);
186
-  }, [block, selectedCell]);
207
+  }, [block, hasValidSelectedCell, selectedCell]);
187 208
 
188 209
   return (
189 210
     <div className="table-toolbar">
@@ -223,8 +244,8 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
223 244
           size="small"
224 245
           icon={<MergeCellsOutlined />}
225 246
           onClick={handleMergeCells}
226
-          disabled={!selectedRange}
227
-          title={selectedRange ? '合并选中的单元格' : '请先按住Shift选择范围'}
247
+          disabled={!isMultiCellRange}
248
+          title={isMultiCellRange ? '合并选中的单元格' : '请先按住Shift选择多个单元格'}
228 249
         >
229 250
           合并单元格
230 251
         </Button>
@@ -255,6 +276,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
255 276
             size="small"
256 277
             icon={<MinusOutlined />}
257 278
             danger
279
+            disabled={!hasValidSelectedCell || rowCount <= 1}
258 280
             title="删除当前行"
259 281
           >
260 282
             删除行
@@ -273,6 +295,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
273 295
             size="small"
274 296
             icon={<MinusOutlined />}
275 297
             danger
298
+            disabled={!hasValidSelectedCell || columnCount <= 1}
276 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 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 58
     // 更新metadata
47 59
     const newMetadata = {
48 60
       ...block.metadata,
49
-      table_width: width,
61
+      table_width: safeWidth,
50 62
       table_width_unit: unit,
63
+      col_widths: normalizedColWidths,
51 64
     };
52 65
     
53 66
     // 重新计算content.col_widths(pt单位)
@@ -56,19 +69,18 @@ export const TableWidthControl: React.FC<TableWidthControlProps> = ({ block }) =
56 69
     
57 70
     let tableActualWidthPt: number;
58 71
     if (unit === 'percent') {
59
-      tableActualWidthPt = pageWidthPt * (width / 100);
72
+      tableActualWidthPt = pageWidthPt * (safeWidth / 100);
60 73
     } else if (unit === 'cm') {
61
-      tableActualWidthPt = width * 28.35; // 1cm = 28.35pt
74
+      tableActualWidthPt = safeWidth * 28.35; // 1cm = 28.35pt
62 75
     } else if (unit === 'inch') {
63
-      tableActualWidthPt = width * 72; // 1inch = 72pt
76
+      tableActualWidthPt = safeWidth * 72; // 1inch = 72pt
64 77
     } else {
65 78
       tableActualWidthPt = pageWidthPt * (width / 100);
66 79
     }
67 80
     
68 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 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 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 268
         const deltaPercent = (finalOffset / tableWidthPx) * 100;
262 269
 
263 270
         const newColWidths = [...colWidths];
@@ -352,8 +359,12 @@ export function useTableResize(options: UseTableResizeOptions): UseTableResizeRe
352 359
         }
353 360
         window.removeEventListener('mousemove', handleResizeMove);
354 361
         window.removeEventListener('mouseup', handleResizeEnd);
362
+        document.body.style.cursor = '';
363
+        document.body.style.userSelect = '';
355 364
       };
356 365
     }
366
+
367
+    return undefined;
357 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 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 126
  * 将 text 字段从富文本数组转换为纯字符串
103 127
  * 
@@ -379,6 +403,7 @@ export function createEmptyRow(cols: number, colWidths?: number[], height?: numb
379 403
  * ```
380 404
  */
381 405
 export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
406
+  assertTableIndex(afterRow, table.content.rows.length, '行');
382 407
   // 使用合理的默认行高
383 408
   const defaultHeight = 58; // 默认行高58磅
384 409
   
@@ -431,6 +456,7 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
431 456
  * ```
432 457
  */
433 458
 export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
459
+  assertTableIndex(afterCol, table.metadata.cols, '列');
434 460
   // 计算新列的宽度
435 461
   // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值
436 462
   let newColWidth = 100; // 默认列宽100磅
@@ -502,6 +528,7 @@ export function insertTableColumn(table: TableBlock, afterCol: number): TableBlo
502 528
  * @returns 新的表格块
503 529
  */
504 530
 export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock {
531
+  assertTableIndex(rowIndex, table.content.rows.length, '行');
505 532
   if (table.content.rows.length <= 1) {
506 533
     throw new Error('表格至少需要一行');
507 534
   }
@@ -530,6 +557,7 @@ export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock
530 557
  * @returns 新的表格块
531 558
  */
532 559
 export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlock {
560
+  assertTableIndex(colIndex, table.metadata.cols, '列');
533 561
   if (table.metadata.cols <= 1) {
534 562
     throw new Error('表格至少需要一列');
535 563
   }
@@ -607,6 +635,8 @@ export function mergeCells(
607 635
   endRow: number,
608 636
   endCol: number
609 637
 ): TableBlock {
638
+  assertTableRange(table, startRow, startCol, endRow, endCol);
639
+
610 640
   // 计算合并范围
611 641
   const rowSpan = endRow - startRow + 1;
612 642
   const colSpan = endCol - startCol + 1;
@@ -732,6 +762,8 @@ export function splitCell(
732 762
   rowIndex: number,
733 763
   colIndex: number
734 764
 ): TableBlock {
765
+  assertTableIndex(rowIndex, table.content.rows.length, '行');
766
+  assertTableIndex(colIndex, table.metadata.cols, '列');
735 767
   const targetCell = table.content.rows[rowIndex]?.cells[colIndex];
736 768
   
737 769
   if (!targetCell) {

+ 28 - 10
src/utils/tableUtils.ts

@@ -19,9 +19,16 @@ export function percentToPtWidths(
19 19
   tableWidthPercent: number = 100,
20 20
   pageWidthPt: number = 478 // A4纸宽度595pt - 左右边距各71pt ≈ 453pt
21 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 39
  * @returns 百分比宽度数组
33 40
  */
34 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 50
   // 归一化,确保总和为100%
41 51
   const totalPercent = percentWidths.reduce((sum, w) => sum + w, 0);
@@ -77,15 +87,18 @@ export function ptToPixel(pt: number): number {
77 87
  * @returns 归一化后的百分比数组
78 88
  */
79 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 96
   if (Math.abs(total - 100) < 0.1) {
84
-    return percentWidths; // 已经接近100%
97
+    return validWidths; // 已经接近100%
85 98
   }
86 99
   
87 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 115
   content: TableContent;
103 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 123
   // 确保metadata.col_widths存在且长度正确
106 124
   if (!metadata.col_widths || metadata.col_widths.length !== metadata.cols) {
107 125
     // 平均分配