Sfoglia il codice sorgente

feat(编辑器): 优化表格单元格选区处理与工具栏功能

- 提取表格可视单元格位置计算逻辑至 blockOperations 工具函数
- 增强表格工具栏支持按行/列方向精确插入(前/后插入)
- 新增表格选区边界计算函数,支持跨度单元格的准确定位
- 优化单元格合并/拆分操作,完整支持跨度单元格的结构变更
- 改进工具栏按钮状态验证,区分有效单元格与合并单元格
- 完善表格交互反馈,支持多向度单元格操作与实时视觉反馈
Zhang Yice 1 mese fa
parent
commit
ee15c6cea6

+ 3 - 39
src/components/Editor/blocks/TableBlock.tsx

@@ -11,6 +11,7 @@ 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 { getTableVisualCellPositions } from '../../../utils/blockOperations';
14 15
 import './TableBlock.css';
15 16
 
16 17
 // ══════════════════════════════════════════════════════════════════════════════
@@ -29,46 +30,8 @@ interface VisualCellPosition {
29 30
   colEnd: number;
30 31
 }
31 32
 
32
-type VisualCellPositions = Map<string, VisualCellPosition>;
33
-
34 33
 const getCellKey = (rowIndex: number, colIndex: number) => `${rowIndex}-${colIndex}`;
35 34
 
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
-
72 35
 /**
73 36
  * TableBlock - 表格块(完整实现)
74 37
  */
@@ -94,7 +57,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
94 57
   const isSelectingRef = useRef(false);
95 58
   const didDragSelectRef = useRef(false);
96 59
   const [isSelecting, setIsSelecting] = useState(false);
97
-  const visualCellPositions = buildVisualCellPositions(block.content.rows);
60
+  const visualCellPositions = getTableVisualCellPositions(block);
98 61
 
99 62
   // ══════════════════════════════════════════════════════════════════════════════
100 63
   // 使用表格调整大小Hook
@@ -383,6 +346,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
383 346
           block={block}
384 347
           selectedCell={selectedCell}
385 348
           selectedRange={selectedRange}
349
+          selectedVisualRange={selectedVisualRange}
386 350
           onClose={() => {
387 351
             setSelectedCell(null);
388 352
             setSelectedRange(null);

+ 120 - 46
src/components/Editor/blocks/TableToolbar.tsx

@@ -7,8 +7,11 @@
7 7
 import React, { useCallback } from 'react';
8 8
 import { Button, Space, Divider, message, Popconfirm } from 'antd';
9 9
 import {
10
-  PlusOutlined,
11 10
   MinusOutlined,
11
+  ArrowUpOutlined,
12
+  ArrowDownOutlined,
13
+  ArrowLeftOutlined,
14
+  ArrowRightOutlined,
12 15
   MergeCellsOutlined,
13 16
   SplitCellsOutlined,
14 17
   DeleteOutlined,
@@ -18,11 +21,15 @@ import { useEditorStore } from '../../../stores/editorStore';
18 21
 import { TableWidthControl } from './TableWidthControl';
19 22
 import {
20 23
   insertTableRow,
24
+  insertTableRowBefore,
21 25
   insertTableColumn,
26
+  insertTableColumnBefore,
22 27
   deleteTableRow,
23 28
   deleteTableColumn,
24
-  mergeCells,
29
+  mergeCellsByVisualBounds,
25 30
   splitCell,
31
+  getTableSelectionBounds,
32
+  getTableCellRangeForVisualBounds,
26 33
 } from '../../../utils/blockOperations';
27 34
 import './TableToolbar.css';
28 35
 
@@ -46,6 +53,12 @@ export interface TableToolbarProps {
46 53
     endRow: number;
47 54
     endCol: number;
48 55
   } | null;
56
+  selectedVisualRange?: {
57
+    rowStart: number;
58
+    rowEnd: number;
59
+    colStart: number;
60
+    colEnd: number;
61
+  } | null;
49 62
   /** 关闭回调 */
50 63
   onClose: () => void;
51 64
 }
@@ -66,6 +79,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
66 79
   block,
67 80
   selectedCell,
68 81
   selectedRange,
82
+  selectedVisualRange,
69 83
   onClose,
70 84
 }) => {
71 85
   const updateBlock = useEditorStore((state) => state.updateBlock);
@@ -79,20 +93,35 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
79 93
     && selectedCell.row < rowCount
80 94
     && selectedCell.col >= 0
81 95
     && 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);
96
+    && !!block.content.rows[selectedCell.row]?.cells[selectedCell.col]
97
+    && (block.content.rows[selectedCell.row]?.cells[selectedCell.col]?.rowspan ?? 1) > 0
98
+    && (block.content.rows[selectedCell.row]?.cells[selectedCell.col]?.colspan ?? 1) > 0;
99
+  const selectionBounds = selectedVisualRange
100
+    || (selectedRange
101
+      ? getTableSelectionBounds(
102
+        block,
103
+        selectedRange.startRow,
104
+        selectedRange.startCol,
105
+        selectedRange.endRow,
106
+        selectedRange.endCol,
107
+      )
108
+      : null);
109
+  const visualCellRange = selectionBounds
110
+    ? getTableCellRangeForVisualBounds(block, selectionBounds)
111
+    : null;
112
+    const hasValidRange = !!selectionBounds && !!visualCellRange;
91 113
   const isMultiCellRange = hasValidRange
