@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +16 -2
  3. package/dist/ai.cjs +1 -0
  4. package/dist/ai.cjs.map +1 -0
  5. package/dist/ai.d.ts +166 -0
  6. package/dist/ai.es.js +1 -0
  7. package/dist/ai.es.js.map +1 -0
  8. package/dist/index.d.ts +321 -3
  9. package/dist/opengridx.css +1 -1
  10. package/dist/opengridx.es.js +6483 -5291
  11. package/dist/opengridx.es.js.map +1 -1
  12. package/dist/opengridx.umd.js +16 -13
  13. package/dist/opengridx.umd.js.map +1 -1
  14. package/docs/API_REFERENCE.md +132 -3
  15. package/docs/README.md +4 -1
  16. package/docs/architecture/datagrid-orchestration.md +23 -6
  17. package/docs/components/header.md +25 -0
  18. package/docs/customization/theming.md +12 -1
  19. package/docs/features/ai-toolkit.md +119 -0
  20. package/docs/features/cell-selection.md +131 -0
  21. package/docs/features/clipboard.md +90 -0
  22. package/docs/features/editing-reordering.md +50 -0
  23. package/docs/features/keyboard-navigation.md +25 -2
  24. package/docs/features/undo-redo.md +79 -0
  25. package/docs/roadmap.md +64 -7
  26. package/lib/ai/coerce.ts +90 -0
  27. package/lib/ai/columns.ts +90 -0
  28. package/lib/ai/index.ts +16 -0
  29. package/lib/ai/schema.ts +214 -0
  30. package/lib/ai/types.ts +88 -0
  31. package/lib/ai/validate.ts +286 -0
  32. package/lib/components/Cell/Cell.css +35 -0
  33. package/lib/components/Cell/Cell.tsx +25 -13
  34. package/lib/components/Cell/CellErrorBoundary.tsx +3 -0
  35. package/lib/components/Cell/GridEditInputCell.css +12 -4
  36. package/lib/components/Cell/GridEditInputCell.tsx +19 -12
  37. package/lib/components/CellSelectionStats/CellSelectionStats.css +31 -0
  38. package/lib/components/CellSelectionStats/CellSelectionStats.tsx +32 -0
  39. package/lib/components/CellSelectionStats/GridCellSelectionStatsArea.tsx +24 -0
  40. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
  41. package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
  42. package/lib/components/DataGrid/DataGrid.tsx +123 -3
  43. package/lib/components/DataGrid/GridLiveRegion.tsx +8 -2
  44. package/lib/components/DataGrid/GridPinnedRows.tsx +9 -0
  45. package/lib/components/DataGrid/GridToolbarSlot.tsx +5 -0
  46. package/lib/components/DataGrid/GridVirtualRows.tsx +9 -0
  47. package/lib/components/FilterPanel/FilterPanel.css +14 -0
  48. package/lib/components/FilterPanel/FilterPanel.tsx +5 -3
  49. package/lib/components/Header/Header.css +12 -0
  50. package/lib/components/Header/Header.tsx +17 -4
  51. package/lib/components/ListView/ListView.css +2 -4
  52. package/lib/components/ListView/ListViewRow.tsx +2 -2
  53. package/lib/components/Row/Row.tsx +38 -1
  54. package/lib/components/Toolbar/GlobalSearch.css +16 -0
  55. package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
  56. package/lib/components/Toolbar/GridToolbar.tsx +10 -4
  57. package/lib/components/ui/Checkbox.tsx +26 -10
  58. package/lib/components/ui/Input.css +43 -0
  59. package/lib/components/ui/Input.tsx +24 -5
  60. package/lib/hooks/core/gridApiPlaceholder.ts +14 -1
  61. package/lib/hooks/core/useDataGrid.ts +27 -0
  62. package/lib/hooks/core/useGridCellSelection.ts +631 -0
  63. package/lib/hooks/core/useGridCellSelectionApi.ts +97 -0
  64. package/lib/hooks/core/useGridClipboardApi.ts +8 -2
  65. package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
  66. package/lib/hooks/core/useGridControlledState.ts +38 -0
  67. package/lib/hooks/core/useGridKeyboardNavigation.ts +32 -0
  68. package/lib/hooks/core/useGridRowInteractions.ts +8 -4
  69. package/lib/hooks/core/useGridUndoRedo.ts +220 -0
  70. package/lib/hooks/features/useAggregation.ts +8 -6
  71. package/lib/hooks/features/useGridBatchEdit.ts +217 -0
  72. package/lib/hooks/features/useGridClipboard.ts +47 -2
  73. package/lib/hooks/features/useGridEditing.ts +39 -51
  74. package/lib/index.ts +16 -0
  75. package/lib/styles/base/variables.css +6 -1
  76. package/lib/styles/opengridx.css +3 -1
  77. package/lib/theme/DataGridThemeProvider.tsx +4 -0
  78. package/lib/theme/presets.ts +8 -0
  79. package/lib/theme/types.ts +4 -0
  80. package/lib/types/index.ts +254 -0
  81. package/lib/utils/attempt.ts +14 -0
  82. package/lib/utils/cellSelection.ts +238 -0
  83. package/lib/utils/editing/commit.ts +69 -0
  84. package/lib/utils/editing/targets.ts +109 -0
  85. package/lib/utils/headerWrap.ts +23 -0
  86. package/lib/utils/parsing.ts +252 -0
  87. package/llms.txt +71 -2
  88. package/package.json +14 -4
