@alaarab/ogrid-react 2.0.0-beta
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/README.md +55 -0
- package/dist/esm/components/BaseInlineCellEditor.js +112 -0
- package/dist/esm/components/CellErrorBoundary.js +43 -0
- package/dist/esm/components/EmptyState.js +19 -0
- package/dist/esm/components/GridContextMenu.js +35 -0
- package/dist/esm/components/MarchingAntsOverlay.js +110 -0
- package/dist/esm/components/OGridLayout.js +91 -0
- package/dist/esm/components/SideBar.js +122 -0
- package/dist/esm/components/StatusBar.js +6 -0
- package/dist/esm/hooks/index.js +25 -0
- package/dist/esm/hooks/useActiveCell.js +62 -0
- package/dist/esm/hooks/useCellEditing.js +15 -0
- package/dist/esm/hooks/useCellSelection.js +327 -0
- package/dist/esm/hooks/useClipboard.js +161 -0
- package/dist/esm/hooks/useColumnChooserState.js +62 -0
- package/dist/esm/hooks/useColumnHeaderFilterState.js +180 -0
- package/dist/esm/hooks/useColumnResize.js +92 -0
- package/dist/esm/hooks/useContextMenu.js +21 -0
- package/dist/esm/hooks/useDataGridState.js +313 -0
- package/dist/esm/hooks/useDateFilterState.js +34 -0
- package/dist/esm/hooks/useDebounce.js +35 -0
- package/dist/esm/hooks/useFillHandle.js +195 -0
- package/dist/esm/hooks/useFilterOptions.js +40 -0
- package/dist/esm/hooks/useInlineCellEditorState.js +44 -0
- package/dist/esm/hooks/useKeyboardNavigation.js +419 -0
- package/dist/esm/hooks/useLatestRef.js +11 -0
- package/dist/esm/hooks/useMultiSelectFilterState.js +59 -0
- package/dist/esm/hooks/useOGrid.js +465 -0
- package/dist/esm/hooks/usePeopleFilterState.js +68 -0
- package/dist/esm/hooks/useRichSelectState.js +58 -0
- package/dist/esm/hooks/useRowSelection.js +80 -0
- package/dist/esm/hooks/useSideBarState.js +39 -0
- package/dist/esm/hooks/useTableLayout.js +77 -0
- package/dist/esm/hooks/useTextFilterState.js +25 -0
- package/dist/esm/hooks/useUndoRedo.js +83 -0
- package/dist/esm/index.js +16 -0
- package/dist/esm/storybook/index.js +1 -0
- package/dist/esm/storybook/mockData.js +73 -0
- package/dist/esm/types/columnTypes.js +1 -0
- package/dist/esm/types/dataGridTypes.js +1 -0
- package/dist/esm/types/index.js +1 -0
- package/dist/esm/utils/dataGridViewModel.js +220 -0
- package/dist/esm/utils/gridRowComparator.js +2 -0
- package/dist/esm/utils/index.js +5 -0
- package/dist/types/components/BaseInlineCellEditor.d.ts +33 -0
- package/dist/types/components/CellErrorBoundary.d.ts +25 -0
- package/dist/types/components/EmptyState.d.ts +26 -0
- package/dist/types/components/GridContextMenu.d.ts +18 -0
- package/dist/types/components/MarchingAntsOverlay.d.ts +15 -0
- package/dist/types/components/OGridLayout.d.ts +37 -0
- package/dist/types/components/SideBar.d.ts +30 -0
- package/dist/types/components/StatusBar.d.ts +24 -0
- package/dist/types/hooks/index.d.ts +48 -0
- package/dist/types/hooks/useActiveCell.d.ts +13 -0
- package/dist/types/hooks/useCellEditing.d.ts +16 -0
- package/dist/types/hooks/useCellSelection.d.ts +22 -0
- package/dist/types/hooks/useClipboard.d.ts +30 -0
- package/dist/types/hooks/useColumnChooserState.d.ts +27 -0
- package/dist/types/hooks/useColumnHeaderFilterState.d.ts +73 -0
- package/dist/types/hooks/useColumnResize.d.ts +23 -0
- package/dist/types/hooks/useContextMenu.d.ts +19 -0
- package/dist/types/hooks/useDataGridState.d.ts +137 -0
- package/dist/types/hooks/useDateFilterState.d.ts +19 -0
- package/dist/types/hooks/useDebounce.d.ts +9 -0
- package/dist/types/hooks/useFillHandle.d.ts +33 -0
- package/dist/types/hooks/useFilterOptions.d.ts +16 -0
- package/dist/types/hooks/useInlineCellEditorState.d.ts +24 -0
- package/dist/types/hooks/useKeyboardNavigation.d.ts +47 -0
- package/dist/types/hooks/useLatestRef.d.ts +6 -0
- package/dist/types/hooks/useMultiSelectFilterState.d.ts +24 -0
- package/dist/types/hooks/useOGrid.d.ts +52 -0
- package/dist/types/hooks/usePeopleFilterState.d.ts +25 -0
- package/dist/types/hooks/useRichSelectState.d.ts +22 -0
- package/dist/types/hooks/useRowSelection.d.ts +22 -0
- package/dist/types/hooks/useSideBarState.d.ts +20 -0
- package/dist/types/hooks/useTableLayout.d.ts +27 -0
- package/dist/types/hooks/useTextFilterState.d.ts +16 -0
- package/dist/types/hooks/useUndoRedo.d.ts +23 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/storybook/index.d.ts +2 -0
- package/dist/types/storybook/mockData.d.ts +37 -0
- package/dist/types/types/columnTypes.d.ts +25 -0
- package/dist/types/types/dataGridTypes.d.ts +152 -0
- package/dist/types/types/index.d.ts +3 -0
- package/dist/types/utils/dataGridViewModel.d.ts +161 -0
- package/dist/types/utils/gridRowComparator.d.ts +2 -0
- package/dist/types/utils/index.d.ts +6 -0
- package/package.json +46 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useState, useLayoutEffect, useCallback, useRef } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Tracks the active cell for keyboard navigation.
|
|
4
|
+
* When wrapperRef and editingCell are provided, scrolls the active cell into view when it changes (and not editing).
|
|
5
|
+
*/
|
|
6
|
+
export function useActiveCell(wrapperRef, editingCell) {
|
|
7
|
+
const [activeCell, _setActiveCell] = useState(null);
|
|
8
|
+
const activeCellRef = useRef(activeCell);
|
|
9
|
+
activeCellRef.current = activeCell;
|
|
10
|
+
// Deduplicating setter — skips state update (and all downstream effects) when
|
|
11
|
+
// the cell coordinates haven't actually changed. This prevents re-renders when
|
|
12
|
+
// rapidly clicking the same cell.
|
|
13
|
+
const setActiveCell = useCallback((cell) => {
|
|
14
|
+
const prev = activeCellRef.current;
|
|
15
|
+
if (prev === cell)
|
|
16
|
+
return;
|
|
17
|
+
if (prev && cell && prev.rowIndex === cell.rowIndex && prev.columnIndex === cell.columnIndex)
|
|
18
|
+
return;
|
|
19
|
+
_setActiveCell(cell);
|
|
20
|
+
}, []);
|
|
21
|
+
// useLayoutEffect ensures focus moves synchronously before the browser can
|
|
22
|
+
// reset focus to body (fixes left/right arrow navigation losing focus)
|
|
23
|
+
useLayoutEffect(() => {
|
|
24
|
+
if (activeCell == null ||
|
|
25
|
+
wrapperRef?.current == null ||
|
|
26
|
+
editingCell != null)
|
|
27
|
+
return;
|
|
28
|
+
const { rowIndex, columnIndex } = activeCell;
|
|
29
|
+
const selector = `[data-row-index="${rowIndex}"][data-col-index="${columnIndex}"]`;
|
|
30
|
+
const cell = wrapperRef.current.querySelector(selector);
|
|
31
|
+
if (cell) {
|
|
32
|
+
// Scroll the cell into view within the table wrapper only — do NOT
|
|
33
|
+
// use native scrollIntoView() which scrolls all ancestor containers
|
|
34
|
+
// including the page, causing an unwanted viewport jump.
|
|
35
|
+
const wrapper = wrapperRef.current;
|
|
36
|
+
const thead = wrapper.querySelector('thead');
|
|
37
|
+
const headerHeight = thead ? thead.getBoundingClientRect().height : 0;
|
|
38
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
39
|
+
const cellRect = cell.getBoundingClientRect();
|
|
40
|
+
// Vertical scroll (account for sticky thead)
|
|
41
|
+
const visibleTop = wrapperRect.top + headerHeight;
|
|
42
|
+
if (cellRect.top < visibleTop) {
|
|
43
|
+
wrapper.scrollTop -= visibleTop - cellRect.top;
|
|
44
|
+
}
|
|
45
|
+
else if (cellRect.bottom > wrapperRect.bottom) {
|
|
46
|
+
wrapper.scrollTop += cellRect.bottom - wrapperRect.bottom;
|
|
47
|
+
}
|
|
48
|
+
// Horizontal scroll
|
|
49
|
+
if (cellRect.left < wrapperRect.left) {
|
|
50
|
+
wrapper.scrollLeft -= wrapperRect.left - cellRect.left;
|
|
51
|
+
}
|
|
52
|
+
else if (cellRect.right > wrapperRect.right) {
|
|
53
|
+
wrapper.scrollLeft += cellRect.right - wrapperRect.right;
|
|
54
|
+
}
|
|
55
|
+
if (document.activeElement !== cell && typeof cell.focus === 'function') {
|
|
56
|
+
cell.focus({ preventScroll: true });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
60
|
+
}, [activeCell, editingCell]); // wrapperRef excluded — refs are stable across renders
|
|
61
|
+
return { activeCell, setActiveCell };
|
|
62
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
/**
|
|
3
|
+
* Manages cell editing state: which cell is being edited and its pending value.
|
|
4
|
+
* @returns Current editing cell, setter, pending editor value, and setter.
|
|
5
|
+
*/
|
|
6
|
+
export function useCellEditing() {
|
|
7
|
+
const [editingCell, setEditingCell] = useState(null);
|
|
8
|
+
const [pendingEditorValue, setPendingEditorValue] = useState(undefined);
|
|
9
|
+
return {
|
|
10
|
+
editingCell,
|
|
11
|
+
setEditingCell,
|
|
12
|
+
pendingEditorValue,
|
|
13
|
+
setPendingEditorValue,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import { normalizeSelectionRange } from '../types';
|
|
3
|
+
/** Compares two selection ranges by value. */
|
|
4
|
+
function rangesEqual(a, b) {
|
|
5
|
+
if (a === b)
|
|
6
|
+
return true;
|
|
7
|
+
if (!a || !b)
|
|
8
|
+
return false;
|
|
9
|
+
return a.startRow === b.startRow && a.endRow === b.endRow &&
|
|
10
|
+
a.startCol === b.startCol && a.endCol === b.endCol;
|
|
11
|
+
}
|
|
12
|
+
/** DOM attribute name used for drag-range highlighting (bypasses React). */
|
|
13
|
+
const DRAG_ATTR = 'data-drag-range';
|
|
14
|
+
/** Auto-scroll config */
|
|
15
|
+
const AUTO_SCROLL_EDGE = 40; // px from wrapper edge to trigger
|
|
16
|
+
const AUTO_SCROLL_MIN_SPEED = 2;
|
|
17
|
+
const AUTO_SCROLL_MAX_SPEED = 20;
|
|
18
|
+
const AUTO_SCROLL_INTERVAL = 16; // ~60fps
|
|
19
|
+
/** Compute scroll speed proportional to distance past the edge, capped. */
|
|
20
|
+
function autoScrollSpeed(distance) {
|
|
21
|
+
const t = Math.min(distance / AUTO_SCROLL_EDGE, 1);
|
|
22
|
+
return AUTO_SCROLL_MIN_SPEED + t * (AUTO_SCROLL_MAX_SPEED - AUTO_SCROLL_MIN_SPEED);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Manages cell selection range with drag-to-select and select-all support.
|
|
26
|
+
* @param params - Row/col counts, active cell setter, and wrapper ref for auto-scroll.
|
|
27
|
+
* @returns Selection range, setters, mouse/keyboard handlers, and drag state.
|
|
28
|
+
*/
|
|
29
|
+
export function useCellSelection(params) {
|
|
30
|
+
const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
|
|
31
|
+
const [selectionRange, _setSelectionRange] = useState(null);
|
|
32
|
+
const isDraggingRef = useRef(false);
|
|
33
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
34
|
+
/** True once a mousemove has been seen during the current drag gesture. */
|
|
35
|
+
const dragMovedRef = useRef(false);
|
|
36
|
+
const dragStartRef = useRef(null);
|
|
37
|
+
const rafRef = useRef(0);
|
|
38
|
+
/** Live drag range kept in a ref — only committed to React state on mouseup. */
|
|
39
|
+
const liveDragRangeRef = useRef(null);
|
|
40
|
+
/** Auto-scroll interval during drag. */
|
|
41
|
+
const autoScrollRef = useRef(null);
|
|
42
|
+
// Ref mirror of selectionRange — lets handleCellMouseDown read current value
|
|
43
|
+
// without adding selectionRange to its useCallback deps (keeps it stable).
|
|
44
|
+
const selectionRangeRef = useRef(selectionRange);
|
|
45
|
+
selectionRangeRef.current = selectionRange;
|
|
46
|
+
// Deduplicating setter — skips re-render when the range hasn't actually changed.
|
|
47
|
+
const setSelectionRange = useCallback((next) => {
|
|
48
|
+
if (rangesEqual(selectionRangeRef.current, next))
|
|
49
|
+
return;
|
|
50
|
+
_setSelectionRange(next);
|
|
51
|
+
}, []);
|
|
52
|
+
const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
|
|
53
|
+
// Only handle primary (left) button — let middle-click scroll and right-click context menu work natively
|
|
54
|
+
if (e.button !== 0)
|
|
55
|
+
return;
|
|
56
|
+
if (globalColIndex < colOffset)
|
|
57
|
+
return;
|
|
58
|
+
// Prevent native text selection during cell drag
|
|
59
|
+
e.preventDefault();
|
|
60
|
+
const dataColIndex = globalColIndex - colOffset;
|
|
61
|
+
const currentRange = selectionRangeRef.current;
|
|
62
|
+
if (e.shiftKey && currentRange != null) {
|
|
63
|
+
setSelectionRange(normalizeSelectionRange({
|
|
64
|
+
startRow: currentRange.startRow,
|
|
65
|
+
startCol: currentRange.startCol,
|
|
66
|
+
endRow: rowIndex,
|
|
67
|
+
endCol: dataColIndex,
|
|
68
|
+
}));
|
|
69
|
+
setActiveCell({ rowIndex, columnIndex: globalColIndex });
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
dragStartRef.current = { row: rowIndex, col: dataColIndex };
|
|
73
|
+
dragMovedRef.current = false;
|
|
74
|
+
const initial = {
|
|
75
|
+
startRow: rowIndex,
|
|
76
|
+
startCol: dataColIndex,
|
|
77
|
+
endRow: rowIndex,
|
|
78
|
+
endCol: dataColIndex,
|
|
79
|
+
};
|
|
80
|
+
setSelectionRange(initial);
|
|
81
|
+
liveDragRangeRef.current = initial;
|
|
82
|
+
setActiveCell({ rowIndex, columnIndex: globalColIndex });
|
|
83
|
+
// Mark drag as "started" but don't set isDragging state yet —
|
|
84
|
+
// setIsDragging(true) is deferred to the first mousemove to avoid
|
|
85
|
+
// a true→false toggle on simple clicks (which causes 2 extra renders).
|
|
86
|
+
isDraggingRef.current = true;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is a stable callback
|
|
90
|
+
[colOffset, setActiveCell]);
|
|
91
|
+
const handleSelectAllCells = useCallback(() => {
|
|
92
|
+
if (rowCount === 0 || visibleColCount === 0)
|
|
93
|
+
return;
|
|
94
|
+
setSelectionRange({
|
|
95
|
+
startRow: 0,
|
|
96
|
+
startCol: 0,
|
|
97
|
+
endRow: rowCount - 1,
|
|
98
|
+
endCol: visibleColCount - 1,
|
|
99
|
+
});
|
|
100
|
+
setActiveCell({ rowIndex: 0, columnIndex: colOffset });
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- setSelectionRange is a stable callback
|
|
102
|
+
}, [rowCount, visibleColCount, colOffset, setActiveCell]);
|
|
103
|
+
/** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
|
|
104
|
+
const lastMousePosRef = useRef(null);
|
|
105
|
+
// Window mouse move/up for drag selection.
|
|
106
|
+
// Performance: during drag, we update a ref + toggle DOM attributes via rAF.
|
|
107
|
+
// React state is only committed on mouseup (single re-render instead of 60-120/s).
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const colOff = colOffset; // capture for closure
|
|
110
|
+
/** Toggle DRAG_ATTR on cell-content divs to show the range highlight via CSS. */
|
|
111
|
+
const applyDragAttrs = (range) => {
|
|
112
|
+
const wrapper = wrapperRef.current;
|
|
113
|
+
if (!wrapper)
|
|
114
|
+
return;
|
|
115
|
+
const minR = Math.min(range.startRow, range.endRow);
|
|
116
|
+
const maxR = Math.max(range.startRow, range.endRow);
|
|
117
|
+
const minC = Math.min(range.startCol, range.endCol);
|
|
118
|
+
const maxC = Math.max(range.startCol, range.endCol);
|
|
119
|
+
const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
|
|
120
|
+
for (let i = 0; i < cells.length; i++) {
|
|
121
|
+
const el = cells[i];
|
|
122
|
+
const r = parseInt(el.getAttribute('data-row-index'), 10);
|
|
123
|
+
const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
|
|
124
|
+
const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
|
|
125
|
+
if (inRange) {
|
|
126
|
+
if (!el.hasAttribute(DRAG_ATTR))
|
|
127
|
+
el.setAttribute(DRAG_ATTR, '');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
if (el.hasAttribute(DRAG_ATTR))
|
|
131
|
+
el.removeAttribute(DRAG_ATTR);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
const clearDragAttrs = () => {
|
|
136
|
+
const wrapper = wrapperRef.current;
|
|
137
|
+
if (!wrapper)
|
|
138
|
+
return;
|
|
139
|
+
const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
|
|
140
|
+
for (let i = 0; i < marked.length; i++)
|
|
141
|
+
marked[i].removeAttribute(DRAG_ATTR);
|
|
142
|
+
};
|
|
143
|
+
/** Resolve mouse coordinates to a cell range (shared by RAF callback and mouseUp flush). */
|
|
144
|
+
const resolveRange = (cx, cy) => {
|
|
145
|
+
if (!dragStartRef.current)
|
|
146
|
+
return null;
|
|
147
|
+
const target = document.elementFromPoint(cx, cy);
|
|
148
|
+
const cell = target?.closest?.('[data-row-index][data-col-index]');
|
|
149
|
+
if (!cell)
|
|
150
|
+
return null;
|
|
151
|
+
const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
|
|
152
|
+
const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
|
|
153
|
+
if (Number.isNaN(r) || Number.isNaN(c) || c < colOff)
|
|
154
|
+
return null;
|
|
155
|
+
const dataCol = c - colOff;
|
|
156
|
+
const start = dragStartRef.current;
|
|
157
|
+
return normalizeSelectionRange({
|
|
158
|
+
startRow: start.row,
|
|
159
|
+
startCol: start.col,
|
|
160
|
+
endRow: r,
|
|
161
|
+
endCol: dataCol,
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
/** Start or update auto-scroll interval based on mouse position relative to wrapper edges. */
|
|
165
|
+
const updateAutoScroll = () => {
|
|
166
|
+
const wrapper = wrapperRef.current;
|
|
167
|
+
const pos = lastMousePosRef.current;
|
|
168
|
+
if (!wrapper || !pos || !isDraggingRef.current) {
|
|
169
|
+
stopAutoScroll();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const rect = wrapper.getBoundingClientRect();
|
|
173
|
+
let dx = 0;
|
|
174
|
+
let dy = 0;
|
|
175
|
+
if (pos.cy < rect.top + AUTO_SCROLL_EDGE) {
|
|
176
|
+
dy = -autoScrollSpeed(rect.top + AUTO_SCROLL_EDGE - pos.cy);
|
|
177
|
+
}
|
|
178
|
+
else if (pos.cy > rect.bottom - AUTO_SCROLL_EDGE) {
|
|
179
|
+
dy = autoScrollSpeed(pos.cy - (rect.bottom - AUTO_SCROLL_EDGE));
|
|
180
|
+
}
|
|
181
|
+
if (pos.cx < rect.left + AUTO_SCROLL_EDGE) {
|
|
182
|
+
dx = -autoScrollSpeed(rect.left + AUTO_SCROLL_EDGE - pos.cx);
|
|
183
|
+
}
|
|
184
|
+
else if (pos.cx > rect.right - AUTO_SCROLL_EDGE) {
|
|
185
|
+
dx = autoScrollSpeed(pos.cx - (rect.right - AUTO_SCROLL_EDGE));
|
|
186
|
+
}
|
|
187
|
+
if (dx === 0 && dy === 0) {
|
|
188
|
+
stopAutoScroll();
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// Start interval if not already running
|
|
192
|
+
if (!autoScrollRef.current) {
|
|
193
|
+
autoScrollRef.current = setInterval(() => {
|
|
194
|
+
const w = wrapperRef.current;
|
|
195
|
+
const p = lastMousePosRef.current;
|
|
196
|
+
if (!w || !p || !isDraggingRef.current) {
|
|
197
|
+
stopAutoScroll();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
const r = w.getBoundingClientRect();
|
|
201
|
+
let sdx = 0;
|
|
202
|
+
let sdy = 0;
|
|
203
|
+
if (p.cy < r.top + AUTO_SCROLL_EDGE)
|
|
204
|
+
sdy = -autoScrollSpeed(r.top + AUTO_SCROLL_EDGE - p.cy);
|
|
205
|
+
else if (p.cy > r.bottom - AUTO_SCROLL_EDGE)
|
|
206
|
+
sdy = autoScrollSpeed(p.cy - (r.bottom - AUTO_SCROLL_EDGE));
|
|
207
|
+
if (p.cx < r.left + AUTO_SCROLL_EDGE)
|
|
208
|
+
sdx = -autoScrollSpeed(r.left + AUTO_SCROLL_EDGE - p.cx);
|
|
209
|
+
else if (p.cx > r.right - AUTO_SCROLL_EDGE)
|
|
210
|
+
sdx = autoScrollSpeed(p.cx - (r.right - AUTO_SCROLL_EDGE));
|
|
211
|
+
if (sdx === 0 && sdy === 0) {
|
|
212
|
+
stopAutoScroll();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
w.scrollTop += sdy;
|
|
216
|
+
w.scrollLeft += sdx;
|
|
217
|
+
// After scrolling, re-resolve the cell under the mouse and update drag range
|
|
218
|
+
const newRange = resolveRange(p.cx, p.cy);
|
|
219
|
+
if (newRange) {
|
|
220
|
+
liveDragRangeRef.current = newRange;
|
|
221
|
+
applyDragAttrs(newRange);
|
|
222
|
+
}
|
|
223
|
+
}, AUTO_SCROLL_INTERVAL);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const stopAutoScroll = () => {
|
|
227
|
+
if (autoScrollRef.current) {
|
|
228
|
+
clearInterval(autoScrollRef.current);
|
|
229
|
+
autoScrollRef.current = null;
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
const onMove = (e) => {
|
|
233
|
+
if (!isDraggingRef.current || !dragStartRef.current)
|
|
234
|
+
return;
|
|
235
|
+
// Promote to a real drag on first mousemove (deferred from mouseDown
|
|
236
|
+
// to avoid a true→false toggle on simple clicks).
|
|
237
|
+
if (!dragMovedRef.current) {
|
|
238
|
+
dragMovedRef.current = true;
|
|
239
|
+
setIsDragging(true);
|
|
240
|
+
}
|
|
241
|
+
// Always store latest position so mouseUp can flush if RAF hasn't executed
|
|
242
|
+
lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
|
|
243
|
+
// Update auto-scroll based on mouse proximity to edges
|
|
244
|
+
updateAutoScroll();
|
|
245
|
+
// Cancel previous pending frame
|
|
246
|
+
if (rafRef.current)
|
|
247
|
+
cancelAnimationFrame(rafRef.current);
|
|
248
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
249
|
+
rafRef.current = 0;
|
|
250
|
+
const pos = lastMousePosRef.current;
|
|
251
|
+
if (!pos)
|
|
252
|
+
return;
|
|
253
|
+
const newRange = resolveRange(pos.cx, pos.cy);
|
|
254
|
+
if (!newRange)
|
|
255
|
+
return;
|
|
256
|
+
// Skip if range unchanged
|
|
257
|
+
const prev = liveDragRangeRef.current;
|
|
258
|
+
if (prev &&
|
|
259
|
+
prev.startRow === newRange.startRow &&
|
|
260
|
+
prev.startCol === newRange.startCol &&
|
|
261
|
+
prev.endRow === newRange.endRow &&
|
|
262
|
+
prev.endCol === newRange.endCol) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
liveDragRangeRef.current = newRange;
|
|
266
|
+
// DOM-only highlighting — no React state update until mouseup
|
|
267
|
+
applyDragAttrs(newRange);
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
const onUp = () => {
|
|
271
|
+
if (!isDraggingRef.current)
|
|
272
|
+
return;
|
|
273
|
+
stopAutoScroll();
|
|
274
|
+
if (rafRef.current) {
|
|
275
|
+
cancelAnimationFrame(rafRef.current);
|
|
276
|
+
rafRef.current = 0;
|
|
277
|
+
}
|
|
278
|
+
isDraggingRef.current = false;
|
|
279
|
+
const wasDrag = dragMovedRef.current;
|
|
280
|
+
if (wasDrag) {
|
|
281
|
+
// Flush: if the last RAF hasn't executed yet, resolve the range now from the
|
|
282
|
+
// last known mouse position so the final committed range is always accurate.
|
|
283
|
+
const pos = lastMousePosRef.current;
|
|
284
|
+
if (pos) {
|
|
285
|
+
const flushed = resolveRange(pos.cx, pos.cy);
|
|
286
|
+
if (flushed)
|
|
287
|
+
liveDragRangeRef.current = flushed;
|
|
288
|
+
}
|
|
289
|
+
// Commit final range to React state (triggers a single re-render)
|
|
290
|
+
const finalRange = liveDragRangeRef.current;
|
|
291
|
+
if (finalRange) {
|
|
292
|
+
setSelectionRange(finalRange);
|
|
293
|
+
setActiveCell({
|
|
294
|
+
rowIndex: finalRange.endRow,
|
|
295
|
+
columnIndex: finalRange.endCol + colOff,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
// For simple clicks (no drag movement), mouseDown already set
|
|
300
|
+
// selectionRange + activeCell — skip redundant state updates.
|
|
301
|
+
// Clean up DOM attributes — React will apply CSS-module classes on the same paint
|
|
302
|
+
clearDragAttrs();
|
|
303
|
+
liveDragRangeRef.current = null;
|
|
304
|
+
lastMousePosRef.current = null;
|
|
305
|
+
dragStartRef.current = null;
|
|
306
|
+
if (wasDrag)
|
|
307
|
+
setIsDragging(false);
|
|
308
|
+
};
|
|
309
|
+
window.addEventListener('mousemove', onMove, true);
|
|
310
|
+
window.addEventListener('mouseup', onUp, true);
|
|
311
|
+
return () => {
|
|
312
|
+
window.removeEventListener('mousemove', onMove, true);
|
|
313
|
+
window.removeEventListener('mouseup', onUp, true);
|
|
314
|
+
if (rafRef.current)
|
|
315
|
+
cancelAnimationFrame(rafRef.current);
|
|
316
|
+
stopAutoScroll();
|
|
317
|
+
};
|
|
318
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
319
|
+
}, [colOffset, setActiveCell]); // wrapperRef excluded — refs are stable across renders
|
|
320
|
+
return {
|
|
321
|
+
selectionRange,
|
|
322
|
+
setSelectionRange,
|
|
323
|
+
handleCellMouseDown,
|
|
324
|
+
handleSelectAllCells,
|
|
325
|
+
isDragging,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { getCellValue, parseValue } from '../utils';
|
|
3
|
+
import { normalizeSelectionRange } from '../types';
|
|
4
|
+
import { useLatestRef } from './useLatestRef';
|
|
5
|
+
/**
|
|
6
|
+
* Manages copy, cut, and paste operations for cell ranges with TSV clipboard format.
|
|
7
|
+
* @param params - Items, columns, selection, editability, and value change callback.
|
|
8
|
+
* @returns Copy/cut/paste handlers, cut/copy ranges, and range clear function.
|
|
9
|
+
*/
|
|
10
|
+
export function useClipboard(params) {
|
|
11
|
+
const { colOffset, beginBatch, endBatch, } = params;
|
|
12
|
+
// Volatile values accessed via refs — keeps callbacks stable
|
|
13
|
+
const itemsRef = useLatestRef(params.items);
|
|
14
|
+
const visibleColsRef = useLatestRef(params.visibleCols);
|
|
15
|
+
const selectionRangeRef = useLatestRef(params.selectionRange);
|
|
16
|
+
const activeCellRef = useLatestRef(params.activeCell);
|
|
17
|
+
const editableRef = useLatestRef(params.editable);
|
|
18
|
+
const onCellValueChangedRef = useLatestRef(params.onCellValueChanged);
|
|
19
|
+
const cutRangeRef = useRef(null);
|
|
20
|
+
const [cutRange, setCutRange] = useState(null);
|
|
21
|
+
const [copyRange, setCopyRange] = useState(null);
|
|
22
|
+
/** In-page clipboard fallback when system clipboard is unavailable. */
|
|
23
|
+
const internalClipboardRef = useRef(null);
|
|
24
|
+
/** Resolve current effective range from selection or active cell. */
|
|
25
|
+
const getEffectiveRange = useCallback(() => {
|
|
26
|
+
const sel = selectionRangeRef.current;
|
|
27
|
+
const ac = activeCellRef.current;
|
|
28
|
+
return sel ?? (ac != null
|
|
29
|
+
? { startRow: ac.rowIndex, startCol: ac.columnIndex - colOffset, endRow: ac.rowIndex, endCol: ac.columnIndex - colOffset }
|
|
30
|
+
: null);
|
|
31
|
+
}, [colOffset, selectionRangeRef, activeCellRef]);
|
|
32
|
+
const handleCopy = useCallback(() => {
|
|
33
|
+
const range = getEffectiveRange();
|
|
34
|
+
if (range == null)
|
|
35
|
+
return;
|
|
36
|
+
const norm = normalizeSelectionRange(range);
|
|
37
|
+
const items = itemsRef.current;
|
|
38
|
+
const visibleCols = visibleColsRef.current;
|
|
39
|
+
const rows = [];
|
|
40
|
+
for (let r = norm.startRow; r <= norm.endRow; r++) {
|
|
41
|
+
const cells = [];
|
|
42
|
+
for (let c = norm.startCol; c <= norm.endCol; c++) {
|
|
43
|
+
if (r >= items.length || c >= visibleCols.length)
|
|
44
|
+
break;
|
|
45
|
+
const item = items[r];
|
|
46
|
+
const col = visibleCols[c];
|
|
47
|
+
const raw = getCellValue(item, col);
|
|
48
|
+
const val = col.valueFormatter ? col.valueFormatter(raw, item) : raw;
|
|
49
|
+
cells.push(val != null && val !== '' ? String(val).replace(/\t/g, ' ').replace(/\n/g, ' ') : '');
|
|
50
|
+
}
|
|
51
|
+
rows.push(cells.join('\t'));
|
|
52
|
+
}
|
|
53
|
+
const tsv = rows.join('\r\n');
|
|
54
|
+
internalClipboardRef.current = tsv;
|
|
55
|
+
setCopyRange(norm);
|
|
56
|
+
void navigator.clipboard.writeText(tsv).catch(() => { });
|
|
57
|
+
}, [getEffectiveRange, itemsRef, visibleColsRef]);
|
|
58
|
+
const handleCut = useCallback(() => {
|
|
59
|
+
if (editableRef.current === false)
|
|
60
|
+
return;
|
|
61
|
+
const range = getEffectiveRange();
|
|
62
|
+
if (range == null || onCellValueChangedRef.current == null)
|
|
63
|
+
return;
|
|
64
|
+
const norm = normalizeSelectionRange(range);
|
|
65
|
+
cutRangeRef.current = norm;
|
|
66
|
+
setCutRange(norm);
|
|
67
|
+
setCopyRange(null);
|
|
68
|
+
handleCopy();
|
|
69
|
+
// handleCopy sets copyRange — override it back since this is a cut
|
|
70
|
+
setCopyRange(null);
|
|
71
|
+
}, [getEffectiveRange, handleCopy, editableRef, onCellValueChangedRef]);
|
|
72
|
+
const handlePaste = useCallback(async () => {
|
|
73
|
+
if (editableRef.current === false)
|
|
74
|
+
return;
|
|
75
|
+
const onCellValueChanged = onCellValueChangedRef.current;
|
|
76
|
+
if (onCellValueChanged == null)
|
|
77
|
+
return;
|
|
78
|
+
let text;
|
|
79
|
+
try {
|
|
80
|
+
text = await navigator.clipboard.readText();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
text = '';
|
|
84
|
+
}
|
|
85
|
+
if (!text.trim() && internalClipboardRef.current != null) {
|
|
86
|
+
text = internalClipboardRef.current;
|
|
87
|
+
}
|
|
88
|
+
if (!text.trim())
|
|
89
|
+
return;
|
|
90
|
+
const norm = getEffectiveRange();
|
|
91
|
+
const anchorRow = norm ? norm.startRow : 0;
|
|
92
|
+
const anchorCol = norm ? norm.startCol : 0;
|
|
93
|
+
const items = itemsRef.current;
|
|
94
|
+
const visibleCols = visibleColsRef.current;
|
|
95
|
+
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
|
96
|
+
beginBatch?.();
|
|
97
|
+
for (let r = 0; r < lines.length; r++) {
|
|
98
|
+
const cells = lines[r].split('\t');
|
|
99
|
+
for (let c = 0; c < cells.length; c++) {
|
|
100
|
+
const targetRow = anchorRow + r;
|
|
101
|
+
const targetCol = anchorCol + c;
|
|
102
|
+
if (targetRow >= items.length || targetCol >= visibleCols.length)
|
|
103
|
+
continue;
|
|
104
|
+
const item = items[targetRow];
|
|
105
|
+
const col = visibleCols[targetCol];
|
|
106
|
+
const colEditable = col.editable === true ||
|
|
107
|
+
(typeof col.editable === 'function' && col.editable(item));
|
|
108
|
+
if (!colEditable)
|
|
109
|
+
continue;
|
|
110
|
+
const rawValue = cells[c] ?? '';
|
|
111
|
+
const oldValue = getCellValue(item, col);
|
|
112
|
+
const result = parseValue(rawValue, oldValue, item, col);
|
|
113
|
+
if (!result.valid)
|
|
114
|
+
continue;
|
|
115
|
+
onCellValueChanged({
|
|
116
|
+
item,
|
|
117
|
+
columnId: col.columnId,
|
|
118
|
+
oldValue,
|
|
119
|
+
newValue: result.value,
|
|
120
|
+
rowIndex: targetRow,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (cutRangeRef.current) {
|
|
125
|
+
const cut = cutRangeRef.current;
|
|
126
|
+
for (let r = cut.startRow; r <= cut.endRow; r++) {
|
|
127
|
+
for (let c = cut.startCol; c <= cut.endCol; c++) {
|
|
128
|
+
if (r >= items.length || c >= visibleCols.length)
|
|
129
|
+
continue;
|
|
130
|
+
const item = items[r];
|
|
131
|
+
const col = visibleCols[c];
|
|
132
|
+
const colEditable = col.editable === true ||
|
|
133
|
+
(typeof col.editable === 'function' && col.editable(item));
|
|
134
|
+
if (!colEditable)
|
|
135
|
+
continue;
|
|
136
|
+
const oldValue = getCellValue(item, col);
|
|
137
|
+
const result = parseValue('', oldValue, item, col);
|
|
138
|
+
if (!result.valid)
|
|
139
|
+
continue;
|
|
140
|
+
onCellValueChanged({
|
|
141
|
+
item,
|
|
142
|
+
columnId: col.columnId,
|
|
143
|
+
oldValue,
|
|
144
|
+
newValue: result.value,
|
|
145
|
+
rowIndex: r,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
cutRangeRef.current = null;
|
|
150
|
+
setCutRange(null);
|
|
151
|
+
}
|
|
152
|
+
endBatch?.();
|
|
153
|
+
setCopyRange(null);
|
|
154
|
+
}, [getEffectiveRange, itemsRef, visibleColsRef, editableRef, onCellValueChangedRef, beginBatch, endBatch]);
|
|
155
|
+
const clearClipboardRanges = useCallback(() => {
|
|
156
|
+
setCopyRange(null);
|
|
157
|
+
setCutRange(null);
|
|
158
|
+
cutRangeRef.current = null;
|
|
159
|
+
}, []);
|
|
160
|
+
return { handleCopy, handleCut, handlePaste, cutRangeRef, cutRange, copyRange, clearClipboardRanges };
|
|
161
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless column chooser state and handlers for Fluent, Material, and Radix.
|
|
3
|
+
* UI packages use this hook and render only trigger + popover (checkboxes, Select All, Clear All).
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useCallback, useEffect } from 'react';
|
|
6
|
+
/**
|
|
7
|
+
* Returns open/setOpen, handleToggle, handleClose (Escape handled in hook),
|
|
8
|
+
* handleCheckboxChange(columnKey)(visible), handleSelectAll, handleClearAll,
|
|
9
|
+
* visibleCount, totalCount. UI renders trigger + popover and wires handlers.
|
|
10
|
+
*/
|
|
11
|
+
export function useColumnChooserState(params) {
|
|
12
|
+
const { columns, visibleColumns, onVisibilityChange } = params;
|
|
13
|
+
const [open, setOpen] = useState(false);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (!open)
|
|
16
|
+
return;
|
|
17
|
+
const handleKeyDown = (event) => {
|
|
18
|
+
if (event.key === 'Escape') {
|
|
19
|
+
event.preventDefault();
|
|
20
|
+
setOpen(false);
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
24
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
25
|
+
}, [open]);
|
|
26
|
+
const handleToggle = useCallback(() => {
|
|
27
|
+
setOpen((prev) => !prev);
|
|
28
|
+
}, []);
|
|
29
|
+
const handleClose = useCallback(() => {
|
|
30
|
+
setOpen(false);
|
|
31
|
+
}, []);
|
|
32
|
+
const handleCheckboxChange = useCallback((columnKey) => (visible) => {
|
|
33
|
+
onVisibilityChange(columnKey, visible);
|
|
34
|
+
}, [onVisibilityChange]);
|
|
35
|
+
const handleSelectAll = useCallback(() => {
|
|
36
|
+
columns.forEach((col) => {
|
|
37
|
+
if (!visibleColumns.has(col.columnId)) {
|
|
38
|
+
onVisibilityChange(col.columnId, true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
}, [columns, visibleColumns, onVisibilityChange]);
|
|
42
|
+
const handleClearAll = useCallback(() => {
|
|
43
|
+
columns.forEach((col) => {
|
|
44
|
+
if (!col.required && visibleColumns.has(col.columnId)) {
|
|
45
|
+
onVisibilityChange(col.columnId, false);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}, [columns, visibleColumns, onVisibilityChange]);
|
|
49
|
+
const visibleCount = visibleColumns.size;
|
|
50
|
+
const totalCount = columns.length;
|
|
51
|
+
return {
|
|
52
|
+
open,
|
|
53
|
+
setOpen,
|
|
54
|
+
handleToggle,
|
|
55
|
+
handleClose,
|
|
56
|
+
handleCheckboxChange,
|
|
57
|
+
handleSelectAll,
|
|
58
|
+
handleClearAll,
|
|
59
|
+
visibleCount,
|
|
60
|
+
totalCount,
|
|
61
|
+
};
|
|
62
|
+
}
|