@alaarab/ogrid-core 1.3.0 → 1.3.2

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.
@@ -1,12 +1,20 @@
1
1
  import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
+ /** DOM attribute name used for drag-range highlighting (bypasses React). */
4
+ const DRAG_ATTR = 'data-drag-range';
3
5
  export function useCellSelection(params) {
4
- const { colOffset, rowCount, visibleColCount, setActiveCell } = params;
6
+ const { colOffset, rowCount, visibleColCount, setActiveCell, wrapperRef } = params;
5
7
  const [selectionRange, setSelectionRange] = useState(null);
6
8
  const isDraggingRef = useRef(false);
7
9
  const [isDragging, setIsDragging] = useState(false);
8
10
  const dragStartRef = useRef(null);
11
+ const rafRef = useRef(0);
12
+ /** Live drag range kept in a ref — only committed to React state on mouseup. */
13
+ const liveDragRangeRef = useRef(null);
9
14
  const handleCellMouseDown = useCallback((e, rowIndex, globalColIndex) => {
15
+ // Only handle primary (left) button — let middle-click scroll and right-click context menu work natively
16
+ if (e.button !== 0)
17
+ return;
10
18
  if (globalColIndex < colOffset)
11
19
  return;
12
20
  // Prevent native text selection during cell drag
@@ -23,12 +31,14 @@ export function useCellSelection(params) {
23
31
  }
24
32
  else {
25
33
  dragStartRef.current = { row: rowIndex, col: dataColIndex };
26
- setSelectionRange({
34
+ const initial = {
27
35
  startRow: rowIndex,
28
36
  startCol: dataColIndex,
29
37
  endRow: rowIndex,
30
38
  endCol: dataColIndex,
31
- });
39
+ };
40
+ setSelectionRange(initial);
41
+ liveDragRangeRef.current = initial;
32
42
  setActiveCell({ rowIndex, columnIndex: globalColIndex });
33
43
  isDraggingRef.current = true;
34
44
  setIsDragging(true);
@@ -45,41 +55,138 @@ export function useCellSelection(params) {
45
55
  });
46
56
  setActiveCell({ rowIndex: 0, columnIndex: colOffset });
47
57
  }, [rowCount, visibleColCount, colOffset, setActiveCell]);
48
- // Window mouse move/up for drag selection
58
+ /** Last known mouse position during drag — used by mouseUp to flush pending RAF work. */
59
+ const lastMousePosRef = useRef(null);
60
+ // Window mouse move/up for drag selection.
61
+ // Performance: during drag, we update a ref + toggle DOM attributes via rAF.
62
+ // React state is only committed on mouseup (single re-render instead of 60-120/s).
49
63
  useEffect(() => {
50
- const onMove = (e) => {
51
- if (!isDraggingRef.current || !dragStartRef.current)
64
+ const colOff = colOffset; // capture for closure
65
+ /** Toggle DRAG_ATTR on cell-content divs to show the range highlight via CSS. */
66
+ const applyDragAttrs = (range) => {
67
+ const wrapper = wrapperRef.current;
68
+ if (!wrapper)
69
+ return;
70
+ const minR = Math.min(range.startRow, range.endRow);
71
+ const maxR = Math.max(range.startRow, range.endRow);
72
+ const minC = Math.min(range.startCol, range.endCol);
73
+ const maxC = Math.max(range.startCol, range.endCol);
74
+ const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
75
+ for (let i = 0; i < cells.length; i++) {
76
+ const el = cells[i];
77
+ const r = parseInt(el.getAttribute('data-row-index'), 10);
78
+ const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
79
+ const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
80
+ if (inRange) {
81
+ if (!el.hasAttribute(DRAG_ATTR))
82
+ el.setAttribute(DRAG_ATTR, '');
83
+ }
84
+ else {
85
+ if (el.hasAttribute(DRAG_ATTR))
86
+ el.removeAttribute(DRAG_ATTR);
87
+ }
88
+ }
89
+ };
90
+ const clearDragAttrs = () => {
91
+ const wrapper = wrapperRef.current;
92
+ if (!wrapper)
52
93
  return;
53
- const target = document.elementFromPoint(e.clientX, e.clientY);
94
+ const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
95
+ for (let i = 0; i < marked.length; i++)
96
+ marked[i].removeAttribute(DRAG_ATTR);
97
+ };
98
+ /** Resolve mouse coordinates to a cell range (shared by RAF callback and mouseUp flush). */
99
+ const resolveRange = (cx, cy) => {
100
+ if (!dragStartRef.current)
101
+ return null;
102
+ const target = document.elementFromPoint(cx, cy);
54
103
  const cell = target?.closest?.('[data-row-index][data-col-index]');
55
104
  if (!cell)
56
- return;
105
+ return null;
57
106
  const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
58
107
  const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
59
- if (Number.isNaN(r) || Number.isNaN(c) || c < colOffset)
60
- return;
61
- const dataCol = c - colOffset;
108
+ if (Number.isNaN(r) || Number.isNaN(c) || c < colOff)
109
+ return null;
110
+ const dataCol = c - colOff;
62
111
  const start = dragStartRef.current;
63
- setSelectionRange(normalizeSelectionRange({
112
+ return normalizeSelectionRange({
64
113
  startRow: start.row,
65
114
  startCol: start.col,
66
115
  endRow: r,
67
116
  endCol: dataCol,
68
- }));
69
- setActiveCell({ rowIndex: r, columnIndex: c });
117
+ });
118
+ };
119
+ const onMove = (e) => {
120
+ if (!isDraggingRef.current || !dragStartRef.current)
121
+ return;
122
+ // Always store latest position so mouseUp can flush if RAF hasn't executed
123
+ lastMousePosRef.current = { cx: e.clientX, cy: e.clientY };
124
+ // Cancel previous pending frame
125
+ if (rafRef.current)
126
+ cancelAnimationFrame(rafRef.current);
127
+ rafRef.current = requestAnimationFrame(() => {
128
+ rafRef.current = 0;
129
+ const pos = lastMousePosRef.current;
130
+ if (!pos)
131
+ return;
132
+ const newRange = resolveRange(pos.cx, pos.cy);
133
+ if (!newRange)
134
+ return;
135
+ // Skip if range unchanged
136
+ const prev = liveDragRangeRef.current;
137
+ if (prev &&
138
+ prev.startRow === newRange.startRow &&
139
+ prev.startCol === newRange.startCol &&
140
+ prev.endRow === newRange.endRow &&
141
+ prev.endCol === newRange.endCol) {
142
+ return;
143
+ }
144
+ liveDragRangeRef.current = newRange;
145
+ // DOM-only highlighting — no React state update until mouseup
146
+ applyDragAttrs(newRange);
147
+ });
70
148
  };
71
149
  const onUp = () => {
150
+ if (!isDraggingRef.current)
151
+ return;
152
+ if (rafRef.current) {
153
+ cancelAnimationFrame(rafRef.current);
154
+ rafRef.current = 0;
155
+ }
72
156
  isDraggingRef.current = false;
73
- setIsDragging(false);
157
+ // Flush: if the last RAF hasn't executed yet, resolve the range now from the
158
+ // last known mouse position so the final committed range is always accurate.
159
+ const pos = lastMousePosRef.current;
160
+ if (pos) {
161
+ const flushed = resolveRange(pos.cx, pos.cy);
162
+ if (flushed)
163
+ liveDragRangeRef.current = flushed;
164
+ }
165
+ // Commit final range to React state (triggers a single re-render)
166
+ const finalRange = liveDragRangeRef.current;
167
+ if (finalRange) {
168
+ setSelectionRange(finalRange);
169
+ setActiveCell({
170
+ rowIndex: finalRange.endRow,
171
+ columnIndex: finalRange.endCol + colOff,
172
+ });
173
+ }
174
+ // Clean up DOM attributes — React will apply CSS-module classes on the same paint
175
+ clearDragAttrs();
176
+ liveDragRangeRef.current = null;
177
+ lastMousePosRef.current = null;
74
178
  dragStartRef.current = null;
179
+ setIsDragging(false);
75
180
  };
76
181
  window.addEventListener('mousemove', onMove, true);
77
182
  window.addEventListener('mouseup', onUp, true);
78
183
  return () => {
79
184
  window.removeEventListener('mousemove', onMove, true);
80
185
  window.removeEventListener('mouseup', onUp, true);
186
+ if (rafRef.current)
187
+ cancelAnimationFrame(rafRef.current);
81
188
  };
82
- }, [colOffset, setActiveCell]);
189
+ }, [colOffset, setActiveCell, wrapperRef]);
83
190
  return {
84
191
  selectionRange,
85
192
  setSelectionRange,
@@ -70,6 +70,7 @@ export function useDataGridState(params) {
70
70
  rowCount: items.length,
71
71
  visibleColCount: visibleCols.length,
72
72
  setActiveCell,
73
+ wrapperRef,
73
74
  });
