@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.
@@ -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,
@@ -0,0 +1,3 @@
1
+ @import "tailwindcss";
2
+
3
+ @source "../**/*.{ts,tsx,css}";