92
-    && (selectedRange.endRow > selectedRange.startRow || selectedRange.endCol > selectedRange.startCol);
114
+    && (selectionBounds.rowEnd > selectionBounds.rowStart || selectionBounds.colEnd > selectionBounds.colStart);
115
+  const selectedCellData = hasValidSelectedCell
116
+    ? block.content.rows[selectedCell.row]?.cells[selectedCell.col]
117
+    : undefined;
118
+  const canEditTableStructure = hasValidSelectedCell;
119
+  const isMergedCell = hasValidSelectedCell
120
+    && ((selectedCellData?.rowspan ?? 1) > 1 || (selectedCellData?.colspan ?? 1) > 1);
93 121
 
94 122
   // 插入行
95 123
   const handleInsertRow = useCallback(() => {
124
+    if (!canEditTableStructure) return;
96 125
     try {
97 126
       const newBlock = insertTableRow(block, selectedCell.row);
98 127
       updateBlock(block.id, {
@@ -103,10 +132,26 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
103 132
     } catch (error: unknown) {
104 133
       message.error(getOperationError(error, '插入行失败'));
105 134
     }
106
-  }, [block, selectedCell.row, updateBlock]);
135
+  }, [block, canEditTableStructure, selectedCell.row, updateBlock]);
136
+
137
+  // 在上方插入行
138
+  const handleInsertRowBefore = useCallback(() => {
139
+    if (!canEditTableStructure) return;
140
+    try {
141
+      const newBlock = insertTableRowBefore(block, selectedCell.row);
142
+      updateBlock(block.id, {
143
+        content: newBlock.content,
144
+        metadata: newBlock.metadata,
145
+      });
146
+      message.success('已在上方插入行');
147
+    } catch (error: unknown) {
148
+      message.error(getOperationError(error, '插入行失败'));
149
+    }
150
+  }, [block, canEditTableStructure, selectedCell.row, updateBlock]);
107 151
 
108 152
   // 插入列
109 153
   const handleInsertColumn = useCallback(() => {
154
+    if (!canEditTableStructure) return;
110 155
     try {
111 156
       const newBlock = insertTableColumn(block, selectedCell.col);
112 157
       updateBlock(block.id, {
@@ -117,10 +162,26 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
117 162
     } catch (error: unknown) {
118 163
       message.error(getOperationError(error, '插入列失败'));
119 164
     }
120
-  }, [block, selectedCell.col, updateBlock]);
165
+  }, [block, canEditTableStructure, selectedCell.col, updateBlock]);
166
+
167
+  // 在左侧插入列
168
+  const handleInsertColumnBefore = useCallback(() => {
169
+    if (!canEditTableStructure) return;
170
+    try {
171
+      const newBlock = insertTableColumnBefore(block, selectedCell.col);
172
+      updateBlock(block.id, {
173
+        content: newBlock.content,
174
+        metadata: newBlock.metadata,
175
+      });
176
+      message.success('已在左侧插入列');
177
+    } catch (error: unknown) {
178
+      message.error(getOperationError(error, '插入列失败'));
179
+    }
180
+  }, [block, canEditTableStructure, selectedCell.col, updateBlock]);
121 181
 
122 182
   // 删除行
123 183
   const handleDeleteRow = useCallback(() => {
184
+    if (!canEditTableStructure) return;
124 185
     try {
125 186
       const newBlock = deleteTableRow(block, selectedCell.row);
126 187
       updateBlock(block.id, {
@@ -132,10 +193,11 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
132 193
     } catch (error: unknown) {
133 194
       message.error(getOperationError(error, '删除行失败'));
134 195
     }
135
-  }, [block, selectedCell.row, updateBlock, onClose]);
196
+  }, [block, canEditTableStructure, selectedCell.row, updateBlock, onClose]);
136 197
 
137 198
   // 删除列
138 199
   const handleDeleteColumn = useCallback(() => {
200
+    if (!canEditTableStructure) return;
139 201
     try {
140 202
       const newBlock = deleteTableColumn(block, selectedCell.col);
141 203
       updateBlock(block.id, {
@@ -147,23 +209,17 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
147 209
     } catch (error: unknown) {
148 210
       message.error(getOperationError(error, '删除列失败'));
149 211
     }
150
-  }, [block, selectedCell.col, updateBlock, onClose]);
212
+  }, [block, canEditTableStructure, selectedCell.col, updateBlock, onClose]);
151 213
 
152 214
   // 合并单元格
153 215
   const handleMergeCells = useCallback(() => {
154
-    if (!selectedRange) {
216
+    if (!isMultiCellRange || !visualCellRange) {
155 217
       message.warning('请先选择要合并的单元格范围(Shift+点击)');
156 218
       return;
157 219
     }
158 220
 
159 221
     try {
160
-      const newBlock = mergeCells(
161
-        block,
162
-        selectedRange.startRow,
163
-        selectedRange.startCol,
164
-        selectedRange.endRow,
165
-        selectedRange.endCol
166
-      );
222
+        const newBlock = mergeCellsByVisualBounds(block, selectionBounds);
167 223
       updateBlock(block.id, {
168 224
         content: newBlock.content,
169 225
       });
@@ -172,10 +228,11 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
172 228
     } catch (error: unknown) {
173 229
       message.error(getOperationError(error, '合并单元格失败'));
174 230
     }
175
-  }, [block, selectedRange, updateBlock, onClose]);
231
+  }, [block, isMultiCellRange, selectionBounds, visualCellRange, updateBlock, onClose]);
176 232
 