74
75
  const { contextMenu, setContextMenu, handleCellContextMenu, closeContextMenu } = useContextMenu();
75
76
  const { handleCopy, handleCut, handlePaste, cutRange, copyRange, clearClipboardRanges } = useClipboard({
@@ -2,35 +2,109 @@ import { useState, useCallback, useRef, useEffect } from 'react';
2
2
  import { normalizeSelectionRange } from '../types';
3
3
  import { getCellValue } from '../utils';
4
4
  import { parseValue } from '../utils/valueParsers';
5
+ /** DOM attribute name for fill-drag range highlighting (same as cell selection drag). */
6
+ const DRAG_ATTR = 'data-drag-range';
5
7
  export function useFillHandle(params) {
6
8
  const { items, visibleCols, editable, onCellValueChanged, selectionRange, setSelectionRange, setActiveCell, colOffset, wrapperRef, beginBatch, endBatch, } = params;
7
9
  const [fillDrag, setFillDrag] = useState(null);
8
10
  const fillDragEndRef = useRef({ endRow: 0, endCol: 0 });
11
+ const rafRef = useRef(0);
12
+ const liveFillRangeRef = useRef(null);
9
13
  useEffect(() => {
10
14
  if (!fillDrag || editable === false || !onCellValueChanged || !wrapperRef.current)
11
15
  return;
12
16
  fillDragEndRef.current = { endRow: fillDrag.startRow, endCol: fillDrag.startCol };
13
- const onMove = (e) => {
14
- const target = document.elementFromPoint(e.clientX, e.clientY);
17
+ liveFillRangeRef.current = null;
18
+ const colOff = colOffset;
19
+ const applyDragAttrs = (range) => {
20
+ const wrapper = wrapperRef.current;
21
+ if (!wrapper)
22
+ return;
23
+ const minR = Math.min(range.startRow, range.endRow);
24
+ const maxR = Math.max(range.startRow, range.endRow);
25
+ const minC = Math.min(range.startCol, range.endCol);
26
+ const maxC = Math.max(range.startCol, range.endCol);
27
+ const cells = wrapper.querySelectorAll('[data-row-index][data-col-index]');
28
+ for (let i = 0; i < cells.length; i++) {
29
+ const el = cells[i];
30
+ const r = parseInt(el.getAttribute('data-row-index'), 10);
31
+ const c = parseInt(el.getAttribute('data-col-index'), 10) - colOff;
32
+ const inRange = r >= minR && r <= maxR && c >= minC && c <= maxC;
33
+ if (inRange) {
34
+ if (!el.hasAttribute(DRAG_ATTR))
35
+ el.setAttribute(DRAG_ATTR, '');
36
+ }
37
+ else {
38
+ if (el.hasAttribute(DRAG_ATTR))
39
+ el.removeAttribute(DRAG_ATTR);
40
+ }
41
+ }
42
+ };
43
+ const clearDragAttrs = () => {
44
+ const wrapper = wrapperRef.current;
45
+ if (!wrapper)
46
+ return;
47
+ const marked = wrapper.querySelectorAll(`[${DRAG_ATTR}]`);
48
+ for (let i = 0; i < marked.length; i++)
49
+ marked[i].removeAttribute(DRAG_ATTR);
50
+ };
51
+ let lastFillMousePos = null;
52
+ const resolveRange = (cx, cy) => {
53
+ const target = document.elementFromPoint(cx, cy);
15
54
  const cell = target?.closest?.('[data-row-index][data-col-index]');
16
55
  if (!cell || !wrapperRef.current?.contains(cell))
17
- return;
56
+ return null;
18
57
  const r = parseInt(cell.getAttribute('data-row-index') ?? '', 10);
19
58
  const c = parseInt(cell.getAttribute('data-col-index') ?? '', 10);
20
- if (Number.isNaN(r) || Number.isNaN(c) || c < colOffset)
21
- return;
22
- const dataCol = c - colOffset;
23
- fillDragEndRef.current = { endRow: r, endCol: dataCol };
24
- const norm = normalizeSelectionRange({
59
+ if (Number.isNaN(r) || Number.isNaN(c) || c < colOff)
60
+ return null;
61
+ const dataCol = c - colOff;
62
+ return normalizeSelectionRange({
25
63
  startRow: fillDrag.startRow,
26
64
  startCol: fillDrag.startCol,
27
65
  endRow: r,
28
66
  endCol: dataCol,
29
67
  });
30
- setSelectionRange(norm);
31
- setActiveCell({ rowIndex: r, columnIndex: c });
68
+ };
69
+ const onMove = (e) => {
70
+ lastFillMousePos = { cx: e.clientX, cy: e.clientY };
71
+ if (rafRef.current)
72
+ cancelAnimationFrame(rafRef.current);
73
+ rafRef.current = requestAnimationFrame(() => {
74
+ rafRef.current = 0;
75
+ if (!lastFillMousePos)
76
+ return;
77
+ const newRange = resolveRange(lastFillMousePos.cx, lastFillMousePos.cy);
78
+ if (!newRange)
79
+ return;
80
+ // Skip if unchanged
81
+ const prev = liveFillRangeRef.current;
82
+ if (prev &&
83
+ prev.startRow === newRange.startRow &&
84
+ prev.startCol === newRange.startCol &&
85
+ prev.endRow === newRange.endRow &&
86
+ prev.endCol === newRange.endCol) {
87
+ return;
88
+ }
89
+ liveFillRangeRef.current = newRange;
90
+ fillDragEndRef.current = { endRow: newRange.endRow, endCol: newRange.endCol };
91
+ applyDragAttrs(newRange);
92
+ });
32
93
  };
33
94
  const onUp = () => {
95
+ if (rafRef.current) {
96
+ cancelAnimationFrame(rafRef.current);
97
+ rafRef.current = 0;
98
+ }
99
+ // Flush: resolve final position if RAF hasn't executed yet
100
+ if (lastFillMousePos) {
101
+ const flushed = resolveRange(lastFillMousePos.cx, lastFillMousePos.cy);
102
+ if (flushed) {
103
+ liveFillRangeRef.current = flushed;
104
+ fillDragEndRef.current = { endRow: flushed.endRow, endCol: flushed.endCol };
105
+ }
106
+ }
107
+ clearDragAttrs();
34
108
  const end = fillDragEndRef.current;
35
109
  const norm = normalizeSelectionRange({
36
110
  startRow: fillDrag.startRow,
@@ -38,6 +112,10 @@ export function useFillHandle(params) {
38
112
  endRow: end.endRow,
39
113
  endCol: end.endCol,
40
114
  });
115
+ // Commit range to React state
116
+ setSelectionRange(norm);
117
+ setActiveCell({ rowIndex: end.endRow, columnIndex: end.endCol + colOff });
118
+ // Apply fill values
41
119
  const startItem = items[norm.startRow];
42
120
  const startColDef = visibleCols[norm.startCol];
43
121
  if (startItem && startColDef) {
@@ -72,12 +150,15 @@ export function useFillHandle(params) {
72
150
  endBatch?.();
73
151
  }
74
152
  setFillDrag(null);
153
+ liveFillRangeRef.current = null;
75
154
  };
76
155
  window.addEventListener('mousemove', onMove, true);
77
156
  window.addEventListener('mouseup', onUp, true);
78
157
  return () => {
79
158
  window.removeEventListener('mousemove', onMove, true);
80
159
  window.removeEventListener('mouseup', onUp, true);
160
+ if (rafRef.current)
161
+ cancelAnimationFrame(rafRef.current);
81
162
  };
82
163
  }, [
83
164
  fillDrag,
@@ -5,7 +5,7 @@ import { toDataGridFilterProps } from '../types';
5
5
  import { useFilterOptions } from './useFilterOptions';
6
6
  const DEFAULT_PAGE_SIZE = 20;
7
7
  export function useOGrid(props, ref) {
8
- const { columns: columnsProp, getRowId, data, dataSource, page: controlledPage, pageSize: controlledPageSize, sort: controlledSort, filters: controlledFilters, visibleColumns: controlledVisibleColumns, isLoading: controlledLoading, onPageChange, onPageSizeChange, onSortChange, onFiltersChange, onVisibleColumnsChange, columnOrder, onColumnOrderChange, freezeRows, freezeCols, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, emptyState, entityLabelPlural = 'items', className, title, layoutMode = 'content', editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo, rowSelection = 'none', selectedRows, onSelectionChange, statusBar, onError, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
8
+ const { columns: columnsProp, getRowId, data, dataSource, page: controlledPage, pageSize: controlledPageSize, sort: controlledSort, filters: controlledFilters, visibleColumns: controlledVisibleColumns, isLoading: controlledLoading, onPageChange, onPageSizeChange, onSortChange, onFiltersChange, onVisibleColumnsChange, columnOrder, onColumnOrderChange, freezeRows, freezeCols, defaultPageSize = DEFAULT_PAGE_SIZE, defaultSortBy, defaultSortDirection = 'asc', toolbar, emptyState, entityLabelPlural = 'items', className, title, layoutMode = 'fill', editable, cellSelection, onCellValueChanged, onUndo, onRedo, canUndo, canRedo, rowSelection = 'none', selectedRows, onSelectionChange, statusBar, onError, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, } = props;
9
9
  const columns = useMemo(() => flattenColumns(columnsProp), [columnsProp]);
10
10
  const isServerSide = dataSource != null;
11
11
  const isClientSide = !isServerSide;
@@ -4,6 +4,7 @@ export interface UseCellSelectionParams {
4
4
  rowCount: number;
5
5
  visibleColCount: number;
6
6
  setActiveCell: (cell: IActiveCell | null) => void;
7
+ wrapperRef: React.RefObject<HTMLElement | null>;
7
8
  }
8
9
  export interface UseCellSelectionResult {
9
10
  selectionRange: ISelectionRange | null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alaarab/ogrid-core",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "OGrid core – framework-agnostic types, hooks, and utilities for OGrid data tables.",
5
5
  "main": "dist/esm/index.js",
6
6
  "module": "dist/esm/index.js",