@opencorestack/opengridx 3.2.2 → 3.4.0
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 +53 -0
- package/README.md +16 -2
- package/dist/ai.cjs +1 -0
- package/dist/ai.cjs.map +1 -0
- package/dist/ai.d.ts +166 -0
- package/dist/ai.es.js +1 -0
- package/dist/ai.es.js.map +1 -0
- package/dist/index.d.ts +321 -3
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +6483 -5291
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +16 -13
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +132 -3
- package/docs/README.md +4 -1
- package/docs/architecture/datagrid-orchestration.md +23 -6
- package/docs/components/header.md +25 -0
- package/docs/customization/theming.md +12 -1
- package/docs/features/ai-toolkit.md +119 -0
- package/docs/features/cell-selection.md +131 -0
- package/docs/features/clipboard.md +90 -0
- package/docs/features/editing-reordering.md +50 -0
- package/docs/features/keyboard-navigation.md +25 -2
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +64 -7
- package/lib/ai/coerce.ts +90 -0
- package/lib/ai/columns.ts +90 -0
- package/lib/ai/index.ts +16 -0
- package/lib/ai/schema.ts +214 -0
- package/lib/ai/types.ts +88 -0
- package/lib/ai/validate.ts +286 -0
- package/lib/components/Cell/Cell.css +35 -0
- package/lib/components/Cell/Cell.tsx +25 -13
- package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
- package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
- package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +123 -3
- package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
- package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
- package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
- package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
- package/lib/components/FilterPanel/FilterPanel.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
- package/lib/components/Header/Header.css +12 -0
- package/lib/components/Header/Header.tsx +17 -4
- package/lib/components/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Row/Row.tsx +38 -1
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/Toolbar/GridToolbar.tsx +10 -4
- package/lib/components/ui/Checkbox.tsx +26 -10
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridCellSelection.ts +631 -0
- package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
- package/lib/hooks/core/useGridClipboardApi.ts +8 -2
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridControlledState.ts +38 -0
- package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
- package/lib/hooks/core/useGridRowInteractions.ts +8 -4
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useAggregation.ts +8 -6
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridClipboard.ts +47 -2
- package/lib/hooks/features/useGridEditing.ts +39 -51
- package/lib/index.ts +16 -0
- package/lib/styles/base/variables.css +6 -1
- package/lib/styles/opengridx.css +3 -1
- package/lib/theme/DataGridThemeProvider.tsx +4 -0
- package/lib/theme/presets.ts +8 -0
- package/lib/theme/types.ts +4 -0
- package/lib/types/index.ts +254 -0
- package/lib/utils/attempt.ts +14 -0
- package/lib/utils/cellSelection.ts +238 -0
- package/lib/utils/editing/commit.ts +69 -0
- package/lib/utils/editing/targets.ts +109 -0
- package/lib/utils/headerWrap.ts +23 -0
- package/lib/utils/parsing.ts +252 -0
- package/llms.txt +71 -2
- package/package.json +14 -4
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import type { MutableRefObject, RefObject } from 'react';
|
|
3
|
+
import { isSameCell } from '../../utils/focus';
|
|
4
|
+
import type { FocusedCell } from '../../utils/focus';
|
|
5
|
+
import { isRangeColumnField, isSameCoordinates } from '../../utils/cellSelection';
|
|
6
|
+
import { writeToClipboard } from '../features/useGridClipboard';
|
|
7
|
+
import type { UseGridCellSelectionReturn } from './useGridCellSelection';
|
|
8
|
+
import type { GridApi, GridCellCoordinates, GridCellSelectionModel } from '../../types';
|
|
9
|
+
|
|
10
|
+
export interface UseGridCellSelectionApiParams {
|
|
11
|
+
apiRef: MutableRefObject<GridApi>;
|
|
12
|
+
selection: UseGridCellSelectionReturn;
|
|
13
|
+
/** The rendered model (controlled or internal). */
|
|
14
|
+
model: GridCellSelectionModel;
|
|
15
|
+
getPendingModel: () => GridCellSelectionModel | null;
|
|
16
|
+
focusedCell: FocusedCell | null;
|
|
17
|
+
setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
|
|
18
|
+
/** The grid root: its `ogx--kb` class tells a keyboard focus move from a pointer one. */
|
|
19
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Runs after useGridKeyboardNavigation. Keeps the range's anchor and the focused cell together:
|
|
24
|
+
* a new anchor (pointer, keyboard, API or a controlled model) moves focus there, and focus moved
|
|
25
|
+
* to another data cell (plain arrows, Tab back in) collapses the range to it. Clears a range whose
|
|
26
|
+
* corner is no longer displayed (`reason: 'dataChange'`). Installs the cell selection API methods.
|
|
27
|
+
*/
|
|
28
|
+
export function useGridCellSelectionApi(params: UseGridCellSelectionApiParams): void {
|
|
29
|
+
const { apiRef } = params;
|
|
30
|
+
const latestRef = useRef(params);
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
latestRef.current = params;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const previousAnchorRef = useRef<GridCellCoordinates | null>(null);
|
|
36
|
+
const previousFocusRef = useRef<FocusedCell | null>(null);
|
|
37
|
+
const reportedDataChangeRef = useRef<GridCellSelectionModel | null>(null);
|
|
38
|
+
|
|
39
|
+
const { selection, model, focusedCell, setFocusedCell, containerRef } = params;
|
|
40
|
+
const anchor = selection.range?.anchor ?? null;
|
|
41
|
+
const anchorRow = selection.resolved?.anchorRow;
|
|
42
|
+
|
|
43
|
+
useLayoutEffect(() => {
|
|
44
|
+
selection.reportFocus(focusedCell);
|
|
45
|
+
const anchorChanged = !isSameCoordinates(previousAnchorRef.current, anchor);
|
|
46
|
+
const focusChanged = !isSameCell(previousFocusRef.current, focusedCell);
|
|
47
|
+
previousAnchorRef.current = anchor;
|
|
48
|
+
previousFocusRef.current = focusedCell;
|
|
49
|
+
if (!selection.enabled) return;
|
|
50
|
+
|
|
51
|
+
if (selection.range && !selection.resolved) {
|
|
52
|
+
// A corner's row or column is no longer displayed. Reported once per model.
|
|
53
|
+
if (reportedDataChangeRef.current !== model) {
|
|
54
|
+
reportedDataChangeRef.current = model;
|
|
55
|
+
selection.setModel([], 'dataChange');
|
|
56
|
+
}
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (anchorChanged && anchor) {
|
|
60
|
+
if (!focusedCell || focusedCell.id !== anchor.id || focusedCell.field !== anchor.field) {
|
|
61
|
+
setFocusedCell({ id: anchor.id, field: anchor.field, rowIndex: anchorRow });
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (focusChanged && focusedCell && focusedCell.id !== null && isRangeColumnField(focusedCell.field)
|
|
66
|
+
&& !isSameCoordinates(anchor, { id: focusedCell.id, field: focusedCell.field })) {
|
|
67
|
+
const cell = { id: focusedCell.id, field: focusedCell.field };
|
|
68
|
+
const byKeyboard = containerRef.current?.classList.contains('ogx--kb') ?? false;
|
|
69
|
+
selection.setModel([{ anchor: cell, head: cell }], byKeyboard ? 'keyboard' : 'pointer');
|
|
70
|
+
}
|
|
71
|
+
}, [selection, model, anchor, anchorRow, focusedCell, setFocusedCell, containerRef]);
|
|
72
|
+
|
|
73
|
+
useLayoutEffect(() => {
|
|
74
|
+
const api = apiRef.current;
|
|
75
|
+
const current = () => latestRef.current;
|
|
76
|
+
api.getCellSelectionModel = () => {
|
|
77
|
+
const { selection: s, getPendingModel, model: rendered } = current();
|
|
78
|
+
return s.enabled ? (getPendingModel() ?? rendered) : [];
|
|
79
|
+
};
|
|
80
|
+
api.setCellSelectionModel = (next) => {
|
|
81
|
+
current().selection.setModel(next, 'api');
|
|
82
|
+
};
|
|
83
|
+
api.selectCellRange = (rangeAnchor, head) => {
|
|
84
|
+
current().selection.setModel([{ anchor: rangeAnchor, head }], 'api');
|
|
85
|
+
};
|
|
86
|
+
api.clearCellSelection = () => {
|
|
87
|
+
current().selection.setModel([], 'clear');
|
|
88
|
+
};
|
|
89
|
+
api.getSelectedCells = () => current().selection.getSelectedCells();
|
|
90
|
+
api.copySelectedCells = async () => {
|
|
91
|
+
const { selection: s } = current();
|
|
92
|
+
if (!s.enabled) return;
|
|
93
|
+
const text = s.getCopyText();
|
|
94
|
+
if (text !== null) await writeToClipboard(text);
|
|
95
|
+
};
|
|
96
|
+
}, [apiRef]);
|
|
97
|
+
}
|
|
@@ -11,15 +11,20 @@ export interface UseGridClipboardApiParams {
|
|
|
11
11
|
/** The grid root: Ctrl/Cmd+C only copies while focus is inside it. */
|
|
12
12
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
13
13
|
disableKeyboardShortcut: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Cell range selection: the text Ctrl/Cmd+C copies instead of the selected rows (`null`: nothing).
|
|
16
|
+
* Absent while `cellSelection` is off, so the shortcut copies rows.
|
|
17
|
+
*/
|
|
18
|
+
getCellRangeCopyText?: () => string | null;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
/**
|
|
17
22
|
* Copies the selected rows (every page, collapsed groups, pinned rows) with the on-screen columns
|
|
18
23
|
* in screen order, on Ctrl/Cmd+C while focus is inside this grid, and installs `copySelectedRows`
|
|
19
|
-
* on the API for programmatic use.
|
|
24
|
+
* on the API for programmatic use. With `cellSelection` on, the shortcut copies the cell range instead.
|
|
20
25
|
*/
|
|
21
26
|
export function useGridClipboardApi(params: UseGridClipboardApiParams): void {
|
|
22
|
-
const { apiRef, columns, getRowId, containerRef, disableKeyboardShortcut } = params;
|
|
27
|
+
const { apiRef, columns, getRowId, containerRef, disableKeyboardShortcut, getCellRangeCopyText } = params;
|
|
23
28
|
|
|
24
29
|
const getGridRootElement = useCallback(() => containerRef.current, [containerRef]);
|
|
25
30
|
const { copySelectedRows } = useGridClipboard({
|
|
@@ -29,6 +34,7 @@ export function useGridClipboardApi(params: UseGridClipboardApiParams): void {
|
|
|
29
34
|
getRowId,
|
|
30
35
|
getRootElement: getGridRootElement,
|
|
31
36
|
disableKeyboardShortcut,
|
|
37
|
+
getShortcutText: getCellRangeCopyText,
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
// Stable identity.
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useRef } from 'react';
|
|
2
|
+
import type { MutableRefObject, RefObject } from 'react';
|
|
3
|
+
import type {
|
|
4
|
+
GridApi,
|
|
5
|
+
GridBatchEditResult,
|
|
6
|
+
GridBeforeClipboardPasteParams,
|
|
7
|
+
GridCellCoordinates,
|
|
8
|
+
GridCellParams,
|
|
9
|
+
GridClipboardPasteResult,
|
|
10
|
+
GridColDef,
|
|
11
|
+
GridEditSkipReason,
|
|
12
|
+
GridRowId,
|
|
13
|
+
GridRowMeta,
|
|
14
|
+
GridRowModel,
|
|
15
|
+
} from '../../types';
|
|
16
|
+
import { attempt } from '../../utils/attempt';
|
|
17
|
+
import { classifyKeyTarget } from '../../utils/focus';
|
|
18
|
+
import type { FocusedCell } from '../../utils/focus';
|
|
19
|
+
import { isRangeColumnField, rectCellCount } from '../../utils/cellSelection';
|
|
20
|
+
import type { CellRangeRect } from '../../utils/cellSelection';
|
|
21
|
+
import { parseCellText, parseTsv } from '../../utils/parsing';
|
|
22
|
+
import { getCellValue, warnCallbackFailure } from '../../utils/values';
|
|
23
|
+
import {
|
|
24
|
+
buildEditTargetContext,
|
|
25
|
+
isEditTargetWritable,
|
|
26
|
+
isTextColumn,
|
|
27
|
+
rectCorners,
|
|
28
|
+
} from '../../utils/editing/targets';
|
|
29
|
+
import type { GridEditTargetContext } from '../../utils/editing/targets';
|
|
30
|
+
import type { GridBatchCellEdit, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
|
|
31
|
+
import type { UseGridCellSelectionReturn } from './useGridCellSelection';
|
|
32
|
+
|
|
33
|
+
/** Above this many cells a paste warns in development. */
|
|
34
|
+
export const LARGE_PASTE_CELLS = 10_000;
|
|
35
|
+
|
|
36
|
+
const emptyResult = (): GridBatchEditResult => ({ updated: [], failed: [], skipped: [] });
|
|
37
|
+
|
|
38
|
+
export interface UseGridClipboardPasteParams<R extends GridRowModel> {
|
|
39
|
+
apiRef: MutableRefObject<GridApi>;
|
|
40
|
+
/** The grid root: paste and Delete are listened for here. */
|
|
41
|
+
containerRef: RefObject<HTMLDivElement | null>;
|
|
42
|
+
viewportRef: RefObject<HTMLDivElement | null>;
|
|
43
|
+
selection: UseGridCellSelectionReturn;
|
|
44
|
+
disableClipboardPaste: boolean;
|
|
45
|
+
disableRangeClear: boolean;
|
|
46
|
+
focusedCell: FocusedCell | null;
|
|
47
|
+
allRenderableRows: R[];
|
|
48
|
+
getRowId: (row: R) => GridRowId;
|
|
49
|
+
/** System columns and the rendered data columns, in render order. */
|
|
50
|
+
navigationColumns: ReadonlyArray<GridColDef<R>>;
|
|
51
|
+
isCellEditable?: (params: GridCellParams<R>) => boolean;
|
|
52
|
+
rowMetaMap: Map<GridRowId, GridRowMeta>;
|
|
53
|
+
getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
54
|
+
/** Whether a cell editor is open: paste and Delete then belong to it. */
|
|
55
|
+
isEditing: boolean;
|
|
56
|
+
batchEdit: UseGridBatchEditReturn<R>;
|
|
57
|
+
onClipboardPaste?: (result: GridClipboardPasteResult) => void;
|
|
58
|
+
onBeforeClipboardPaste?: (params: GridBeforeClipboardPasteParams) => boolean | string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function warnLargePaste(cellCount: number): void {
|
|
62
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
63
|
+
console.warn(`[OpenGridX] Pasting ${cellCount} cells. Pastes above ${LARGE_PASTE_CELLS} cells can be slow; consider importing the data instead.`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Clipboard paste and range clear (v3.4). With `cellSelection` on: Ctrl/Cmd+V (the `paste` event,
|
|
68
|
+
* so no clipboard permission is needed) writes tab-separated text into the editable cells, and
|
|
69
|
+
* Delete / Backspace on a multi-cell range empties them. Both are one batch edit (`useGridBatchEdit`),
|
|
70
|
+
* so one undo. Installs `apiRef.pasteText`. Runs after useGridCellSelectionApi.
|
|
71
|
+
*/
|
|
72
|
+
export function useGridClipboardPaste<R extends GridRowModel>(params: UseGridClipboardPasteParams<R>): void {
|
|
73
|
+
const { apiRef, containerRef, selection, disableClipboardPaste, disableRangeClear } = params;
|
|
74
|
+
const latestRef = useRef(params);
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
latestRef.current = params;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const getContext = useCallback((): GridEditTargetContext<R> => {
|
|
80
|
+
const { allRenderableRows, getRowId, navigationColumns } = latestRef.current;
|
|
81
|
+
return buildEditTargetContext(allRenderableRows, getRowId, navigationColumns);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
/** The range's rectangle (display indices), or null. */
|
|
85
|
+
const currentRect = useCallback((): CellRangeRect | null => {
|
|
86
|
+
const { selection: s } = latestRef.current;
|
|
87
|
+
if (!s.enabled) return null;
|
|
88
|
+
return s.getCurrentRange().resolved;
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
/** The cell a paste starts from: the range's top-left cell, else the focused data cell. */
|
|
92
|
+
const defaultAnchor = useCallback((ctx: GridEditTargetContext<R>): GridCellCoordinates | null => {
|
|
93
|
+
const rect = currentRect();
|
|
94
|
+
if (rect && ctx.rowIds[rect.top] !== undefined && ctx.columns[rect.left]) {
|
|
95
|
+
return { id: ctx.rowIds[rect.top], field: ctx.columns[rect.left].field };
|
|
96
|
+
}
|
|
97
|
+
const focus = latestRef.current.focusedCell;
|
|
98
|
+
if (focus && focus.id !== null && isRangeColumnField(focus.field)) return { id: focus.id, field: focus.field };
|
|
99
|
+
return null;
|
|
100
|
+
}, [currentRect]);
|
|
101
|
+
|
|
102
|
+
const paste = useCallback(async (clipboardText: string, anchorOverride: GridCellCoordinates | undefined, viaKeyboard: boolean): Promise<GridBatchEditResult> => {
|
|
103
|
+
const ctx = getContext();
|
|
104
|
+
const anchor = anchorOverride ?? defaultAnchor(ctx);
|
|
105
|
+
let text = clipboardText;
|
|
106
|
+
const { onBeforeClipboardPaste } = latestRef.current;
|
|
107
|
+
if (onBeforeClipboardPaste) {
|
|
108
|
+
const verdict = attempt(() => onBeforeClipboardPaste({ text, anchor }));
|
|
109
|
+
if (!verdict.ok) {
|
|
110
|
+
warnCallbackFailure('onBeforeClipboardPaste', 'onBeforeClipboardPaste threw; the paste was cancelled', verdict.error);
|
|
111
|
+
return emptyResult();
|
|
112
|
+
}
|
|
113
|
+
if (verdict.value === false) return emptyResult();
|
|
114
|
+
if (typeof verdict.value === 'string') text = verdict.value;
|
|
115
|
+
}
|
|
116
|
+
if (!anchor) return emptyResult();
|
|
117
|
+
const block = parseTsv(text);
|
|
118
|
+
const anchorRow = ctx.rowIndexById.get(anchor.id);
|
|
119
|
+
const anchorCol = ctx.colIndexByField.get(anchor.field);
|
|
120
|
+
if (block.length === 0 || anchorRow === undefined || anchorCol === undefined) return emptyResult();
|
|
121
|
+
|
|
122
|
+
const lastRow = ctx.rowIds.length - 1;
|
|
123
|
+
const lastCol = ctx.columns.length - 1;
|
|
124
|
+
// A range of several cells bounds the paste; one value fills all of it.
|
|
125
|
+
const range = anchorOverride ? null : currentRect();
|
|
126
|
+
const multiCellRange = range && rectCellCount(range) > 1 ? range : null;
|
|
127
|
+
const singleValue = block.length === 1 && block[0].length === 1;
|
|
128
|
+
const blockWidth = block.reduce((max, line) => Math.max(max, line.length), 0);
|
|
129
|
+
let rect: CellRangeRect;
|
|
130
|
+
if (singleValue && multiCellRange) {
|
|
131
|
+
rect = multiCellRange;
|
|
132
|
+
} else {
|
|
133
|
+
rect = {
|
|
134
|
+
top: anchorRow,
|
|
135
|
+
left: anchorCol,
|
|
136
|
+
bottom: Math.min(anchorRow + block.length - 1, lastRow, multiCellRange ? multiCellRange.bottom : lastRow),
|
|
137
|
+
right: Math.min(anchorCol + blockWidth - 1, lastCol, multiCellRange ? multiCellRange.right : lastCol),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const cellCount = rectCellCount(rect);
|
|
141
|
+
if (cellCount > LARGE_PASTE_CELLS) warnLargePaste(cellCount);
|
|
142
|
+
|
|
143
|
+
const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin } = latestRef.current;
|
|
144
|
+
const rules = { isCellEditable, rowMetaMap, getSpanOrigin };
|
|
145
|
+
const edits: GridBatchCellEdit[] = [];
|
|
146
|
+
const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
|
|
147
|
+
for (let r = rect.top; r <= rect.bottom; r++) {
|
|
148
|
+
const id = ctx.rowIds[r];
|
|
149
|
+
const row = batchEdit.getRow(id) ?? ctx.rows[r];
|
|
150
|
+
const line = singleValue ? block[0] : block[r - rect.top];
|
|
151
|
+
for (let c = rect.left; c <= rect.right; c++) {
|
|
152
|
+
const cellText = singleValue ? block[0][0] : line?.[c - rect.left];
|
|
153
|
+
if (cellText === undefined) continue;
|
|
154
|
+
const colDef = ctx.columns[c];
|
|
155
|
+
if (!isEditTargetWritable(ctx, rules, row, r, c)) {
|
|
156
|
+
skipped.push({ id, field: colDef.field, reason: 'notEditable' });
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const parsed = parseCellText(cellText, colDef, row, { current: getCellValue(row, colDef.field, colDef) });
|
|
160
|
+
if (!parsed.ok) {
|
|
161
|
+
skipped.push({ id, field: colDef.field, reason: 'invalidValue' });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
edits.push({ id, field: colDef.field, value: parsed.value });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The pasted area becomes the selection, so one look shows what changed.
|
|
169
|
+
if (latestRef.current.selection.enabled) {
|
|
170
|
+
const { anchor: from, head: to } = rectCorners(ctx, rect);
|
|
171
|
+
latestRef.current.selection.setModel([{ anchor: from, head: to }], viaKeyboard ? 'keyboard' : 'api');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const outcome = await batchEdit.applyEdits(edits, 'paste');
|
|
175
|
+
const result: GridBatchEditResult = {
|
|
176
|
+
updated: outcome.result.updated,
|
|
177
|
+
failed: outcome.result.failed,
|
|
178
|
+
skipped: [...skipped, ...outcome.result.skipped],
|
|
179
|
+
};
|
|
180
|
+
latestRef.current.onClipboardPaste?.({ ...result, text });
|
|
181
|
+
return result;
|
|
182
|
+
}, [getContext, defaultAnchor, currentRect]);
|
|
183
|
+
|
|
184
|
+
/** Delete / Backspace: empties the editable cells of a multi-cell range. Returns whether it applied. */
|
|
185
|
+
const clearRange = useCallback((): boolean => {
|
|
186
|
+
const rect = currentRect();
|
|
187
|
+
if (!rect || rectCellCount(rect) <= 1) return false;
|
|
188
|
+
const ctx = getContext();
|
|
189
|
+
const { batchEdit, isCellEditable, rowMetaMap, getSpanOrigin } = latestRef.current;
|
|
190
|
+
const rules = { isCellEditable, rowMetaMap, getSpanOrigin };
|
|
191
|
+
const edits: GridBatchCellEdit[] = [];
|
|
192
|
+
for (let r = rect.top; r <= rect.bottom; r++) {
|
|
193
|
+
const id = ctx.rowIds[r];
|
|
194
|
+
if (id === undefined) continue;
|
|
195
|
+
const row = batchEdit.getRow(id) ?? ctx.rows[r];
|
|
196
|
+
for (let c = rect.left; c <= rect.right; c++) {
|
|
197
|
+
const colDef = ctx.columns[c];
|
|
198
|
+
if (!colDef || !isEditTargetWritable(ctx, rules, row, r, c)) continue;
|
|
199
|
+
edits.push({ id, field: colDef.field, value: isTextColumn(colDef) ? '' : null });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
void batchEdit.applyEdits(edits, 'clear');
|
|
203
|
+
return true;
|
|
204
|
+
}, [currentRect, getContext]);
|
|
205
|
+
|
|
206
|
+
const pasteEnabled = selection.enabled && !disableClipboardPaste;
|
|
207
|
+
const clearEnabled = selection.enabled && !disableRangeClear;
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
const root = containerRef.current;
|
|
211
|
+
if (!root || (!pasteEnabled && !clearEnabled)) return;
|
|
212
|
+
/** Only events aimed at this grid's cells (not an editor, a text field or a nested grid). */
|
|
213
|
+
const ownsTarget = (event: Event) => {
|
|
214
|
+
const viewport = latestRef.current.viewportRef.current;
|
|
215
|
+
if (!viewport || event.defaultPrevented || latestRef.current.isEditing) return false;
|
|
216
|
+
const kind = classifyKeyTarget(event.target, viewport);
|
|
217
|
+
return kind === 'grid' || kind === 'control';
|
|
218
|
+
};
|
|
219
|
+
// Some engines (WebKit on Linux) only fire `paste` on an editable element, never on a focused
|
|
220
|
+
// cell. On the paste shortcut, focus moves to an off-screen textarea for that one event, so the
|
|
221
|
+
// browser's paste lands there and bubbles to the root; focus then goes back to the cell.
|
|
222
|
+
let capture: { textarea: HTMLTextAreaElement; restore: HTMLElement | null; timer: number } | null = null;
|
|
223
|
+
const endCapture = () => {
|
|
224
|
+
if (!capture) return;
|
|
225
|
+
const { textarea, restore, timer } = capture;
|
|
226
|
+
capture = null;
|
|
227
|
+
window.clearTimeout(timer);
|
|
228
|
+
const hadFocus = document.activeElement === textarea;
|
|
229
|
+
textarea.remove();
|
|
230
|
+
if (hadFocus && restore && restore.isConnected) restore.focus({ preventScroll: true });
|
|
231
|
+
};
|
|
232
|
+
const startCapture = () => {
|
|
233
|
+
endCapture();
|
|
234
|
+
const textarea = document.createElement('textarea');
|
|
235
|
+
textarea.setAttribute('aria-hidden', 'true');
|
|
236
|
+
textarea.tabIndex = -1;
|
|
237
|
+
textarea.className = 'ogx__paste-capture';
|
|
238
|
+
textarea.style.cssText = 'position:fixed;top:0;left:-10000px;width:1px;height:1px;opacity:0;';
|
|
239
|
+
root.appendChild(textarea);
|
|
240
|
+
const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
241
|
+
// No paste event within the timeout (an empty or non-text clipboard): give focus back anyway.
|
|
242
|
+
capture = { textarea, restore, timer: window.setTimeout(endCapture, 250) };
|
|
243
|
+
textarea.focus({ preventScroll: true });
|
|
244
|
+
};
|
|
245
|
+
const isCaptureTarget = (event: Event) => capture !== null && event.target === capture.textarea;
|
|
246
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
247
|
+
if (isCaptureTarget(event)) {
|
|
248
|
+
const text = event.clipboardData ? event.clipboardData.getData('text/plain') : '';
|
|
249
|
+
event.preventDefault();
|
|
250
|
+
endCapture();
|
|
251
|
+
if (text !== '') void paste(text, undefined, true);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (!pasteEnabled || !ownsTarget(event)) return;
|
|
255
|
+
if (!latestRef.current.navigationColumns.some(c => c.editable)) return;
|
|
256
|
+
const text = event.clipboardData ? event.clipboardData.getData('text/plain') : '';
|
|
257
|
+
if (text === '') return;
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
void paste(text, undefined, true);
|
|
260
|
+
};
|
|
261
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
262
|
+
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && (event.key === 'v' || event.key === 'V' || event.code === 'KeyV')) {
|
|
263
|
+
if (pasteEnabled && ownsTarget(event) && latestRef.current.navigationColumns.some(c => c.editable)) startCapture();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (!clearEnabled || (event.key !== 'Delete' && event.key !== 'Backspace')) return;
|
|
267
|
+
if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return;
|
|
268
|
+
if (!ownsTarget(event)) return;
|
|
269
|
+
if (clearRange()) event.preventDefault();
|
|
270
|
+
};
|
|
271
|
+
root.addEventListener('paste', onPaste);
|
|
272
|
+
root.addEventListener('keydown', onKeyDown);
|
|
273
|
+
return () => {
|
|
274
|
+
endCapture();
|
|
275
|
+
root.removeEventListener('paste', onPaste);
|
|
276
|
+
root.removeEventListener('keydown', onKeyDown);
|
|
277
|
+
};
|
|
278
|
+
}, [containerRef, pasteEnabled, clearEnabled, paste, clearRange]);
|
|
279
|
+
|
|
280
|
+
useLayoutEffect(() => {
|
|
281
|
+
apiRef.current.pasteText = (text, anchor) => paste(text, anchor, false);
|
|
282
|
+
}, [apiRef, paste]);
|
|
283
|
+
}
|
|
@@ -7,6 +7,9 @@ import type {
|
|
|
7
7
|
GridPaginationModel,
|
|
8
8
|
GridAggregationModel,
|
|
9
9
|
GridPivotModel,
|
|
10
|
+
GridCellSelectionModel,
|
|
11
|
+
GridCellSelectionChangeDetails,
|
|
12
|
+
GridCellSelectionReason,
|
|
10
13
|
} from '../../types';
|
|
11
14
|
import type { GridInitialState, GridDensityState } from '../../state/types';
|
|
12
15
|
|
|
@@ -42,6 +45,9 @@ export interface UseGridControlledStateParams {
|
|
|
42
45
|
onRowSelectionModelChange?: (model: GridRowId[]) => void;
|
|
43
46
|
|
|
44
47
|
density?: GridDensity;
|
|
48
|
+
|
|
49
|
+
cellSelectionModel?: GridCellSelectionModel;
|
|
50
|
+
onCellSelectionModelChange?: (model: GridCellSelectionModel, details: GridCellSelectionChangeDetails) => void;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
53
|
export interface UseGridControlledStateReturn {
|
|
@@ -94,8 +100,17 @@ export interface UseGridControlledStateReturn {
|
|
|
94
100
|
|
|
95
101
|
// density (prop, else initialState.density, else 'standard')
|
|
96
102
|
density: GridDensity;
|
|
103
|
+
|
|
104
|
+
// cell selection
|
|
105
|
+
cellSelectionModel: GridCellSelectionModel;
|
|
106
|
+
/** Sets the internal model when uncontrolled and fires onCellSelectionModelChange. */
|
|
107
|
+
handleCellSelectionModelChange: (model: GridCellSelectionModel, reason: GridCellSelectionReason) => void;
|
|
108
|
+
/** Like `getPendingRowSelectionModel`, for the cell selection. Stable identity. */
|
|
109
|
+
getPendingCellSelectionModel: () => GridCellSelectionModel | null;
|
|
97
110
|
}
|
|
98
111
|
|
|
112
|
+
const EMPTY_CELL_SELECTION_MODEL: GridCellSelectionModel = [];
|
|
113
|
+
|
|
99
114
|
const EMPTY_PIVOT_MODEL: GridPivotModel = { rowFields: [], columnFields: [], valueFields: [] };
|
|
100
115
|
const DEFAULT_PAGE_SIZE = 100;
|
|
101
116
|
|
|
@@ -128,6 +143,8 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
128
143
|
rowSelectionModel: propRowSelectionModel,
|
|
129
144
|
onRowSelectionModelChange,
|
|
130
145
|
density: propDensity,
|
|
146
|
+
cellSelectionModel: propCellSelectionModel,
|
|
147
|
+
onCellSelectionModelChange,
|
|
131
148
|
} = params;
|
|
132
149
|
|
|
133
150
|
// ── Sort ─────────────────────────────────────────────────────────────────
|
|
@@ -248,6 +265,23 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
248
265
|
const [initialDensity] = useState<GridDensity>(() => initialState?.density?.density ?? 'standard');
|
|
249
266
|
const density = propDensity ?? initialDensity;
|
|
250
267
|
|
|
268
|
+
// ── Cell selection ───────────────────────────────────────────────────────
|
|
269
|
+
const isCellSelectionControlled = propCellSelectionModel !== undefined;
|
|
270
|
+
const [internalCellSelectionModel, setInternalCellSelectionModel] = useState<GridCellSelectionModel>(EMPTY_CELL_SELECTION_MODEL);
|
|
271
|
+
const cellSelectionModel = isCellSelectionControlled ? propCellSelectionModel : internalCellSelectionModel;
|
|
272
|
+
|
|
273
|
+
const pendingCellSelectionRef = useRef<GridCellSelectionModel | null>(null);
|
|
274
|
+
useLayoutEffect(() => {
|
|
275
|
+
pendingCellSelectionRef.current = null;
|
|
276
|
+
});
|
|
277
|
+
const getPendingCellSelectionModel = useCallback(() => pendingCellSelectionRef.current, []);
|
|
278
|
+
|
|
279
|
+
const handleCellSelectionModelChange = useCallback((model: GridCellSelectionModel, reason: GridCellSelectionReason) => {
|
|
280
|
+
pendingCellSelectionRef.current = model;
|
|
281
|
+
if (!isCellSelectionControlled) setInternalCellSelectionModel(model);
|
|
282
|
+
onCellSelectionModelChange?.(model, { reason });
|
|
283
|
+
}, [isCellSelectionControlled, onCellSelectionModelChange]);
|
|
284
|
+
|
|
251
285
|
return {
|
|
252
286
|
sortModel: sortModel as GridSortItem[],
|
|
253
287
|
isSortControlled,
|
|
@@ -281,5 +315,9 @@ export function useGridControlledState(params: UseGridControlledStateParams): Us
|
|
|
281
315
|
getPendingRowSelectionModel,
|
|
282
316
|
|
|
283
317
|
density,
|
|
318
|
+
|
|
319
|
+
cellSelectionModel,
|
|
320
|
+
handleCellSelectionModelChange,
|
|
321
|
+
getPendingCellSelectionModel,
|
|
284
322
|
};
|
|
285
323
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
isSystemField,
|
|
15
15
|
} from '../../utils/focus';
|
|
16
16
|
import type { FocusedCell } from '../../utils/focus';
|
|
17
|
+
import { isRangeColumnField } from '../../utils/cellSelection';
|
|
17
18
|
import type { GridRowModel, GridRowId, GridColDef, GridCellParams, GridSortItem, GridSortDirection, GridRowParams, GridRowMeta } from '../../types';
|
|
18
19
|
import type { GridEditingState } from '../features/useGridEditing';
|
|
19
20
|
|
|
@@ -93,6 +94,21 @@ export interface UseGridKeyboardNavigationParams<R extends GridRowModel> {
|
|
|
93
94
|
* Navigation skips the cells of the focused span and lands on span origins.
|
|
94
95
|
*/
|
|
95
96
|
getSpanOrigin?: (rowId: GridRowId, field: string) => { rowId: GridRowId; field: string } | null;
|
|
97
|
+
/**
|
|
98
|
+
* Cell range selection (`cellSelection`), absent while it is off. Shift+navigation keys on a
|
|
99
|
+
* data cell move the range's head (focus stays on the anchor), Escape collapses the range and
|
|
100
|
+
* Ctrl/Cmd+A selects every data cell instead of every row.
|
|
101
|
+
*/
|
|
102
|
+
cellSelection?: GridKeyboardCellSelection;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface GridKeyboardCellSelection {
|
|
106
|
+
/** Returns whether the key was used. */
|
|
107
|
+
extendSelection: (key: string, withModifier: boolean, pageSize: number, from: FocusedCell) => boolean;
|
|
108
|
+
/** Returns whether a multi-cell range was collapsed. */
|
|
109
|
+
collapseSelection: () => boolean;
|
|
110
|
+
/** Returns whether anything was selected. */
|
|
111
|
+
selectAllCells: () => boolean;
|
|
96
112
|
}
|
|
97
113
|
|
|
98
114
|
export interface UseGridKeyboardNavigationReturn {
|
|
@@ -170,6 +186,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
170
186
|
pinExpandColumn = true,
|
|
171
187
|
rowHeight,
|
|
172
188
|
getSpanOrigin,
|
|
189
|
+
cellSelection,
|
|
173
190
|
} = params;
|
|
174
191
|
|
|
175
192
|
const [storedFocus, setFocusedCell] = useState<FocusedCell | null>(null);
|
|
@@ -419,11 +436,19 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
419
436
|
|
|
420
437
|
// Tab is not captured outside edit mode: the grid is a single tab stop and Tab leaves it
|
|
421
438
|
// (or reaches focusable content inside cells and detail panels).
|
|
439
|
+
if (key === 'Escape' && cellSelection?.collapseSelection()) {
|
|
440
|
+
event.preventDefault();
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
422
443
|
if (key === 'Tab' || key === 'Escape') return;
|
|
423
444
|
// Enter and Space activate a control rendered inside a cell.
|
|
424
445
|
if (targetKind === 'control' && (key === 'Enter' || isSpace)) return;
|
|
425
446
|
|
|
426
447
|
if ((event.ctrlKey || event.metaKey) && (key === 'a' || key === 'A')) {
|
|
448
|
+
if (cellSelection) {
|
|
449
|
+
if (cellSelection.selectAllCells()) event.preventDefault();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
427
452
|
if (!rowSelectionEnabled || !multipleRowSelectionEnabled || !handleSelectAll) return;
|
|
428
453
|
event.preventDefault();
|
|
429
454
|
handleSelectAll(true);
|
|
@@ -496,6 +521,12 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
496
521
|
const withModifier = event.ctrlKey || event.metaKey;
|
|
497
522
|
const page = pagination ? pageSize : 10;
|
|
498
523
|
|
|
524
|
+
// Shift+navigation on a data cell extends the cell range; focus stays on its anchor.
|
|
525
|
+
if (cellSelection && event.shiftKey && id !== null && isRangeColumnField(field)
|
|
526
|
+
&& cellSelection.extendSelection(key, withModifier, page, focusedCell)) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
|
|
499
530
|
const step = (r: number, c: number): { r: number; c: number } => {
|
|
500
531
|
switch (key) {
|
|
501
532
|
case 'ArrowRight': return c + 1 > lastCol ? { r: r + 1, c: 0 } : { r, c: c + 1 };
|
|
@@ -558,6 +589,7 @@ export function useGridKeyboardNavigation<R extends GridRowModel>(
|
|
|
558
589
|
scrollCellIntoView,
|
|
559
590
|
getSpanOrigin,
|
|
560
591
|
getRowId,
|
|
592
|
+
cellSelection,
|
|
561
593
|
]);
|
|
562
594
|
|
|
563
595
|
return {
|
|
@@ -51,6 +51,10 @@ export function useGridRowInteractions<R extends GridRowModel>(
|
|
|
51
51
|
selectableRowIds,
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
// Depend on the selection callbacks, not the object around them (a new one each render), so the
|
|
55
|
+
// handlers keep their identity and memoised rows do not re-render.
|
|
56
|
+
const { clickRow, toggleRow } = rowSelection;
|
|
57
|
+
|
|
54
58
|
const handleRowClick = useCallback((rowParams: GridRowParams<R>) => {
|
|
55
59
|
const { id } = rowParams;
|
|
56
60
|
|
|
@@ -66,16 +70,16 @@ export function useGridRowInteractions<R extends GridRowModel>(
|
|
|
66
70
|
|
|
67
71
|
// The pivot Grand Total is clickable (onRowClick) but, like any synthetic row, not selectable.
|
|
68
72
|
if (!disableRowSelectionOnClick && !isSyntheticRowId(id, rowMetaMap)) {
|
|
69
|
-
|
|
73
|
+
clickRow(id);
|
|
70
74
|
}
|
|
71
|
-
}, [isHierarchyEnabled, activeHierarchyHandlers, onRowClick, rowMetaMap, disableRowSelectionOnClick,
|
|
75
|
+
}, [isHierarchyEnabled, activeHierarchyHandlers, onRowClick, rowMetaMap, disableRowSelectionOnClick, clickRow]);
|
|
72
76
|
|
|
73
77
|
// Honors disableMultipleRowSelection like a row click does. Synthetic group / subtotal rows
|
|
74
78
|
// and the pivot Grand Total are not data: their ids never enter the selection model.
|
|
75
79
|
const handleSelectionChange = useCallback((rowId: GridRowId, isSelected: boolean) => {
|
|
76
80
|
if (isSyntheticRowId(rowId, rowMetaMap)) return;
|
|
77
|
-
|
|
78
|
-
}, [rowMetaMap,
|
|
81
|
+
toggleRow(rowId, isSelected);
|
|
82
|
+
}, [rowMetaMap, toggleRow]);
|
|
79
83
|
|
|
80
84
|
return { rowSelection, handleRowClick, handleSelectionChange };
|
|
81
85
|
}
|