From eddbe563e2658dfd6d36c0352f3103227a13b024 Mon Sep 17 00:00:00 2001 From: mango <1711456624@qq.com> Date: Sat, 25 Jul 2026 17:15:00 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(datagrid):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E9=80=89=E5=8C=BA=E5=8D=95=E5=85=83=E6=A0=BC=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E7=B2=98=E8=B4=B4=20(#718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现从选中单元格起按二维剪贴板矩阵粘贴,支持多行多列、NULL 空值和现有草稿更新。\n\nFixes #718 --- frontend/src/components/DataGrid.tsx | 2 + .../components/dataGridClipboardPaste.test.ts | 148 +++++++++ .../src/components/dataGridClipboardPaste.ts | 96 ++++++ .../useDataGridBatchActions.test.tsx | 291 ++++++++++++++++++ .../src/components/useDataGridBatchActions.ts | 110 ++++++- 5 files changed, 645 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/dataGridClipboardPaste.test.ts create mode 100644 frontend/src/components/dataGridClipboardPaste.ts create mode 100644 frontend/src/components/useDataGridBatchActions.test.tsx diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 794f6605..d84a08ba 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -2039,6 +2039,7 @@ const DataGrid: React.FC = ({ containerRef, copiedCellPatch, currentSelectionRef, + deletedRowKeys, displayColumnNames, displayDataRef, effectiveEditLocator, @@ -2060,6 +2061,7 @@ const DataGrid: React.FC = ({ setCellContextMenu, setCellEditMode, setCopiedCellPatch, + setModifiedColumns, setModifiedRows, setSelectedCells, markCellSelectionDeleteEligible, diff --git a/frontend/src/components/dataGridClipboardPaste.test.ts b/frontend/src/components/dataGridClipboardPaste.test.ts new file mode 100644 index 00000000..489c41fc --- /dev/null +++ b/frontend/src/components/dataGridClipboardPaste.test.ts @@ -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, + }); + }); +}); diff --git a/frontend/src/components/dataGridClipboardPaste.ts b/frontend/src/components/dataGridClipboardPaste.ts new file mode 100644 index 00000000..f9d1c8fd --- /dev/null +++ b/frontend/src/components/dataGridClipboardPaste.ts @@ -0,0 +1,96 @@ +export type DataGridClipboardValue = string | null; + +export interface DataGridClipboardPasteRow { + rowKey: string; + values: Record; + modifiedValues: Record; + 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>; + columnNames: string[]; + startRowIndex: number; + startColumnIndex: number; + rowKeyField: string; + addedRowKeys: Set; + modifiedRows: Record; + deletedRowKeys: Set; + 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 = {}; + + 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 = {}; + 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 }; +}; diff --git a/frontend/src/components/useDataGridBatchActions.test.tsx b/frontend/src/components/useDataGridBatchActions.test.tsx new file mode 100644 index 00000000..1dbc9108 --- /dev/null +++ b/frontend/src/components/useDataGridBatchActions.test.tsx @@ -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; + parent: MockHTMLElement | null; + selectorMatches: Set; + + constructor(attributes: Record = {}, 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(); + 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; + let documentTarget: ReturnType & { activeElement: MockHTMLElement | null; elementFromPoint: ReturnType }; + + 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, + } = {}) => { + 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(); + 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(), + 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() }, + 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) => `${key}:${JSON.stringify(params || {})}`, + updateCellSelection, + }; + + const Harness = () => { + useDataGridBatchActions(ctx as any); + return null; + }; + act(() => { renderer = create(); }); + + return { + container, + ctx, + currentSelectionRef, + selectionStartRef, + setAddedRows, + setModifiedRows, + setModifiedColumns, + setSelectedCells, + updateCellSelection, + rerender: () => act(() => { renderer?.update(); }), + }; + }; + + const selectCell = (container: ReturnType['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(); + }); +}); diff --git a/frontend/src/components/useDataGridBatchActions.ts b/frontend/src/components/useDataGridBatchActions.ts index 969226d1..b5a16b84 100644 --- a/frontend/src/components/useDataGridBatchActions.ts +++ b/frontend/src/components/useDataGridBatchActions.ts @@ -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 & { CELL_SELECTION_DRAG_THRESHOLD_PX: number; GONAVI_ROW_KEY: string; addedRows: any[]; + deletedRowKeys: Set; modifiedRows: Record; selectedCells: Set; copiedCellPatch: { sourceRowKey: string; values: Record } | null; @@ -42,6 +44,7 @@ type DataGridBatchActionsContext = Record & { setCopiedCellPatch: React.Dispatch< React.SetStateAction<{ sourceRowKey: string; values: Record } | null> >; + setModifiedColumns: React.Dispatch>>>; setModifiedRows: React.Dispatch>>; setSelectedCells: React.Dispatch>>; 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(); + 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;