diff --git a/src/Cell.tsx b/src/Cell.tsx index 29cd201693..43cd02a188 100644 --- a/src/Cell.tsx +++ b/src/Cell.tsx @@ -5,21 +5,9 @@ import { useRovingTabIndex } from './hooks'; import { createCellEvent, getCellClassname, getCellStyle, isCellEditableUtil } from './utils'; import type { CellRendererProps } from './types'; -const cellCopied = css` - @layer rdg.Cell { - background-color: #ccccff; - } -`; - -const cellCopiedClassname = `rdg-cell-copied ${cellCopied}`; - const cellDraggedOver = css` @layer rdg.Cell { background-color: #ccccff; - - &.${cellCopied} { - background-color: #9999ff; - } } `; @@ -30,7 +18,6 @@ function Cell( column, colSpan, isCellSelected, - isCopied, isDraggedOver, row, rowIdx, @@ -51,7 +38,6 @@ function Cell( className = getCellClassname( column, { - [cellCopiedClassname]: isCopied, [cellDraggedOverClassname]: isDraggedOver }, typeof cellClass === 'function' ? cellClass(row) : cellClass, diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index dc6f6aeb58..25f9f2e0c8 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -33,6 +33,8 @@ import { import type { CalculatedColumn, CellClickArgs, + CellClipboardEvent, + CellCopyPasteEvent, CellKeyboardEvent, CellKeyDownArgs, CellMouseEvent, @@ -40,11 +42,9 @@ import type { CellSelectArgs, Column, ColumnOrColumnGroup, - CopyEvent, Direction, FillEvent, Maybe, - PasteEvent, Position, Renderers, RowsChangeData, @@ -161,8 +161,6 @@ export interface DataGridProps extends Sha onSortColumnsChange?: Maybe<(sortColumns: SortColumn[]) => void>; defaultColumnOptions?: Maybe, NoInfer>>; onFill?: Maybe<(event: FillEvent>) => NoInfer>; - onCopy?: Maybe<(event: CopyEvent>) => void>; - onPaste?: Maybe<(event: PasteEvent>) => NoInfer>; /** * Event props @@ -182,6 +180,12 @@ export interface DataGridProps extends Sha onCellKeyDown?: Maybe< (args: CellKeyDownArgs, NoInfer>, event: CellKeyboardEvent) => void >; + onCellCopy?: Maybe< + (args: CellCopyPasteEvent, NoInfer>, event: CellClipboardEvent) => void + >; + onCellPaste?: Maybe< + (args: CellCopyPasteEvent, NoInfer>, event: CellClipboardEvent) => NoInfer + >; /** Function called whenever cell selection is changed */ onSelectedCellChange?: Maybe<(args: CellSelectArgs, NoInfer>) => void>; /** Called when the grid is scrolled */ @@ -247,8 +251,8 @@ function DataGrid( onColumnResize, onColumnsReorder, onFill, - onCopy, - onPaste, + onCellCopy, + onCellPaste, // Toggles and modes enableVirtualization: rawEnableVirtualization, // Miscellaneous @@ -296,7 +300,6 @@ function DataGrid( const [measuredColumnWidths, setMeasuredColumnWidths] = useState( (): ReadonlyMap => new Map() ); - const [copiedCell, setCopiedCell] = useState<{ row: R; columnKey: string } | null>(null); const [isDragging, setDragging] = useState(false); const [draggedOverRowIdx, setOverRowIdx] = useState(undefined); const [scrollToPosition, setScrollToPosition] = useState(null); @@ -594,39 +597,13 @@ function DataGrid( ); if (cellEvent.isGridDefaultPrevented()) return; } + if (!(event.target instanceof Element)) return; const isCellEvent = event.target.closest('.rdg-cell') !== null; const isRowEvent = isTreeGrid && event.target === focusSinkRef.current; if (!isCellEvent && !isRowEvent) return; - // eslint-disable-next-line @typescript-eslint/no-deprecated - const { keyCode } = event; - - if ( - selectedCellIsWithinViewportBounds && - (onPaste != null || onCopy != null) && - isCtrlKeyHeldDown(event) - ) { - // event.key may differ by keyboard input language, so we use event.keyCode instead - // event.nativeEvent.code cannot be used either as it would break copy/paste for the DVORAK layout - const cKey = 67; - const vKey = 86; - if (keyCode === cKey) { - // copy highlighted text only - if (window.getSelection()?.isCollapsed === false) return; - handleCopy(); - return; - } - if (keyCode === vKey) { - handlePaste(); - return; - } - } - switch (event.key) { - case 'Escape': - setCopiedCell(null); - return; case 'ArrowUp': case 'ArrowDown': case 'ArrowLeft': @@ -670,31 +647,21 @@ function DataGrid( updateRow(columns[selectedPosition.idx], selectedPosition.rowIdx, selectedPosition.row); } - function handleCopy() { + function handleCellCopy(event: CellClipboardEvent) { + if (!selectedCellIsWithinViewportBounds) return; const { idx, rowIdx } = selectedPosition; - const sourceRow = rows[rowIdx]; - const sourceColumnKey = columns[idx].key; - setCopiedCell({ row: sourceRow, columnKey: sourceColumnKey }); - onCopy?.({ sourceRow, sourceColumnKey }); + onCellCopy?.({ row: rows[rowIdx], column: columns[idx] }, event); } - function handlePaste() { - if (!onPaste || !onRowsChange || copiedCell === null || !isCellEditable(selectedPosition)) { + function handleCellPaste(event: CellClipboardEvent) { + if (!onCellPaste || !onRowsChange || !isCellEditable(selectedPosition)) { return; } const { idx, rowIdx } = selectedPosition; - const targetColumn = columns[idx]; - const targetRow = rows[rowIdx]; - - const updatedTargetRow = onPaste({ - sourceRow: copiedCell.row, - sourceColumnKey: copiedCell.columnKey, - targetRow, - targetColumnKey: targetColumn.key - }); - - updateRow(targetColumn, rowIdx, updatedTargetRow); + const column = columns[idx]; + const updatedRow = onCellPaste({ row: rows[rowIdx], column }, event); + updateRow(column, rowIdx, updatedRow); } function handleCellInput(event: KeyboardEvent) { @@ -712,7 +679,7 @@ function DataGrid( return; } - if (isCellEditable(selectedPosition) && isDefaultCellInput(event)) { + if (isCellEditable(selectedPosition) && isDefaultCellInput(event, onCellPaste != null)) { setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, @@ -1037,11 +1004,6 @@ function DataGrid( onCellContextMenu: onCellContextMenuLatest, rowClass, gridRowStart, - copiedCellIdx: - copiedCell !== null && copiedCell.row === row - ? columns.findIndex((c) => c.key === copiedCell.columnKey) - : undefined, - selectedCellIdx: selectedRowIdx === rowIdx ? selectedIdx : undefined, draggedOverCellIdx: getDraggedOverCellIdx(rowIdx), setDraggedOverRowIdx: isDragging ? setDraggedOverRowIdx : undefined, @@ -1121,6 +1083,8 @@ function DataGrid( ref={gridRef} onScroll={handleScroll} onKeyDown={handleKeyDown} + onCopy={handleCellCopy} + onPaste={handleCellPaste} data-testid={testId} > diff --git a/src/Row.tsx b/src/Row.tsx index 9b811a3397..9b9d8e0097 100644 --- a/src/Row.tsx +++ b/src/Row.tsx @@ -15,7 +15,6 @@ function Row( selectedCellIdx, isRowSelectionDisabled, isRowSelected, - copiedCellIdx, draggedOverCellIdx, lastFrozenColumnIndex, row, @@ -75,7 +74,6 @@ function Row( colSpan, row, rowIdx, - isCopied: copiedCellIdx === idx, isDraggedOver: draggedOverCellIdx === idx, isCellSelected, onClick: onCellClick, diff --git a/src/TreeDataGrid.tsx b/src/TreeDataGrid.tsx index 16e4f87232..aa46a01157 100644 --- a/src/TreeDataGrid.tsx +++ b/src/TreeDataGrid.tsx @@ -2,7 +2,7 @@ import { forwardRef, useCallback, useMemo } from 'react'; import type { Key, RefAttributes } from 'react'; import { useLatestFunc } from './hooks'; -import { assertIsValidKeyGetter, isCtrlKeyHeldDown } from './utils'; +import { assertIsValidKeyGetter } from './utils'; import type { CellKeyboardEvent, CellKeyDownArgs, @@ -321,12 +321,6 @@ function TreeDataGrid( selectCell({ idx, rowIdx: parentRowAndIndex[1] }); } } - - // Prevent copy/paste on group rows - // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isCtrlKeyHeldDown(event) && (event.keyCode === 67 || event.keyCode === 86)) { - event.preventGridDefault(); - } } function handleRowsChange(updatedRows: R[], { indexes, column }: RowsChangeData) { @@ -364,7 +358,6 @@ function TreeDataGrid( onCellContextMenu, onRowChange, lastFrozenColumnIndex, - copiedCellIdx, draggedOverCellIdx, setDraggedOverRowIdx, selectedCellEditor, @@ -403,7 +396,6 @@ function TreeDataGrid( onCellContextMenu, onRowChange, lastFrozenColumnIndex, - copiedCellIdx, draggedOverCellIdx, setDraggedOverRowIdx, selectedCellEditor diff --git a/src/index.ts b/src/index.ts index d76283ea28..be00221294 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,8 +29,7 @@ export type { SelectHeaderRowEvent, SelectRowEvent, FillEvent, - CopyEvent, - PasteEvent, + CellCopyPasteEvent, SortDirection, SortColumn, ColSpanArgs, diff --git a/src/types.ts b/src/types.ts index 561bb651be..a0b13a6f19 100644 --- a/src/types.ts +++ b/src/types.ts @@ -149,7 +149,6 @@ export interface CellRendererProps > { column: CalculatedColumn; colSpan: number | undefined; - isCopied: boolean; isDraggedOver: boolean; isCellSelected: boolean; onClick: RenderRowProps['onCellClick']; @@ -167,25 +166,27 @@ export type CellMouseEvent = CellEvent>; export type CellKeyboardEvent = CellEvent>; +export type CellClipboardEvent = React.ClipboardEvent; + export interface CellClickArgs { - rowIdx: number; - row: TRow; column: CalculatedColumn; + row: TRow; + rowIdx: number; selectCell: (enableEditor?: boolean) => void; } interface SelectCellKeyDownArgs { mode: 'SELECT'; - row: TRow; column: CalculatedColumn; + row: TRow; rowIdx: number; selectCell: (position: Position, enableEditor?: Maybe) => void; } export interface EditCellKeyDownArgs { mode: 'EDIT'; - row: TRow; column: CalculatedColumn; + row: TRow; rowIdx: number; navigate: () => void; onClose: (commitChanges?: boolean, shouldFocusCell?: boolean) => void; @@ -220,7 +221,6 @@ export interface RenderRowProps extends BaseRenderRowProps { row: TRow; lastFrozenColumnIndex: number; - copiedCellIdx: number | undefined; draggedOverCellIdx: number | undefined; selectedCellEditor: ReactElement> | undefined; onRowChange: (column: CalculatedColumn, rowIdx: number, newRow: TRow) => void; @@ -249,16 +249,9 @@ export interface FillEvent { targetRow: TRow; } -export interface CopyEvent { - sourceColumnKey: string; - sourceRow: TRow; -} - -export interface PasteEvent { - sourceColumnKey: string; - sourceRow: TRow; - targetColumnKey: string; - targetRow: TRow; +export interface CellCopyPasteEvent { + column: CalculatedColumn; + row: TRow; } export interface GroupRow { diff --git a/src/utils/keyboardUtils.ts b/src/utils/keyboardUtils.ts index e12609eb4f..5bb352521e 100644 --- a/src/utils/keyboardUtils.ts +++ b/src/utils/keyboardUtils.ts @@ -52,10 +52,16 @@ export function isCtrlKeyHeldDown(e: React.KeyboardEvent): boolean { return (e.ctrlKey || e.metaKey) && e.key !== 'Control'; } -export function isDefaultCellInput(event: React.KeyboardEvent): boolean { - const vKey = 86; +// event.key may differ by keyboard input language, so we use event.keyCode instead +// event.nativeEvent.code cannot be used either as it would break copy/paste for the DVORAK layout +const vKey = 86; + +export function isDefaultCellInput( + event: React.KeyboardEvent, + isUserHandlingPaste: boolean +): boolean { // eslint-disable-next-line @typescript-eslint/no-deprecated - if (isCtrlKeyHeldDown(event) && event.keyCode !== vKey) return false; + if (isCtrlKeyHeldDown(event) && (event.keyCode !== vKey || isUserHandlingPaste)) return false; return !nonInputKeys.has(event.key); } diff --git a/test/browser/TreeDataGrid.test.tsx b/test/browser/TreeDataGrid.test.tsx index c5073b4617..8744e564c5 100644 --- a/test/browser/TreeDataGrid.test.tsx +++ b/test/browser/TreeDataGrid.test.tsx @@ -99,7 +99,7 @@ function TestGrid({ groupBy }: { groupBy: string[] }) { (): ReadonlySet => new Set([]) ); - function onPaste(event: PasteEvent) { + function onCellPaste(event: PasteEvent) { return { ...event.targetRow, [event.targetColumnKey]: event.sourceRow[event.sourceColumnKey as keyof Row] @@ -120,7 +120,7 @@ function TestGrid({ groupBy }: { groupBy: string[] }) { expandedGroupIds={expandedGroupIds} onExpandedGroupIdsChange={setExpandedGroupIds} onRowsChange={setRows} - onPaste={onPaste} + onCellPaste={onCellPaste} /> ); } diff --git a/test/browser/copyPaste.test.tsx b/test/browser/copyPaste.test.tsx index f78c389f67..520533acfb 100644 --- a/test/browser/copyPaste.test.tsx +++ b/test/browser/copyPaste.test.tsx @@ -70,8 +70,8 @@ function CopyPasteTest({ rows={rows} bottomSummaryRows={bottomSummaryRows} onRowsChange={setRows} - onPaste={onPasteCallback ? onPaste : undefined} - onCopy={onCopyCallback ? onCopySpy : undefined} + onCellPaste={onPasteCallback ? onPaste : undefined} + onCellCopy={onCopyCallback ? onCopySpy : undefined} /> ); } diff --git a/test/browser/utils.tsx b/test/browser/utils.tsx index 3b00f7538b..f8b8bb3c7f 100644 --- a/test/browser/utils.tsx +++ b/test/browser/utils.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { page, userEvent } from '@vitest/browser/context'; import { css } from '@linaria/core'; @@ -126,6 +126,7 @@ export async function copySelectedCellOld() { } export function copySelectedCell() { + fireEvent.copy(document.activeElement!); return userEvent.keyboard('{Control>}c{/Control}'); } @@ -137,6 +138,7 @@ export async function pasteSelectedCellOld() { } export function pasteSelectedCell() { + fireEvent.paste(document.activeElement!); return userEvent.keyboard('{Control>}v{/Control}'); } diff --git a/website/routes/AllFeatures.lazy.tsx b/website/routes/AllFeatures.lazy.tsx index 4d412f687b..5903819ef9 100644 --- a/website/routes/AllFeatures.lazy.tsx +++ b/website/routes/AllFeatures.lazy.tsx @@ -1,9 +1,10 @@ import { useState } from 'react'; import { createLazyFileRoute } from '@tanstack/react-router'; import { css } from '@linaria/core'; +import clsx from 'clsx'; import DataGrid, { SelectColumn, textEditor } from '../../src'; -import type { Column, CopyEvent, FillEvent, PasteEvent } from '../../src'; +import type { CalculatedColumn, CellCopyPasteEvent, Column, FillEvent } from '../../src'; import { textEditorClassname } from '../../src/editors/textEditor'; import { useDirection } from '../directionContext'; @@ -22,6 +23,8 @@ const highlightClassname = css` } `; +const copiedRowClassname = css``; + export interface Row { id: string; avatar: string; @@ -171,17 +174,23 @@ function AllFeatures() { const initialRows = Route.useLoaderData(); const [rows, setRows] = useState(initialRows); const [selectedRows, setSelectedRows] = useState((): ReadonlySet => new Set()); + const [copiedCell, setCopiedCell] = useState<{ row: Row; column: CalculatedColumn } | null>( + null + ); function handleFill({ columnKey, sourceRow, targetRow }: FillEvent): Row { return { ...targetRow, [columnKey]: sourceRow[columnKey as keyof Row] }; } - function handlePaste({ - sourceColumnKey, - sourceRow, - targetColumnKey, - targetRow - }: PasteEvent): Row { + function handleCellPaste({ row, column }: CellCopyPasteEvent): Row { + if (!copiedCell) { + return row; + } + + const sourceColumnKey = copiedCell.column.key; + const sourceRow = copiedCell.row; + const targetColumnKey = column.key; + const incompatibleColumns = ['email', 'zipCode', 'date']; if ( sourceColumnKey === 'avatar' || @@ -190,42 +199,74 @@ function AllFeatures() { incompatibleColumns.includes(sourceColumnKey)) && sourceColumnKey !== targetColumnKey) ) { - return targetRow; + return row; } - return { ...targetRow, [targetColumnKey]: sourceRow[sourceColumnKey as keyof Row] }; + return { ...row, [targetColumnKey]: sourceRow[sourceColumnKey as keyof Row] }; } - function handleCopy({ sourceRow, sourceColumnKey }: CopyEvent): void { - if (window.isSecureContext) { - navigator.clipboard.writeText(sourceRow[sourceColumnKey as keyof Row]); + function handleCellCopy( + { row, column }: CellCopyPasteEvent, + event: React.ClipboardEvent + ): void { + // copy highlighted text only + if (window.getSelection()?.isCollapsed === false) { + setCopiedCell(null); + return; } + + setCopiedCell({ row, column }); + event.clipboardData.setData('text/plain', row[column.key as keyof Row]); + event.preventDefault(); } return ( - row.id === 'id_2'} - onSelectedRowsChange={setSelectedRows} - className="fill-grid" - rowClass={(row, index) => - row.id.includes('7') || index === 0 ? highlightClassname : undefined - } - direction={direction} - onCellClick={(args, event) => { - if (args.column.key === 'title') { - event.preventGridDefault(); - args.selectCell(true); - } - }} - /> + <> + {copiedCell && ( + + )} + row.id === 'id_2'} + onSelectedRowsChange={setSelectedRows} + className="fill-grid" + rowClass={(row, index) => { + return clsx({ + [highlightClassname]: row.id.includes('7') || index === 0, + [copiedRowClassname]: copiedCell?.row === row + }); + }} + direction={direction} + onCellClick={(args, event) => { + if (args.column.key === 'title') { + event.preventGridDefault(); + args.selectCell(true); + } + }} + onCellKeyDown={(_, event) => { + if (event.key === 'Escape') { + setCopiedCell(null); + } + }} + /> + ); }