浏览代码

feat(编辑器): 重构表格组件,优化单元格引用和结构验证逻辑

Zhang Yice 1 月之前
父节点
当前提交
007e85fbac

+ 56 - 23
src/components/Editor/blocks/TableBlock.tsx

@@ -11,7 +11,12 @@ 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 { getTableCellRangeForVisualBounds, getTableVisualCellPositions } from '../../../utils/blockOperations';
14
+import {
15
+  getTableCellRangeForVisualBounds,
16
+  getTableCellReference,
17
+  getTableVisualCellPositions,
18
+  type TableVisualCellPosition,
19
+} from '../../../utils/blockOperations';
15 20
 import { tableWidthToPt } from '../../../utils/tableUtils';
16 21
 import './TableBlock.css';
17 22
 
@@ -24,18 +29,9 @@ export interface TableBlockProps {
24 29
   readOnly?: boolean;
25 30
 }
26 31
 
27
-interface VisualCellPosition {
28
-  rowStart: number;
29
-  rowEnd: number;
30
-  colStart: number;
31
-  colEnd: number;
32
-}
33
-
34
-const getCellKey = (rowIndex: number, colIndex: number) => `${rowIndex}-${colIndex}`;
35
-
36 32
 function getVisualSelection(
37
-  anchor: VisualCellPosition | undefined,
38
-  target: VisualCellPosition | undefined,
33
+  anchor: TableVisualCellPosition | undefined,
34
+  target: TableVisualCellPosition | undefined,
39 35
 ) {
40 36
   if (!anchor || !target) return null;
41 37
   return {
@@ -62,12 +58,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
62 58
     endRow: number;
63 59
     endCol: number;
64 60
   } | null>(null);
65
-  const [selectedVisualRange, setSelectedVisualRange] = useState<VisualCellPosition | null>(null);
61
+  const [selectedVisualRange, setSelectedVisualRange] = useState<TableVisualCellPosition | null>(null);
66 62
   
67 63
   const tableRef = useRef<HTMLTableElement>(null);
68 64
   const containerRef = useRef<HTMLDivElement>(null);
69 65
   const selectionAnchorRef = useRef<{ row: number; col: number } | null>(null);
70
-  const selectionAnchorVisualRef = useRef<VisualCellPosition | null>(null);
66
+  const selectionAnchorVisualRef = useRef<TableVisualCellPosition | null>(null);
71 67
   const isSelectingRef = useRef(false);
72 68
   const didDragSelectRef = useRef(false);
73 69
   const [isSelecting, setIsSelecting] = useState(false);
@@ -169,7 +165,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
169 165
       if (!selectedCell) return;
170 166
 
171 167
       const styleRange = selectedVisualRange || (() => {
172
-        const position = visualCellPositions.get(getCellKey(selectedCell.row, selectedCell.col));
168
+        const position = getTableCellReference(
169
+          block,
170
+          selectedCell.row,
171
+          selectedCell.col,
172
+          visualCellPositions,
173
+        )?.visual;
173 174
         return position || null;
174 175
       })();
175 176
 
@@ -177,7 +178,7 @@ export const TableBlock: React.FC<TableBlockProps> = ({
177 178
         return {
178 179
           ...row,
179 180
           cells: row.cells.map((cell, colIdx) => {
180
-            const cellPosition = visualCellPositions.get(getCellKey(rowIdx, colIdx));
181
+            const cellPosition = getTableCellReference(block, rowIdx, colIdx, visualCellPositions)?.visual;
181 182
             const isInRange = !!styleRange && !!cellPosition
182 183
               && cellPosition.rowStart <= styleRange.rowEnd
183 184
               && cellPosition.rowEnd >= styleRange.rowStart
@@ -216,8 +217,18 @@ export const TableBlock: React.FC<TableBlockProps> = ({
216 217
 
217 218
     if (shiftKey && selectedCell) {
218 219
       // Shift+点击:选择范围
219
-      const anchorPosition = visualCellPositions.get(getCellKey(selectedCell.row, selectedCell.col));
220
-      const targetPosition = visualCellPositions.get(getCellKey(rowIndex, colIndex));
220
+      const anchorPosition = getTableCellReference(
221
+        block,
222
+        selectedCell.row,
223
+        selectedCell.col,
224
+        visualCellPositions,
225
+      )?.visual;
226
+      const targetPosition = getTableCellReference(
227
+        block,
228
+        rowIndex,
229
+        colIndex,
230
+        visualCellPositions,
231
+      )?.visual;
221 232
       const visualRange = getVisualSelection(anchorPosition, targetPosition);
222 233
       if (visualRange) {
223 234
         setSelectedVisualRange(visualRange);
@@ -238,7 +249,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
238 249
     setSelectedCell({ row: rowIndex, col: colIndex });
239 250
 
240 251
     const anchorPosition = selectionAnchorVisualRef.current;
241
-    const targetPosition = visualCellPositions.get(getCellKey(rowIndex, colIndex));
252
+    const targetPosition = getTableCellReference(
253
+      block,
254
+      rowIndex,
255
+      colIndex,
256
+      visualCellPositions,
257
+    )?.visual;
242 258
     const visualRange = getVisualSelection(anchorPosition ?? undefined, targetPosition);
243 259
     if (visualRange) {
244 260
       setSelectedVisualRange(visualRange);
@@ -257,20 +273,32 @@ export const TableBlock: React.FC<TableBlockProps> = ({
257 273
     if (!isEditorTarget) {
258 274
       event.preventDefault();
259 275
     }
260
-    selectionAnchorRef.current = { row: rowIndex, col: colIndex };
261
-    selectionAnchorVisualRef.current = visualCellPositions.get(getCellKey(rowIndex, colIndex)) || null;
262 276
     isSelectingRef.current = true;
263 277
     setIsSelecting(true);
264 278
 
265 279
     if (event.shiftKey && selectedCell) {
280
+      didDragSelectRef.current = true;
266 281
       selectionAnchorRef.current = selectedCell;
282
+      selectionAnchorVisualRef.current = getTableCellReference(
283
+        block,
284
+        selectedCell.row,
285
+        selectedCell.col,
286
+        visualCellPositions,
287
+      )?.visual || null;
267 288
       updateSelectedRange(rowIndex, colIndex);
268 289
     } else {
290
+      selectionAnchorRef.current = { row: rowIndex, col: colIndex };
291
+      selectionAnchorVisualRef.current = getTableCellReference(
292
+        block,
293
+        rowIndex,
294
+        colIndex,
295
+        visualCellPositions,
296
+      )?.visual || null;
269 297
       setSelectedCell({ row: rowIndex, col: colIndex });
270 298
       setSelectedRange(null);
271 299
       setSelectedVisualRange(null);
272 300
     }
273
-  }, [readOnly, selectedCell, updateSelectedRange, visualCellPositions]);
301
+  }, [block, readOnly, selectedCell, updateSelectedRange, visualCellPositions]);
274 302
 
275 303
   const handleCellMouseEnter = useCallback((rowIndex: number, colIndex: number) => {
276 304
     if (isSelectingRef.current) {
@@ -426,7 +454,12 @@ export const TableBlock: React.FC<TableBlockProps> = ({
426 454
               >
427 455
                 {row.cells.map((cell, colIndex) => {
428 456
                   // 检查单元格是否在选择范围内
429
-                  const cellPosition = visualCellPositions.get(getCellKey(rowIndex, colIndex));
457
+                  const cellPosition = getTableCellReference(
458
+                    block,
459
+                    rowIndex,
460
+                    colIndex,
461
+                    visualCellPositions,
462
+                  )?.visual;
430 463
                   const isInRange = selectedVisualRange && cellPosition
431 464
                     ? cellPosition.rowStart <= selectedVisualRange.rowEnd &&
432 465
                       cellPosition.rowEnd >= selectedVisualRange.rowStart &&

+ 4 - 6
src/components/Editor/blocks/TableToolbar.tsx

@@ -30,7 +30,7 @@ import {
30 30
   splitCell,
31 31
   getTableSelectionBounds,
32 32
   getTableCellRangeForVisualBounds,
33
-  getTableVisualCellPositions,
33
+  getTableCellReference,
34 34
 } from '../../../utils/blockOperations';
35 35
 import './TableToolbar.css';
36 36
 
@@ -116,11 +116,9 @@ export const TableToolbar: React.FC<TableToolbarProps> = ({
116 116
   const selectedCellData = hasValidSelectedCell
117 117
     ? block.content.rows[selectedCell.row]?.cells[selectedCell.col]
118 118
     : undefined;
119
-  const selectedVisualPosition = getTableVisualCellPositions(block).get(
120
-    `${selectedCell.row}-${selectedCell.col}`,
121
-  );
122
-  const operationRow = selectedVisualPosition?.rowStart ?? selectedCell.row;
123
-  const operationCol = selectedVisualPosition?.colStart ?? selectedCell.col;
119
+  const selectedReference = getTableCellReference(block, selectedCell.row, selectedCell.col);
120
+  const operationRow = selectedReference?.visual.rowStart ?? selectedCell.row;
121
+  const operationCol = selectedReference?.visual.colStart ?? selectedCell.col;
124 122
   const canEditTableStructure = hasValidSelectedCell;
125 123
   const isMergedCell = hasValidSelectedCell
126 124
     && ((selectedCellData?.rowspan ?? 1) > 1 || (selectedCellData?.colspan ?? 1) > 1);

+ 17 - 3
src/services/clientExportService.ts

@@ -1,5 +1,6 @@
1 1
 import type { DocumentBlock, RichText, TableBlock } from '../types/editor';
2 2
 import { downloadBlob, safeFileName } from '../utils/download';
3
+import { getTableVisualCellPositions } from '../utils/blockOperations';
3 4
 
4 5
 function toPlainText(content: string | RichText[]): string {
5 6
   return typeof content === 'string'
@@ -12,13 +13,26 @@ function escapeTableCell(value: string): string {
12 13
 }
13 14
 
14 15
 function tableToMarkdown(block: TableBlock): string {
15
-  const rows = block.content.rows.map((row) =>
16
-    row.cells.map((cell) => escapeTableCell(toPlainText(cell.text)))
16
+  const columnCount = Math.max(block.metadata.cols, 1);
17
+  const rows = Array.from(
18
+    { length: block.content.rows.length },
19
+    () => Array<string>(columnCount).fill(''),
17 20
   );
21
+  const positions = getTableVisualCellPositions(block);
22
+
23
+  block.content.rows.forEach((row, rowIndex) => {
24
+    row.cells.forEach((cell, cellIndex) => {
25
+      const position = positions.get(`${rowIndex}-${cellIndex}`);
26
+      if (!position || position.rowStart >= rows.length || position.colStart >= columnCount) {
27
+        return;
28
+      }
29
+
30
+      rows[position.rowStart][position.colStart] = escapeTableCell(toPlainText(cell.text));
31
+    });
32
+  });
18 33
 
19 34
   if (rows.length === 0) return '';
20 35
 
21
-  const columnCount = Math.max(...rows.map((row) => row.length), 1);
22 36
   const normalizeRow = (row: string[]) =>
23 37
     `| ${Array.from({ length: columnCount }, (_, index) => row[index] ?? '').join(' | ')} |`;
24 38
 

+ 12 - 1
src/stores/editorStore.ts

@@ -23,7 +23,11 @@ import type {
23 23
 } from '../types/editor';
24 24
 import { blockService } from '../services/blockService';
25 25
 import { getErrorMessage } from '../services/api';
26
-import { normalizeTableBlock, serializeTableBlock } from '../utils/blockOperations';
26
+import {
27
+  normalizeTableBlock,
28
+  serializeTableBlock,
29
+  validateTableStructure,
30
+} from '../utils/blockOperations';
27 31
 
28 32
 // 并发保存限制器(最多同时进行 3 个请求,降低服务器压力)
29 33
 const saveConcurrencyLimit = pLimit(3);
@@ -459,6 +463,13 @@ export const useEditorStore = create<EditorStore>((set, get) => {
459 463
         }
460 464
         return block;
461 465
       });
466
+
467
+      const invalidTable = normalizedBlocks.find(
468
+        (block) => block.type === 'table' && !validateTableStructure(block as TableBlock),
469
+      );
470
+      if (invalidTable) {
471
+        throw new Error('文档包含无效的表格结构');
472
+      }
462 473
       
463 474
       // 保存原始blocks快照,用于检测修改
464 475
       const snapshot = JSON.stringify(normalizedBlocks);

+ 100 - 11
src/utils/blockOperations.ts

@@ -130,6 +130,12 @@ export interface TableVisualCellPosition {
130 130
   colEnd: number;
131 131
 }
132 132
 
133
+export interface TableCellReference {
134
+  rowIndex: number;
135
+  cellIndex: number;
136
+  visual: TableVisualCellPosition;
137
+}
138
+
133 139
 export function getTableVisualCellPositions(table: TableBlock): Map<string, TableVisualCellPosition> {
134 140
   const occupied: boolean[][] = [];
135 141
   const positions = new Map<string, TableVisualCellPosition>();
@@ -172,6 +178,18 @@ export function getTableVisualCellPositions(table: TableBlock): Map<string, Tabl
172 178
   return positions;
173 179
 }
174 180
 
181
+export function getTableCellReference(
182
+  table: TableBlock,
183
+  rowIndex: number,
184
+  cellIndex: number,
185
+  positions: Map<string, TableVisualCellPosition> = getTableVisualCellPositions(table),
186
+): TableCellReference | null {
187
+  const visual = positions.get(`${rowIndex}-${cellIndex}`);
188
+  if (!visual) return null;
189
+
190
+  return { rowIndex, cellIndex, visual };
191
+}
192
+
175 193
 export function getTableCellsForVisualBounds(
176 194
   table: TableBlock,
177 195
   bounds: TableVisualCellPosition,
@@ -194,6 +212,52 @@ export function getTableCellsForVisualBounds(
194 212
   return cells;
195 213
 }
196 214
 
215
+export function validateTableStructure(table: TableBlock): boolean {
216
+  const columnCount = table.metadata?.cols;
217
+  const rowCount = table.metadata?.rows;
218
+  const rows = table.content?.rows;
219
+
220
+  if (!Number.isInteger(columnCount) || columnCount <= 0 || columnCount > 1000) return false;
221
+  if (!Number.isInteger(rowCount) || rowCount <= 0 || rowCount > 1000) return false;
222
+  if (!Array.isArray(rows) || rows.length !== rowCount) return false;
223
+  if (!Array.isArray(table.metadata.col_widths) || table.metadata.col_widths.length !== columnCount) {
224
+    return false;
225
+  }
226
+  if (table.content.col_widths && table.content.col_widths.length !== columnCount) return false;
227
+  if (table.metadata.col_widths.some((width) => !Number.isFinite(width) || width <= 0)) return false;
228
+  if (table.content.col_widths?.some((width) => !Number.isFinite(width) || width <= 0)) return false;
229
+
230
+  for (const row of rows) {
231
+    if (!Array.isArray(row.cells) || row.cells.length > columnCount) return false;
232
+    if (row.height !== undefined && (!Number.isFinite(row.height) || row.height <= 0)) return false;
233
+
234
+    for (const cell of row.cells) {
235
+      const rowspan = cell.rowspan;
236
+      const colspan = cell.colspan;
237
+      if (!Number.isInteger(rowspan) || !Number.isInteger(colspan)) return false;
238
+
239
+      const isHidden = rowspan === 0 && colspan === 0;
240
+      if (isHidden) {
241
+        const colIndex = cell.col_index;
242
+        if (typeof colIndex !== 'number' || !Number.isInteger(colIndex) || colIndex < 1 || colIndex > columnCount) {
243
+          return false;
244
+        }
245
+        continue;
246
+      }
247
+
248
+      if (rowspan < 1 || rowspan > rowCount || colspan < 1 || colspan > columnCount) return false;
249
+      if (cell.width !== undefined && (!Number.isFinite(cell.width) || cell.width <= 0)) return false;
250
+    }
251
+  }
252
+
253
+  return [...getTableVisualCellPositions(table).values()].every((position) =>
254
+    position.rowStart >= 0
255
+    && position.rowEnd < rowCount
256
+    && position.colStart >= 0
257
+    && position.colEnd < columnCount
258
+  );
259
+}
260
+
197 261
 function createHiddenCell(colIndex: number): TableCell {
198 262
   return {
199 263
     text: '',
@@ -327,12 +391,12 @@ export function flattenTextToString(text: string | RichText[]): string {
327 391
 
328 392
 /**
329 393
  * 序列化表格单元格为后端格式
330
- * 将富文本数组的 text 字段转换为纯字符串
394
+ * 保留富文本数组,确保单元格内部格式可以跨保存恢复
331 395
  * 
332 396
  * @param cell 前端单元格数据
333 397
  * @param colIndex 列索引(从1开始)
334 398
  * @param defaultWidth 默认宽度(磅)
335
- * @returns 序列化后的单元格(text为纯字符串
399
+ * @returns 序列化后的单元格(text保留字符串或富文本数组
336 400
  */
337 401
 export function serializeTableCell(
338 402
   cell: TableCell,
@@ -376,7 +440,7 @@ export function serializeTableCell(
376 440
   }
377 441
   
378 442
   return {
379
-    text: flattenTextToString(cell.text), // 转换为纯字符串
443
+    text: cell.text,
380 444
     rowspan: cell.rowspan ?? 1,
381 445
     colspan: cell.colspan ?? 1,
382 446
     col_index: cell.col_index !== undefined ? cell.col_index : colIndex,
@@ -388,7 +452,7 @@ export function serializeTableCell(
388 452
 
389 453
 /**
390 454
  * 序列化表格块为后端格式
391
- * 将所有单元格的 text 从富文本数组转换为纯字符串
455
+ * 保留单元格富文本内容,并补齐后端所需的单元格字段
392 456
  * 
393 457
  * @param table 表格块
394 458
  * @returns 序列化后的表格块
@@ -908,7 +972,7 @@ export function deleteTableColumn(table: TableBlock, colIndex: number): TableBlo
908 972
  * - 主单元格(左上角)保留完整的字段信息
909 973
  * - 被合并的单元格标记为rowspan=0, colspan=0, text=''
910 974
  * - 保留所有单元格的col_index
911
- * - 合并后的文本统一为纯字符串格
975
+ * - 合并后的文本在包含富文本时保留片段样
912 976
  * 
913 977
  * @param table 表格块
914 978
  * @param startRow 起始行
@@ -1006,6 +1070,8 @@ export function mergeCellsByVisualBounds(
1006 1070
   
1007 1071
   // 收集所有被合并单元格的文本内容
1008 1072
   const displayTexts: string[] = [];
1073
+  const mergedRichText: RichText[] = [];
1074
+  let hasRichText = false;
1009 1075
   
1010 1076
   // 辅助函数:将 text 字段规范化为纯字符串
1011 1077
   const normalizeText = (text: string | RichText[]): string => {
@@ -1027,7 +1093,29 @@ export function mergeCellsByVisualBounds(
1027 1093
         && position.colEnd <= selectionBounds.colEnd;
1028 1094
       if (isContained) {
1029 1095
         const text = normalizeText(cell.text);
1030
-        if (text.trim()) displayTexts.push(text.trim());
1096
+        if (text.trim()) {
1097
+          displayTexts.push(text.trim());
1098
+          if (Array.isArray(cell.text)) {
1099
+            if (!hasRichText) {
1100
+              displayTexts.slice(0, -1).forEach((previousText) => {
1101
+                if (mergedRichText.length > 0) {
1102
+                  mergedRichText.push({ text: ' ', style: {} });
1103
+                }
1104
+                mergedRichText.push({ text: previousText, style: {} });
1105
+              });
1106
+            }
1107
+            hasRichText = true;
1108
+            if (mergedRichText.length > 0) {
1109
+              mergedRichText.push({ text: ' ', style: {} });
1110
+            }
1111
+            mergedRichText.push(...cell.text);
1112
+          } else if (hasRichText) {
1113
+            if (mergedRichText.length > 0) {
1114
+              mergedRichText.push({ text: ' ', style: {} });
1115
+            }
1116
+            mergedRichText.push({ text: text.trim(), style: {} });
1117
+          }
1118
+        }
1031 1119
       }
1032 1120
     });
1033 1121
   });
@@ -1045,12 +1133,13 @@ export function mergeCellsByVisualBounds(
1045 1133
 
1046 1134
       if (key === primaryKey) {
1047 1135
         // 主单元格:设置完整的标准格式
1048
-        // 合并后的文本统一为纯字符串格
1136
+        // 合并后的文本在包含富文本时保留片段样
1049 1137
         const mergedText = displayTexts.join(' ');
1138
+        const mergedContent = hasRichText ? mergedRichText : mergedText;
1050 1139
         
1051 1140
         // 构建标准格式的单元格对象
1052 1141
         return {
1053
-          text: mergedText || normalizeText(cell.text),
1142
+          text: mergedContent.length > 0 ? mergedContent : normalizeText(cell.text),
1054 1143
           rowspan: rowSpan,
1055 1144
           colspan: colSpan,
1056 1145
           col_index: colIdx + 1, // 列索引从1开始
@@ -1226,9 +1315,9 @@ export function validateBlock(block: Partial<DocumentBlock>): boolean {
1226 1315
     case 'table': {
1227 1316
       const table = block as Partial<TableBlock>;
1228 1317
       return !!(
1229
-        table.metadata?.cols &&
1230
-        table.metadata?.rows &&
1231
-        Array.isArray(table.content?.rows)
1318
+        table.metadata &&
1319
+        table.content &&
1320
+        validateTableStructure(table as TableBlock)
1232 1321
       );
1233 1322
     }
1234 1323