@xcelsior/ui-spreadsheets 1.0.14 → 1.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/preview.tsx +1 -0
- package/.turbo/turbo-build.log +28 -0
- package/.turbo/turbo-lint.log +147 -12
- package/CHANGELOG.md +9 -0
- package/dist/index.d.mts +41 -10
- package/dist/index.d.ts +41 -10
- package/dist/index.js +672 -165
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +670 -163
- package/dist/index.mjs.map +1 -1
- package/dist/styles/globals.css +1402 -0
- package/dist/styles/globals.css.map +1 -0
- package/dist/styles/globals.d.mts +2 -0
- package/dist/styles/globals.d.ts +2 -0
- package/package.json +8 -5
- package/src/components/Spreadsheet.stories.tsx +33 -8
- package/src/components/Spreadsheet.tsx +172 -116
- package/src/components/SpreadsheetCell.tsx +94 -86
- package/src/hooks/useSpreadsheetKeyboardShortcuts.ts +58 -6
- package/src/hooks/useSpreadsheetSelection.ts +678 -0
- package/src/index.ts +3 -0
- package/src/styles/globals.css +3 -0
- package/src/types.ts +42 -8
- package/tsup.config.ts +1 -1
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
import { useCallback, useState, useRef, useMemo } from 'react';
|
|
2
|
+
import type { CellPosition, CellRange, CellEdit, SpreadsheetColumn } from '../types';
|
|
3
|
+
|
|
4
|
+
export interface UseSpreadsheetSelectionOptions<T> {
|
|
5
|
+
/** Data rows */
|
|
6
|
+
data: T[];
|
|
7
|
+
/** Visible columns */
|
|
8
|
+
columns: SpreadsheetColumn<T>[];
|
|
9
|
+
/** Function to get row ID */
|
|
10
|
+
getRowId: (row: T) => string | number;
|
|
11
|
+
/** Callback when cell range selection changes */
|
|
12
|
+
onCellRangeSelectionChange?: (range: CellRange | null) => void;
|
|
13
|
+
/** Whether cell editing is enabled */
|
|
14
|
+
enableCellEditing?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UseSpreadsheetSelectionReturn {
|
|
18
|
+
/** Currently focused cell */
|
|
19
|
+
focusedCell: CellPosition | null;
|
|
20
|
+
/** Set focused cell */
|
|
21
|
+
setFocusedCell: (cell: CellPosition | null) => void;
|
|
22
|
+
/** Currently editing cell */
|
|
23
|
+
editingCell: CellPosition | null;
|
|
24
|
+
/** Set editing cell */
|
|
25
|
+
setEditingCell: (cell: CellPosition | null) => void;
|
|
26
|
+
/** Selected cell range */
|
|
27
|
+
selectedCellRange: CellRange | null;
|
|
28
|
+
/** Set selected cell range */
|
|
29
|
+
setSelectedCellRange: (range: CellRange | null) => void;
|
|
30
|
+
/** Whether currently dragging to select */
|
|
31
|
+
isDragging: boolean;
|
|
32
|
+
/** Handle cell click */
|
|
33
|
+
handleCellClick: (rowId: string | number, columnId: string, event: React.MouseEvent) => void;
|
|
34
|
+
/** Handle cell mouse down (start drag selection) */
|
|
35
|
+
handleCellMouseDown: (
|
|
36
|
+
rowId: string | number,
|
|
37
|
+
columnId: string,
|
|
38
|
+
event: React.MouseEvent
|
|
39
|
+
) => void;
|
|
40
|
+
/** Handle cell mouse enter (during drag) */
|
|
41
|
+
handleCellMouseEnter: (rowId: string | number, columnId: string) => void;
|
|
42
|
+
/** Handle mouse up (end drag) */
|
|
43
|
+
handleMouseUp: () => void;
|
|
44
|
+
/** Check if cell is in selection range */
|
|
45
|
+
isCellInSelection: (rowId: string | number, columnId: string) => boolean;
|
|
46
|
+
/** Get selection edge for a cell */
|
|
47
|
+
getCellSelectionEdge: (
|
|
48
|
+
rowId: string | number,
|
|
49
|
+
columnId: string
|
|
50
|
+
) => { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean } | undefined;
|
|
51
|
+
/** Get all cells in the current selection */
|
|
52
|
+
getSelectedCells: () => CellPosition[];
|
|
53
|
+
/** Get cell values from the selection */
|
|
54
|
+
getSelectedCellValues: () => { position: CellPosition; value: any }[];
|
|
55
|
+
/** Clear selection */
|
|
56
|
+
clearSelection: () => void;
|
|
57
|
+
/** Navigate to adjacent cell */
|
|
58
|
+
navigateCell: (direction: 'up' | 'down' | 'left' | 'right', extendSelection?: boolean) => void;
|
|
59
|
+
/** Handle Tab key navigation */
|
|
60
|
+
handleTabNavigation: (shiftKey: boolean) => void;
|
|
61
|
+
/** Enter edit mode for focused cell */
|
|
62
|
+
enterEditMode: () => void;
|
|
63
|
+
/** Exit edit mode */
|
|
64
|
+
exitEditMode: () => void;
|
|
65
|
+
/** Clipboard data from copy operation */
|
|
66
|
+
clipboardData: { values: any[][]; startCell: CellPosition } | null;
|
|
67
|
+
/** Copy selected cells to clipboard */
|
|
68
|
+
copySelectedCells: () => void;
|
|
69
|
+
/** Paste clipboard data at focused cell */
|
|
70
|
+
pasteClipboard: () => CellEdit[];
|
|
71
|
+
/** Paste from system clipboard (async) */
|
|
72
|
+
pasteFromSystemClipboard: () => Promise<CellEdit[]>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Hook for managing Excel-like cell selection in spreadsheet
|
|
77
|
+
* Supports:
|
|
78
|
+
* - Single cell selection (click)
|
|
79
|
+
* - Range selection (shift+click)
|
|
80
|
+
* - Drag selection (mouse drag)
|
|
81
|
+
* - Keyboard navigation with selection extension (shift+arrow)
|
|
82
|
+
* - Copy/paste for multiple cells
|
|
83
|
+
*/
|
|
84
|
+
export function useSpreadsheetSelection<T extends Record<string, any>>({
|
|
85
|
+
data,
|
|
86
|
+
columns,
|
|
87
|
+
getRowId,
|
|
88
|
+
onCellRangeSelectionChange,
|
|
89
|
+
enableCellEditing = true,
|
|
90
|
+
}: UseSpreadsheetSelectionOptions<T>): UseSpreadsheetSelectionReturn {
|
|
91
|
+
const [focusedCell, setFocusedCell] = useState<CellPosition | null>(null);
|
|
92
|
+
const [editingCell, setEditingCell] = useState<CellPosition | null>(null);
|
|
93
|
+
const [selectedCellRange, setSelectedCellRangeState] = useState<CellRange | null>(null);
|
|
94
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
95
|
+
const [clipboardData, setClipboardData] = useState<{
|
|
96
|
+
values: any[][];
|
|
97
|
+
startCell: CellPosition;
|
|
98
|
+
} | null>(null);
|
|
99
|
+
|
|
100
|
+
// Track the anchor cell for shift+click and drag selection
|
|
101
|
+
const anchorCell = useRef<CellPosition | null>(null);
|
|
102
|
+
|
|
103
|
+
// Create a map for quick row index lookup
|
|
104
|
+
const rowIndexMap = useMemo(() => {
|
|
105
|
+
const map = new Map<string | number, number>();
|
|
106
|
+
data.forEach((row, index) => {
|
|
107
|
+
map.set(getRowId(row), index);
|
|
108
|
+
});
|
|
109
|
+
return map;
|
|
110
|
+
}, [data, getRowId]);
|
|
111
|
+
|
|
112
|
+
// Create a map for quick column index lookup
|
|
113
|
+
const columnIndexMap = useMemo(() => {
|
|
114
|
+
const map = new Map<string, number>();
|
|
115
|
+
columns.forEach((col, index) => {
|
|
116
|
+
map.set(col.id, index);
|
|
117
|
+
});
|
|
118
|
+
return map;
|
|
119
|
+
}, [columns]);
|
|
120
|
+
|
|
121
|
+
// Wrapper for setSelectedCellRange that also calls the callback
|
|
122
|
+
const setSelectedCellRange = useCallback(
|
|
123
|
+
(range: CellRange | null) => {
|
|
124
|
+
setSelectedCellRangeState(range);
|
|
125
|
+
onCellRangeSelectionChange?.(range);
|
|
126
|
+
},
|
|
127
|
+
[onCellRangeSelectionChange]
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Get normalized range (ensure start is top-left, end is bottom-right)
|
|
131
|
+
const getNormalizedRange = useCallback(
|
|
132
|
+
(
|
|
133
|
+
range: CellRange | null
|
|
134
|
+
): {
|
|
135
|
+
startRowIdx: number;
|
|
136
|
+
endRowIdx: number;
|
|
137
|
+
startColIdx: number;
|
|
138
|
+
endColIdx: number;
|
|
139
|
+
} | null => {
|
|
140
|
+
if (!range) return null;
|
|
141
|
+
|
|
142
|
+
const startRowIdx = rowIndexMap.get(range.start.rowId);
|
|
143
|
+
const endRowIdx = rowIndexMap.get(range.end.rowId);
|
|
144
|
+
const startColIdx = columnIndexMap.get(range.start.columnId);
|
|
145
|
+
const endColIdx = columnIndexMap.get(range.end.columnId);
|
|
146
|
+
|
|
147
|
+
if (
|
|
148
|
+
startRowIdx === undefined ||
|
|
149
|
+
endRowIdx === undefined ||
|
|
150
|
+
startColIdx === undefined ||
|
|
151
|
+
endColIdx === undefined
|
|
152
|
+
) {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
startRowIdx: Math.min(startRowIdx, endRowIdx),
|
|
158
|
+
endRowIdx: Math.max(startRowIdx, endRowIdx),
|
|
159
|
+
startColIdx: Math.min(startColIdx, endColIdx),
|
|
160
|
+
endColIdx: Math.max(startColIdx, endColIdx),
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
[rowIndexMap, columnIndexMap]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Check if a cell is within the selection range
|
|
167
|
+
const isCellInSelection = useCallback(
|
|
168
|
+
(rowId: string | number, columnId: string): boolean => {
|
|
169
|
+
const normalizedRange = getNormalizedRange(selectedCellRange);
|
|
170
|
+
if (!normalizedRange) return false;
|
|
171
|
+
|
|
172
|
+
const rowIdx = rowIndexMap.get(rowId);
|
|
173
|
+
const colIdx = columnIndexMap.get(columnId);
|
|
174
|
+
|
|
175
|
+
if (rowIdx === undefined || colIdx === undefined) return false;
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
rowIdx >= normalizedRange.startRowIdx &&
|
|
179
|
+
rowIdx <= normalizedRange.endRowIdx &&
|
|
180
|
+
colIdx >= normalizedRange.startColIdx &&
|
|
181
|
+
colIdx <= normalizedRange.endColIdx
|
|
182
|
+
);
|
|
183
|
+
},
|
|
184
|
+
[selectedCellRange, rowIndexMap, columnIndexMap, getNormalizedRange]
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Get selection edge for visual border styling
|
|
188
|
+
const getCellSelectionEdge = useCallback(
|
|
189
|
+
(
|
|
190
|
+
rowId: string | number,
|
|
191
|
+
columnId: string
|
|
192
|
+
): { top?: boolean; right?: boolean; bottom?: boolean; left?: boolean } | undefined => {
|
|
193
|
+
if (!isCellInSelection(rowId, columnId)) return undefined;
|
|
194
|
+
|
|
195
|
+
const normalizedRange = getNormalizedRange(selectedCellRange);
|
|
196
|
+
if (!normalizedRange) return undefined;
|
|
197
|
+
|
|
198
|
+
const rowIdx = rowIndexMap.get(rowId);
|
|
199
|
+
const colIdx = columnIndexMap.get(columnId);
|
|
200
|
+
|
|
201
|
+
if (rowIdx === undefined || colIdx === undefined) return undefined;
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
top: rowIdx === normalizedRange.startRowIdx,
|
|
205
|
+
bottom: rowIdx === normalizedRange.endRowIdx,
|
|
206
|
+
left: colIdx === normalizedRange.startColIdx,
|
|
207
|
+
right: colIdx === normalizedRange.endColIdx,
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
[isCellInSelection, selectedCellRange, rowIndexMap, columnIndexMap, getNormalizedRange]
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Get all cells in the current selection
|
|
214
|
+
const getSelectedCells = useCallback((): CellPosition[] => {
|
|
215
|
+
const normalizedRange = getNormalizedRange(selectedCellRange);
|
|
216
|
+
if (!normalizedRange) {
|
|
217
|
+
return focusedCell ? [focusedCell] : [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const cells: CellPosition[] = [];
|
|
221
|
+
for (
|
|
222
|
+
let rowIdx = normalizedRange.startRowIdx;
|
|
223
|
+
rowIdx <= normalizedRange.endRowIdx;
|
|
224
|
+
rowIdx++
|
|
225
|
+
) {
|
|
226
|
+
const row = data[rowIdx];
|
|
227
|
+
if (!row) continue;
|
|
228
|
+
const rowId = getRowId(row);
|
|
229
|
+
|
|
230
|
+
for (
|
|
231
|
+
let colIdx = normalizedRange.startColIdx;
|
|
232
|
+
colIdx <= normalizedRange.endColIdx;
|
|
233
|
+
colIdx++
|
|
234
|
+
) {
|
|
235
|
+
const column = columns[colIdx];
|
|
236
|
+
if (!column) continue;
|
|
237
|
+
cells.push({ rowId, columnId: column.id });
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return cells;
|
|
241
|
+
}, [selectedCellRange, focusedCell, data, columns, getRowId, getNormalizedRange]);
|
|
242
|
+
|
|
243
|
+
// Get cell values from selection
|
|
244
|
+
const getSelectedCellValues = useCallback((): { position: CellPosition; value: any }[] => {
|
|
245
|
+
const cells = getSelectedCells();
|
|
246
|
+
return cells.map((cell) => {
|
|
247
|
+
const row = data.find((r) => getRowId(r) === cell.rowId);
|
|
248
|
+
const column = columns.find((c) => c.id === cell.columnId);
|
|
249
|
+
const value =
|
|
250
|
+
row && column
|
|
251
|
+
? column.getValue
|
|
252
|
+
? column.getValue(row)
|
|
253
|
+
: row[cell.columnId]
|
|
254
|
+
: undefined;
|
|
255
|
+
return { position: cell, value };
|
|
256
|
+
});
|
|
257
|
+
}, [getSelectedCells, data, columns, getRowId]);
|
|
258
|
+
|
|
259
|
+
// Handle cell click
|
|
260
|
+
const handleCellClick = useCallback(
|
|
261
|
+
(rowId: string | number, columnId: string, event: React.MouseEvent) => {
|
|
262
|
+
event.stopPropagation();
|
|
263
|
+
|
|
264
|
+
const newCell: CellPosition = { rowId, columnId };
|
|
265
|
+
|
|
266
|
+
if (event.shiftKey && anchorCell.current) {
|
|
267
|
+
// Extend selection from anchor to clicked cell
|
|
268
|
+
setSelectedCellRange({
|
|
269
|
+
start: anchorCell.current,
|
|
270
|
+
end: newCell,
|
|
271
|
+
});
|
|
272
|
+
setFocusedCell(newCell);
|
|
273
|
+
} else {
|
|
274
|
+
// Single cell selection
|
|
275
|
+
anchorCell.current = newCell;
|
|
276
|
+
setFocusedCell(newCell);
|
|
277
|
+
setSelectedCellRange(null);
|
|
278
|
+
|
|
279
|
+
// Enter edit mode if editable
|
|
280
|
+
const column = columns.find((c) => c.id === columnId);
|
|
281
|
+
if (column?.editable && enableCellEditing) {
|
|
282
|
+
setEditingCell(newCell);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
[columns, enableCellEditing, setSelectedCellRange]
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// Handle cell mouse down (for shift+click selection)
|
|
290
|
+
const handleCellMouseDown = useCallback(
|
|
291
|
+
(rowId: string | number, columnId: string, event: React.MouseEvent) => {
|
|
292
|
+
// Only handle left mouse button
|
|
293
|
+
if (event.button !== 0) return;
|
|
294
|
+
|
|
295
|
+
const newCell: CellPosition = { rowId, columnId };
|
|
296
|
+
|
|
297
|
+
if (event.shiftKey && anchorCell.current) {
|
|
298
|
+
// Shift+click: extend selection from anchor to this cell
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
setSelectedCellRange({
|
|
301
|
+
start: anchorCell.current,
|
|
302
|
+
end: newCell,
|
|
303
|
+
});
|
|
304
|
+
setFocusedCell(newCell);
|
|
305
|
+
setEditingCell(null);
|
|
306
|
+
} else {
|
|
307
|
+
// Regular click: focus cell, clear any selection, and enter edit mode if editable
|
|
308
|
+
anchorCell.current = newCell;
|
|
309
|
+
setFocusedCell(newCell);
|
|
310
|
+
setSelectedCellRange(null);
|
|
311
|
+
|
|
312
|
+
// Enter edit mode if editable
|
|
313
|
+
const column = columns.find((c) => c.id === columnId);
|
|
314
|
+
if (column?.editable && enableCellEditing) {
|
|
315
|
+
setEditingCell(newCell);
|
|
316
|
+
} else {
|
|
317
|
+
setEditingCell(null);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
[columns, enableCellEditing, setSelectedCellRange]
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// Handle cell mouse enter (currently unused - drag selection disabled)
|
|
325
|
+
const handleCellMouseEnter = useCallback((_rowId: string | number, _columnId: string) => {
|
|
326
|
+
// Drag selection is disabled - selection only via shift+click or shift+arrow
|
|
327
|
+
}, []);
|
|
328
|
+
|
|
329
|
+
// Handle mouse up (end drag)
|
|
330
|
+
const handleMouseUp = useCallback(() => {
|
|
331
|
+
setIsDragging(false);
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
334
|
+
// Clear selection
|
|
335
|
+
const clearSelection = useCallback(() => {
|
|
336
|
+
setFocusedCell(null);
|
|
337
|
+
setEditingCell(null);
|
|
338
|
+
setSelectedCellRange(null);
|
|
339
|
+
anchorCell.current = null;
|
|
340
|
+
}, [setSelectedCellRange]);
|
|
341
|
+
|
|
342
|
+
// Navigate to adjacent cell
|
|
343
|
+
const navigateCell = useCallback(
|
|
344
|
+
(direction: 'up' | 'down' | 'left' | 'right', extendSelection = false) => {
|
|
345
|
+
const currentCell = focusedCell;
|
|
346
|
+
if (!currentCell) return;
|
|
347
|
+
|
|
348
|
+
const currentRowIdx = rowIndexMap.get(currentCell.rowId);
|
|
349
|
+
const currentColIdx = columnIndexMap.get(currentCell.columnId);
|
|
350
|
+
|
|
351
|
+
if (currentRowIdx === undefined || currentColIdx === undefined) return;
|
|
352
|
+
|
|
353
|
+
let newRowIdx = currentRowIdx;
|
|
354
|
+
let newColIdx = currentColIdx;
|
|
355
|
+
|
|
356
|
+
switch (direction) {
|
|
357
|
+
case 'up':
|
|
358
|
+
newRowIdx = Math.max(0, currentRowIdx - 1);
|
|
359
|
+
break;
|
|
360
|
+
case 'down':
|
|
361
|
+
newRowIdx = Math.min(data.length - 1, currentRowIdx + 1);
|
|
362
|
+
break;
|
|
363
|
+
case 'left':
|
|
364
|
+
newColIdx = Math.max(0, currentColIdx - 1);
|
|
365
|
+
break;
|
|
366
|
+
case 'right':
|
|
367
|
+
newColIdx = Math.min(columns.length - 1, currentColIdx + 1);
|
|
368
|
+
break;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const newRow = data[newRowIdx];
|
|
372
|
+
const newColumn = columns[newColIdx];
|
|
373
|
+
|
|
374
|
+
if (!newRow || !newColumn) return;
|
|
375
|
+
|
|
376
|
+
const newCell: CellPosition = {
|
|
377
|
+
rowId: getRowId(newRow),
|
|
378
|
+
columnId: newColumn.id,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
setFocusedCell(newCell);
|
|
382
|
+
setEditingCell(null);
|
|
383
|
+
|
|
384
|
+
if (extendSelection) {
|
|
385
|
+
// Extend selection from anchor
|
|
386
|
+
if (!anchorCell.current) {
|
|
387
|
+
anchorCell.current = currentCell;
|
|
388
|
+
}
|
|
389
|
+
setSelectedCellRange({
|
|
390
|
+
start: anchorCell.current,
|
|
391
|
+
end: newCell,
|
|
392
|
+
});
|
|
393
|
+
} else {
|
|
394
|
+
// Clear selection and set new anchor
|
|
395
|
+
anchorCell.current = newCell;
|
|
396
|
+
setSelectedCellRange(null);
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
[focusedCell, data, columns, getRowId, rowIndexMap, columnIndexMap, setSelectedCellRange]
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
// Handle Tab key navigation
|
|
403
|
+
const handleTabNavigation = useCallback(
|
|
404
|
+
(shiftKey: boolean) => {
|
|
405
|
+
const currentCell = focusedCell;
|
|
406
|
+
if (!currentCell) return;
|
|
407
|
+
|
|
408
|
+
const currentRowIdx = rowIndexMap.get(currentCell.rowId);
|
|
409
|
+
const currentColIdx = columnIndexMap.get(currentCell.columnId);
|
|
410
|
+
|
|
411
|
+
if (currentRowIdx === undefined || currentColIdx === undefined) return;
|
|
412
|
+
|
|
413
|
+
let newRowIdx = currentRowIdx;
|
|
414
|
+
let newColIdx = currentColIdx;
|
|
415
|
+
|
|
416
|
+
if (shiftKey) {
|
|
417
|
+
// Move backwards
|
|
418
|
+
newColIdx--;
|
|
419
|
+
if (newColIdx < 0) {
|
|
420
|
+
newColIdx = columns.length - 1;
|
|
421
|
+
newRowIdx--;
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// Move forwards
|
|
425
|
+
newColIdx++;
|
|
426
|
+
if (newColIdx >= columns.length) {
|
|
427
|
+
newColIdx = 0;
|
|
428
|
+
newRowIdx++;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Wrap around
|
|
433
|
+
if (newRowIdx < 0) {
|
|
434
|
+
newRowIdx = data.length - 1;
|
|
435
|
+
} else if (newRowIdx >= data.length) {
|
|
436
|
+
newRowIdx = 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const newRow = data[newRowIdx];
|
|
440
|
+
const newColumn = columns[newColIdx];
|
|
441
|
+
|
|
442
|
+
if (!newRow || !newColumn) return;
|
|
443
|
+
|
|
444
|
+
const newCell: CellPosition = {
|
|
445
|
+
rowId: getRowId(newRow),
|
|
446
|
+
columnId: newColumn.id,
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
setFocusedCell(newCell);
|
|
450
|
+
setEditingCell(null);
|
|
451
|
+
setSelectedCellRange(null);
|
|
452
|
+
anchorCell.current = newCell;
|
|
453
|
+
},
|
|
454
|
+
[focusedCell, data, columns, getRowId, rowIndexMap, columnIndexMap, setSelectedCellRange]
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
// Enter edit mode
|
|
458
|
+
const enterEditMode = useCallback(() => {
|
|
459
|
+
if (!focusedCell || !enableCellEditing) return;
|
|
460
|
+
|
|
461
|
+
const column = columns.find((c) => c.id === focusedCell.columnId);
|
|
462
|
+
if (column?.editable) {
|
|
463
|
+
setEditingCell(focusedCell);
|
|
464
|
+
}
|
|
465
|
+
}, [focusedCell, columns, enableCellEditing]);
|
|
466
|
+
|
|
467
|
+
// Exit edit mode
|
|
468
|
+
const exitEditMode = useCallback(() => {
|
|
469
|
+
setEditingCell(null);
|
|
470
|
+
}, []);
|
|
471
|
+
|
|
472
|
+
// Copy selected cells
|
|
473
|
+
const copySelectedCells = useCallback(() => {
|
|
474
|
+
const normalizedRange = getNormalizedRange(selectedCellRange);
|
|
475
|
+
|
|
476
|
+
if (!normalizedRange && !focusedCell) return;
|
|
477
|
+
|
|
478
|
+
const startRowIdx =
|
|
479
|
+
normalizedRange?.startRowIdx ?? rowIndexMap.get(focusedCell!.rowId) ?? 0;
|
|
480
|
+
const endRowIdx = normalizedRange?.endRowIdx ?? startRowIdx;
|
|
481
|
+
const startColIdx =
|
|
482
|
+
normalizedRange?.startColIdx ?? columnIndexMap.get(focusedCell!.columnId) ?? 0;
|
|
483
|
+
const endColIdx = normalizedRange?.endColIdx ?? startColIdx;
|
|
484
|
+
|
|
485
|
+
const values: any[][] = [];
|
|
486
|
+
const textRows: string[] = [];
|
|
487
|
+
|
|
488
|
+
for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx++) {
|
|
489
|
+
const row = data[rowIdx];
|
|
490
|
+
if (!row) continue;
|
|
491
|
+
|
|
492
|
+
const rowValues: any[] = [];
|
|
493
|
+
const textCells: string[] = [];
|
|
494
|
+
|
|
495
|
+
for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx++) {
|
|
496
|
+
const column = columns[colIdx];
|
|
497
|
+
if (!column) continue;
|
|
498
|
+
|
|
499
|
+
const value = column.getValue ? column.getValue(row) : row[column.id];
|
|
500
|
+
rowValues.push(value);
|
|
501
|
+
textCells.push(value != null ? String(value) : '');
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
values.push(rowValues);
|
|
505
|
+
textRows.push(textCells.join('\t'));
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Store in internal clipboard
|
|
509
|
+
const startRow = data[startRowIdx];
|
|
510
|
+
const startColumn = columns[startColIdx];
|
|
511
|
+
if (startRow && startColumn) {
|
|
512
|
+
setClipboardData({
|
|
513
|
+
values,
|
|
514
|
+
startCell: {
|
|
515
|
+
rowId: getRowId(startRow),
|
|
516
|
+
columnId: startColumn.id,
|
|
517
|
+
},
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Copy to system clipboard
|
|
522
|
+
const text = textRows.join('\n');
|
|
523
|
+
navigator.clipboard.writeText(text).catch(() => {
|
|
524
|
+
// Fallback for browsers without clipboard API
|
|
525
|
+
console.warn('Could not copy to system clipboard');
|
|
526
|
+
});
|
|
527
|
+
}, [
|
|
528
|
+
selectedCellRange,
|
|
529
|
+
focusedCell,
|
|
530
|
+
data,
|
|
531
|
+
columns,
|
|
532
|
+
getRowId,
|
|
533
|
+
getNormalizedRange,
|
|
534
|
+
rowIndexMap,
|
|
535
|
+
columnIndexMap,
|
|
536
|
+
]);
|
|
537
|
+
|
|
538
|
+
// Parse text into 2D array (tab-separated columns, newline-separated rows)
|
|
539
|
+
const parseClipboardText = useCallback((text: string): any[][] => {
|
|
540
|
+
const lines = text.split(/\r?\n/);
|
|
541
|
+
// Remove trailing empty line if present
|
|
542
|
+
if (lines.length > 0 && lines[lines.length - 1] === '') {
|
|
543
|
+
lines.pop();
|
|
544
|
+
}
|
|
545
|
+
return lines.map((line) => line.split('\t'));
|
|
546
|
+
}, []);
|
|
547
|
+
|
|
548
|
+
// Helper function to create edits from a 2D values array
|
|
549
|
+
// If there's a selection, fills the selection. Otherwise starts from focused cell.
|
|
550
|
+
const createEditsFromValues = useCallback(
|
|
551
|
+
(values: any[][]): CellEdit[] => {
|
|
552
|
+
if (!focusedCell) return [];
|
|
553
|
+
|
|
554
|
+
const edits: CellEdit[] = [];
|
|
555
|
+
const normalizedRange = getNormalizedRange(selectedCellRange);
|
|
556
|
+
|
|
557
|
+
// Determine the target range
|
|
558
|
+
let startRowIdx: number;
|
|
559
|
+
let endRowIdx: number;
|
|
560
|
+
let startColIdx: number;
|
|
561
|
+
let endColIdx: number;
|
|
562
|
+
|
|
563
|
+
if (normalizedRange) {
|
|
564
|
+
// Use the selection range
|
|
565
|
+
startRowIdx = normalizedRange.startRowIdx;
|
|
566
|
+
endRowIdx = normalizedRange.endRowIdx;
|
|
567
|
+
startColIdx = normalizedRange.startColIdx;
|
|
568
|
+
endColIdx = normalizedRange.endColIdx;
|
|
569
|
+
} else {
|
|
570
|
+
// No selection, start from focused cell and use clipboard dimensions
|
|
571
|
+
const focusRowIdx = rowIndexMap.get(focusedCell.rowId);
|
|
572
|
+
const focusColIdx = columnIndexMap.get(focusedCell.columnId);
|
|
573
|
+
if (focusRowIdx === undefined || focusColIdx === undefined) return [];
|
|
574
|
+
|
|
575
|
+
startRowIdx = focusRowIdx;
|
|
576
|
+
endRowIdx = Math.min(focusRowIdx + values.length - 1, data.length - 1);
|
|
577
|
+
startColIdx = focusColIdx;
|
|
578
|
+
endColIdx = Math.min(
|
|
579
|
+
focusColIdx + (values[0]?.length || 1) - 1,
|
|
580
|
+
columns.length - 1
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Fill the target range with clipboard values (tiling if needed)
|
|
585
|
+
for (let rowIdx = startRowIdx; rowIdx <= endRowIdx; rowIdx++) {
|
|
586
|
+
const row = data[rowIdx];
|
|
587
|
+
if (!row) continue;
|
|
588
|
+
|
|
589
|
+
const rowId = getRowId(row);
|
|
590
|
+
const valueRowIdx = (rowIdx - startRowIdx) % values.length;
|
|
591
|
+
|
|
592
|
+
for (let colIdx = startColIdx; colIdx <= endColIdx; colIdx++) {
|
|
593
|
+
const column = columns[colIdx];
|
|
594
|
+
if (!column || !column.editable) continue;
|
|
595
|
+
|
|
596
|
+
const valueColIdx = (colIdx - startColIdx) % (values[valueRowIdx]?.length || 1);
|
|
597
|
+
let cellValue = values[valueRowIdx]?.[valueColIdx];
|
|
598
|
+
|
|
599
|
+
// Convert value based on column type
|
|
600
|
+
if (column.type === 'number' && typeof cellValue === 'string') {
|
|
601
|
+
const parsed = parseFloat(cellValue);
|
|
602
|
+
cellValue = isNaN(parsed) ? cellValue : parsed;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
edits.push({
|
|
606
|
+
rowId,
|
|
607
|
+
columnId: column.id,
|
|
608
|
+
value: cellValue,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return edits;
|
|
614
|
+
},
|
|
615
|
+
[
|
|
616
|
+
focusedCell,
|
|
617
|
+
selectedCellRange,
|
|
618
|
+
data,
|
|
619
|
+
columns,
|
|
620
|
+
getRowId,
|
|
621
|
+
rowIndexMap,
|
|
622
|
+
columnIndexMap,
|
|
623
|
+
getNormalizedRange,
|
|
624
|
+
]
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
// Paste from internal clipboard
|
|
628
|
+
const pasteClipboard = useCallback((): CellEdit[] => {
|
|
629
|
+
if (!clipboardData?.values) return [];
|
|
630
|
+
return createEditsFromValues(clipboardData.values);
|
|
631
|
+
}, [clipboardData, createEditsFromValues]);
|
|
632
|
+
|
|
633
|
+
// Paste from system clipboard (async)
|
|
634
|
+
const pasteFromSystemClipboard = useCallback(async (): Promise<CellEdit[]> => {
|
|
635
|
+
if (!focusedCell) return [];
|
|
636
|
+
|
|
637
|
+
try {
|
|
638
|
+
const text = await navigator.clipboard.readText();
|
|
639
|
+
if (!text) {
|
|
640
|
+
// Fall back to internal clipboard
|
|
641
|
+
return pasteClipboard();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const values = parseClipboardText(text);
|
|
645
|
+
return createEditsFromValues(values);
|
|
646
|
+
} catch {
|
|
647
|
+
// Clipboard API not available or permission denied, fall back to internal clipboard
|
|
648
|
+
return pasteClipboard();
|
|
649
|
+
}
|
|
650
|
+
}, [focusedCell, pasteClipboard, parseClipboardText, createEditsFromValues]);
|
|
651
|
+
|
|
652
|
+
return {
|
|
653
|
+
focusedCell,
|
|
654
|
+
setFocusedCell,
|
|
655
|
+
editingCell,
|
|
656
|
+
setEditingCell,
|
|
657
|
+
selectedCellRange,
|
|
658
|
+
setSelectedCellRange,
|
|
659
|
+
isDragging,
|
|
660
|
+
handleCellClick,
|
|
661
|
+
handleCellMouseDown,
|
|
662
|
+
handleCellMouseEnter,
|
|
663
|
+
handleMouseUp,
|
|
664
|
+
isCellInSelection,
|
|
665
|
+
getCellSelectionEdge,
|
|
666
|
+
getSelectedCells,
|
|
667
|
+
getSelectedCellValues,
|
|
668
|
+
clearSelection,
|
|
669
|
+
navigateCell,
|
|
670
|
+
handleTabNavigation,
|
|
671
|
+
enterEditMode,
|
|
672
|
+
exitEditMode,
|
|
673
|
+
clipboardData,
|
|
674
|
+
copySelectedCells,
|
|
675
|
+
pasteClipboard,
|
|
676
|
+
pasteFromSystemClipboard,
|
|
677
|
+
};
|
|
678
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,9 +23,12 @@ export type {
|
|
|
23
23
|
SpreadsheetToolbarProps,
|
|
24
24
|
SpreadsheetColumnGroupHeaderProps,
|
|
25
25
|
CellPosition,
|
|
26
|
+
CellRange,
|
|
27
|
+
CellEdit,
|
|
26
28
|
CellHighlight,
|
|
27
29
|
CellComment,
|
|
28
30
|
SelectionState,
|
|
31
|
+
SelectionEdge,
|
|
29
32
|
PaginationState,
|
|
30
33
|
RowAction,
|
|
31
34
|
ToolbarMenuItem,
|