feat(datagrid): 支持选区单元格复制粘贴 (#718)

实现从选中单元格起按二维剪贴板矩阵粘贴,支持多行多列、NULL 空值和现有草稿更新。\n\nFixes #718
This commit is contained in:
mango
2026-07-25 17:15:00 +08:00
parent 3be626af05
commit eddbe563e2
5 changed files with 645 additions and 2 deletions

View File

@@ -2039,6 +2039,7 @@ const DataGrid: React.FC<DataGridProps> = ({
containerRef,
copiedCellPatch,
currentSelectionRef,
deletedRowKeys,
displayColumnNames,
displayDataRef,
effectiveEditLocator,
@@ -2060,6 +2061,7 @@ const DataGrid: React.FC<DataGridProps> = ({
setCellContextMenu,
setCellEditMode,
setCopiedCellPatch,
setModifiedColumns,
setModifiedRows,
setSelectedCells,
markCellSelectionDeleteEligible,

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest';
import { buildDataGridClipboardPasteRows, parseDataGridClipboardText } from './dataGridClipboardPaste';
const isValueEqual = (left: unknown, right: unknown) => {
if (left === right) return true;
const leftNullish = left === null || left === undefined;
const rightNullish = right === null || right === undefined;
return leftNullish && rightNullish;
};
describe('dataGridClipboardPaste helpers', () => {
it('parses rows, columns, empty values, CRLF and database NULL values', () => {
expect(parseDataGridClipboardText('alpha\tNULL\r\n\tbeta\r\n')).toEqual([
['alpha', null],
['', 'beta'],
]);
expect(parseDataGridClipboardText('alpha\n\n')).toEqual([
['alpha'],
[''],
]);
expect(parseDataGridClipboardText('')).toEqual([['']]);
});
it('maps a clipboard matrix by coordinates without shifting past read-only columns', () => {
const result = buildDataGridClipboardPasteRows({
matrix: [
['11', 'ignored', 'Ada'],
['12', 'ignored', null],
['outside', 'outside', 'outside'],
],
rows: [
{ rowKey: 'row-1', id: '1', generated: 'A', name: 'old' },
{ rowKey: 'row-2', id: '2', generated: 'B', name: 'value' },
],
columnNames: ['id', 'generated', 'name'],
startRowIndex: 0,
startColumnIndex: 0,
rowKeyField: 'rowKey',
addedRowKeys: new Set(),
modifiedRows: {},
deletedRowKeys: new Set(),
isWritableColumn: (columnName) => columnName !== 'generated',
isValueEqual,
});
expect(result).toEqual({
rows: [
{
rowKey: 'row-1',
values: { id: '11', name: 'Ada' },
modifiedValues: { id: '11', name: 'Ada' },
modifiedColumnNames: ['id', 'name'],
isAdded: false,
},
{
rowKey: 'row-2',
values: { id: '12', name: null },
modifiedValues: { id: '12', name: null },
modifiedColumnNames: ['id', 'name'],
isAdded: false,
},
],
updatedCellCount: 4,
});
});
it('preserves other drafts and removes values pasted back to their originals', () => {
const result = buildDataGridClipboardPasteRows({
matrix: [['original']],
rows: [{ rowKey: 'row-1', name: 'original', note: 'base' }],
columnNames: ['name', 'note'],
startRowIndex: 0,
startColumnIndex: 0,
rowKeyField: 'rowKey',
addedRowKeys: new Set(),
modifiedRows: { 'row-1': { name: 'draft', note: 'kept' } },
deletedRowKeys: new Set(),
isWritableColumn: () => true,
isValueEqual,
});
expect(result).toEqual({
rows: [{
rowKey: 'row-1',
values: { name: 'original' },
modifiedValues: { note: 'kept' },
modifiedColumnNames: ['note'],
isAdded: false,
}],
updatedCellCount: 1,
});
});
it('preserves a previous draft after its column becomes read-only', () => {
const result = buildDataGridClipboardPasteRows({
matrix: [['updated']],
rows: [{ rowKey: 'row-1', name: 'base', payload: 'original' }],
columnNames: ['name', 'payload'],
startRowIndex: 0,
startColumnIndex: 0,
rowKeyField: 'rowKey',
addedRowKeys: new Set(),
modifiedRows: { 'row-1': { payload: 'draft' } },
deletedRowKeys: new Set(),
isWritableColumn: (columnName) => columnName === 'name',
isValueEqual,
});
expect(result.rows[0]).toEqual({
rowKey: 'row-1',
values: { name: 'updated' },
modifiedValues: { payload: 'draft', name: 'updated' },
modifiedColumnNames: ['payload', 'name'],
isAdded: false,
});
});
it('updates added rows and skips deleted rows', () => {
const result = buildDataGridClipboardPasteRows({
matrix: [['new'], ['deleted']],
rows: [
{ rowKey: 'new-1', name: '' },
{ rowKey: 'row-2', name: 'old' },
],
columnNames: ['name'],
startRowIndex: 0,
startColumnIndex: 0,
rowKeyField: 'rowKey',
addedRowKeys: new Set(['new-1']),
modifiedRows: {},
deletedRowKeys: new Set(['row-2']),
isWritableColumn: () => true,
isValueEqual,
});
expect(result).toEqual({
rows: [{
rowKey: 'new-1',
values: { name: 'new' },
modifiedValues: {},
modifiedColumnNames: [],
isAdded: true,
}],
updatedCellCount: 1,
});
});
});

View File

@@ -0,0 +1,96 @@
export type DataGridClipboardValue = string | null;
export interface DataGridClipboardPasteRow {
rowKey: string;
values: Record<string, DataGridClipboardValue>;
modifiedValues: Record<string, any>;
modifiedColumnNames: string[];
isAdded: boolean;
}
export const parseDataGridClipboardText = (text: string): DataGridClipboardValue[][] => {
const normalized = text.replace(/\r\n?/g, '\n');
const content = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized;
return content.split('\n').map((line) => (
line.split('\t').map((value) => value === 'NULL' ? null : value)
));
};
export const buildDataGridClipboardPasteRows = ({
matrix,
rows,
columnNames,
startRowIndex,
startColumnIndex,
rowKeyField,
addedRowKeys,
modifiedRows,
deletedRowKeys,
isWritableColumn,
isValueEqual,
}: {
matrix: DataGridClipboardValue[][];
rows: Array<Record<string, any>>;
columnNames: string[];
startRowIndex: number;
startColumnIndex: number;
rowKeyField: string;
addedRowKeys: Set<string>;
modifiedRows: Record<string, any>;
deletedRowKeys: Set<string>;
isWritableColumn: (columnName: string) => boolean;
isValueEqual: (left: any, right: any) => boolean;
}): { rows: DataGridClipboardPasteRow[]; updatedCellCount: number } => {
if (!matrix.length || startRowIndex < 0 || startColumnIndex < 0) {
return { rows: [], updatedCellCount: 0 };
}
const pasteRows: DataGridClipboardPasteRow[] = [];
let updatedCellCount = 0;
matrix.forEach((sourceValues, sourceRowIndex) => {
const baseRow = rows[startRowIndex + sourceRowIndex];
const rowKeyValue = baseRow?.[rowKeyField];
if (rowKeyValue === undefined || rowKeyValue === null) return;
const rowKey = String(rowKeyValue);
if (deletedRowKeys.has(rowKey)) return;
const existing = modifiedRows[rowKey] || {};
const currentRow = addedRowKeys.has(rowKey) ? baseRow : { ...baseRow, ...existing };
const values: Record<string, DataGridClipboardValue> = {};
sourceValues.forEach((nextValue, sourceColumnIndex) => {
const columnName = columnNames[startColumnIndex + sourceColumnIndex];
if (!columnName || !isWritableColumn(columnName)) return;
if (isValueEqual(currentRow?.[columnName], nextValue)) return;
values[columnName] = nextValue;
updatedCellCount += 1;
});
if (Object.keys(values).length === 0) return;
const modifiedValues: Record<string, any> = {};
const modifiedColumnNames: string[] = [];
if (!addedRowKeys.has(rowKey)) {
const candidateValues = { ...existing, ...values };
Object.keys(candidateValues).forEach((columnName) => {
if (!isValueEqual(baseRow?.[columnName], candidateValues[columnName])) {
modifiedValues[columnName] = candidateValues[columnName];
modifiedColumnNames.push(columnName);
}
});
}
pasteRows.push({
rowKey,
values,
modifiedValues,
modifiedColumnNames,
isAdded: addedRowKeys.has(rowKey),
});
});
return { rows: pasteRows, updatedCellCount };
};

View File

@@ -0,0 +1,291 @@
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDataGridBatchActions } from './useDataGridBatchActions';
const messageApi = vi.hoisted(() => ({
info: vi.fn(),
success: vi.fn(),
}));
vi.mock('antd', () => ({ message: messageApi }));
const CELL_KEY_SEP = '\u0001';
const makeCellKey = (rowKey: string, colName: string) => `${rowKey}${CELL_KEY_SEP}${colName}`;
const splitCellKey = (cellKey: string) => {
const index = cellKey.indexOf(CELL_KEY_SEP);
return index === -1 ? null : { rowKey: cellKey.slice(0, index), colName: cellKey.slice(index + 1) };
};
class MockHTMLElement {
attributes: Record<string, string>;
parent: MockHTMLElement | null;
selectorMatches: Set<string>;
constructor(attributes: Record<string, string> = {}, parent: MockHTMLElement | null = null, selectorMatches: string[] = []) {
this.attributes = attributes;
this.parent = parent;
this.selectorMatches = new Set(selectorMatches);
}
closest(selector: string): MockHTMLElement | null {
if (selector === '[data-row-key][data-col-name]' && this.attributes['data-row-key'] && this.attributes['data-col-name']) {
return this;
}
if (this.selectorMatches.has(selector)) return this;
return this.parent?.closest(selector) || null;
}
getAttribute(name: string) {
return this.attributes[name] ?? null;
}
}
const createEventTarget = () => {
const listeners = new Map<string, EventListener>();
return {
listeners,
addEventListener: vi.fn((name: string, listener: EventListener) => listeners.set(name, listener)),
removeEventListener: vi.fn((name: string) => listeners.delete(name)),
};
};
describe('useDataGridBatchActions clipboard paste', () => {
let renderer: ReactTestRenderer | null = null;
let windowTarget: ReturnType<typeof createEventTarget>;
let documentTarget: ReturnType<typeof createEventTarget> & { activeElement: MockHTMLElement | null; elementFromPoint: ReturnType<typeof vi.fn> };
beforeEach(() => {
messageApi.info.mockReset();
messageApi.success.mockReset();
windowTarget = createEventTarget();
documentTarget = {
...createEventTarget(),
activeElement: null,
elementFromPoint: vi.fn(() => null),
};
vi.stubGlobal('HTMLElement', MockHTMLElement);
vi.stubGlobal('window', windowTarget);
vi.stubGlobal('document', documentTarget);
});
afterEach(() => {
act(() => renderer?.unmount());
renderer = null;
vi.unstubAllGlobals();
});
const renderHook = ({
canModifyData = true,
addedRows = [] as any[],
modifiedRows = {} as Record<string, any>,
} = {}) => {
const containerTarget = createEventTarget();
const container = {
...containerTarget,
contains: vi.fn(() => true),
querySelector: vi.fn(() => null),
};
const rows = [
{ key: 'row-1', id: '1', generated: 'A', name: 'alpha' },
{ key: 'row-2', id: '2', generated: 'B', name: 'beta' },
...addedRows,
];
const selectedCells = new Set<string>();
const currentSelectionRef = { current: selectedCells };
const selectionStartRef = { current: null as null | { rowKey: string; colName: string; rowIndex: number; colIndex: number } };
const setAddedRows = vi.fn();
const setModifiedRows = vi.fn();
const setModifiedColumns = vi.fn();
const setSelectedCells = vi.fn();
const updateCellSelection = vi.fn();
const ctx = {
CELL_SELECTION_DRAG_THRESHOLD_PX: 4,
GONAVI_ROW_KEY: 'key',
addedRows,
batchEditSetNull: false,
batchEditValue: '',
canModifyData,
cancelAnimationFrame: vi.fn(),
cellEditModeRef: { current: false },
cellSelectionAutoScrollRafRef: { current: null },
cellSelectionPointerRef: { current: null },
cellSelectionRafRef: { current: null },
cellSelectionScrollRafRef: { current: null },
closeBatchEditModal: vi.fn(),
columnIndexMap: new Map([['id', 0], ['generated', 1], ['name', 2]]),
containerRef: { current: container },
copiedCellPatch: null,
currentSelectionRef,
deletedRowKeys: new Set<string>(),
displayColumnNames: ['id', 'generated', 'name'],
displayDataRef: { current: rows },
effectiveEditLocator: {},
isActive: true,
isCellValueEqualForDiff: (left: unknown, right: unknown) => left === right,
isDraggingRef: { current: false },
isTableSurfaceActive: true,
isWritableResultColumn: (columnName: string) => columnName !== 'generated',
makeCellKey,
markCellSelectionDeleteEligible: vi.fn(),
modifiedRows,
pendingCellSelectionStartRef: { current: null },
requestAnimationFrame: (callback: FrameRequestCallback) => { callback(0); return 1; },
rowIndexMapRef: { current: new Map<string, number>() },
rowKeyStr: String,
selectedCells,
selectedRowKeysRef: { current: [] },
selectionStartRef,
setAddedRows,
setCellContextMenu: vi.fn(),
setCellEditMode: vi.fn(),
setCopiedCellPatch: vi.fn(),
setModifiedColumns,
setModifiedRows,
setSelectedCells,
splitCellKey,
suppressCellSelectionClickRef: { current: false },
translateDataGrid: (key: string, params?: Record<string, unknown>) => `${key}:${JSON.stringify(params || {})}`,
updateCellSelection,
};
const Harness = () => {
useDataGridBatchActions(ctx as any);
return null;
};
act(() => { renderer = create(<Harness />); });
return {
container,
ctx,
currentSelectionRef,
selectionStartRef,
setAddedRows,
setModifiedRows,
setModifiedColumns,
setSelectedCells,
updateCellSelection,
rerender: () => act(() => { renderer?.update(<Harness />); }),
};
};
const selectCell = (container: ReturnType<typeof renderHook>['container'], rowKey: string, colName: string) => {
const cell = new MockHTMLElement({ 'data-row-key': rowKey, 'data-col-name': colName });
act(() => {
(container.listeners.get('mousedown') as any)?.({ button: 0, target: cell, clientX: 10, clientY: 10 });
(documentTarget.listeners.get('mouseup') as any)?.({ target: cell, clientX: 10, clientY: 10 });
});
return cell;
};
it('pastes a two-dimensional matrix from the selected anchor cell', () => {
const hook = renderHook({ modifiedRows: { 'row-1': { name: 'draft' } } });
const cell = selectCell(hook.container, 'row-1', 'id');
expect(hook.selectionStartRef.current).toEqual({ rowKey: 'row-1', colName: 'id', rowIndex: 0, colIndex: 0 });
expect(hook.setSelectedCells).toHaveBeenCalledWith(new Set([makeCellKey('row-1', 'id')]));
const preventDefault = vi.fn();
act(() => {
(windowTarget.listeners.get('paste') as any)?.({
target: cell,
clipboardData: {
types: ['text/plain'],
getData: vi.fn(() => '11\tignored\tAda\r\n12\tignored\tNULL\r\n'),
},
preventDefault,
});
});
expect(preventDefault).toHaveBeenCalledOnce();
expect(hook.setModifiedRows).toHaveBeenCalledOnce();
const nextRows = hook.setModifiedRows.mock.calls[0][0]({ 'row-1': { name: 'draft' } });
expect(nextRows).toEqual({
'row-1': { id: '11', name: 'Ada' },
'row-2': { id: '12', name: null },
});
const nextColumns = hook.setModifiedColumns.mock.calls[0][0]({});
expect(nextColumns['row-1']).toEqual(new Set(['id', 'name']));
expect(nextColumns['row-2']).toEqual(new Set(['id', 'name']));
expect(messageApi.success).toHaveBeenCalledWith('data_grid.message.pasted_columns_to_rows:{"rows":2,"cells":4}');
});
it('resolves the selected row and column again before pasting', () => {
const hook = renderHook();
const cell = selectCell(hook.container, 'row-2', 'name');
hook.ctx.displayDataRef.current = [
{ key: 'row-2', id: '2', generated: 'B', name: 'beta' },
{ key: 'row-1', id: '1', generated: 'A', name: 'alpha' },
];
hook.ctx.displayColumnNames = ['name', 'id', 'generated'];
hook.ctx.columnIndexMap = new Map([['name', 0], ['id', 1], ['generated', 2]]);
hook.rerender();
const preventDefault = vi.fn();
act(() => {
(windowTarget.listeners.get('paste') as any)?.({
target: cell,
clipboardData: { types: ['text/plain'], getData: vi.fn(() => '') },
preventDefault,
});
});
expect(preventDefault).toHaveBeenCalledOnce();
const nextRows = hook.setModifiedRows.mock.calls[0][0]({});
expect(nextRows).toEqual({ 'row-2': { name: '' } });
});
it('allows grid paste inside a shortcut-guarded floating window', () => {
const hook = renderHook();
const floatingWindow = new MockHTMLElement({}, null, ['[data-gonavi-close-shortcut-guard]']);
const cell = new MockHTMLElement({ 'data-row-key': 'row-1', 'data-col-name': 'name' }, floatingWindow);
selectCell(hook.container, 'row-1', 'name');
documentTarget.activeElement = floatingWindow;
const preventDefault = vi.fn();
act(() => {
(windowTarget.listeners.get('paste') as any)?.({
target: cell,
clipboardData: { types: ['text/plain'], getData: vi.fn(() => 'updated') },
preventDefault,
});
});
expect(preventDefault).toHaveBeenCalledOnce();
expect(hook.setModifiedRows).toHaveBeenCalledOnce();
});
it('does not intercept paste for read-only grids or editable targets', () => {
const readOnlyHook = renderHook({ canModifyData: false });
selectCell(readOnlyHook.container, 'row-1', 'id');
expect(windowTarget.listeners.has('paste')).toBe(true);
const readOnlyPreventDefault = vi.fn();
act(() => {
(windowTarget.listeners.get('paste') as any)?.({
target: new MockHTMLElement(),
clipboardData: { types: ['text/plain'], getData: vi.fn(() => '11') },
preventDefault: readOnlyPreventDefault,
});
});
expect(readOnlyPreventDefault).not.toHaveBeenCalled();
act(() => renderer?.unmount());
renderer = null;
const editableHook = renderHook();
selectCell(editableHook.container, 'row-1', 'id');
const input = new MockHTMLElement({}, null, ['input, textarea, select, [contenteditable="true"], .ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover']);
documentTarget.activeElement = input;
const editablePreventDefault = vi.fn();
act(() => {
(windowTarget.listeners.get('paste') as any)?.({
target: input,
clipboardData: { types: ['text/plain'], getData: vi.fn(() => '11') },
preventDefault: editablePreventDefault,
});
});
expect(editablePreventDefault).not.toHaveBeenCalled();
expect(editableHook.setModifiedRows).not.toHaveBeenCalled();
});
});

View File

@@ -2,12 +2,14 @@ import { useCallback, useEffect } from 'react';
import type React from 'react';
import { message } from 'antd';
import type { Item } from './DataGridCore';
import { buildDataGridClipboardPasteRows, parseDataGridClipboardText } from './dataGridClipboardPaste';
import { canSelectGridCellForClipboard } from './dataGridSelectionCopy';
type DataGridBatchActionsContext = Record<string, any> & {
CELL_SELECTION_DRAG_THRESHOLD_PX: number;
GONAVI_ROW_KEY: string;
addedRows: any[];
deletedRowKeys: Set<string>;
modifiedRows: Record<string, any>;
selectedCells: Set<string>;
copiedCellPatch: { sourceRowKey: string; values: Record<string, any> } | null;
@@ -42,6 +44,7 @@ type DataGridBatchActionsContext = Record<string, any> & {
setCopiedCellPatch: React.Dispatch<
React.SetStateAction<{ sourceRowKey: string; values: Record<string, any> } | null>
>;
setModifiedColumns: React.Dispatch<React.SetStateAction<Record<string, Set<string>>>>;
setModifiedRows: React.Dispatch<React.SetStateAction<Record<string, any>>>;
setSelectedCells: React.Dispatch<React.SetStateAction<Set<string>>>;
markCellSelectionDeleteEligible: (eligible: boolean) => void;
@@ -73,6 +76,7 @@ export const useDataGridBatchActions = (ctx: DataGridBatchActionsContext) => {
containerRef,
copiedCellPatch,
currentSelectionRef,
deletedRowKeys,
displayColumnNames,
displayDataRef,
effectiveEditLocator,
@@ -94,6 +98,7 @@ export const useDataGridBatchActions = (ctx: DataGridBatchActionsContext) => {
setCellContextMenu,
setCellEditMode,
setCopiedCellPatch,
setModifiedColumns,
setModifiedRows,
setSelectedCells,
markCellSelectionDeleteEligible,
@@ -402,6 +407,25 @@ const handleBatchFillCells = useCallback(() => {
ensureAutoScroll();
};
const selectSingleCell = (cellInfo: { rowKey: string; colName: string }) => {
const currentData = displayDataRef.current;
const rowIndex = currentData.findIndex((row) => String(row?.[GONAVI_ROW_KEY]) === cellInfo.rowKey);
const colIndex = columnIndexMap.get(cellInfo.colName) ?? -1;
if (rowIndex === -1 || colIndex === -1) return;
const nextSelection = new Set([makeCellKey(cellInfo.rowKey, cellInfo.colName)]);
selectionStartRef.current = {
rowKey: cellInfo.rowKey,
colName: cellInfo.colName,
rowIndex,
colIndex,
};
currentSelectionRef.current = nextSelection;
setSelectedCells(nextSelection);
markCellSelectionDeleteEligible(false);
updateCellSelection(nextSelection);
};
const onMouseDown = (e: MouseEvent) => {
if (e.button !== 0) return;
const target = e.target instanceof HTMLElement ? e.target : null;
@@ -445,8 +469,14 @@ const handleBatchFillCells = useCallback(() => {
};
const onMouseUp = (e: MouseEvent) => {
const pendingStart = pendingCellSelectionStartRef.current;
pendingCellSelectionStartRef.current = null;
if (!isDraggingRef.current) return;
if (!isDraggingRef.current) {
if (pendingStart && canModifyData) {
selectSingleCell(pendingStart);
}
return;
}
isDraggingRef.current = false;
cellSelectionPointerRef.current = null;
stopAutoScroll();
@@ -484,11 +514,86 @@ const handleBatchFillCells = useCallback(() => {
});
};
const onPaste = (e: ClipboardEvent) => {
if (!canModifyData || !selectionStartRef.current) return;
const activeElement = document.activeElement as HTMLElement | null;
const eventTarget = e.target instanceof HTMLElement ? e.target : null;
const nativePasteGuard = 'input, textarea, select, [contenteditable="true"], .ant-modal, .ant-dropdown, .ant-select-dropdown, .ant-picker-dropdown, .ant-popover';
if (activeElement?.closest(nativePasteGuard) || eventTarget?.closest(nativePasteGuard)) return;
const clipboardData = e.clipboardData;
if (!clipboardData?.types.includes('text/plain')) return;
const matrix = parseDataGridClipboardText(clipboardData.getData('text/plain'));
const currentRows = displayDataRef.current;
const start = selectionStartRef.current;
const startRowIndex = currentRows.findIndex((row) => rowKeyStr(row?.[GONAVI_ROW_KEY]) === start.rowKey);
const startColumnIndex = columnIndexMap.get(start.colName) ?? -1;
if (startRowIndex === -1 || startColumnIndex === -1) return;
const addedRowKeys = new Set<string>();
addedRows.forEach((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key !== undefined && key !== null) addedRowKeys.add(rowKeyStr(key));
});
const result = buildDataGridClipboardPasteRows({
matrix,
rows: currentRows,
columnNames: displayColumnNames,
startRowIndex,
startColumnIndex,
rowKeyField: GONAVI_ROW_KEY,
addedRowKeys,
modifiedRows,
deletedRowKeys,
isWritableColumn: (columnName) => isWritableResultColumn(columnName, effectiveEditLocator),
isValueEqual: isCellValueEqualForDiff,
});
e.preventDefault();
if (result.updatedCellCount === 0) {
void message.info(translateDataGrid('data_grid.message.selected_cells_no_update'));
return;
}
const pasteRowsByKey = new Map(result.rows.map((row) => [row.rowKey, row]));
setAddedRows((prev) => prev.map((row) => {
const key = row?.[GONAVI_ROW_KEY];
if (key === undefined || key === null) return row;
const pasteRow = pasteRowsByKey.get(rowKeyStr(key));
return pasteRow?.isAdded ? { ...row, ...pasteRow.values } : row;
}));
setModifiedRows((prev) => {
const next = { ...prev };
result.rows.forEach((row) => {
if (row.isAdded) return;
if (Object.keys(row.modifiedValues).length === 0) delete next[row.rowKey];
else next[row.rowKey] = row.modifiedValues;
});
return next;
});
setModifiedColumns((prev) => {
const next = { ...prev };
result.rows.forEach((row) => {
if (row.isAdded) return;
if (row.modifiedColumnNames.length === 0) delete next[row.rowKey];
else next[row.rowKey] = new Set(row.modifiedColumnNames);
});
return next;
});
void message.success(translateDataGrid('data_grid.message.pasted_columns_to_rows', {
rows: result.rows.length,
cells: result.updatedCellCount,
}));
};
container.addEventListener('mousedown', onMouseDown);
container.addEventListener('mousemove', onMouseMove);
container.addEventListener('click', onClickCapture, true);
container.addEventListener('scroll', onScroll, true);
document.addEventListener('mouseup', onMouseUp);
window.addEventListener('paste', onPaste);
return () => {
container.removeEventListener('mousedown', onMouseDown);
@@ -496,6 +601,7 @@ const handleBatchFillCells = useCallback(() => {
container.removeEventListener('click', onClickCapture, true);
container.removeEventListener('scroll', onScroll, true);
document.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('paste', onPaste);
if (cellSelectionRafRef.current !== null) {
cancelAnimationFrame(cellSelectionRafRef.current);
cellSelectionRafRef.current = null;
@@ -509,7 +615,7 @@ const handleBatchFillCells = useCallback(() => {
cellSelectionPointerRef.current = null;
isDraggingRef.current = false;
};
}, [canModifyData, isActive, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, isWritableResultColumn, markCellSelectionDeleteEligible, updateCellSelection]);
}, [addedRows, canModifyData, deletedRowKeys, isActive, isTableSurfaceActive, displayColumnNames, columnIndexMap, effectiveEditLocator, isCellValueEqualForDiff, isWritableResultColumn, markCellSelectionDeleteEligible, modifiedRows, rowKeyStr, setAddedRows, setModifiedColumns, setModifiedRows, setSelectedCells, translateDataGrid, updateCellSelection]);
const handleCopySelectedColumnsFromRow = useCallback(() => {
const activeSelection = currentSelectionRef.current.size > 0 ? currentSelectionRef.current : selectedCells;