|
|
@@ -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}
|