mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 01:03:51 +08:00
✨ feat(datagrid): 支持选区单元格复制粘贴 (#718)
实现从选中单元格起按二维剪贴板矩阵粘贴,支持多行多列、NULL 空值和现有草稿更新。\n\nFixes #718
This commit is contained in:
@@ -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,
|
||||
|
||||
148
frontend/src/components/dataGridClipboardPaste.test.ts
Normal file
148
frontend/src/components/dataGridClipboardPaste.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
96
frontend/src/components/dataGridClipboardPaste.ts
Normal file
96
frontend/src/components/dataGridClipboardPaste.ts
Normal 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 };
|
||||
};
|
||||
291
frontend/src/components/useDataGridBatchActions.test.tsx
Normal file
291
frontend/src/components/useDataGridBatchActions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user