@opencorestack/opengridx 3.3.0 → 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 +31 -0
- package/README.md +4 -1
- 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 +148 -2
- package/dist/opengridx.css +1 -1
- package/dist/opengridx.es.js +6084 -5523
- package/dist/opengridx.es.js.map +1 -1
- package/dist/opengridx.umd.js +17 -15
- package/dist/opengridx.umd.js.map +1 -1
- package/docs/API_REFERENCE.md +98 -1
- package/docs/README.md +3 -1
- package/docs/architecture/datagrid-orchestration.md +10 -1
- package/docs/customization/theming.md +9 -0
- package/docs/features/ai-toolkit.md +119 -0
- package/docs/features/clipboard.md +88 -0
- package/docs/features/editing-reordering.md +50 -0
- package/docs/features/keyboard-navigation.md +11 -0
- package/docs/features/undo-redo.md +79 -0
- package/docs/roadmap.md +1 -1
- 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 +5 -0
- package/lib/components/Cell/GridEditInputCell.css +12 -4
- package/lib/components/Cell/GridEditInputCell.tsx +19 -12
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.css +14 -0
- package/lib/components/ColumnVisibilityPanel/ColumnVisibilityPanel.tsx +3 -2
- package/lib/components/DataGrid/DataGrid.tsx +62 -2
- package/lib/components/FilterPanel/FilterPanel.css +14 -0
- package/lib/components/FilterPanel/FilterPanel.tsx +3 -2
- package/lib/components/ListView/ListView.css +2 -4
- package/lib/components/ListView/ListViewRow.tsx +2 -2
- package/lib/components/Toolbar/GlobalSearch.css +16 -0
- package/lib/components/Toolbar/GlobalSearch.tsx +3 -2
- package/lib/components/ui/Checkbox.tsx +16 -5
- package/lib/components/ui/Input.css +43 -0
- package/lib/components/ui/Input.tsx +24 -5
- package/lib/hooks/core/gridApiPlaceholder.ts +8 -1
- package/lib/hooks/core/useDataGrid.ts +27 -0
- package/lib/hooks/core/useGridCellSelection.ts +30 -2
- package/lib/hooks/core/useGridClipboardPaste.ts +283 -0
- package/lib/hooks/core/useGridUndoRedo.ts +220 -0
- package/lib/hooks/features/useGridBatchEdit.ts +217 -0
- package/lib/hooks/features/useGridEditing.ts +24 -45
- package/lib/index.ts +7 -0
- package/lib/styles/opengridx.css +2 -1
- package/lib/types/index.ts +124 -0
- package/lib/utils/editing/commit.ts +69 -0
- package/lib/utils/editing/targets.ts +109 -0
- package/lib/utils/parsing.ts +252 -0
- package/llms.txt +55 -1
- package/package.json +8 -2
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef, useCallback, useLayoutEffect } from 'react';
|
|
2
|
+
import { Input } from '../ui/Input';
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
interface GlobalSearchProps {
|
|
@@ -143,9 +144,9 @@ export function GlobalSearch({ value = '', onChange, placeholder = 'Search...',
|
|
|
143
144
|
<div className="ogx-global-search__icon-wrapper">
|
|
144
145
|
<SearchIcon />
|
|
145
146
|
</div>
|
|
146
|
-
<
|
|
147
|
+
<Input
|
|
147
148
|
ref={inputRef}
|
|
148
|
-
|
|
149
|
+
inputClassName="ogx-global-search__input"
|
|
149
150
|
value={localValue}
|
|
150
151
|
onChange={(e) => setLocalValue(e.target.value)}
|
|
151
152
|
onFocus={handleFocus}
|
|
@@ -4,7 +4,10 @@ import React from 'react';
|
|
|
4
4
|
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
|
5
5
|
indeterminate?: boolean;
|
|
6
6
|
label?: string;
|
|
7
|
+
/** A ref to the `<input>`; the forwarded `ref` reaches it as well. */
|
|
7
8
|
inputRef?: React.Ref<HTMLInputElement>;
|
|
9
|
+
/** Classes added to the `<input>` itself, next to `ogx-checkbox__input`. `className` goes to the wrapper `<label>`. */
|
|
10
|
+
inputClassName?: string;
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
/** Points a callback or object ref at `element`. Outside the component, so it does not mutate a prop in render scope. */
|
|
@@ -16,13 +19,18 @@ function assignRef(ref: React.Ref<HTMLInputElement> | undefined, element: HTMLIn
|
|
|
16
19
|
}
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
/**
|
|
23
|
+
* A styled checkbox: a visually hidden `<input type="checkbox">` inside a `<label>`. Other props
|
|
24
|
+
* (`onChange`, `onMouseDown`, `tabIndex`, `aria-*`, …) go to the `<input>`, and so does `ref`.
|
|
25
|
+
*/
|
|
26
|
+
export const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox({
|
|
20
27
|
indeterminate = false,
|
|
21
28
|
label,
|
|
22
29
|
className = '',
|
|
30
|
+
inputClassName = '',
|
|
23
31
|
inputRef,
|
|
24
32
|
...props
|
|
25
|
-
})
|
|
33
|
+
}, forwardedRef) {
|
|
26
34
|
const internalRef = React.useRef<HTMLInputElement>(null);
|
|
27
35
|
|
|
28
36
|
// Combine refs
|
|
@@ -30,7 +38,8 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
|
|
30
38
|
internalRef.current = element;
|
|
31
39
|
|
|
32
40
|
assignRef(inputRef, element);
|
|
33
|
-
|
|
41
|
+
assignRef(forwardedRef, element);
|
|
42
|
+
}, [inputRef, forwardedRef]);
|
|
34
43
|
|
|
35
44
|
React.useEffect(() => {
|
|
36
45
|
if (internalRef.current) {
|
|
@@ -51,7 +60,7 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
|
|
51
60
|
<input
|
|
52
61
|
ref={setRef}
|
|
53
62
|
type="checkbox"
|
|
54
|
-
className=
|
|
63
|
+
className={['ogx-checkbox__input', inputClassName].filter(Boolean).join(' ')}
|
|
55
64
|
{...props}
|
|
56
65
|
/>
|
|
57
66
|
<span className="ogx-checkbox__box">
|
|
@@ -69,4 +78,6 @@ export const Checkbox: React.FC<CheckboxProps> = ({
|
|
|
69
78
|
{label && <span className="ogx-checkbox__label">{label}</span>}
|
|
70
79
|
</label>
|
|
71
80
|
);
|
|
72
|
-
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
Checkbox.displayName = 'Checkbox';
|
|
@@ -57,3 +57,46 @@
|
|
|
57
57
|
.ogx-input-wrapper--disabled .ogx-input {
|
|
58
58
|
cursor: not-allowed;
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
/* ── Cell variant: an editor that fills a grid cell ───────────────────── */
|
|
62
|
+
.ogx-input-wrapper--cell {
|
|
63
|
+
/* Not positioned: the editing cell's focus outline must stay painted over the editor. */
|
|
64
|
+
position: static;
|
|
65
|
+
display: flex;
|
|
66
|
+
width: 100%;
|
|
67
|
+
height: 100%;
|
|
68
|
+
border: none;
|
|
69
|
+
border-radius: 0;
|
|
70
|
+
background: transparent;
|
|
71
|
+
box-shadow: none;
|
|
72
|
+
transition: none;
|
|
73
|
+
}
|
|
74
|
+
.ogx-input-wrapper--cell:hover:not(.ogx-input-wrapper--disabled),
|
|
75
|
+
.ogx-input-wrapper--cell:focus-within:not(.ogx-input-wrapper--disabled) {
|
|
76
|
+
box-shadow: none;
|
|
77
|
+
}
|
|
78
|
+
.ogx-input-wrapper--cell .ogx-input {
|
|
79
|
+
width: 100%;
|
|
80
|
+
min-width: 0;
|
|
81
|
+
height: 100%;
|
|
82
|
+
padding: 0 10px;
|
|
83
|
+
font-family: inherit;
|
|
84
|
+
font-size: inherit;
|
|
85
|
+
line-height: normal;
|
|
86
|
+
color: inherit;
|
|
87
|
+
}
|
|
88
|
+
.ogx-input-wrapper--cell .ogx-input__adornment--start {
|
|
89
|
+
padding-left: 10px;
|
|
90
|
+
}
|
|
91
|
+
.ogx-input-wrapper--cell .ogx-input__adornment--start + .ogx-input {
|
|
92
|
+
padding-left: 4px;
|
|
93
|
+
}
|
|
94
|
+
.ogx-input-wrapper--cell .ogx-input__adornment--end {
|
|
95
|
+
padding-right: 10px;
|
|
96
|
+
}
|
|
97
|
+
.ogx-input-wrapper--cell.ogx-input-wrapper--error,
|
|
98
|
+
.ogx-input-wrapper--cell.ogx-input-wrapper--error:focus-within:not(.ogx-input-wrapper--disabled) {
|
|
99
|
+
/* Positioned, so the error edge is painted over the cell's focus outline. */
|
|
100
|
+
position: relative;
|
|
101
|
+
box-shadow: inset 0 0 0 2px var(--ogx-color-error);
|
|
102
|
+
}
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
import React from 'react';
|
|
3
2
|
|
|
4
3
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
@@ -6,24 +5,40 @@ export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement>
|
|
|
6
5
|
error?: boolean;
|
|
7
6
|
startAdornment?: React.ReactNode;
|
|
8
7
|
endAdornment?: React.ReactNode;
|
|
8
|
+
/** Classes added to the `<input>` itself, next to `ogx-input`. `className` goes to the wrapper. */
|
|
9
|
+
inputClassName?: string;
|
|
10
|
+
/**
|
|
11
|
+
* `'field'` (default): a bordered form field. `'cell'`: fills a grid cell with no border,
|
|
12
|
+
* background or focus ring, in the cell's font, for use in `renderEditCell`.
|
|
13
|
+
*/
|
|
14
|
+
variant?: 'field' | 'cell';
|
|
9
15
|
}
|
|
10
16
|
|
|
11
|
-
|
|
17
|
+
/**
|
|
18
|
+
* A text field: an `<input>` inside an `.ogx-input-wrapper` that holds the adornments.
|
|
19
|
+
* `ref` reaches the `<input>` (React 18 and 19).
|
|
20
|
+
*/
|
|
21
|
+
export const Input = React.forwardRef<HTMLInputElement, InputProps>(function Input({
|
|
12
22
|
fullWidth = false,
|
|
13
23
|
error = false,
|
|
14
24
|
startAdornment,
|
|
15
25
|
endAdornment,
|
|
16
26
|
className = '',
|
|
27
|
+
inputClassName = '',
|
|
28
|
+
variant = 'field',
|
|
17
29
|
...props
|
|
18
|
-
})
|
|
30
|
+
}, ref) {
|
|
19
31
|
const wrapperClassNames = [
|
|
20
32
|
'ogx-input-wrapper',
|
|
33
|
+
variant === 'cell' && 'ogx-input-wrapper--cell',
|
|
21
34
|
fullWidth && 'ogx-input-wrapper--full-width',
|
|
22
35
|
error && 'ogx-input-wrapper--error',
|
|
23
36
|
props.disabled && 'ogx-input-wrapper--disabled',
|
|
24
37
|
className
|
|
25
38
|
].filter(Boolean).join(' ');
|
|
26
39
|
|
|
40
|
+
const inputClassNames = ['ogx-input', inputClassName].filter(Boolean).join(' ');
|
|
41
|
+
|
|
27
42
|
return (
|
|
28
43
|
<div className={wrapperClassNames}>
|
|
29
44
|
{startAdornment && (
|
|
@@ -32,7 +47,9 @@ export const Input: React.FC<InputProps> = ({
|
|
|
32
47
|
</div>
|
|
33
48
|
)}
|
|
34
49
|
<input
|
|
35
|
-
|
|
50
|
+
ref={ref}
|
|
51
|
+
className={inputClassNames}
|
|
52
|
+
aria-invalid={error ? true : undefined}
|
|
36
53
|
{...props}
|
|
37
54
|
/>
|
|
38
55
|
{endAdornment && (
|
|
@@ -42,4 +59,6 @@ export const Input: React.FC<InputProps> = ({
|
|
|
42
59
|
)}
|
|
43
60
|
</div>
|
|
44
61
|
);
|
|
45
|
-
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
Input.displayName = 'Input';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { GridApi, GridRowModel, GridValidRowModel } from '../../types';
|
|
1
|
+
import type { GridApi, GridBatchEditResult, GridRowModel, GridValidRowModel } from '../../types';
|
|
2
2
|
|
|
3
3
|
const noop = () => {};
|
|
4
|
+
const emptyEditResult = (): Promise<GridBatchEditResult> => Promise.resolve({ updated: [], failed: [], skipped: [] });
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* A complete GridApi whose methods are safe no-ops. `useGridApiRef()` starts with one so
|
|
@@ -38,5 +39,11 @@ export function createGridApiPlaceholder<R extends GridValidRowModel = GridRowMo
|
|
|
38
39
|
clearCellSelection: noop,
|
|
39
40
|
getSelectedCells: () => [],
|
|
40
41
|
copySelectedCells: () => Promise.resolve(),
|
|
42
|
+
pasteText: emptyEditResult,
|
|
43
|
+
undo: emptyEditResult,
|
|
44
|
+
redo: emptyEditResult,
|
|
45
|
+
canUndo: () => false,
|
|
46
|
+
canRedo: () => false,
|
|
47
|
+
clearHistory: noop,
|
|
41
48
|
};
|
|
42
49
|
}
|
|
@@ -18,6 +18,7 @@ type GetRowId = (row: GridRowModel) => GridRowId;
|
|
|
18
18
|
type GridAction =
|
|
19
19
|
| { type: 'SET_ROWS'; payload: GridRowModel[] | ((prev: GridRowModel[]) => GridRowModel[]); getRowId: GetRowId }
|
|
20
20
|
| { type: 'REPLACE_ROW'; id: GridRowId; row: GridRowModel }
|
|
21
|
+
| { type: 'REPLACE_ROWS'; rows: ReadonlyMap<GridRowId, GridRowModel> }
|
|
21
22
|
| { type: 'SET_COLUMNS'; payload: GridColDef[] }
|
|
22
23
|
| { type: 'SET_DIMENSIONS'; payload: { viewportWidth: number; viewportHeight: number } }
|
|
23
24
|
| { type: 'SET_DATASOURCE_LOADING'; payload: boolean }
|
|
@@ -134,6 +135,26 @@ function gridReducer(state: GridState, action: GridAction): GridState {
|
|
|
134
135
|
};
|
|
135
136
|
}
|
|
136
137
|
|
|
138
|
+
case 'REPLACE_ROWS': {
|
|
139
|
+
// A batch edit (paste, range clear, undo, redo): every stored row in one update.
|
|
140
|
+
let changed = false;
|
|
141
|
+
const idRowsLookup = new Map(state.rows.idRowsLookup);
|
|
142
|
+
const idByRow = new Map(state.rows.idByRow);
|
|
143
|
+
for (const [id, row] of action.rows) {
|
|
144
|
+
const previous = idRowsLookup.get(id);
|
|
145
|
+
if (previous === undefined) continue;
|
|
146
|
+
changed = true;
|
|
147
|
+
idRowsLookup.set(id, row);
|
|
148
|
+
idByRow.delete(previous);
|
|
149
|
+
idByRow.set(row, id);
|
|
150
|
+
}
|
|
151
|
+
if (!changed) return state;
|
|
152
|
+
return {
|
|
153
|
+
...state,
|
|
154
|
+
rows: { ...state.rows, idRowsLookup, idByRow },
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
137
158
|
case 'SET_COLUMNS': {
|
|
138
159
|
const columnLookup = new Map<string, GridColDef>();
|
|
139
160
|
const orderedFields: string[] = [];
|
|
@@ -270,6 +291,11 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
270
291
|
dispatch({ type: 'REPLACE_ROW', id, row });
|
|
271
292
|
}, []);
|
|
272
293
|
|
|
294
|
+
/** Replaces several stored rows in one update (a batch edit); ids no longer stored are ignored. */
|
|
295
|
+
const replaceRows = useCallback((rowsById: ReadonlyMap<GridRowId, GridRowModel>) => {
|
|
296
|
+
if (rowsById.size > 0) dispatch({ type: 'REPLACE_ROWS', rows: rowsById });
|
|
297
|
+
}, []);
|
|
298
|
+
|
|
273
299
|
const setColumns = useCallback((newColumns: GridColDef[]) => {
|
|
274
300
|
dispatch({ type: 'SET_COLUMNS', payload: newColumns });
|
|
275
301
|
}, []);
|
|
@@ -307,6 +333,7 @@ export function useDataGrid<R extends GridRowModel = GridRowModel>(params: UseDa
|
|
|
307
333
|
apiRef,
|
|
308
334
|
setRows,
|
|
309
335
|
replaceRow,
|
|
336
|
+
replaceRows,
|
|
310
337
|
setColumns,
|
|
311
338
|
setDimensions,
|
|
312
339
|
setDataSourceLoading,
|
|
@@ -116,6 +116,11 @@ export interface UseGridCellSelectionReturn {
|
|
|
116
116
|
reportFocus: (cell: FocusedCell | null) => void;
|
|
117
117
|
/** Sets the model, keeping only the first range, unless it is unchanged. */
|
|
118
118
|
setModel: (model: GridCellSelectionModel, reason: GridCellSelectionReason) => void;
|
|
119
|
+
/**
|
|
120
|
+
* The current range (a pending model included) and where it lies, read at call time. Its indices
|
|
121
|
+
* address the renderable rows and the range columns of `navigationColumns` (`isRangeColumnField`).
|
|
122
|
+
*/
|
|
123
|
+
getCurrentRange: () => { range: GridCellSelectionModel[number] | null; resolved: ResolvedCellRange | null };
|
|
119
124
|
}
|
|
120
125
|
|
|
121
126
|
interface DragState {
|
|
@@ -392,6 +397,21 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
392
397
|
return field === undefined ? null : { id, field };
|
|
393
398
|
}, []);
|
|
394
399
|
|
|
400
|
+
/** The row whose band holds `centerY` (px from the top of the unpinned rows), with `field`. */
|
|
401
|
+
const headRowAtOffset = useCallback((centerY: number, field: string): GridCellCoordinates | null => {
|
|
402
|
+
const { context: ctx, layout, pinnedTopRowCount } = latestRef.current;
|
|
403
|
+
const heights = layout.cumulativeHeights;
|
|
404
|
+
if (!ctx || heights.length === 0 || centerY < 0) return null;
|
|
405
|
+
let lo = 0;
|
|
406
|
+
let hi = heights.length - 1;
|
|
407
|
+
while (lo < hi) {
|
|
408
|
+
const mid = (lo + hi) >> 1;
|
|
409
|
+
if (heights[mid] > centerY) hi = mid; else lo = mid + 1;
|
|
410
|
+
}
|
|
411
|
+
const id = ctx.rowIds[lo + pinnedTopRowCount];
|
|
412
|
+
return id === undefined ? null : { id, field };
|
|
413
|
+
}, []);
|
|
414
|
+
|
|
395
415
|
// The auto-scroll loop schedules the next frame through this ref: a callback cannot name itself.
|
|
396
416
|
const runDragFrameRef = useRef<(allowScroll: boolean) => void>(() => {});
|
|
397
417
|
const runDragFrame = useCallback((allowScroll: boolean) => {
|
|
@@ -416,7 +436,14 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
416
436
|
const x = clamp(drag.x, box.left + 1, box.left + width - 2);
|
|
417
437
|
let y = drag.y;
|
|
418
438
|
if (y < box.top || y > box.top + height || y < headerBottom) y = clamp(y, bodyTop + 1, Math.max(bodyTop + 1, bodyBottom - 2));
|
|
419
|
-
|
|
439
|
+
// During fast auto-scroll the rows under the pointer may not be rendered yet (virtualization
|
|
440
|
+
// lags a frame or more on a slow machine), so the hit test finds no cell. Work out the row from
|
|
441
|
+
// the scroll position and the row heights instead, keeping the column the head already has.
|
|
442
|
+
const canHitTest = typeof viewport.ownerDocument.elementFromPoint === 'function';
|
|
443
|
+
const hit = headAtPoint(viewport, x, y);
|
|
444
|
+
const head = hit ?? (canHitTest && drag.moved
|
|
445
|
+
? headRowAtOffset(y - box.top - (stickyTop ? stickyTop.getBoundingClientRect().height : 0) + viewport.scrollTop, drag.head.field)
|
|
446
|
+
: null);
|
|
420
447
|
if (head && !isSameCoordinates(head, drag.head)) {
|
|
421
448
|
drag.head = head;
|
|
422
449
|
if (!drag.moved && !isSameCoordinates(head, drag.anchor)) {
|
|
@@ -443,7 +470,7 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
443
470
|
if (canScrollX) viewport.scrollLeft += dx;
|
|
444
471
|
// Keep going while the pointer rests in the edge zone; the next frame picks the new head.
|
|
445
472
|
drag.frame = requestAnimationFrame(() => runDragFrameRef.current(true));
|
|
446
|
-
}, [headAtPoint, setRange]);
|
|
473
|
+
}, [headAtPoint, headRowAtOffset, setRange]);
|
|
447
474
|
useLayoutEffect(() => {
|
|
448
475
|
runDragFrameRef.current = runDragFrame;
|
|
449
476
|
}, [runDragFrame]);
|
|
@@ -598,6 +625,7 @@ export function useGridCellSelection<R extends GridRowModel>(params: UseGridCell
|
|
|
598
625
|
stats,
|
|
599
626
|
reportFocus,
|
|
600
627
|
setModel,
|
|
628
|
+
getCurrentRange: currentRange,
|
|
601
629
|
};
|
|
602
630
|
}
|
|
603
631
|
|
|
@@ -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
|
+
}
|