@@ -0,0 +1,220 @@
1
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { MutableRefObject, RefObject } from 'react';
3
+ import type {
4
+ GridApi,
5
+ GridBatchEditResult,
6
+ GridColDef,
7
+ GridHistoryChangeParams,
8
+ GridRowId,
9
+ GridRowModel,
10
+ } from '../../types';
11
+ import { classifyKeyTarget } from '../../utils/focus';
12
+ import type { FocusedCell } from '../../utils/focus';
13
+ import { scrollColumnIntoView, scrollRowIntoView } from '../../utils/scroll';
14
+ import { boundingTargetRect, buildEditTargetContext, rectCorners } from '../../utils/editing/targets';
15
+ import type { GridCellChange } from '../../utils/editing/commit';
16
+ import type { GridEditSource, UseGridBatchEditReturn } from '../features/useGridBatchEdit';
17
+ import type { UseGridCellSelectionReturn } from './useGridCellSelection';
18
+ import type { LayoutResult } from './useLayout';
19
+
20
+ /** Default number of actions `undoRedo` keeps. */
21
+ export const UNDO_HISTORY_LIMIT = 100;
22
+
23
+ type CommitListener = (changes: GridCellChange[], source: GridEditSource) => void;
24
+
25
+ export interface GridEditCommitChannel {
26
+ /** Passed to the commit paths (`useGridEditing`, `useGridBatchEdit`) as `onCommitted`. Stable. */
27
+ emit: CommitListener;
28
+ /** Set by `useGridUndoRedo`, which runs later in the hook order. Stable. */
29
+ setListener: (listener: CommitListener | null) => void;
30
+ }
31
+
32
+ /**
33
+ * Connects the commit paths, which run early in the hook order, to the history, which runs late
34
+ * (it needs the focus and selection hooks). Created before `useGridBatchEdit`.
35
+ */
36
+ export function useGridEditCommitChannel(): GridEditCommitChannel {
37
+ const listenerRef = useRef<CommitListener | null>(null);
38
+ const emit = useCallback<CommitListener>((changes, source) => {
39
+ const listener = listenerRef.current;
40
+ if (listener) listener(changes, source);
41
+ }, []);
42
+ const setListener = useCallback((listener: CommitListener | null) => {
43
+ listenerRef.current = listener;
44
+ }, []);
45
+ return useMemo(() => ({ emit, setListener }), [emit, setListener]);
46
+ }
47
+
48
+ export interface UseGridUndoRedoParams<R extends GridRowModel> {
49
+ apiRef: MutableRefObject<GridApi>;
50
+ enabled: boolean;
51
+ limit: number;
52
+ onHistoryChange?: (params: GridHistoryChangeParams) => void;
53
+ channel: GridEditCommitChannel;
54
+ batchEdit: UseGridBatchEditReturn<R>;
55
+ isEditing: boolean;
56
+ containerRef: RefObject<HTMLDivElement | null>;
57
+ viewportRef: RefObject<HTMLDivElement | null>;
58
+ selection: UseGridCellSelectionReturn;
59
+ setFocusedCell: React.Dispatch<React.SetStateAction<FocusedCell | null>>;
60
+ allRenderableRows: R[];
61
+ getRowId: (row: R) => GridRowId;
62
+ navigationColumns: ReadonlyArray<GridColDef<R>>;
63
+ layout: LayoutResult<R>;
64
+ rowHeight: number;
65
+ pinnedTopRowCount: number;
66
+ }
67
+
68
+ const emptyResult = (): GridBatchEditResult => ({ updated: [], failed: [], skipped: [] });
69
+
70
+ /** 'undo', 'redo' or null for a keydown. Ctrl/Cmd+Z, Ctrl/Cmd+Shift+Z, Ctrl+Y (also on non-Latin layouts). */
71
+ function historyShortcut(event: KeyboardEvent): 'undo' | 'redo' | null {
72
+ if (event.altKey || !(event.ctrlKey || event.metaKey)) return null;
73
+ const key = (event.key ?? '').toLowerCase();
74
+ const latin = /^[a-z]$/.test(key);
75
+ const isZ = key === 'z' || (!latin && event.code === 'KeyZ');
76
+ const isY = key === 'y' || (!latin && event.code === 'KeyY');
77
+ if (isZ) return event.shiftKey ? 'redo' : 'undo';
78
+ if (isY && event.ctrlKey && !event.shiftKey) return 'redo';
79
+ return null;
80
+ }
81
+
82
+ /**
83
+ * Undo / redo of cell edits, pastes and range clears (`undoRedo`, v3.4). An action is recorded only
84
+ * after `processRowUpdate` succeeded, as the changed cells with their value before and the stored
85
+ * value after. Undo writes the `before` values as a batch edit (so `valueSetter` and
86
+ * `processRowUpdate` run); a cell that no longer holds its `after` value is skipped (`'changed'`), a
87
+ * row that is gone too (`'missing'`). Redo is the reverse. Runs after useGridClipboardPaste.
88
+ */
89
+ export function useGridUndoRedo<R extends GridRowModel>(params: UseGridUndoRedoParams<R>): void {
90
+ const { apiRef, enabled, channel, containerRef } = params;
91
+ const latestRef = useRef(params);
92
+ useLayoutEffect(() => {
93
+ latestRef.current = params;
94
+ });
95
+
96
+ const undoStackRef = useRef<GridCellChange[][]>([]);
97
+ const redoStackRef = useRef<GridCellChange[][]>([]);
98
+ const lastEmittedRef = useRef<GridHistoryChangeParams>({ canUndo: false, canRedo: false, size: 0 });
99
+ // Undo and redo run one after the other, each on the rows the previous one stored.
100
+ const queueRef = useRef<Promise<unknown>>(Promise.resolve());
101
+
102
+ const notify = useCallback(() => {
103
+ const next = {
104
+ canUndo: undoStackRef.current.length > 0,
105
+ canRedo: redoStackRef.current.length > 0,
106
+ size: undoStackRef.current.length,
107
+ };
108
+ const last = lastEmittedRef.current;
109
+ if (last.canUndo === next.canUndo && last.canRedo === next.canRedo && last.size === next.size) return;
110
+ lastEmittedRef.current = next;
111
+ latestRef.current.onHistoryChange?.(next);
112
+ }, []);
113
+
114
+ const record = useCallback((changes: GridCellChange[]) => {
115
+ if (!latestRef.current.enabled || changes.length === 0) return;
116
+ const stack = undoStackRef.current;
117
+ stack.push(changes);
118
+ const limit = Math.max(1, latestRef.current.limit);
119
+ if (stack.length > limit) stack.splice(0, stack.length - limit);
120
+ redoStackRef.current = [];
121
+ notify();
122
+ }, [notify]);
123
+
124
+ useLayoutEffect(() => {
125
+ channel.setListener(enabled ? record : null);
126
+ return () => channel.setListener(null);
127
+ }, [channel, enabled, record]);
128
+
129
+ const clearHistory = useCallback(() => {
130
+ undoStackRef.current = [];
131
+ redoStackRef.current = [];
132
+ notify();
133
+ }, [notify]);
134
+
135
+ // Turning undoRedo off forgets the history.
136
+ useEffect(() => {
137
+ if (!enabled) clearHistory();
138
+ }, [enabled, clearHistory]);
139
+
140
+ /** Selects the affected cells (their bounding rectangle) and scrolls its first cell into view. */
141
+ const reveal = useCallback((cells: GridCellChange[]) => {
142
+ const p = latestRef.current;
143
+ const ctx = buildEditTargetContext(p.allRenderableRows, p.getRowId, p.navigationColumns);
144
+ const rect = boundingTargetRect(ctx, cells);
145
+ if (!rect) return;
146
+ const { anchor, head } = rectCorners(ctx, rect);
147
+ if (p.selection.enabled) p.selection.setModel([{ anchor, head }], 'keyboard');
148
+ else p.setFocusedCell({ id: anchor.id, field: anchor.field, rowIndex: rect.top });
149
+ const viewport = p.viewportRef.current;
150
+ if (!viewport) return;
151
+ scrollRowIntoView(viewport, rect.top - p.pinnedTopRowCount, p.layout.cumulativeHeights, p.layout.pinnedBottomHeight, p.rowHeight);
152
+ scrollColumnIntoView(viewport, p.layout.unpinnedColsWithWidth.findIndex(c => c.field === anchor.field), p.layout);
153
+ }, []);
154
+
155
+ /** Replays the top action of one stack onto the other. */
156
+ const replay = useCallback((direction: 'undo' | 'redo'): Promise<GridBatchEditResult> => {
157
+ const run = async (): Promise<GridBatchEditResult> => {
158
+ if (!latestRef.current.enabled) return emptyResult();
159
+ const from = direction === 'undo' ? undoStackRef : redoStackRef;
160
+ const to = direction === 'undo' ? redoStackRef : undoStackRef;
161
+ const action = from.current.pop();
162
+ if (!action) return emptyResult();
163
+ notify();
164
+ // Undo writes `before` where `after` still holds; redo writes `after` where `before` holds.
165
+ const edits = action.map(change => direction === 'undo'
166
+ ? { id: change.id, field: change.field, value: change.before, expect: { value: change.after } }
167
+ : { id: change.id, field: change.field, value: change.after, expect: { value: change.before } });
168
+ const outcome = await latestRef.current.batchEdit.applyEdits(edits, direction);
169
+ if (outcome.committed.length > 0) {
170
+ // The opposite action: for an undo, `before` is what it stored and `after` what it replaced.
171
+ to.current.push(direction === 'undo'
172
+ ? outcome.committed.map(c => ({ id: c.id, field: c.field, before: c.after, after: c.before }))
173
+ : outcome.committed);
174
+ }
175
+ // Rows whose processRowUpdate failed keep their part of the action, to try again.
176
+ const failedIds = new Set<GridRowId>(outcome.result.failed.map(f => f.id));
177
+ if (failedIds.size > 0) {
178
+ const remainder = action.filter(change => failedIds.has(change.id));
179
+ if (remainder.length > 0) from.current.push(remainder);
180
+ }
181
+ notify();
182
+ if (outcome.committed.length > 0) reveal(outcome.committed);
183
+ return outcome.result;
184
+ };
185
+ const next = queueRef.current.then(run, run);
186
+ queueRef.current = next;
187
+ return next;
188
+ }, [notify, reveal]);
189
+
190
+ const undo = useCallback(() => replay('undo'), [replay]);
191
+ const redo = useCallback(() => replay('redo'), [replay]);
192
+
193
+ useEffect(() => {
194
+ const root = containerRef.current;
195
+ if (!root || !enabled) return;
196
+ const onKeyDown = (event: KeyboardEvent) => {
197
+ if (event.defaultPrevented) return;
198
+ const shortcut = historyShortcut(event);
199
+ if (!shortcut) return;
200
+ const viewport = latestRef.current.viewportRef.current;
201
+ if (!viewport || latestRef.current.isEditing) return;
202
+ // Inside an editor or a text field the input's own undo wins.
203
+ const kind = classifyKeyTarget(event.target, viewport);
204
+ if (kind !== 'grid' && kind !== 'control') return;
205
+ event.preventDefault();
206
+ void (shortcut === 'undo' ? undo() : redo());
207
+ };
208
+ root.addEventListener('keydown', onKeyDown);
209
+ return () => root.removeEventListener('keydown', onKeyDown);
210
+ }, [containerRef, enabled, undo, redo]);
211
+
212
+ useLayoutEffect(() => {
213
+ const api = apiRef.current;
214
+ api.undo = undo;
215
+ api.redo = redo;
216
+ api.canUndo = () => latestRef.current.enabled && undoStackRef.current.length > 0;
217
+ api.canRedo = () => latestRef.current.enabled && redoStackRef.current.length > 0;
218
+ api.clearHistory = clearHistory;
219
+ }, [apiRef, undo, redo, clearHistory]);
220
+ }
@@ -108,14 +108,16 @@ export function useAggregation<R extends GridRowModel>(
108
108
 
109
109
  let cancelled = false;
110
110
  const request = latestRequestRef.current;
111
+ // Built outside the try: React Compiler cannot compile `??` inside a try statement yet.
112
+ const query = {
113
+ sortModel: request.sortModel ?? [],
114
+ filterModel: request.filterModel ?? { items: [] },
115
+ groupKeys: [],
116
+ aggregationModel: request.aggregationModel,
117
+ };
111
118
  let pending: Promise<GridAggregationResult>;
112
119
  try {
113
- pending = Promise.resolve(getAggregations({
114
- sortModel: request.sortModel ?? [],
115
- filterModel: request.filterModel ?? { items: [] },
116
- groupKeys: [],
117
- aggregationModel: request.aggregationModel,
118
- }));
120
+ pending = Promise.resolve(getAggregations(query));
119
121
  } catch (err) {
120
122
  pending = Promise.reject(err);
121
123
  }
@@ -0,0 +1,217 @@
1
+ import { useCallback, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { GridBatchEditResult, GridColDef, GridEditSkipReason, GridRowId, GridRowModel } from '../../types';
3
+ import { attempt } from '../../utils/attempt';
4
+ import { getCellValue } from '../../utils/values';
5
+ import {
6
+ buildEditedRow,
7
+ invalidProcessedRowError,
8
+ isPromiseLike,
9
+ isSameValue,
10
+ reportEditError,
11
+ } from '../../utils/editing/commit';
12
+ import type { GridCellChange } from '../../utils/editing/commit';
13
+
14
+ /** What made a commit: history records `edit`, `paste` and `clear`; `undo` / `redo` replay it. */
15
+ export type GridEditSource = 'edit' | 'paste' | 'clear' | 'undo' | 'redo';
16
+
17
+ /** One cell to write in a batch edit. */
18
+ export interface GridBatchCellEdit {
19
+ id: GridRowId;
20
+ field: string;
21
+ value: unknown;
22
+ /**
23
+ * Undo/redo: the value the cell must still hold. When it holds something else the cell is
24
+ * skipped with reason `'changed'` (values are compared, not row objects).
25
+ */
26
+ expect?: { value: unknown };
27
+ }
28
+
29
+ /** A batch edit's result, plus the cell changes that were stored (what history records). */
30
+ export interface GridBatchEditOutcome {
31
+ result: GridBatchEditResult;
32
+ committed: GridCellChange[];
33
+ }
34
+
35
+ export interface UseGridBatchEditParams<R extends GridRowModel> {
36
+ /** The rendered row store (`state.rows.idRowsLookup`). */
37
+ rowsLookup: ReadonlyMap<GridRowId, R>;
38
+ columns: GridColDef<R>[];
39
+ processRowUpdate?: (newRow: R, oldRow: R) => R | Promise<R>;
40
+ onProcessRowUpdateError?: (error: unknown) => void;
41
+ /** Stores rows in one update (`replaceRows` of the row store). */
42
+ replaceRows: (rows: ReadonlyMap<GridRowId, R>) => void;
43
+ /** Receives the stored changes of every commit that is not an undo or redo (history). */
44
+ onCommitted?: (changes: GridCellChange[], source: GridEditSource) => void;
45
+ }
46
+
47
+ export interface UseGridBatchEditReturn<R extends GridRowModel> {
48
+ /**
49
+ * Writes cells through `valueSetter` → `processRowUpdate` (once per row, rows in parallel) →
50
+ * the store (one update for every row that succeeded). A row that throws or rejects is reported
51
+ * to `onProcessRowUpdateError` and does not stop the others. Cells whose value does not change
52
+ * are left alone. Resolves when every row has settled.
53
+ */
54
+ applyEdits: (edits: readonly GridBatchCellEdit[], source: GridEditSource) => Promise<GridBatchEditOutcome>;
55
+ /** The latest row under `id`, including rows stored but not rendered yet. */
56
+ getRow: (id: GridRowId) => R | undefined;
57
+ /** Stores one committed row (a single-cell edit), so `getRow` sees it before the next render. */
58
+ storeRow: (id: GridRowId, row: R) => void;
59
+ }
60
+
61
+ interface RowCell<R extends GridRowModel> {
62
+ field: string;
63
+ value: unknown;
64
+ colDef: GridColDef<R> | undefined;
65
+ order: number;
66
+ }
67
+
68
+ interface RowGroup<R extends GridRowModel> {
69
+ id: GridRowId;
70
+ row: R;
71
+ cells: RowCell<R>[];
72
+ }
73
+
74
+ type Settled = { id: GridRowId; ok: true; processed: unknown } | { id: GridRowId; ok: false; error: unknown };
75
+
76
+ /**
77
+ * Batch edits: several cells committed together through the single-edit pipeline (paste, range clear,
78
+ * undo, redo). One `processRowUpdate` per row, one store update for the batch, failures isolated per
79
+ * row and reported through the same path as single edits. The returned functions are stable.
80
+ */
81
+ export function useGridBatchEdit<R extends GridRowModel>(params: UseGridBatchEditParams<R>): UseGridBatchEditReturn<R> {
82
+ const latestRef = useRef(params);
83
+ // Rows stored since the last render. The rendered lookup catches up on the next render, when
84
+ // this is emptied; until then an undo right after a paste still reads the pasted rows.
85
+ const overlayRef = useRef(new Map<GridRowId, R>());
86
+ const warnedFieldsRef = useRef(new Set<string>());
87
+ useLayoutEffect(() => {
88
+ latestRef.current = params;
89
+ overlayRef.current.clear();
90
+ });
91
+
92
+ const getRow = useCallback((id: GridRowId): R | undefined => {
93
+ const pending = overlayRef.current.get(id);
94
+ return pending === undefined ? latestRef.current.rowsLookup.get(id) : pending;
95
+ }, []);
96
+
97
+ const store = useCallback((rows: Map<GridRowId, R>) => {
98
+ if (rows.size === 0) return;
99
+ rows.forEach((row, id) => { overlayRef.current.set(id, row); });
100
+ latestRef.current.replaceRows(rows);
101
+ }, []);
102
+
103
+ const storeRow = useCallback((id: GridRowId, row: R) => {
104
+ store(new Map([[id, row]]));
105
+ }, [store]);
106
+
107
+ const applyEdits = useCallback((edits: readonly GridBatchCellEdit[], source: GridEditSource): Promise<GridBatchEditOutcome> => {
108
+ const { columns, processRowUpdate } = latestRef.current;
109
+ const report = (error: unknown) => reportEditError(error, latestRef.current.onProcessRowUpdateError);
110
+ const columnOrder = new Map<string, number>();
111
+ const columnByField = new Map<string, GridColDef<R>>();
112
+ columns.forEach((col, index) => {
113
+ columnOrder.set(col.field, index);
114
+ columnByField.set(col.field, col);
115
+ });
116
+
117
+ const skipped: { id: GridRowId; field: string; reason: GridEditSkipReason }[] = [];
118
+ const groups = new Map<GridRowId, RowGroup<R>>();
119
+ for (const edit of edits) {
120
+ const row = getRow(edit.id);
121
+ if (row === undefined) {
122
+ skipped.push({ id: edit.id, field: edit.field, reason: 'missing' });
123
+ continue;
124
+ }
125
+ const colDef = columnByField.get(edit.field);
126
+ if (edit.expect && !isSameValue(getCellValue(row, edit.field, colDef), edit.expect.value)) {
127
+ skipped.push({ id: edit.id, field: edit.field, reason: 'changed' });
128
+ continue;
129
+ }
130
+ let group = groups.get(edit.id);
131
+ if (!group) {
132
+ group = { id: edit.id, row, cells: [] };
133
+ groups.set(edit.id, group);
134
+ }
135
+ const order = columnOrder.get(edit.field) ?? Number.MAX_SAFE_INTEGER;
136
+ const existing = group.cells.findIndex(cell => cell.field === edit.field);
137
+ const cell = { field: edit.field, value: edit.value, colDef, order };
138
+ // The same cell twice: the last value wins.
139
+ if (existing === -1) group.cells.push(cell);
140
+ else group.cells[existing] = cell;
141
+ }
142
+
143
+ const settled: Settled[] = [];
144
+ const pending: Promise<Settled>[] = [];
145
+ const warned = warnedFieldsRef.current;
146
+ groups.forEach(group => {
147
+ const { id, row } = group;
148
+ // Cells that already hold the value are not written; a row with none left is left alone.
149
+ const cells = group.cells
150
+ .filter(cell => !isSameValue(getCellValue(row, cell.field, cell.colDef), cell.value))
151
+ .sort((a, b) => a.order - b.order);
152
+ group.cells = cells;
153
+ if (cells.length === 0) return;
154
+ const outcome = attempt<unknown>(() => {
155
+ let newRow = row;
156
+ for (const cell of cells) newRow = buildEditedRow(newRow, cell, cell.colDef, warned);
157
+ return processRowUpdate ? processRowUpdate(newRow, row) : newRow;
158
+ });
159
+ if (!outcome.ok) {
160
+ settled.push({ id, ok: false, error: outcome.error });
161
+ return;
162
+ }
163
+ const value = outcome.value;
164
+ if (isPromiseLike(value)) {
165
+ pending.push(Promise.resolve(value).then(
166
+ (processed): Settled => ({ id, ok: true, processed }),
167
+ (error: unknown): Settled => ({ id, ok: false, error }),
168
+ ));
169
+ } else {
170
+ settled.push({ id, ok: true, processed: value });
171
+ }
172
+ });
173
+
174
+ const finish = (all: Settled[]): GridBatchEditOutcome => {
175
+ const byId = new Map<GridRowId, Settled>();
176
+ all.forEach(entry => { byId.set(entry.id, entry); });
177
+ const updated: GridRowId[] = [];
178
+ const failed: { id: GridRowId; error: unknown }[] = [];
179
+ const committed: GridCellChange[] = [];
180
+ const stored = new Map<GridRowId, R>();
181
+ groups.forEach(group => {
182
+ const entry = byId.get(group.id);
183
+ if (!entry) return;
184
+ if (!entry.ok) {
185
+ failed.push({ id: group.id, error: entry.error });
186
+ report(entry.error);
187
+ return;
188
+ }
189
+ const processed = entry.processed;
190
+ if (typeof processed !== 'object' || processed === null) {
191
+ const error = invalidProcessedRowError(processed, 'The row was not saved.');
192
+ failed.push({ id: group.id, error });
193
+ report(error);
194
+ return;
195
+ }
196
+ const storedRow = processed as R;
197
+ stored.set(group.id, storedRow);
198
+ updated.push(group.id);
199
+ for (const cell of group.cells) {
200
+ const before = getCellValue(group.row, cell.field, cell.colDef);
201
+ const after = getCellValue(storedRow, cell.field, cell.colDef);
202
+ if (!isSameValue(before, after)) committed.push({ id: group.id, field: cell.field, before, after });
203
+ }
204
+ });
205
+ store(stored);
206
+ if (committed.length > 0 && source !== 'undo' && source !== 'redo') {
207
+ latestRef.current.onCommitted?.(committed, source);
208
+ }
209
+ return { result: { updated, failed, skipped }, committed };
210
+ };
211
+
212
+ if (pending.length === 0) return Promise.resolve(finish(settled));
213
+ return Promise.all(pending).then(results => finish([...settled, ...results]));
214
+ }, [getRow, store]);
215
+
216
+ return useMemo(() => ({ applyEdits, getRow, storeRow }), [applyEdits, getRow, storeRow]);
217
+ }
@@ -27,6 +27,11 @@ export interface UseGridClipboardProps {
27
27
  getRootElement?: () => HTMLElement | null;
28
28
  /** Turns the Ctrl/Cmd+C shortcut off. `copySelectedRows` still works. */
29
29
  disableKeyboardShortcut?: boolean;
30
+ /**
31
+ * When given, Ctrl/Cmd+C writes the text it returns instead of copying the selected rows, and
32
+ * writes nothing when it returns `null` (cell range selection).
33
+ */
34
+ getShortcutText?: () => string | null;
30
35
  }
31
36
 
32
37
  /** Grid-made columns that never hold row data. */
@@ -58,6 +63,39 @@ export function buildClipboardTsv(rows: GridRowModel[], columns: GridColDef[]):
58
63
  return [header, ...lines].join('\n');
59
64
  }
60
65
 
66
+ /** A cell value formatted the way the cell shows it; a valueFormatter that throws gives the raw text. */
67
+ function formatCopyValue(row: GridRowModel, col: GridColDef): string {
68
+ const value = getCellValue(row, col.field, col);
69
+ try {
70
+ return formatExportValue(row, col, value);
71
+ } catch {
72
+ return value == null ? '' : String(value);
73
+ }
74
+ }
75
+
76
+ /**
77
+ * A cell range as Excel and Google Sheets copy it: tabs between columns, line breaks between rows and
78
+ * no header line. `rows` are the data rows of the range (synthetic rows already left out); a position
79
+ * for which `isCovered` returns true (covered by a span whose origin sits elsewhere) is left empty.
80
+ */
81
+ export function buildRangeTsv(
82
+ rows: readonly GridRowModel[],
83
+ columns: readonly GridColDef[],
84
+ isCovered?: (row: GridRowModel, col: GridColDef) => boolean,
85
+ ): string {
86
+ const lines: string[] = new Array(rows.length);
87
+ for (let r = 0; r < rows.length; r++) {
88
+ const row = rows[r];
89
+ const cells: string[] = new Array(columns.length);
90
+ for (let c = 0; c < columns.length; c++) {
91
+ const col = columns[c];
92
+ cells[c] = isCovered?.(row, col) ? '' : escapeTsvField(formatCopyValue(row, col));
93
+ }
94
+ lines[r] = cells.join('\t');
95
+ }
96
+ return lines.join('\n');
97
+ }
98
+
61
99
  /**
62
100
  * The textarea + execCommand fallback, for browsers without the async Clipboard API (plain http)
63
101
  * or when it rejects. It has to focus the textarea, so focus and the page selection are put back
@@ -98,7 +136,7 @@ function copyTextSynchronous(text: string): boolean {
98
136
  * Writes text to the clipboard: the async Clipboard API when there is one, the execCommand
99
137
  * fallback when it is missing or rejects. Rejects when neither worked.
100
138
  */
101
- async function writeToClipboard(text: string): Promise<void> {
139
+ export async function writeToClipboard(text: string): Promise<void> {
102
140
  const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
103
141
  if (clipboard && typeof clipboard.writeText === 'function') {
104
142
  try {
@@ -180,7 +218,14 @@ export function useGridClipboard(props: UseGridClipboardProps) {
180
218
  const selection = window.getSelection();
181
219
  if (selection && !selection.isCollapsed && selection.toString() !== '') return;
182
220
 
183
- copySelectedRows().catch((err: unknown) => {
221
+ const { getShortcutText } = latestRef.current;
222
+ const copy = getShortcutText
223
+ ? () => {
224
+ const text = getShortcutText();
225
+ return text === null ? Promise.resolve() : writeToClipboard(text);
226
+ }
227
+ : copySelectedRows;
228
+ copy().catch((err: unknown) => {
184
229
  console.error('[OpenGridX] Failed to copy to clipboard:', err);
185
230
  });
186
231
  };