177 233
   // 拆分单元格
178 234
   const handleSplitCell = useCallback(() => {
235
+    if (!hasValidSelectedCell || !isMergedCell) return;
179 236
     try {
180 237
       const newBlock = splitCell(block, selectedCell.row, selectedCell.col);
181 238
       updateBlock(block.id, {
@@ -186,7 +243,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
186 243
     } catch (error: unknown) {
187 244
       message.error(getOperationError(error, '拆分单元格失败'));
188 245
     }
189
-  }, [block, selectedCell, updateBlock, onClose]);
246
+  }, [block, hasValidSelectedCell, isMergedCell, selectedCell, updateBlock, onClose]);
190 247
 
191 248
   // 删除整个表格
192 249
   const handleDeleteTable = useCallback(async () => {
@@ -199,13 +256,6 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
199 256
     }
200 257
   }, [block.id, deleteBlock, onClose]);
201 258
 
202
-  // 检查当前单元格是否已合并
203
-  const isMergedCell = useCallback(() => {
204
-    if (!hasValidSelectedCell) return false;
205
-    const cell = block.content.rows[selectedCell.row]?.cells[selectedCell.col];
206
-    return cell && (cell.rowspan > 1 || cell.colspan > 1);
207
-  }, [block, hasValidSelectedCell, selectedCell]);
208
-
209 259
   return (
210 260
     <div className="table-toolbar">
211 261
       <Space size="small">
@@ -218,22 +268,46 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
218 268
         <Button
219 269
           type="text"
220 270
           size="small"
221
-          icon={<PlusOutlined />}
271
+          icon={<ArrowUpOutlined />}
272
+          onClick={handleInsertRowBefore}
273
+          disabled={!canEditTableStructure}
274
+          title={canEditTableStructure ? '在上方插入行' : '请选择未合并的可见单元格'}
275
+        >
276
+          上方插入行
277
+        </Button>
278
+
279
+        <Button
280
+          type="text"
281
+          size="small"
282
+          icon={<ArrowDownOutlined />}
222 283
           onClick={handleInsertRow}
223
-          title="在下方插入行"
284
+          disabled={!canEditTableStructure}
285
+          title={canEditTableStructure ? '在下方插入行' : '请选择未合并的可见单元格'}
224 286
         >
225
-          插入行
287
+          在下方插入行
226 288
         </Button>
227 289
 
228 290
         {/* 插入列 */}
229 291
         <Button
230 292
           type="text"
231 293
           size="small"
232
-          icon={<PlusOutlined />}
294
+          icon={<ArrowLeftOutlined />}
295
+          onClick={handleInsertColumnBefore}
296
+          disabled={!canEditTableStructure}
297
+          title={canEditTableStructure ? '在左侧插入列' : '请选择未合并的可见单元格'}
298
+        >
299
+          左侧插入列
300
+        </Button>
301
+
302
+        <Button
303
+          type="text"
304
+          size="small"
305
+          icon={<ArrowRightOutlined />}
233 306
           onClick={handleInsertColumn}
234
-          title="在右侧插入列"
307
+          disabled={!canEditTableStructure}
308
+          title={canEditTableStructure ? '在右侧插入列' : '请选择未合并的可见单元格'}
235 309
         >
236
-          插入列
310
+          在右侧插入列
237 311
         </Button>
238 312
 
239 313
         <Divider type="vertical" style={{ margin: '0 4px' }} />
@@ -245,7 +319,7 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
245 319
           icon={<MergeCellsOutlined />}
246 320
           onClick={handleMergeCells}
247 321
           disabled={!isMultiCellRange}
248
-          title={isMultiCellRange ? '合并选中的单元格' : '请先按住Shift选择多个单元格'}
322
+          title={isMultiCellRange ? '合并选中的单元格' : '请选择连续且不截断已有合并的单元格'}
249 323
         >
250 324
           合并单元格
251 325
         </Button>
@@ -256,8 +330,8 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
256 330
           size="small"
257 331
           icon={<SplitCellsOutlined />}
258 332
           onClick={handleSplitCell}
259
-          disabled={!isMergedCell()}
260
-          title={isMergedCell() ? '拆分此单元格' : '此单元格未合并'}
333
+          disabled={!isMergedCell}
334
+          title={isMergedCell ? '拆分此单元格' : '此单元格未合并'}
261 335
         >
262 336
           拆分单元格
263 337
         </Button>
@@ -276,8 +350,8 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
276 350
             size="small"
277 351
             icon={<MinusOutlined />}
278 352
             danger
279
-            disabled={!hasValidSelectedCell || rowCount <= 1}
280
-            title="删除当前行"
353
+            disabled={!canEditTableStructure || rowCount <= 1}
354
+            title={canEditTableStructure ? '删除当前行' : '请选择未合并的可见单元格'}
281 355
           >
282 356
             删除行
283 357
           </Button>
@@ -295,8 +369,8 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
295 369
             size="small"
296 370
             icon={<MinusOutlined />}
297 371
             danger
298
-            disabled={!hasValidSelectedCell || columnCount <= 1}
299
-            title="删除当前列"
372
+            disabled={!canEditTableStructure || columnCount <= 1}
373
+            title={canEditTableStructure ? '删除当前列' : '请选择未合并的可见单元格'}
300 374
           >
301 375
             删除列
302 376
           </Button>

+ 417 - 73
src/utils/blockOperations.ts

@@ -117,11 +117,178 @@ function assertTableRange(
117 117
   assertTableIndex(startCol, table.metadata.cols, '起始列');
118 118
   assertTableIndex(endCol, table.metadata.cols, '结束列');
119 119
   if (startCol > endCol) throw new Error('列范围无效');
120
-  if (table.content.rows.some((row) => row.cells.length <= endCol)) {
120
+  const selectedRows = table.content.rows.slice(startRow, endRow + 1);
121
+  if (selectedRows.some((row) => row.cells.length <= endCol)) {
121 122
     throw new Error('表格单元格结构无效');
122 123
   }
123 124
 }
124 125
 
126
+export interface TableVisualCellPosition {
127
+  rowStart: number;
128
+  rowEnd: number;
129
+  colStart: number;
130
+  colEnd: number;
131
+}
132
+
133
+export function getTableVisualCellPositions(table: TableBlock): Map<string, TableVisualCellPosition> {
134
+  const occupied: boolean[][] = [];
135
+  const positions = new Map<string, TableVisualCellPosition>();
136
+
137
+  table.content.rows.forEach((row, rowIndex) => {
138
+    if (!occupied[rowIndex]) occupied[rowIndex] = [];
139
+    let visualCol = 0;
140
+
141
+    row.cells.forEach((cell, cellIndex) => {
142
+      const rowSpan = cell.rowspan ?? 1;
143
+      const colSpan = cell.colspan ?? 1;
144
+      if (rowSpan <= 0 || colSpan <= 0) {
145
+        const hiddenCol = Number.isInteger(cell.col_index) && (cell.col_index ?? 0) > 0
146
+          ? (cell.col_index ?? 1) - 1
147
+          : visualCol;
148
+        visualCol = Math.max(visualCol, hiddenCol + 1);
149
+        return;
150
+      }
151
+
152
+      while (occupied[rowIndex][visualCol]) visualCol += 1;
153
+
154
+      const position = {
155
+        rowStart: rowIndex,
156
+        rowEnd: rowIndex + rowSpan - 1,
157
+        colStart: visualCol,
158
+        colEnd: visualCol + colSpan - 1,
159
+      };
160
+      positions.set(`${rowIndex}-${cellIndex}`, position);
161
+
162
+      for (let occupiedRow = position.rowStart; occupiedRow <= position.rowEnd; occupiedRow += 1) {
163
+        if (!occupied[occupiedRow]) occupied[occupiedRow] = [];
164
+        for (let occupiedCol = position.colStart; occupiedCol <= position.colEnd; occupiedCol += 1) {
165
+          occupied[occupiedRow][occupiedCol] = true;
166
+        }
167
+      }
168
+      visualCol = position.colEnd + 1;
169
+    });
170
+  });
171
+
172
+  return positions;
173
+}
174
+
175
+function createHiddenCell(colIndex: number): TableCell {
176
+  return {
177
+    text: '',
178
+    rowspan: 0,
179
+    colspan: 0,
180
+    col_index: colIndex + 1,
181
+    style: {},
182
+    word_style: 'Normal',
183
+    width: 100,
184
+  };
185
+}
186
+
187
+interface PositionedTableCell {
188
+  cell: TableCell;
189
+  position: TableVisualCellPosition;
190
+}
191
+
192
+function rebuildTableRows(
193
+  cells: PositionedTableCell[],
194
+  rowHeights: Array<number | undefined>,
195
+  columnCount: number,
196
+): TableRow[] {
197
+  const starts = new Map<string, PositionedTableCell>();
198
+  for (const entry of cells) {
199
+    starts.set(`${entry.position.rowStart}-${entry.position.colStart}`, entry);
200
+  }
201
+
202
+  return rowHeights.map((height, rowIndex) => {
203
+    const rowCells: TableCell[] = [];
204
+    for (let colIndex = 0; colIndex < columnCount; colIndex += 1) {
205
+      const start = starts.get(`${rowIndex}-${colIndex}`);
206
+      if (start) {
207
+        rowCells.push({
208
+          ...start.cell,
209
+          rowspan: start.position.rowEnd - start.position.rowStart + 1,
210
+          colspan: start.position.colEnd - start.position.colStart + 1,
211
+          col_index: colIndex + 1,
212
+        });
213
+        continue;
214
+      }
215
+
216
+      const covered = cells.some(({ position }) =>
217
+        position.rowStart <= rowIndex
218
+        && position.rowEnd >= rowIndex
219
+        && position.colStart <= colIndex
220
+        && position.colEnd >= colIndex
221
+      );
222
+      rowCells.push(covered ? createHiddenCell(colIndex) : createEmptyCell(colIndex + 1));
223
+    }
224
+
225
+    return {
226
+      cells: rowCells,
227
+      ...(height !== undefined ? { height } : {}),
228
+    };
229
+  });
230
+}
231
+
232
+export function getTableSelectionBounds(
233
+  table: TableBlock,
234
+  startRow: number,
235
+  startCol: number,
236
+  endRow: number,
237
+  endCol: number,
238
+): TableVisualCellPosition | null {
239
+  if (![startRow, startCol, endRow, endCol].every(Number.isInteger)) return null;
240
+  if (startRow < 0 || endRow < startRow || endRow >= table.content.rows.length) return null;
241
+
242
+  const positions = getTableVisualCellPositions(table);
243
+  const startPosition = positions.get(`${startRow}-${startCol}`);
244
+  const endPosition = positions.get(`${endRow}-${endCol}`);
245
+  if (!startPosition || !endPosition) return null;
246
+
247
+  const bounds = {
248
+    rowStart: Math.min(startPosition.rowStart, endPosition.rowStart),
249
+    rowEnd: Math.max(startPosition.rowEnd, endPosition.rowEnd),
250
+    colStart: Math.min(startPosition.colStart, endPosition.colStart),
251
+    colEnd: Math.max(startPosition.colEnd, endPosition.colEnd),
252
+  };
253
+
254
+  for (const position of positions.values()) {
255
+    const intersects = position.rowStart <= bounds.rowEnd
256
+      && position.rowEnd >= bounds.rowStart
257
+      && position.colStart <= bounds.colEnd
258
+      && position.colEnd >= bounds.colStart;
259
+    const isContained = position.rowStart >= bounds.rowStart
260
+      && position.rowEnd <= bounds.rowEnd
261
+      && position.colStart >= bounds.colStart
262
+      && position.colEnd <= bounds.colEnd;
263
+    if (intersects && !isContained) return null;
264
+  }
265
+
266
+  return bounds;
267
+}
268
+
269
+export function getTableCellRangeForVisualBounds(
270
+  table: TableBlock,
271
+  bounds: TableVisualCellPosition,
272
+): { startRow: number; startCol: number; endRow: number; endCol: number } | null {
273
+  const positions = getTableVisualCellPositions(table);
274
+  let start: { row: number; col: number } | null = null;
275
+  let end: { row: number; col: number } | null = null;
276
+
277
+  for (const [key, position] of positions.entries()) {
278
+    if (position.rowStart !== bounds.rowStart || position.colStart !== bounds.colStart) continue;
279
+    const [row, col] = key.split('-').map(Number);
280
+    start = { row, col };
281
+  }
282
+  for (const [key, position] of positions.entries()) {
283
+    if (position.rowEnd !== bounds.rowEnd || position.colEnd !== bounds.colEnd) continue;
284
+    const [row, col] = key.split('-').map(Number);
285
+    end = { row, col };
286
+  }
287
+
288
+  if (!start || !end) return null;
289
+  return { startRow: start.row, startCol: start.col, endRow: end.row, endCol: end.col };
290
+}
291
+
125 292
 /**
126 293
  * 将 text 字段从富文本数组转换为纯字符串
127 294
  * 
@@ -188,8 +355,8 @@ export function serializeTableCell(
188 355
   
189 356
   return {
190 357
     text: flattenTextToString(cell.text), // 转换为纯字符串
191
-    rowspan: cell.rowspan || 1,
192
-    colspan: cell.colspan || 1,
358
+    rowspan: cell.rowspan ?? 1,
359
+    colspan: cell.colspan ?? 1,
193 360
     col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
194 361
     style: style,
195 362
     word_style: cell.word_style || 'Normal',
@@ -296,8 +463,8 @@ export function normalizeTableCell(
296 463
   
297 464
   return {
298 465
     text: cell.text || '',
299
-    rowspan: cell.rowspan || 1,
300
-    colspan: cell.colspan || 1,
466
+    rowspan: cell.rowspan ?? 1,
467
+    colspan: cell.colspan ?? 1,
301 468
     col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
302 469
     style: style,
303 470
     word_style: cell.word_style || 'Normal',
@@ -402,8 +569,10 @@ export function createEmptyRow(cols: number, colWidths?: number[], height?: numb
402 569
  * const newTable = insertTableRow(table, 1); // 在第2行后插入
403 570
  * ```
404 571
  */
405
-export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
406
-  assertTableIndex(afterRow, table.content.rows.length, '行');
572
+function insertTableRowAt(table: TableBlock, insertIndex: number): TableBlock {
573
+  if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.content.rows.length) {
574
+    throw new Error('行索引无效');
575
+  }
407 576
   // 使用合理的默认行高
408 577
   const defaultHeight = 58; // 默认行高58磅
409 578
   
@@ -426,8 +595,26 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
426 595
   }
427 596
   
428 597
   const newRow = createEmptyRow(table.metadata.cols, colWidths, defaultHeight);
429
-  const rows = [...table.content.rows];
430
-  rows.splice(afterRow + 1, 0, newRow);
598
+  const positions = getTableVisualCellPositions(table);
599
+  const rows = table.content.rows.map((row, rowIndex) => ({
600
+    ...row,
601
+    cells: row.cells.map((cell, cellIndex) => {
602
+      const position = positions.get(`${rowIndex}-${cellIndex}`);
603
+      if (!position || position.rowStart >= insertIndex || position.rowEnd < insertIndex) return cell;
604
+      return { ...cell, rowspan: (cell.rowspan ?? 1) + 1 };
605
+    }),
606
+  }));
607
+
608
+  newRow.cells = newRow.cells.map((cell, colIndex) => {
609
+    const spanningCell = [...positions.values()].find((position) =>
610
+      position.rowStart < insertIndex
611
+      && position.rowEnd >= insertIndex
612
+      && position.colStart <= colIndex
613
+      && position.colEnd >= colIndex
614
+    );
615
+    return spanningCell ? createHiddenCell(colIndex) : cell;
616
+  });
617
+  rows.splice(insertIndex, 0, newRow);
431 618
   
432 619
   return {
433 620
     ...table,
@@ -443,6 +630,23 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
443 630
   };
444 631
 }
