@opencorestack/opengridx 3.4.0 → 3.5.1
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/CHANGELOG.md +45 -0
- package/README.md +10 -3
- package/dist/ai.cjs +2 -1
- package/dist/ai.cjs.map +1 -1
- package/dist/ai.d.ts +203 -3
- package/dist/ai.es.js +2 -1
- package/dist/ai.es.js.map +1 -1
- package/dist/index.d.ts +248 -4
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +7334 -5906
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -16
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +88 -7
- package/docs/README.md +3 -1
- package/docs/architecture/datagrid-orchestration.md +11 -5
- package/docs/components/filter-panel.md +1 -0
- package/docs/components/header.md +4 -1
- package/docs/components/toolbar.md +7 -0
- package/docs/customization/theming.md +3 -1
- package/docs/features/ai-toolkit.md +99 -5
- package/docs/features/cell-selection.md +8 -2
- package/docs/features/fill-handle.md +125 -0
- package/docs/features/filtering.md +3 -0
- package/docs/features/header-filters.md +138 -0
- package/docs/features/keyboard-navigation.md +20 -2
- package/docs/features/virtualization.md +5 -5
- package/docs/performance.md +212 -1
- package/docs/roadmap.md +3 -4
- package/lib/ai/agentTools.ts +160 -0
- package/lib/ai/index.ts +19 -2
- package/lib/ai/promptHandler.ts +109 -0
- package/lib/ai/types.ts +89 -25
- package/lib/ai/validate.ts +10 -0
- package/lib/components/AiAssistant/GridAiAssistantArea.tsx +26 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.css +171 -0
- package/lib/components/AiAssistant/GridAiAssistantPanel.tsx +145 -0
- package/lib/components/AiAssistant/useSpeechInput.ts +74 -0
- package/lib/components/Cell/Cell.css +28 -0
- package/lib/components/Cell/Cell.tsx +6 -2
- package/lib/components/DataGrid/DataGrid.tsx +84 -4
- package/lib/components/DataGrid/GridLiveRegion.tsx +5 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +5 -1
- package/lib/components/DataGrid/GridVirtualRows.tsx +5 -1
- package/lib/components/FilterPanel/FilterPanel.tsx +2 -9
- package/lib/components/Header/Header.tsx +44 -23
- package/lib/components/Header/headerColumnStyle.ts +28 -0
- package/lib/components/HeaderFilter/HeaderFilter.css +160 -0
- package/lib/components/HeaderFilter/HeaderFilterCell.tsx +370 -0
- package/lib/components/HeaderFilter/HeaderFilterOperatorMenu.tsx +124 -0
- package/lib/components/HeaderFilter/HeaderFilterRow.tsx +157 -0
- package/lib/components/Toolbar/GridToolbar.tsx +51 -0
- package/lib/hooks/core/gridApiPlaceholder.ts +8 -0
- package/lib/hooks/core/gridToolbarHostContext.ts +5 -0
- package/lib/hooks/core/useGridApiMethods.ts +39 -0
- package/lib/hooks/core/useGridAriaRows.ts +5 -3
- package/lib/hooks/core/useGridCellSelection.ts +77 -11
- package/lib/hooks/core/useGridColumnsPanel.ts +24 -1
- package/lib/hooks/core/useGridControlledState.ts +27 -0
- package/lib/hooks/core/useGridDevWarnings.ts +21 -6
- package/lib/hooks/core/useGridFillHandle.ts +323 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +29 -10
- package/lib/hooks/core/useGridMaxContentHeight.ts +16 -0
- package/lib/hooks/core/useGridToolbarProps.ts +19 -0
- package/lib/hooks/core/useGridVirtualization.ts +19 -0
- package/lib/hooks/features/useGridAiAssistant.ts +284 -0
- package/lib/hooks/features/useGridBatchEdit.ts +2 -2
- package/lib/index.ts +3 -0
- package/lib/styles/opengridx.css +2 -0
- package/lib/theme/DataGridThemeProvider.tsx +2 -0
- package/lib/theme/types.ts +2 -0
- package/lib/types/index.ts +219 -1
- package/lib/utils/aiAssistant.ts +205 -0
- package/lib/utils/aiBrand.ts +17 -0
- package/lib/utils/cellSelection.ts +25 -5
- package/lib/utils/editing/fill.ts +238 -0
- package/lib/utils/filtering/index.ts +14 -0
- package/lib/utils/focus/index.ts +9 -3
- package/lib/utils/headerFilters.ts +189 -0
- package/lib/utils/maxElementHeight.ts +53 -0
- package/llms.txt +44 -4
- package/package.json +3 -2
|
@@ -121,6 +121,8 @@ export interface UseGridCellSelectionReturn {
|
|
|
121
121
|
* address the renderable rows and the range columns of `navigationColumns` (`isRangeColumnField`).
|
|
122
122
|
*/
|
|
123
123
|
getCurrentRange: () => { range: GridCellSelectionModel[number] | null; resolved: ResolvedCellRange | null };
|
|
124
|
+
/** Starts a tracked pointer drag with the range drag's auto-scroll (fill handle). Stable. @since v3.5 */
|
|
125
|
+
startTrackedDrag: (options: GridTrackedDragOptions) => void;
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
interface DragState {
|
|
@@ -130,7 +132,24 @@ interface DragState {
|
|
|
130
132
|
y: number;
|
|
131
133
|
frame: number;
|
|
132
134
|
moved: boolean;
|
|
133
|
-
|
|
135
|
+
/** Ends the drag; `cancelled` unless the button was released normally (tracked drags: Escape cancels). */
|
|
136
|
+
stop: (cancelled?: boolean, altKey?: boolean) => void;
|
|
137
|
+
/** A tracked drag (fill handle): told the cell under the pointer each frame instead of moving the range. */
|
|
138
|
+
onTrack?: (head: GridCellCoordinates, x: number, y: number) => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A drag that reuses the range drag's hit testing and auto-scroll without selecting (the fill handle, v3.5). */
|
|
142
|
+
export interface GridTrackedDragOptions {
|
|
143
|
+
/** The cell the drag starts on. */
|
|
144
|
+
start: GridCellCoordinates;
|
|
145
|
+
x: number;
|
|
146
|
+
y: number;
|
|
147
|
+
/** Pointer events of this pointer are followed (touch included), not mouse events. */
|
|
148
|
+
pointerId: number;
|
|
149
|
+
/** Each frame: the cell under the pointer (or nearest it) and the pointer position. */
|
|
150
|
+
onTrack: (head: GridCellCoordinates, x: number, y: number) => void;
|
|
151
|
+
/** Once: released (`cancelled: false`, with the Alt key state) or cancelled. */
|
|
152
|
+
onEnd: (details: { cancelled: boolean; altKey: boolean }) => void;
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
/** The body cell of `viewport` holding `target`: its row index and field. Not cells of nested grids or detail panels. */
|
|
@@ -451,8 +470,10 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
451
470
|
latestRef.current.containerRef.current?.classList.add(RANGE_DRAGGING_CLASS);
|
|
452
471
|
clearTextSelection(viewport.ownerDocument);
|
|
453
472
|
}
|
|
454
|
-
setRange(drag.anchor, head, 'pointer');
|
|
473
|
+
if (!drag.onTrack) setRange(drag.anchor, head, 'pointer');
|
|
455
474
|
}
|
|
475
|
+
// A tracked drag hears every frame: its target can change while the head stays in one cell.
|
|
476
|
+
if (drag.onTrack) drag.onTrack(drag.head, drag.x, drag.y);
|
|
456
477
|
|
|
457
478
|
if (!allowScroll) return;
|
|
458
479
|
const outside = drag.y < bodyTop || drag.y > bodyBottom || drag.x < box.left || drag.x > box.left + width;
|
|
@@ -475,11 +496,22 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
475
496
|
runDragFrameRef.current = runDragFrame;
|
|
476
497
|
}, [runDragFrame]);
|
|
477
498
|
|
|
478
|
-
const startDrag = useCallback((
|
|
499
|
+
const startDrag = useCallback((
|
|
500
|
+
anchor: GridCellCoordinates,
|
|
501
|
+
head: GridCellCoordinates,
|
|
502
|
+
x: number,
|
|
503
|
+
y: number,
|
|
504
|
+
doc: Document,
|
|
505
|
+
tracked?: Pick<GridTrackedDragOptions, 'pointerId' | 'onTrack' | 'onEnd'>,
|
|
506
|
+
) => {
|
|
479
507
|
dragRef.current?.stop();
|
|
508
|
+
// A range drag follows the mouse; a tracked drag (fill handle) follows its pointer, touch included.
|
|
509
|
+
const moveType = tracked ? 'pointermove' : 'mousemove';
|
|
510
|
+
const upType = tracked ? 'pointerup' : 'mouseup';
|
|
511
|
+
const isOwnEvent = (event: MouseEvent) => !tracked || (event as PointerEvent).pointerId === tracked.pointerId;
|
|
480
512
|
const onMove = (event: MouseEvent) => {
|
|
481
513
|
const drag = dragRef.current;
|
|
482
|
-
if (!drag) return;
|
|
514
|
+
if (!drag || !isOwnEvent(event)) return;
|
|
483
515
|
if (event.buttons === 0) {
|
|
484
516
|
// Released outside the window.
|
|
485
517
|
drag.stop();
|
|
@@ -497,12 +529,12 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
497
529
|
};
|
|
498
530
|
const onUp = (event: MouseEvent) => {
|
|
499
531
|
const drag = dragRef.current;
|
|
500
|
-
if (!drag) return;
|
|
532
|
+
if (!drag || !isOwnEvent(event)) return;
|
|
501
533
|
drag.x = event.clientX;
|
|
502
534
|
drag.y = event.clientY;
|
|
503
535
|
runDragFrame(false);
|
|
504
536
|
const moved = drag.moved;
|
|
505
|
-
drag.stop();
|
|
537
|
+
drag.stop(false, event.altKey);
|
|
506
538
|
if (moved) clearTextSelection(doc);
|
|
507
539
|
if (moved) {
|
|
508
540
|
// The click that follows lands on the common ancestor of both cells (often the row):
|
|
@@ -511,26 +543,57 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
511
543
|
setTimeout(() => { suppressClickRef.current = false; }, 0);
|
|
512
544
|
}
|
|
513
545
|
};
|
|
546
|
+
const onCancel = (event: Event) => {
|
|
547
|
+
if (isOwnEvent(event as MouseEvent)) dragRef.current?.stop();
|
|
548
|
+
};
|
|
549
|
+
// Escape abandons a tracked drag (the fill writes nothing).
|
|
550
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
551
|
+
if (event.key !== 'Escape' || !dragRef.current) return;
|
|
552
|
+
event.preventDefault();
|
|
553
|
+
event.stopPropagation();
|
|
554
|
+
dragRef.current.stop();
|
|
555
|
+
};
|
|
514
556
|
const view = doc.defaultView;
|
|
515
557
|
const onBlur = () => dragRef.current?.stop();
|
|
558
|
+
let ended = false;
|
|
516
559
|
const drag: DragState = {
|
|
517
560
|
anchor, head, x, y, frame: 0, moved: false,
|
|
518
|
-
|
|
561
|
+
onTrack: tracked?.onTrack,
|
|
562
|
+
stop: (cancelled = true, altKey = false) => {
|
|
519
563
|
if (drag.frame) cancelAnimationFrame(drag.frame);
|
|
520
564
|
drag.frame = 0;
|
|
521
|
-
doc.removeEventListener(
|
|
522
|
-
doc.removeEventListener(
|
|
565
|
+
doc.removeEventListener(moveType, onMove, true);
|
|
566
|
+
doc.removeEventListener(upType, onUp, true);
|
|
567
|
+
if (tracked) {
|
|
568
|
+
doc.removeEventListener('pointercancel', onCancel, true);
|
|
569
|
+
doc.removeEventListener('keydown', onKeyDown, true);
|
|
570
|
+
}
|
|
523
571
|
view?.removeEventListener('blur', onBlur);
|
|
524
572
|
latestRef.current.containerRef.current?.classList.remove(RANGE_DRAGGING_CLASS);
|
|
525
573
|
if (dragRef.current === drag) dragRef.current = null;
|
|
574
|
+
if (!ended) {
|
|
575
|
+
ended = true;
|
|
576
|
+
if (tracked) tracked.onEnd({ cancelled, altKey });
|
|
577
|
+
}
|
|
526
578
|
},
|
|
527
579
|
};
|
|
528
580
|
dragRef.current = drag;
|
|
529
|
-
doc.addEventListener(
|
|
530
|
-
doc.addEventListener(
|
|
581
|
+
doc.addEventListener(moveType, onMove, true);
|
|
582
|
+
doc.addEventListener(upType, onUp, true);
|
|
583
|
+
if (tracked) {
|
|
584
|
+
doc.addEventListener('pointercancel', onCancel, true);
|
|
585
|
+
doc.addEventListener('keydown', onKeyDown, true);
|
|
586
|
+
}
|
|
531
587
|
view?.addEventListener('blur', onBlur);
|
|
532
588
|
}, [runDragFrame]);
|
|
533
589
|
|
|
590
|
+
const startTrackedDrag = useCallback((options: GridTrackedDragOptions) => {
|
|
591
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
592
|
+
if (!viewport || !latestRef.current.enabled) return;
|
|
593
|
+
const { start, x, y, pointerId, onTrack, onEnd } = options;
|
|
594
|
+
startDrag(start, start, x, y, viewport.ownerDocument, { pointerId, onTrack, onEnd });
|
|
595
|
+
}, [startDrag]);
|
|
596
|
+
|
|
534
597
|
const handleMouseDown = useCallback((event: React.MouseEvent<HTMLElement>) => {
|
|
535
598
|
if (event.button !== 0 || event.defaultPrevented) return;
|
|
536
599
|
const { context: ctx } = latestRef.current;
|
|
@@ -538,6 +601,8 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
538
601
|
const viewport = event.currentTarget;
|
|
539
602
|
const hit = cellFromTarget(event.target, viewport);
|
|
540
603
|
if (!hit || !isRangeColumnField(hit.field)) return;
|
|
604
|
+
// The fill handle starts its own drag (useGridFillHandle).
|
|
605
|
+
if ((event.target as Element).closest?.('.ogx__cell-fill-handle')) return;
|
|
541
606
|
// Typing targets and open editors keep the pointer to themselves.
|
|
542
607
|
if (hit.element.classList.contains('ogx__cell--editing') || isTextEntryElement(event.target as Element)) return;
|
|
543
608
|
const id = ctx.rowIds[hit.rowIndex];
|
|
@@ -626,6 +691,7 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
626
691
|
reportFocus,
|
|
627
692
|
setModel,
|
|
628
693
|
getCurrentRange: currentRange,
|
|
694
|
+
startTrackedDrag,
|
|
629
695
|
};
|
|
630
696
|
}
|
|
631
697
|
|
|
@@ -16,26 +16,46 @@ export interface UseGridColumnsPanelResult {
|
|
|
16
16
|
standalonePanelRef: RefObject<HTMLDivElement | null>;
|
|
17
17
|
/** The column menu's **Manage columns** action. */
|
|
18
18
|
openColumnsPanel: () => void;
|
|
19
|
+
/** Passed to the toolbar as `forceFiltersOpen`. */
|
|
20
|
+
toolbarFiltersRequested: boolean;
|
|
21
|
+
/** Passed to the toolbar as `onFiltersPanelClose`. */
|
|
22
|
+
closeToolbarFiltersPanel: () => void;
|
|
23
|
+
/**
|
|
24
|
+
* Opens the toolbar's filter panel (a header filter cell's "Custom filter"); undefined while no
|
|
25
|
+
* `GridToolbar` that can show it is rendered.
|
|
26
|
+
*/
|
|
27
|
+
openFiltersPanel: (() => void) | undefined;
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/**
|
|
22
31
|
* Opens the Columns panel for the column menu: in the toolbar when a `GridToolbar` has registered
|
|
23
32
|
* through `GridToolbarHostContext`, otherwise as the standalone panel, which closes on Escape or a
|
|
24
|
-
* mousedown outside it.
|
|
33
|
+
* mousedown outside it. Also opens the toolbar's filter panel for the header filter row, when a
|
|
34
|
+
* `GridToolbar` has registered that it can show it.
|
|
25
35
|
*/
|
|
26
36
|
export function useGridColumnsPanel(): UseGridColumnsPanelResult {
|
|
27
37
|
const [toolbarPanelRequested, setToolbarPanelRequested] = useState(false);
|
|
28
38
|
const [standalonePanelOpen, setStandalonePanelOpen] = useState(false);
|
|
29
39
|
const standalonePanelRef = useRef<HTMLDivElement>(null);
|
|
30
40
|
const toolbarHostCountRef = useRef(0);
|
|
41
|
+
// State, not a ref: whether "Custom filter" can open the panel decides how it renders.
|
|
42
|
+
const [filtersHostCount, setFiltersHostCount] = useState(0);
|
|
43
|
+
const [toolbarFiltersRequested, setToolbarFiltersRequested] = useState(false);
|
|
31
44
|
|
|
32
45
|
const toolbarHost = useMemo<GridToolbarHost>(() => ({
|
|
33
46
|
registerColumnsPanel: () => {
|
|
34
47
|
toolbarHostCountRef.current += 1;
|
|
35
48
|
return () => { toolbarHostCountRef.current -= 1; };
|
|
36
49
|
},
|
|
50
|
+
registerFiltersPanel: () => {
|
|
51
|
+
setFiltersHostCount(count => count + 1);
|
|
52
|
+
return () => setFiltersHostCount(count => count - 1);
|
|
53
|
+
},
|
|
37
54
|
}), []);
|
|
38
55
|
|
|
56
|
+
const requestFiltersPanel = useCallback(() => setToolbarFiltersRequested(true), []);
|
|
57
|
+
const closeToolbarFiltersPanel = useCallback(() => setToolbarFiltersRequested(false), []);
|
|
58
|
+
|
|
39
59
|
const openColumnsPanel = useCallback(() => {
|
|
40
60
|
if (toolbarHostCountRef.current > 0) {
|
|
41
61
|
setToolbarPanelRequested(true);
|
|
@@ -73,5 +93,8 @@ export function useGridColumnsPanel(): UseGridColumnsPanelResult {
|
|
|
73
93
|
closeStandalonePanel,
|
|
74
94
|
standalonePanelRef,
|
|
75
95
|
openColumnsPanel,
|
|
96
|
+
toolbarFiltersRequested,
|
|
97
|
+
closeToolbarFiltersPanel,
|
|
98
|
+
openFiltersPanel: filtersHostCount > 0 ? requestFiltersPanel : undefined,
|
|
76
99
|
};
|
|
77
100
|
}
|
|
@@ -7,11 +7,13 @@ import type {
|
|
|
7
7
|
GridPaginationModel,
|
|
8
8
|
GridAggregationModel,
|
|
9
9
|
GridPivotModel,
|
|
10
|
+
GridRowGroupingModel,
|
|
10
11
|
GridCellSelectionModel,
|
|
11
12
|
GridCellSelectionChangeDetails,
|
|
12
13
|
GridCellSelectionReason,
|
|
13
14
|
} from '../../types';
|
|
14
15
|
import type { GridInitialState, GridDensityState } from '../../state/types';
|
|
16
|
+
import { EMPTY_ROW_GROUPING_MODEL } from '../../utils/gridModes';
|
|
15
17
|
|
|
16
18
|
export type GridDensity = GridDensityState['density'];
|
|
17
19
|
|
|
@@ -27,6 +29,9 @@ export interface UseGridControlledStateParams {
|
|
|
27
29
|
aggregationModel?: GridAggregationModel;
|
|
28
30
|
onAggregationModelChange?: (model: GridAggregationModel) => void;
|
|
29
31
|
|
|
32
|
+
rowGroupingModel?: GridRowGroupingModel;
|
|
33
|
+
onRowGroupingModelChange?: (model: GridRowGroupingModel) => void;
|
|
34
|
+
|
|
30
35
|
columnVisibilityModel?: Record<string, boolean>;
|
|
31
36
|
onColumnVisibilityModelChange?: (model: Record<string, boolean>) => void;
|
|
32
37
|
|
|
@@ -68,6 +73,11 @@ export interface UseGridControlledStateReturn {
|
|
|
68
73
|
aggregationModel: GridAggregationModel;
|
|
69
74
|
handleAggregationModelChange: (model: GridAggregationModel) => void;
|
|
70
75
|
|
|
76
|
+
// row grouping (v3.5: a controlled/uncontrolled pair)
|
|
77
|
+
rowGroupingModel: GridRowGroupingModel;
|
|
78
|
+
/** Sets the internal model when uncontrolled and fires onRowGroupingModelChange. */
|
|
79
|
+
handleRowGroupingModelChange: (model: GridRowGroupingModel) => void;
|
|
80
|
+
|
|
71
81
|
// column visibility
|
|
72
82
|
columnVisibilityModel: Record<string, boolean>;
|
|
73
83
|
handleColumnVisibilityModelChange: (model: Record<string, boolean>) => void;
|
|
@@ -131,6 +141,8 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
131
141
|
onFilterModelChange,
|
|
132
142
|
aggregationModel: propAggregationModel,
|
|
133
143
|
onAggregationModelChange,
|
|
144
|
+
rowGroupingModel: propRowGroupingModel,
|
|
145
|
+
onRowGroupingModelChange,
|
|
134
146
|
columnVisibilityModel: propColumnVisibilityModel,
|
|
135
147
|
onColumnVisibilityModelChange,
|
|
136
148
|
pinnedColumns: propPinnedColumns,
|
|
@@ -190,6 +202,18 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
190
202
|
onAggregationModelChange?.(model);
|
|
191
203
|
}, [isAggregationControlled, onAggregationModelChange]);
|
|
192
204
|
|
|
205
|
+
// ── Row grouping ─────────────────────────────────────────────────────────
|
|
206
|
+
// A passed rowGroupingModel controls grouping and is used as is (the hierarchy hooks memoise on
|
|
207
|
+
// it); without one the grid keeps its own model, changed through apiRef and the AI assistant.
|
|
208
|
+
const isRowGroupingControlled = propRowGroupingModel !== undefined;
|
|
209
|
+
const [internalRowGroupingModel, setInternalRowGroupingModel] = useState<GridRowGroupingModel>(EMPTY_ROW_GROUPING_MODEL);
|
|
210
|
+
const rowGroupingModel = (isRowGroupingControlled ? propRowGroupingModel : internalRowGroupingModel) || EMPTY_ROW_GROUPING_MODEL;
|
|
211
|
+
|
|
212
|
+
const handleRowGroupingModelChange = useCallback((model: GridRowGroupingModel) => {
|
|
213
|
+
if (!isRowGroupingControlled) setInternalRowGroupingModel(model);
|
|
214
|
+
onRowGroupingModelChange?.(model);
|
|
215
|
+
}, [isRowGroupingControlled, onRowGroupingModelChange]);
|
|
216
|
+
|
|
193
217
|
// ── Column visibility ────────────────────────────────────────────────────
|
|
194
218
|
const isColumnVisibilityControlled = propColumnVisibilityModel !== undefined;
|
|
195
219
|
const [internalColumnVisibilityModel, setInternalColumnVisibilityModel] = useState<Record<string, boolean>>(
|
|
@@ -295,6 +319,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
295
319
|
aggregationModel,
|
|
296
320
|
handleAggregationModelChange,
|
|
297
321
|
|
|
322
|
+
rowGroupingModel,
|
|
323
|
+
handleRowGroupingModelChange,
|
|
324
|
+
|
|
298
325
|
columnVisibilityModel,
|
|
299
326
|
handleColumnVisibilityModelChange,
|
|
300
327
|
|
|
@@ -10,8 +10,16 @@ export interface UseGridDevWarningsParams {
|
|
|
10
10
|
totalRowCount: number;
|
|
11
11
|
/** `pinnedRows` is set while tree data or row grouping is active, where it has no effect. */
|
|
12
12
|
pinnedRowsIgnored?: boolean;
|
|
13
|
-
/** Pixel height of the scrollable content. */
|
|
13
|
+
/** Pixel height of the scrollable content, uncapped. */
|
|
14
14
|
scrollHeight?: number;
|
|
15
|
+
/**
|
|
16
|
+
* The tallest element the browser lays out (`getMaxElementHeight`): `undefined` until measured (the warning
|
|
17
|
+
* waits), `Infinity` where there is no layout engine (the lowest known cap is used).
|
|
18
|
+
*/
|
|
19
|
+
maxContentHeight?: number;
|
|
20
|
+
/** Unpinned rows the user can scroll to, and how many unpinned rows there are. */
|
|
21
|
+
reachableRowCount?: number;
|
|
22
|
+
unpinnedRowCount?: number;
|
|
15
23
|
/** `listView` is set but `listViewColumn` is not, so the grid view is shown. */
|
|
16
24
|
listViewWithoutColumn?: boolean;
|
|
17
25
|
/** Number of items the list view renders (0 when the list view is not shown). It does not virtualize. */
|
|
@@ -31,7 +39,7 @@ export const LIST_VIEW_WARN_THRESHOLD = 2000;
|
|
|
31
39
|
export const MAX_SCROLLABLE_HEIGHT = 17_800_000;
|
|
32
40
|
|
|
33
41
|
export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
|
|
34
|
-
const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
|
|
42
|
+
const { paginationRequested, isRowGrouping, autoHeight, viewportHeight, renderedRowCount, totalRowCount, pinnedRowsIgnored = false, scrollHeight = 0, maxContentHeight, reachableRowCount, unpinnedRowCount, listViewWithoutColumn = false, listViewRowCount = 0 } = params;
|
|
35
43
|
const warnedPaginationRef = useRef(false);
|
|
36
44
|
const warnedUnboundedRef = useRef(false);
|
|
37
45
|
const warnedPinnedRowsRef = useRef(false);
|
|
@@ -67,14 +75,21 @@ export function useGridDevWarnings(params: UseGridDevWarningsParams): void {
|
|
|
67
75
|
}, [pinnedRowsIgnored]);
|
|
68
76
|
|
|
69
77
|
useEffect(() => {
|
|
70
|
-
if (process.env.NODE_ENV === 'production' || warnedHeightRef.current ||
|
|
78
|
+
if (process.env.NODE_ENV === 'production' || warnedHeightRef.current || maxContentHeight === undefined) return;
|
|
79
|
+
const measured = Number.isFinite(maxContentHeight);
|
|
80
|
+
// Once the browser's cap is measured, warn only when rows are actually cut off; before that (and
|
|
81
|
+
// where there is no layout engine) fall back to the lowest known cap.
|
|
82
|
+
if (measured ? scrollHeight <= maxContentHeight : scrollHeight <= MAX_SCROLLABLE_HEIGHT) return;
|
|
71
83
|
warnedHeightRef.current = true;
|
|
84
|
+
const reach = measured && reachableRowCount !== undefined && unpinnedRowCount !== undefined
|
|
85
|
+
? `Only the first ${reachableRowCount.toLocaleString('en-US')} of ${unpinnedRowCount.toLocaleString('en-US')} rows can be scrolled to in this browser. `
|
|
86
|
+
: 'The last rows may not be reachable by scrolling. ';
|
|
72
87
|
console.warn(
|
|
73
|
-
`[OpenGridX] The grid content is ${Math.round(scrollHeight)}px tall
|
|
74
|
-
'(about 17.9M px in Firefox, 33.5M px in Chrome and Safari)
|
|
88
|
+
`[OpenGridX] The grid content is ${Math.round(scrollHeight)}px tall, more than this browser lays out ` +
|
|
89
|
+
'(about 17.9M px in Firefox, 33.5M px in Chrome and Safari). ' + reach +
|
|
75
90
|
'Use pagination, server-side or infinite loading, or a smaller rowHeight. See docs/features/virtualization.md.'
|
|
76
91
|
);
|
|
77
|
-
}, [scrollHeight]);
|
|
92
|
+
}, [scrollHeight, maxContentHeight, reachableRowCount, unpinnedRowCount]);
|
|
78
93
|
|
|
79
94
|
useEffect(() => {
|
|
80
95
|
if (process.env.NODE_ENV === 'production' || warnedPaginationRef.current) return;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
|
|
2
|
+
import type { RefObject } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
GridCellCoordinates,
|
|
5
|
+
GridCellParams,
|
|
6
|
+
GridCellSelectionModel,
|
|
7
|
+
GridColDef,
|
|
8
|
+
GridEditSkipReason,
|
|
9
|
+
GridFillDirection,
|
|
10
|
+
GridFillResult,
|
|
11
|
+
GridRowId,
|
|
12
|
+
GridRowMeta,
|
|
13
|
+
GridRowModel,
|
|
14
|
+
} from '../../types';
|
|
15
|
+
import { attempt } from '../../utils/attempt';
|
|
16
|
+
import { classifyKeyTarget } from '../../utils/focus';
|
|
17
|
+
import { isRangeColumnField } from '../../utils/cellSelection';
|
|
18
|
+
import type { CellRangeRect, GridFillHandlePosition } from '../../utils/cellSelection';
|
|
19
|
+
import { isSyntheticRowId } from '../../utils/syntheticRows';
|
|
20
|
+
import { getCellValue, warnCallbackFailure } from '../../utils/values';
|
|
21
|
+
import { buildEditTargetContext, isEditTargetWritable } from '../../utils/editing/targets';
|
|
22
|
+
import type { GridEditTargetContext } from '../../utils/editing/targets';
|
|
23
|
+
import {
|
|
24
|
+
computeFillValues,
|
|
25
|
+
fillHandleCell,
|
|
26
|
+
fillLines,
|
|
27
|
+
fillRangeCorners,
|
|
28
|
+
fillTargetFromPointer,
|
|
29
|
+
} from '../../utils/editing/fill';
|
|
30
|
+
import type { GridBatchCellEdit, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
|
|
31
|
+
import type { UseGridCellSelectionReturn } from './useGridCellSelection';
|
|
32
|
+
|
|
33
|
+
/** The class of the fill handle square (public from v3.5). */
|
|
34
|
+
export const FILL_HANDLE_CLASS = 'ogx__cell-fill-handle';
|
|
35
|
+
|
|
36
|
+
export interface UseGridFillHandleParams<R extends GridRowModel> {
|
|
37
|
+
/** The grid root: Ctrl/Cmd+D and Ctrl/Cmd+R are listened for here. */
|
|
38
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
39
|
+
viewportRef: RefObject<HTMLDivElement | null>;
|
|
40
|
+
selection: UseGridCellSelectionReturn;
|
|
41
|
+
disableFillHandle: boolean;
|
|
42
|
+
/** Whether a cell editor is open: no handle and no shortcuts meanwhile. */
|
|
43
|
+
isEditing: boolean;
|
|
44
|
+
allRenderableRows: R[];
|
|
45
|
+
getRowId: (row: R) => GridRowId;
|
|
46
|
+
/** System columns and the rendered data columns, in render order. */
|
|
47
|
+
navigationColumns: ReadonlyArray<GridColDef<R>>;
|
|
48
|
+
columnIndexMap: Map<string, number>;
|
|
49
|
+
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
50
|
+
rowMetaMap: Map<GridRowId, GridRowMeta>;
|
|
51
|
+
getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
52
|
+
batchEdit: UseGridBatchEditReturn<R>;
|
|
53
|
+
onFill?: (result: GridFillResult) => void;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface UseGridFillHandleReturn {
|
|
57
|
+
/** Where the rows draw the handle, or null. */
|
|
58
|
+
handle: GridFillHandlePosition | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface FillDragState<R extends GridRowModel> {
|
|
62
|
+
source: CellRangeRect;
|
|
63
|
+
/** The source range model, restored when the drag ends where it started or is cancelled. */
|
|
64
|
+
sourceModel: GridCellSelectionModel;
|
|
65
|
+
anchor: { row: number; col: number };
|
|
66
|
+
ctx: GridEditTargetContext<R>;
|
|
67
|
+
x: number;
|
|
68
|
+
y: number;
|
|
69
|
+
scrollLeft: number;
|
|
70
|
+
scrollTop: number;
|
|
71
|
+
target: { rect: CellRangeRect; direction: GridFillDirection } | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 'down', 'right' or null for a keydown: Ctrl/Cmd+D and Ctrl/Cmd+R (also on non-Latin layouts). */
|
|
75
|
+
function fillShortcut(event: KeyboardEvent): 'down' | 'right' | null {
|
|
76
|
+
if (event.altKey || event.shiftKey || !(event.ctrlKey || event.metaKey)) return null;
|
|
77
|
+
const key = (event.key ?? '').toLowerCase();
|
|
78
|
+
const latin = /^[a-z]$/.test(key);
|
|
79
|
+
if (key === 'd' || (!latin && event.code === 'KeyD')) return 'down';
|
|
80
|
+
if (key === 'r' || (!latin && event.code === 'KeyR')) return 'right';
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The fill handle (v3.5). With `cellSelection` on, the bottom-right cell of the range carries a
|
|
86
|
+
* square; dragging it extends the range in one direction (the larger pointer offset wins, with the
|
|
87
|
+
* range drag's auto-scroll) and fills the new cells from the source range on release: numbers or
|
|
88
|
+
* dates with an equal step continue as a series, anything else repeats, Alt at release copies, and
|
|
89
|
+
* `GridColDef.fillValue` has the last word. Ctrl/Cmd+D fills down from the range's top row and
|
|
90
|
+
* Ctrl/Cmd+R right from its left column. Every fill is one batch edit (source `'fill'`), so one undo
|
|
91
|
+
* step. Runs after useGridUndoRedo.
|
|
92
|
+
*/
|
|
93
|
+
export function useGridFillHandle<R extends GridRowModel>(params: UseGridFillHandleParams<R>): UseGridFillHandleReturn {
|
|
94
|
+
const {
|
|
95
|
+
containerRef, viewportRef, selection, disableFillHandle, isEditing, allRenderableRows, getRowId,
|
|
96
|
+
navigationColumns, columnIndexMap, getSpanOrigin,
|
|
97
|
+
} = params;
|
|
98
|
+
const latestRef = useRef(params);
|
|
99
|
+
useLayoutEffect(() => {
|
|
100
|
+
latestRef.current = params;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const enabled = selection.enabled && !disableFillHandle;
|
|
104
|
+
const hasEditableColumn = useMemo(() => navigationColumns.some(c => c.editable), [navigationColumns]);
|
|
105
|
+
|
|
106
|
+
// The handle: on the range's bottom-right cell (or the cell whose span covers it).
|
|
107
|
+
const resolved = selection.resolved;
|
|
108
|
+
const handle = useMemo<GridFillHandlePosition | null>(() => {
|
|
109
|
+
if (!enabled || !hasEditableColumn || !resolved) return null;
|
|
110
|
+
const fields = navigationColumns.filter(c => isRangeColumnField(c.field)).map(c => c.field);
|
|
111
|
+
const rowIdAt = (r: number) => {
|
|
112
|
+
const row = allRenderableRows[r];
|
|
113
|
+
return row === undefined ? undefined : getRowId(row);
|
|
114
|
+
};
|
|
115
|
+
const cell = fillHandleCell(resolved, rowIdAt, fields, getSpanOrigin);
|
|
116
|
+
if (!cell) return null;
|
|
117
|
+
const col = columnIndexMap.get(cell.field);
|
|
118
|
+
return col === undefined ? null : { row: cell.row, col };
|
|
119
|
+
}, [enabled, hasEditableColumn, resolved, allRenderableRows, getRowId, navigationColumns, columnIndexMap, getSpanOrigin]);
|
|
120
|
+
|
|
121
|
+
const mountedRef = useRef(true);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
mountedRef.current = true;
|
|
124
|
+
return () => { mountedRef.current = false; };
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const getContext = useCallback((): GridEditTargetContext<R> => {
|
|
128
|
+
const p = latestRef.current;
|
|
129
|
+
return buildEditTargetContext(p.allRenderableRows, p.getRowId, p.navigationColumns);
|
|
130
|
+
}, []);
|
|
131
|
+
|
|
132
|
+
/** Selects `rect` with its corners chosen for the fill. */
|
|
133
|
+
const selectRect = useCallback((
|
|
134
|
+
ctx: GridEditTargetContext<R>,
|
|
135
|
+
rect: CellRangeRect,
|
|
136
|
+
direction: GridFillDirection,
|
|
137
|
+
anchor: { row: number; col: number },
|
|
138
|
+
reason: 'pointer' | 'keyboard',
|
|
139
|
+
) => {
|
|
140
|
+
const corners = fillRangeCorners(rect, direction, anchor);
|
|
141
|
+
const toCell = (p: { row: number; col: number }): GridCellCoordinates => ({ id: ctx.rowIds[p.row], field: ctx.columns[p.col].field });
|
|
142
|
+
latestRef.current.selection.setModel([{ anchor: toCell(corners.anchor), head: toCell(corners.head) }], reason);
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
/** Fills `target` from `source` (display indices) and selects `target`. */
|
|
146
|
+
const fill = useCallback(async (
|
|
147
|
+
ctx: GridEditTargetContext<R>,
|
|
148
|
+
source: CellRangeRect,
|
|
149
|
+
target: CellRangeRect,
|
|
150
|
+
direction: GridFillDirection,
|
|
151
|
+
copy: boolean,
|
|
152
|
+
anchor: { row: number; col: number },
|
|
153
|
+
reason: 'pointer' | 'keyboard',
|
|
154
|
+
): Promise<GridFillResult> => {
|
|
155
|
+
const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin: spanOrigin } = latestRef.current;
|
|
156
|
+
const rules = { isCellEditable, rowMetaMap, getSpanOrigin: spanOrigin };
|
|
157
|
+
const vertical = direction === 'down' || direction === 'up';
|
|
158
|
+
const rowAt = (r: number): R | undefined => {
|
|
159
|
+
const id = ctx.rowIds[r];
|
|
160
|
+
return id === undefined ? undefined : batchEdit.getRow(id) ?? ctx.rows[r];
|
|
161
|
+
};
|
|
162
|
+
const edits: GridBatchCellEdit[] = [];
|
|
163
|
+
const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
|
|
164
|
+
for (const line of fillLines(source, target, direction)) {
|
|
165
|
+
if (line.targets.length === 0) continue;
|
|
166
|
+
const position = (k: number) => (vertical ? { r: k, c: line.line } : { r: line.line, c: k });
|
|
167
|
+
// The source values in fill order; synthetic rows hold no data and are left out.
|
|
168
|
+
const sourceValues: unknown[] = [];
|
|
169
|
+
for (const k of line.source) {
|
|
170
|
+
const { r, c } = position(k);
|
|
171
|
+
const row = rowAt(r);
|
|
172
|
+
const colDef = ctx.columns[c];
|
|
173
|
+
if (!row || !colDef || isSyntheticRowId(ctx.rowIds[r], rowMetaMap)) continue;
|
|
174
|
+
sourceValues.push(getCellValue(row, colDef.field, colDef));
|
|
175
|
+
}
|
|
176
|
+
if (sourceValues.length === 0) continue;
|
|
177
|
+
const values = computeFillValues(sourceValues, line.targets.length, !copy);
|
|
178
|
+
line.targets.forEach((k, index) => {
|
|
179
|
+
const { r, c } = position(k);
|
|
180
|
+
const row = rowAt(r);
|
|
181
|
+
const colDef = ctx.columns[c];
|
|
182
|
+
const id = ctx.rowIds[r];
|
|
183
|
+
if (!row || !colDef || id === undefined) return;
|
|
184
|
+
if (!isEditTargetWritable(ctx, rules, row, r, c)) {
|
|
185
|
+
skipped.push({ id, field: colDef.field, reason: 'notEditable' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let value = values[index];
|
|
189
|
+
const fillValue = colDef.fillValue;
|
|
190
|
+
if (fillValue) {
|
|
191
|
+
const decided = attempt(() => fillValue.call(colDef, {
|
|
192
|
+
row, id, field: colDef.field, colDef, direction, sourceValues: sourceValues.slice(), index, value, copy,
|
|
193
|
+
}));
|
|
194
|
+
if (!decided.ok) {
|
|
195
|
+
warnCallbackFailure(`fillValue:${colDef.field}`, `fillValue of column "${colDef.field}" threw; the cell was skipped`, decided.error);
|
|
196
|
+
skipped.push({ id, field: colDef.field, reason: 'invalidValue' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
value = decided.value;
|
|
200
|
+
}
|
|
201
|
+
edits.push({ id, field: colDef.field, value });
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The source and the filled cells become the range.
|
|
206
|
+
selectRect(ctx, target, direction, anchor, reason);
|
|
207
|
+
|
|
208
|
+
const outcome = await batchEdit.applyEdits(edits, 'fill');
|
|
209
|
+
const result: GridFillResult = {
|
|
210
|
+
updated: outcome.result.updated,
|
|
211
|
+
failed: outcome.result.failed,
|
|
212
|
+
skipped: [...skipped, ...outcome.result.skipped],
|
|
213
|
+
direction,
|
|
214
|
+
};
|
|
215
|
+
latestRef.current.onFill?.(result);
|
|
216
|
+
return result;
|
|
217
|
+
}, [selectRect]);
|
|
218
|
+
|
|
219
|
+
// ── Pointer: drag the handle ────────────────────────────────────────────
|
|
220
|
+
const dragRef = useRef<FillDragState<R> | null>(null);
|
|
221
|
+
|
|
222
|
+
const onTrack = useCallback((head: GridCellCoordinates, x: number, y: number) => {
|
|
223
|
+
const drag = dragRef.current;
|
|
224
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
225
|
+
if (!drag || !viewport) return;
|
|
226
|
+
const ctx = drag.ctx;
|
|
227
|
+
const row = ctx.rowIndexById.get(head.id);
|
|
228
|
+
const col = ctx.colIndexByField.get(head.field);
|
|
229
|
+
if (row === undefined || col === undefined) return;
|
|
230
|
+
const dx = x - drag.x + viewport.scrollLeft - drag.scrollLeft;
|
|
231
|
+
const dy = y - drag.y + viewport.scrollTop - drag.scrollTop;
|
|
232
|
+
const target = fillTargetFromPointer(drag.source, { row, col }, dx, dy);
|
|
233
|
+
drag.target = target;
|
|
234
|
+
if (target) selectRect(ctx, target.rect, target.direction, drag.anchor, 'pointer');
|
|
235
|
+
else latestRef.current.selection.setModel(drag.sourceModel, 'pointer');
|
|
236
|
+
}, [selectRect]);
|
|
237
|
+
|
|
238
|
+
const onEnd = useCallback(({ cancelled, altKey }: { cancelled: boolean; altKey: boolean }) => {
|
|
239
|
+
const drag = dragRef.current;
|
|
240
|
+
dragRef.current = null;
|
|
241
|
+
if (!drag || !mountedRef.current) return;
|
|
242
|
+
const ctx = drag.ctx;
|
|
243
|
+
if (cancelled || !drag.target) {
|
|
244
|
+
latestRef.current.selection.setModel(drag.sourceModel, 'pointer');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
void fill(ctx, drag.source, drag.target.rect, drag.target.direction, altKey, drag.anchor, 'pointer');
|
|
248
|
+
}, [fill]);
|
|
249
|
+
|
|
250
|
+
const dragEnabled = enabled && hasEditableColumn;
|
|
251
|
+
useEffect(() => {
|
|
252
|
+
const viewport = viewportRef.current;
|
|
253
|
+
if (!viewport || !dragEnabled) return;
|
|
254
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
255
|
+
if (event.button !== 0 || !(event.target instanceof Element)) return;
|
|
256
|
+
const handleEl = event.target.closest(`.${FILL_HANDLE_CLASS}`);
|
|
257
|
+
if (!handleEl || handleEl.closest('.ogx__viewport') !== viewport || latestRef.current.isEditing) return;
|
|
258
|
+
const { selection: s } = latestRef.current;
|
|
259
|
+
const { range, resolved: current } = s.getCurrentRange();
|
|
260
|
+
if (!range || !current) return;
|
|
261
|
+
const ctx = getContext();
|
|
262
|
+
const startId = ctx.rowIds[current.bottom];
|
|
263
|
+
const startCol = ctx.columns[current.right];
|
|
264
|
+
if (startId === undefined || !startCol) return;
|
|
265
|
+
// No mousedown follows, so the range drag does not start and focus does not move.
|
|
266
|
+
event.preventDefault();
|
|
267
|
+
const doc = viewport.ownerDocument;
|
|
268
|
+
if (!viewport.contains(doc.activeElement)) viewport.focus({ preventScroll: true });
|
|
269
|
+
// Pointer events keep reaching the grid even after the handle moves to another cell.
|
|
270
|
+
attempt(() => viewport.setPointerCapture(event.pointerId));
|
|
271
|
+
dragRef.current = {
|
|
272
|
+
source: { top: current.top, bottom: current.bottom, left: current.left, right: current.right },
|
|
273
|
+
sourceModel: [range],
|
|
274
|
+
anchor: { row: current.anchorRow, col: current.anchorCol },
|
|
275
|
+
ctx,
|
|
276
|
+
x: event.clientX,
|
|
277
|
+
y: event.clientY,
|
|
278
|
+
scrollLeft: viewport.scrollLeft,
|
|
279
|
+
scrollTop: viewport.scrollTop,
|
|
280
|
+
target: null,
|
|
281
|
+
};
|
|
282
|
+
s.startTrackedDrag({
|
|
283
|
+
start: { id: startId, field: startCol.field },
|
|
284
|
+
x: event.clientX,
|
|
285
|
+
y: event.clientY,
|
|
286
|
+
pointerId: event.pointerId,
|
|
287
|
+
onTrack,
|
|
288
|
+
onEnd,
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
viewport.addEventListener('pointerdown', onPointerDown);
|
|
292
|
+
return () => viewport.removeEventListener('pointerdown', onPointerDown);
|
|
293
|
+
}, [viewportRef, dragEnabled, getContext, onTrack, onEnd]);
|
|
294
|
+
|
|
295
|
+
// ── Keyboard: Ctrl/Cmd+D and Ctrl/Cmd+R ────────────────────────────────
|
|
296
|
+
useEffect(() => {
|
|
297
|
+
const root = containerRef.current;
|
|
298
|
+
if (!root || !enabled) return;
|
|
299
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
300
|
+
if (event.defaultPrevented) return;
|
|
301
|
+
const shortcut = fillShortcut(event);
|
|
302
|
+
if (!shortcut) return;
|
|
303
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
304
|
+
if (!viewport || latestRef.current.isEditing) return;
|
|
305
|
+
const kind = classifyKeyTarget(event.target, viewport);
|
|
306
|
+
if (kind !== 'grid' && kind !== 'control') return;
|
|
307
|
+
const { range, resolved: current } = latestRef.current.selection.getCurrentRange();
|
|
308
|
+
if (!range || !current) return;
|
|
309
|
+
// The browser's bookmark (Ctrl+D) and reload (Ctrl/Cmd+R) never fire inside the grid.
|
|
310
|
+
event.preventDefault();
|
|
311
|
+
const rect = { top: current.top, bottom: current.bottom, left: current.left, right: current.right };
|
|
312
|
+
const source = shortcut === 'down' ? { ...rect, bottom: rect.top } : { ...rect, right: rect.left };
|
|
313
|
+
if (shortcut === 'down' ? rect.bottom === rect.top : rect.right === rect.left) return;
|
|
314
|
+
void fill(getContext(), source, rect, shortcut, true, { row: current.anchorRow, col: current.anchorCol }, 'keyboard');
|
|
315
|
+
};
|
|
316
|
+
root.addEventListener('keydown', onKeyDown);
|
|
317
|
+
return () => root.removeEventListener('keydown', onKeyDown);
|
|
318
|
+
}, [containerRef, enabled, fill, getContext]);
|
|
319
|
+
|
|
320
|
+
return { handle: isEditing ? null : handle };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type { GridFillHandlePosition };
|