445 632
 
633
+export function insertTableRow(table: TableBlock, afterRow: number): TableBlock {
634
+  assertTableIndex(afterRow, table.content.rows.length, '行');
635
+  return insertTableRowAt(table, afterRow + 1);
636
+}
637
+
638
+/**
639
+ * 在表格中插入行
640
+ *
641
+ * @param table 表格块
642
+ * @param beforeRow 在此行之前插入
643
+ * @returns 新的表格块
644
+ */
645
+export function insertTableRowBefore(table: TableBlock, beforeRow: number): TableBlock {
646
+  assertTableIndex(beforeRow, table.content.rows.length, '行');
647
+  return insertTableRowAt(table, beforeRow);
648
+}
649
+
446 650
 /**
447 651
  * 在表格中插入列
448 652
  * 
@@ -455,55 +659,58 @@ export function insertTableRow(table: TableBlock, afterRow: number): TableBlock
455 659
  * const newTable = insertTableColumn(table, 1); // 在第2列后插入
456 660
  * ```
457 661
  */
458
-export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
459
-  assertTableIndex(afterCol, table.metadata.cols, '列');
662
+function insertTableColumnAt(table: TableBlock, insertIndex: number): TableBlock {
663
+  if (!Number.isInteger(insertIndex) || insertIndex < 0 || insertIndex > table.metadata.cols) {
664
+    throw new Error('列索引无效');
665
+  }
460 666
   // 计算新列的宽度
461 667
   // 如果有content.col_widths,使用相邻列的宽度;否则使用默认值
462 668
   let newColWidth = 100; // 默认列宽100磅
463 669
   
464
-  if (table.content.col_widths && table.content.col_widths.length > afterCol) {
465
-    newColWidth = table.content.col_widths[afterCol];
466
-  } else if (table.metadata.col_widths && table.metadata.col_widths.length > afterCol) {
670
+  const adjacentCol = Math.min(insertIndex, Math.max(table.metadata.cols - 1, 0));
671
+  if (table.content.col_widths && table.content.col_widths.length > adjacentCol) {
672
+    newColWidth = table.content.col_widths[adjacentCol];
673
+  } else if (table.metadata.col_widths && table.metadata.col_widths.length > adjacentCol) {
467 674
     // 从百分比反推pt值
468 675
     const tableWidthPercent = table.metadata.table_width || 100;
469 676
     const pageWidthPt = 478;
470 677
     const tableActualWidthPt = pageWidthPt * (tableWidthPercent / 100);
471
-    newColWidth = tableActualWidthPt * table.metadata.col_widths[afterCol] / 100;
678
+    newColWidth = tableActualWidthPt * table.metadata.col_widths[adjacentCol] / 100;
472 679
   }
473 680
   
474
-  const rows = table.content.rows.map((row) => {
475
-    const cells = [...row.cells];
476
-    
477
-    // 插入新单元格,使用计算出的宽度
478
-    const newCell = createEmptyCell(afterCol + 2, newColWidth);
479
-    cells.splice(afterCol + 1, 0, newCell);
480
-    
481
-    // 更新后续单元格的 col_index
482
-    for (let i = afterCol + 2; i < cells.length; i++) {
483
-      if (cells[i].col_index !== undefined) {
484
-        cells[i] = {
485
-          ...cells[i],
486
-          col_index: i + 1,
487
-        };
488
-      }
489
-    }
490
-    
491
-    return { 
492
-      cells,
493
-      height: row.height, // 保留行高
494
-    };
681
+  const positions = getTableVisualCellPositions(table);
682
+  const positionedCells: PositionedTableCell[] = [];
683
+  table.content.rows.forEach((row, rowIndex) => {
684
+    row.cells.forEach((cell, cellIndex) => {
685
+      const position = positions.get(`${rowIndex}-${cellIndex}`);
686
+      if (!position) return;
687
+      positionedCells.push({
688
+        cell,
689
+        position: position.colStart < insertIndex && position.colEnd >= insertIndex
690
+          ? { ...position, colEnd: position.colEnd + 1 }
691
+          : position.colStart >= insertIndex
692
+            ? { ...position, colStart: position.colStart + 1, colEnd: position.colEnd + 1 }
693
+            : position,
694
+      });
695
+    });
495 696
   });
697
+
698
+  const rows = rebuildTableRows(
699
+    positionedCells,
700
+    table.content.rows.map((row) => row.height),
701
+    table.metadata.cols + 1,
702
+  );
496 703
   
497 704
   // 更新metadata.col_widths(百分比)
498 705
   const metadataColWidths = [...table.metadata.col_widths];
499 706
   // 新列使用相邻列的百分比,如果没有则平均分配
500
-  const newColPercent = metadataColWidths[afterCol] || (100 / (metadataColWidths.length + 1));
501
-  metadataColWidths.splice(afterCol + 1, 0, newColPercent);
707
+  const newColPercent = metadataColWidths[adjacentCol] || (100 / (metadataColWidths.length + 1));
708
+  metadataColWidths.splice(insertIndex, 0, newColPercent);
502 709
   
503 710
   // 更新content.col_widths(pt单位)
504 711
   const contentColWidths = table.content.col_widths ? [...table.content.col_widths] : [];
505 712
   if (contentColWidths.length > 0) {
506
-    contentColWidths.splice(afterCol + 1, 0, newColWidth);
713
+    contentColWidths.splice(insertIndex, 0, newColWidth);
507 714
   }
508 715
   
509 716
   return {
@@ -520,6 +727,23 @@ export function insertTableColumn(table: TableBlock, afterCol: number): TableBlo
520 727
   };
521 728
 }
522 729
 
730
+export function insertTableColumn(table: TableBlock, afterCol: number): TableBlock {
731
+  assertTableIndex(afterCol, table.metadata.cols, '列');
732
+  return insertTableColumnAt(table, afterCol + 1);
733
+}
734
+
735
+/**
736
+ * 在表格中插入列
737
+ *
738
+ * @param table 表格块
739
+ * @param beforeCol 在此列之前插入
740
+ * @returns 新的表格块
741
+ */
742
+export function insertTableColumnBefore(table: TableBlock, beforeCol: number): TableBlock {
743
+  assertTableIndex(beforeCol, table.metadata.cols, '列');
744
+  return insertTableColumnAt(table, beforeCol);
745
+}
746
+
523 747
 /**
524 748
  * 删除表格行
525 749
  * 
@@ -533,7 +757,39 @@ export function deleteTableRow(table: TableBlock, rowIndex: number): TableBlock
533 757
     throw new Error('表格至少需要一行');
534 758
   }
535 759
   
536
-  const rows = table.content.rows.filter((_, i) => i !== rowIndex);
760
+  const positions = getTableVisualCellPositions(table);
761
+  const positionedCells: PositionedTableCell[] = [];
762
+  table.content.rows.forEach((row, sourceRow) => {
763
+    row.cells.forEach((cell, sourceCol) => {
764
+      const position = positions.get(`${sourceRow}-${sourceCol}`);
765
+      if (!position) return;
766
+      if (position.rowStart <= rowIndex && position.rowEnd >= rowIndex) {
767
+        if (position.rowStart === position.rowEnd) return;
768
+        positionedCells.push({
769
+          cell,
770
+          position: {
771
+            ...position,
772
+            rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart,
773
+            rowEnd: position.rowEnd - 1,
774
+          },
775
+        });
776
+        return;
777
+      }
778
+      positionedCells.push({
779
+        cell,
780
+        position: {
781
+          ...position,
782
+          rowStart: position.rowStart > rowIndex ? position.rowStart - 1 : position.rowStart,
783
+          rowEnd: position.rowEnd > rowIndex ? position.rowEnd - 1 : position.rowEnd,
784
+        },
785
+      });
786
+    });
787
+  });
788
+
789
+  const rowHeights = table.content.rows
790
+    .filter((_, index) => index !== rowIndex)
791
+    .map((row) => row.height);
792
+  const rows = rebuildTableRows(positionedCells, rowHeights, table.metadata.cols);
537 793
   
538 794
   return {
539 795
     ...table,
@@ -562,18 +818,40 @@ export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlo
562 818
     throw new Error('表格至少需要一列');
563 819
   }
564 820
   
565
-  const rows = table.content.rows.map((row) => {
566
-    const cells = row.cells.filter((_, i) => i !== colIndex);
567
-    
568
-    // 更新剩余单元格的 col_index
569
-    return {
570
-      cells: cells.map((cell, i) => ({
571
-        ...cell,
572
-        col_index: i + 1,
573
-      })),
574
-      height: row.height, // 保留行高
575
-    };
821
+  const positions = getTableVisualCellPositions(table);
822
+  const positionedCells: PositionedTableCell[] = [];
823
+  table.content.rows.forEach((row, sourceRow) => {
824
+    row.cells.forEach((cell, sourceCol) => {
825
+      const position = positions.get(`${sourceRow}-${sourceCol}`);
826
+      if (!position) return;
827
+      if (position.colStart <= colIndex && position.colEnd >= colIndex) {
828
+        if (position.colStart === position.colEnd) return;
829
+        positionedCells.push({
830
+          cell,
831
+          position: {
832
+            ...position,
833
+            colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart,
834
+            colEnd: position.colEnd - 1,
835
+          },
836
+        });
837
+        return;
838
+      }
839
+      positionedCells.push({
840
+        cell,
841
+        position: {
842
+          ...position,
843
+          colStart: position.colStart > colIndex ? position.colStart - 1 : position.colStart,
844
+          colEnd: position.colEnd > colIndex ? position.colEnd - 1 : position.colEnd,
845
+        },
846
+      });
847
+    });
576 848
   });
849
+
850
+  const rows = rebuildTableRows(
851
+    positionedCells,
852
+    table.content.rows.map((row) => row.height),
853
+    table.metadata.cols - 1,
854
+  );
577 855
   
578 856
   // 更新metadata.col_widths
579 857
   const metadataColWidths = table.metadata.col_widths.filter((_, i) => i !== colIndex);
@@ -637,9 +915,72 @@ export function mergeCells(
637 915
 ): TableBlock {
638 916
   assertTableRange(table, startRow, startCol, endRow, endCol);
639 917
 
918
+  const selectionBounds = getTableSelectionBounds(table, startRow, startCol, endRow, endCol);
919
+  if (!selectionBounds) {
920
+    throw new Error('合并范围不能截断已有合并单元格');
921
+  }
922
+  return mergeCellsByVisualBounds(table, selectionBounds);
923
+}
924
+
925
+/**
926
+ * 按视觉网格边界合并单元格。
927
+ *
928
+ * 视觉坐标与行内 cells 数组下标不是同一个坐标系,尤其在跨行单元格
929
+ * 产生隐藏占位时,不能先把视觉范围当作数组范围再次校验。
930
+ */
931
+export function mergeCellsByVisualBounds(
932
+  table: TableBlock,
933
+  selectionBounds: TableVisualCellPosition,
934
+): TableBlock {
935
+  if (selectionBounds.rowStart > selectionBounds.rowEnd) {
936
+    throw new Error('行范围无效');
937
+  }
938
+  if (selectionBounds.colStart > selectionBounds.colEnd) {
939
+    throw new Error('列范围无效');
940
+  }
941
+  const visualPositions = getTableVisualCellPositions(table);
942
+  const visualColumnEnd = Math.max(
943
+    table.metadata.cols - 1,
944
+    ...[...visualPositions.values()].map((position) => position.colEnd),
945
+  );
946
+  const visualRowEnd = Math.max(
947
+    table.content.rows.length - 1,
948
+    ...[...visualPositions.values()].map((position) => position.rowEnd),
949
+  );
950
+  if (
951
+    selectionBounds.rowStart < 0
952
+    || selectionBounds.rowEnd > visualRowEnd
953
+    || selectionBounds.colStart < 0
954
+    || selectionBounds.colEnd > visualColumnEnd
955
+  ) {
956
+    throw new Error('结束索引无效');
957
+  }
958
+
959
+  for (const position of visualPositions.values()) {
960
+    const intersects = position.rowStart <= selectionBounds.rowEnd
961
+      && position.rowEnd >= selectionBounds.rowStart
962
+      && position.colStart <= selectionBounds.colEnd
963
+      && position.colEnd >= selectionBounds.colStart;
964
+    const isContained = position.rowStart >= selectionBounds.rowStart
965
+      && position.rowEnd <= selectionBounds.rowEnd
966
+      && position.colStart >= selectionBounds.colStart
967
+      && position.colEnd <= selectionBounds.colEnd;
968
+    if (intersects && !isContained) {
969
+      throw new Error('合并范围不能截断已有合并单元格');
970
+    }
971
+  }
972
+
973
+  const primaryEntry = [...visualPositions.entries()].find(([, position]) =>
974
+    position.rowStart === selectionBounds.rowStart && position.colStart === selectionBounds.colStart
975
+  );
976
+  if (!primaryEntry) {
977
+    throw new Error('合并起始单元格不存在');
978
+  }
979
+  const primaryKey = primaryEntry[0];
980
+
640 981
   // 计算合并范围
641
-  const rowSpan = endRow - startRow + 1;
642
-  const colSpan = endCol - startCol + 1;
982
+  const rowSpan = selectionBounds.rowEnd - selectionBounds.rowStart + 1;
983
+  const colSpan = selectionBounds.colEnd - selectionBounds.colStart + 1;
643 984
   
644 985
   // 收集所有被合并单元格的文本内容
645 986
   const displayTexts: string[] = [];
@@ -655,29 +996,32 @@ export function mergeCells(
655 996
   
656 997
   // 收集文本
657 998
   table.content.rows.forEach((row, rowIdx) => {
658
-    if (rowIdx >= startRow && rowIdx <= endRow) {
659
-      row.cells.forEach((cell, colIdx) => {
660
-        if (colIdx >= startCol && colIdx <= endCol) {
661
-          const text = normalizeText(cell.text);
662
-          if (text.trim()) {
663
-            displayTexts.push(text.trim());
664
-          }
665
-        }
666
-      });
667
-    }
999
+    row.cells.forEach((cell, colIdx) => {
1000
+      const position = visualPositions.get(`${rowIdx}-${colIdx}`);
1001
+      if (!position) return;
1002
+      const isContained = position.rowStart >= selectionBounds.rowStart
1003
+        && position.rowEnd <= selectionBounds.rowEnd
1004
+        && position.colStart >= selectionBounds.colStart
1005
+        && position.colEnd <= selectionBounds.colEnd;
1006
+      if (isContained) {
1007
+        const text = normalizeText(cell.text);
1008
+        if (text.trim()) displayTexts.push(text.trim());
1009
+      }
1010
+    });
668 1011
   });
669 1012
   
670 1013
   const rows = table.content.rows.map((row, rowIdx) => {
671
-    if (rowIdx < startRow || rowIdx > endRow) {
672
-      return row;
673
-    }
674
-    
675 1014
     const cells = row.cells.map((cell, colIdx) => {
676
-      if (colIdx < startCol || colIdx > endCol) {
677
-        return cell;
678
-      }
679
-      
680
-      if (rowIdx === startRow && colIdx === startCol) {
1015
+      const key = `${rowIdx}-${colIdx}`;
1016
+      const position = visualPositions.get(key);
1017
+      if (!position) return cell;
1018
+      const isContained = position.rowStart >= selectionBounds.rowStart
1019
+        && position.rowEnd <= selectionBounds.rowEnd
1020
+        && position.colStart >= selectionBounds.colStart
1021
+        && position.colEnd <= selectionBounds.colEnd;
1022
+      if (!isContained) return cell;
1023
+
1024
+      if (key === primaryKey) {
681 1025
         // 主单元格:设置完整的标准格式
682 1026
         // 合并后的文本统一为纯字符串格式
683 1027
         const mergedText = displayTexts.join(' ');
@@ -687,7 +1031,7 @@ export function mergeCells(
687 1031
           text: mergedText || normalizeText(cell.text),
688 1032
           rowspan: rowSpan,
689 1033
           colspan: colSpan,
690
-          col_index: startCol + 1, // 列索引从1开始
1034
+          col_index: colIdx + 1, // 列索引从1开始
691 1035
           style: {
692 1036
             ...cell.style, // 保留原有样式
693 1037
           },