diff --git a/packages/code-studio/src/assets/svg/cursor-copy.svg b/packages/code-studio/src/assets/svg/cursor-copy.svg new file mode 100644 index 0000000000..28421e86b4 --- /dev/null +++ b/packages/code-studio/src/assets/svg/cursor-copy.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/packages/code-studio/src/main/AppMainContainer.scss b/packages/code-studio/src/main/AppMainContainer.scss index b45dd6e8ee..3524998f8f 100644 --- a/packages/code-studio/src/main/AppMainContainer.scss +++ b/packages/code-studio/src/main/AppMainContainer.scss @@ -248,6 +248,12 @@ $nav-space: 4px; // give a gap around some buttons for focus area that are in na } } +.grid-cursor-copy { + cursor: + url('../assets/svg/cursor-copy.svg') 8 8, + copy; +} + .grid-cursor-linker { cursor: url('../assets/svg/cursor-linker.svg') 8 8, diff --git a/packages/components/src/context-actions/ContextActionUtils.ts b/packages/components/src/context-actions/ContextActionUtils.ts index 4e76570dea..49c1872279 100644 --- a/packages/components/src/context-actions/ContextActionUtils.ts +++ b/packages/components/src/context-actions/ContextActionUtils.ts @@ -17,6 +17,9 @@ export interface ContextAction { icon?: IconDefinition | React.ReactElement; iconColor?: string; shortcut?: Shortcut; + + /* Display text for the shortcut if the shortcut is not wired up through the Shortcut class */ + shortcutText?: string; isGlobal?: boolean; group?: number; order?: number; diff --git a/packages/components/src/context-actions/ContextMenuItem.tsx b/packages/components/src/context-actions/ContextMenuItem.tsx index febf5c72ab..9af75cfef3 100644 --- a/packages/components/src/context-actions/ContextMenuItem.tsx +++ b/packages/components/src/context-actions/ContextMenuItem.tsx @@ -71,7 +71,8 @@ const ContextMenuItem = React.forwardRef( 'data-testid': dataTestId, } = props; - const displayShortcut = menuItem.shortcut?.getDisplayText(); + const displayShortcut = + menuItem.shortcutText ?? menuItem.shortcut?.getDisplayText(); let icon: IconDefinition | React.ReactElement | null = null; if (menuItem.icon) { const menuItemIcon = menuItem.icon; diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.scss b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.scss index 457c74e1bb..e5235adc1d 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.scss +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.scss @@ -31,3 +31,7 @@ $panel-message-overlay-top: 30px; .grid-cursor-linker { cursor: crosshair; } + +.grid-cursor-copy { + cursor: copy; +} diff --git a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx index 4101c6a68e..1b895ab508 100644 --- a/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx +++ b/packages/dashboard-core-plugins/src/panels/IrisGridPanel.tsx @@ -1308,6 +1308,7 @@ export class IrisGridPanel extends PureComponent< )} columnAllowedCursor="linker" columnNotAllowedCursor="linker-not-allowed" + copyCursor="copy" customColumns={customColumns} customColumnFormatMap={customColumnFormatMap} columnSelectionValidator={this.isColumnSelectionValid} diff --git a/packages/grid/src/Grid.test.tsx b/packages/grid/src/Grid.test.tsx index e85dc2cd66..ffacabe39f 100644 --- a/packages/grid/src/Grid.test.tsx +++ b/packages/grid/src/Grid.test.tsx @@ -220,7 +220,8 @@ function mouseDoubleClick( function keyDown(key: string, component: Grid, extraArgs?: KeyboardEventInit) { const args = { key, ...extraArgs }; - component.handleKeyDown( + component.notifyKeyboardHandlers( + 'onDown', new KeyboardEvent('keydown', args) as unknown as React.KeyboardEvent ); } diff --git a/packages/grid/src/Grid.tsx b/packages/grid/src/Grid.tsx index fed4bd08c7..04cdbf2ef5 100644 --- a/packages/grid/src/Grid.tsx +++ b/packages/grid/src/Grid.tsx @@ -34,7 +34,10 @@ import { GridTokenMouseHandler, } from './mouse-handlers'; import './Grid.scss'; -import KeyHandler, { GridKeyboardEvent } from './KeyHandler'; +import KeyHandler, { + GridKeyHandlerFunctionName, + GridKeyboardEvent, +} from './KeyHandler'; import { EditKeyHandler, PasteKeyHandler, @@ -342,7 +345,9 @@ class Grid extends PureComponent { this.handleEditCellChange = this.handleEditCellChange.bind(this); this.handleEditCellCommit = this.handleEditCellCommit.bind(this); this.handleDoubleClick = this.handleDoubleClick.bind(this); + this.notifyKeyboardHandlers = this.notifyKeyboardHandlers.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); + this.handleKeyUp = this.handleKeyUp.bind(this); this.handleMouseDown = this.handleMouseDown.bind(this); this.handleMouseDrag = this.handleMouseDrag.bind(this); this.handleMouseMove = this.handleMouseMove.bind(this); @@ -1715,14 +1720,20 @@ class Grid extends PureComponent { } /** - * Handle a key down event from the keyboard. Pass the event to the registered keyboard handlers until one handles it. - * @param event Keyboard event + * Notify all of the keyboard handlers for this grid of a keyboard event. + * @param functionName The name of the function in the keyboard handler to call + * @param event The keyboard event to notify */ - handleKeyDown(event: GridKeyboardEvent): void { + notifyKeyboardHandlers( + functionName: GridKeyHandlerFunctionName, + event: GridKeyboardEvent + ): void { const keyHandlers = this.getKeyHandlers(); for (let i = 0; i < keyHandlers.length; i += 1) { const keyHandler = keyHandlers[i]; - const result = keyHandler.onDown(event, this); + const result = + keyHandler[functionName] != null && + keyHandler[functionName](event, this); if (result !== false) { const options = result as EventHandlerResultOptions; if (options?.stopPropagation ?? true) event.stopPropagation(); @@ -1732,6 +1743,14 @@ class Grid extends PureComponent { } } + handleKeyDown(event: GridKeyboardEvent): void { + this.notifyKeyboardHandlers('onDown', event); + } + + handleKeyUp(event: GridKeyboardEvent): void { + this.notifyKeyboardHandlers('onUp', event); + } + /** * Notify all of the mouse handlers for this grid of a mouse event. * @param functionName The name of the function in the mouse handler to call @@ -2229,6 +2248,7 @@ class Grid extends PureComponent { onContextMenu={this.handleContextMenu} onDoubleClick={this.handleDoubleClick} onKeyDown={this.handleKeyDown} + onKeyUp={this.handleKeyUp} onMouseDown={this.handleMouseDown} onMouseMove={this.handleMouseMove} onMouseLeave={this.handleMouseLeave} diff --git a/packages/grid/src/KeyHandler.ts b/packages/grid/src/KeyHandler.ts index 8b4138f52d..f6216f5adf 100644 --- a/packages/grid/src/KeyHandler.ts +++ b/packages/grid/src/KeyHandler.ts @@ -15,6 +15,8 @@ import type Grid from './Grid'; */ export type GridKeyboardEvent = KeyboardEvent | React.KeyboardEvent; +export type GridKeyHandlerFunctionName = 'onDown' | 'onUp'; + export class KeyHandler { order: number; @@ -33,6 +35,16 @@ export class KeyHandler { onDown(event: GridKeyboardEvent, grid: Grid): EventHandlerResult { return false; } + + /** + * Handle a keyup event on the grid. + * @param event The keyboard event + * @param grid The grid component the key press is on + * @returns Response indicating if the key was consumed + */ + onUp(event: GridKeyboardEvent, grid: Grid): EventHandlerResult { + return false; + } } export default KeyHandler; diff --git a/packages/iris-grid/src/IrisGrid.test.tsx b/packages/iris-grid/src/IrisGrid.test.tsx index d255416fe8..059e8fb9ba 100644 --- a/packages/iris-grid/src/IrisGrid.test.tsx +++ b/packages/iris-grid/src/IrisGrid.test.tsx @@ -80,7 +80,10 @@ function makeComponent( function keyDown(key, component, extraArgs?) { const args = { key, ...extraArgs }; - component.grid.handleKeyDown(new KeyboardEvent('keydown', args)); + component.grid.notifyKeyboardHandlers( + 'onDown', + new KeyboardEvent('keydown', args) + ); } it('renders without crashing', () => { diff --git a/packages/iris-grid/src/IrisGrid.tsx b/packages/iris-grid/src/IrisGrid.tsx index 4dc456270b..d66b0c92e1 100644 --- a/packages/iris-grid/src/IrisGrid.tsx +++ b/packages/iris-grid/src/IrisGrid.tsx @@ -120,6 +120,7 @@ import { IrisGridColumnSelectMouseHandler, IrisGridColumnTooltipMouseHandler, IrisGridContextMenuHandler, + IrisGridCopyCellMouseHandler, IrisGridDataSelectMouseHandler, IrisGridFilterMouseHandler, IrisGridRowTreeMouseHandler, @@ -311,6 +312,9 @@ export interface IrisGridProps { // eslint-disable-next-line react/no-unused-prop-types columnNotAllowedCursor: string; + + // eslint-disable-next-line react/no-unused-prop-types + copyCursor: string; name: string; onlyFetchVisibleColumns: boolean; @@ -485,6 +489,7 @@ export class IrisGrid extends Component { columnSelectionValidator: null, columnAllowedCursor: null, columnNotAllowedCursor: null, + copyCursor: null, name: 'table', onlyFetchVisibleColumns: true, showSearchBar: false, @@ -617,6 +622,8 @@ export class IrisGrid extends Component { this.gotoRowRef = React.createRef(); + this.isCopying = false; + this.toggleFilterBarAction = { action: () => this.toggleFilterBar(), shortcut: SHORTCUTS.TABLE.TOGGLE_QUICK_FILTER, @@ -694,15 +701,12 @@ export class IrisGrid extends Component { columnHeaderGroups, } = props; + const { dh } = model; const keyHandlers: KeyHandler[] = [ new ReverseKeyHandler(this), new ClearFilterKeyHandler(this), ]; - if (canCopy) { - keyHandlers.push(new CopyKeyHandler(this)); - } - const { dh } = model; - const mouseHandlers = [ + const mouseHandlers: GridMouseHandler[] = [ new IrisGridCellOverflowMouseHandler(this), new IrisGridRowTreeMouseHandler(this), new IrisGridTokenMouseHandler(this), @@ -714,7 +718,10 @@ export class IrisGrid extends Component { new IrisGridDataSelectMouseHandler(this), new PendingMouseHandler(this), ]; - + if (canCopy) { + keyHandlers.push(new CopyKeyHandler(this)); + mouseHandlers.push(new IrisGridCopyCellMouseHandler(this)); + } const movedColumns = movedColumnsProp.length > 0 ? movedColumnsProp @@ -1010,6 +1017,8 @@ export class IrisGrid extends Component { gotoRowRef: React.RefObject; + isCopying: boolean; + toggleFilterBarAction: Action; toggleSearchBarAction: Action; @@ -2031,6 +2040,25 @@ export class IrisGrid extends Component { } } + copyColumnHeader(columnIndex: GridRangeIndex, columnDepth = 0): void { + if (columnIndex === null) { + return; + } + const { canCopy } = this.props; + const { movedColumns } = this.state; + + if (canCopy) { + const copyOperation = { + columnIndex, + columnDepth, + movedColumns, + }; + this.setState({ copyOperation }); + } else { + log.error('Attempted copyColumnHeader for user without copy permission.'); + } + } + /** * Copy the provided ranges to the clipboard * @paramranges The ranges to copy diff --git a/packages/iris-grid/src/IrisGridCopyHandler.test.tsx b/packages/iris-grid/src/IrisGridCopyHandler.test.tsx index d8b9ebf546..cb4bab3d91 100644 --- a/packages/iris-grid/src/IrisGridCopyHandler.test.tsx +++ b/packages/iris-grid/src/IrisGridCopyHandler.test.tsx @@ -5,7 +5,12 @@ import { GridTestUtils } from '@deephaven/grid'; import { copyToClipboard } from '@deephaven/utils'; import dh from '@deephaven/jsapi-shim'; import IrisGridTestUtils from './IrisGridTestUtils'; -import IrisGridCopyHandler, { CopyOperation } from './IrisGridCopyHandler'; +import IrisGridCopyHandler, { + CopyOperation, + CopyHeaderOperation, + CopyRangesOperation, +} from './IrisGridCopyHandler'; +import IrisGridProxyModel from './IrisGridProxyModel'; jest.mock('@deephaven/utils', () => ({ ...jest.requireActual('@deephaven/utils'), @@ -29,12 +34,12 @@ function makeSnapshotFn() { return jest.fn(() => Promise.resolve(DEFAULT_EXPECTED_TEXT)); } -function makeCopyOperation( +function makeCopyRangesOperation( ranges = GridTestUtils.makeRanges(), includeHeaders = false, movedColumns = [], userColumnWidths = IrisGridTestUtils.makeUserColumnWidths() -): CopyOperation { +): CopyRangesOperation { return { ranges, includeHeaders, @@ -43,16 +48,29 @@ function makeCopyOperation( }; } +function makeCopyHeaderOperation( + columnIndex = 0, + columnDepth = 0, + movedColumns = [] +): CopyHeaderOperation { + return { + columnIndex, + columnDepth, + movedColumns, + }; +} + function makeModel() { const model = irisGridTestUtils.makeModel(); model.textSnapshot = makeSnapshotFn(); + model.textForColumnHeader = jest.fn((c: number) => c.toString()); return model; } function mountCopySelection({ model = makeModel(), - copyOperation = makeCopyOperation(), -} = {}) { + copyOperation = makeCopyRangesOperation(), +}: { model?: IrisGridProxyModel; copyOperation?: CopyOperation } = {}) { return render( ); @@ -66,9 +84,20 @@ it('renders without crashing', () => { mountCopySelection(); }); +it('copies column header', async () => { + const copyOperation = makeCopyHeaderOperation(); + const model = makeModel(); + mountCopySelection({ copyOperation, model }); + screen.getByRole('progressbar', { hidden: true }); + screen.getByText('Fetching header for clipboard...'); + expect(model.textForColumnHeader).toHaveBeenCalled(); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalledWith('0')); +}); + it('copies immediately if less than 10,000 rows of data', async () => { const ranges = GridTestUtils.makeRanges(1, 10000); - const copyOperation = makeCopyOperation(ranges); + const copyOperation = makeCopyRangesOperation(ranges); const model = makeModel(); mountCopySelection({ copyOperation, model }); screen.getByRole('progressbar', { hidden: true }); @@ -84,7 +113,7 @@ it('prompts to copy if more than 10,000 rows of data', async () => { const user = userEvent.setup({ delay: null }); const model = makeModel(); const ranges = GridTestUtils.makeRanges(1, 10001); - const copyOperation = makeCopyOperation(ranges); + const copyOperation = makeCopyRangesOperation(ranges); mountCopySelection({ copyOperation, model }); const copyBtn = screen.getByText('Copy'); expect(copyBtn).toBeTruthy(); @@ -112,7 +141,7 @@ it('shows click to copy if async copy fails', async () => { mockedCopyToClipboard.mockReturnValueOnce(Promise.reject(error)); const ranges = GridTestUtils.makeRanges(); - const copyOperation = makeCopyOperation(ranges); + const copyOperation = makeCopyRangesOperation(ranges); mountCopySelection({ copyOperation }); await waitFor(() => @@ -138,7 +167,7 @@ it('shows click to copy if async copy fails', async () => { it('retry option available if fetching fails', async () => { const user = userEvent.setup({ delay: null }); const ranges = GridTestUtils.makeRanges(); - const copyOperation = makeCopyOperation(ranges); + const copyOperation = makeCopyRangesOperation(ranges); const model = makeModel(); model.textSnapshot = jest.fn(() => Promise.reject()); @@ -166,7 +195,7 @@ it('shows an error if the copy fails permissions', async () => { mockedCopyToClipboard.mockReturnValueOnce(Promise.reject(error)); const ranges = GridTestUtils.makeRanges(); - const copyOperation = makeCopyOperation(ranges); + const copyOperation = makeCopyRangesOperation(ranges); mountCopySelection({ copyOperation }); await waitFor(() => diff --git a/packages/iris-grid/src/IrisGridCopyHandler.tsx b/packages/iris-grid/src/IrisGridCopyHandler.tsx index f87b87b1af..0310b82ec6 100644 --- a/packages/iris-grid/src/IrisGridCopyHandler.tsx +++ b/packages/iris-grid/src/IrisGridCopyHandler.tsx @@ -27,15 +27,37 @@ type Values = T[keyof T]; type ButtonStateType = Values; -export type CopyOperation = { +type CommonCopyOperation = { + movedColumns: readonly MoveOperation[]; + error?: string; +}; + +export type CopyRangesOperation = CommonCopyOperation & { ranges: readonly GridRange[]; includeHeaders: boolean; formatValues?: boolean; - movedColumns: readonly MoveOperation[]; userColumnWidths: ModelSizeMap; - error?: string; }; +export type CopyHeaderOperation = CommonCopyOperation & { + columnIndex: number; + columnDepth: number; +}; + +export type CopyOperation = CopyRangesOperation | CopyHeaderOperation; + +function isCopyRangesOperation( + copyOperation: CopyOperation +): copyOperation is CopyRangesOperation { + return (copyOperation as CopyRangesOperation).ranges != null; +} + +function isCopyHeaderOperation( + copyOperation: CopyOperation +): copyOperation is CopyHeaderOperation { + return (copyOperation as CopyHeaderOperation).columnIndex != null; +} + interface IrisGridCopyHandlerProps { model: IrisGridModel; copyOperation: CopyOperation; @@ -75,8 +97,11 @@ class IrisGridCopyHandler extends Component< // Large copy operation, confirmation required CONFIRMATION_REQUIRED: 'CONFIRMATION_REQUIRED', - // Fetch is currently in progress - FETCH_IN_PROGRESS: 'FETCH_IN_PROGRESS', + // Fetch is currently in progress for copy ranges operation + FETCH_RANGES_IN_PROGRESS: 'FETCH_RANGES_IN_PROGRESS', + + // Fetch is currently in progress for copy header operation + FETCH_HEADER_IN_PROGRESS: 'FETCH_HEADER_IN_PROGRESS', // There was an error fetching the data FETCH_ERROR: 'FETCH_ERROR', @@ -111,8 +136,10 @@ class IrisGridCopyHandler extends Component< return `Fetched ${rowCount.toLocaleString()} rows!`; case IrisGridCopyHandler.COPY_STATES.FETCH_ERROR: return 'Unable to copy data.'; - case IrisGridCopyHandler.COPY_STATES.FETCH_IN_PROGRESS: + case IrisGridCopyHandler.COPY_STATES.FETCH_RANGES_IN_PROGRESS: return `Fetching ${rowCount.toLocaleString()} rows for clipboard...`; + case IrisGridCopyHandler.COPY_STATES.FETCH_HEADER_IN_PROGRESS: + return 'Fetching header for clipboard...'; case IrisGridCopyHandler.COPY_STATES.DONE: return 'Copied to Clipboard!'; default: @@ -186,7 +213,7 @@ class IrisGridCopyHandler extends Component< return; } - const { ranges, error } = copyOperation; + const { error } = copyOperation; if (error != null) { log.debug('Showing copy error', error); this.setState({ @@ -198,18 +225,23 @@ class IrisGridCopyHandler extends Component< return; } - const rowCount = GridRange.rowCount(ranges); + this.setState({ isShown: true, error: undefined }); - this.setState({ rowCount, isShown: true, error: undefined }); + if (isCopyRangesOperation(copyOperation)) { + const { ranges } = copyOperation; + const rowCount = GridRange.rowCount(ranges); + this.setState({ rowCount }); - if (rowCount > IrisGridCopyHandler.NO_PROMPT_THRESHOLD) { - this.setState({ - buttonState: IrisGridCopyHandler.BUTTON_STATES.COPY, - copyState: IrisGridCopyHandler.COPY_STATES.CONFIRMATION_REQUIRED, - }); - } else { - this.startFetch(); + if (rowCount > IrisGridCopyHandler.NO_PROMPT_THRESHOLD) { + this.setState({ + buttonState: IrisGridCopyHandler.BUTTON_STATES.COPY, + copyState: IrisGridCopyHandler.COPY_STATES.CONFIRMATION_REQUIRED, + }); + return; + } } + + this.startFetch(); } stopCopy(): void { @@ -276,41 +308,65 @@ class IrisGridCopyHandler extends Component< async startFetch(): Promise { this.stopFetch(); - this.setState({ - buttonState: IrisGridCopyHandler.BUTTON_STATES.FETCH_IN_PROGRESS, - copyState: IrisGridCopyHandler.COPY_STATES.FETCH_IN_PROGRESS, - }); - const { model, copyOperation } = this.props; - const { - ranges, - includeHeaders, - userColumnWidths, - movedColumns, - formatValues, - } = copyOperation; - log.debug('startFetch', ranges); - - const hiddenColumns = IrisGridUtils.getHiddenColumns(userColumnWidths); - let modelRanges = GridUtils.getModelRanges(ranges, movedColumns); - if (hiddenColumns.length > 0) { - const subtractRanges = hiddenColumns.map(GridRange.makeColumn); - modelRanges = GridRange.subtractRangesFromRanges( - modelRanges, - subtractRanges + + if (isCopyHeaderOperation(copyOperation)) { + const { columnIndex, columnDepth, movedColumns } = copyOperation; + log.debug('startFetch copyHeader', columnIndex, columnDepth); + + this.setState({ + buttonState: IrisGridCopyHandler.BUTTON_STATES.FETCH_IN_PROGRESS, + copyState: IrisGridCopyHandler.COPY_STATES.FETCH_HEADER_IN_PROGRESS, + }); + + const modelIndex = GridUtils.getModelIndex(columnIndex, movedColumns); + const copyText = model.textForColumnHeader(modelIndex, columnDepth); + if (copyText === undefined) { + this.fetchPromise = undefined; + this.setState({ + error: 'Invalid column header selected.', + copyState: IrisGridCopyHandler.COPY_STATES.DONE, + }); + return; + } + this.fetchPromise = PromiseUtils.makeCancelable(copyText); + } else { + const { + ranges, + includeHeaders, + userColumnWidths, + movedColumns, + formatValues, + } = copyOperation; + log.debug('startFetch copyRanges', ranges); + + this.setState({ + buttonState: IrisGridCopyHandler.BUTTON_STATES.FETCH_IN_PROGRESS, + copyState: IrisGridCopyHandler.COPY_STATES.FETCH_RANGES_IN_PROGRESS, + }); + + const hiddenColumns = IrisGridUtils.getHiddenColumns(userColumnWidths); + let modelRanges = GridUtils.getModelRanges(ranges, movedColumns); + if (hiddenColumns.length > 0) { + const subtractRanges = hiddenColumns.map(GridRange.makeColumn); + modelRanges = GridRange.subtractRangesFromRanges( + modelRanges, + subtractRanges + ); + } + + // Remove the hidden columns from the snapshot + const formatValue = + formatValues != null && formatValues + ? (value: unknown, column: Column) => + model.displayString(value, column.type, column.name) + : (value: unknown) => `${value}`; + + this.fetchPromise = PromiseUtils.makeCancelable( + model.textSnapshot(modelRanges, includeHeaders, formatValue) ); } - // Remove the hidden columns from the snapshot - const formatValue = - formatValues != null && formatValues - ? (value: unknown, column: Column) => - model.displayString(value, column.type, column.name) - : (value: unknown) => `${value}`; - - this.fetchPromise = PromiseUtils.makeCancelable( - model.textSnapshot(modelRanges, includeHeaders, formatValue) - ); try { const text = await this.fetchPromise; this.fetchPromise = undefined; diff --git a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx index 03e5a03e62..7cc3676f9a 100644 --- a/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx +++ b/packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx @@ -337,6 +337,7 @@ class IrisGridContextMenuHandler extends GridMouseHandler { actions.push({ title: 'Copy Column Name', group: IrisGridContextMenuHandler.GROUP_COPY, + shortcutText: ContextActionUtils.isMacPlatform() ? '⌥Click' : 'Alt+Click', action: () => { copyToClipboard(model.textForColumnHeader(modelIndex) ?? '').catch(e => log.error('Unable to copy header', e) @@ -632,6 +633,9 @@ class IrisGridContextMenuHandler extends GridMouseHandler { actions.push({ title: 'Copy Cell', group: IrisGridContextMenuHandler.GROUP_COPY, + shortcutText: ContextActionUtils.isMacPlatform() + ? '⌥Click' + : 'Alt+Click', order: 10, action: () => { irisGrid.copyCell(columnIndex, rowIndex); diff --git a/packages/iris-grid/src/mousehandlers/IrisGridCopyCellMouseHandler.ts b/packages/iris-grid/src/mousehandlers/IrisGridCopyCellMouseHandler.ts new file mode 100644 index 0000000000..8359dd639c --- /dev/null +++ b/packages/iris-grid/src/mousehandlers/IrisGridCopyCellMouseHandler.ts @@ -0,0 +1,66 @@ +import { + Grid, + GridMouseHandler, + GridPoint, + EventHandlerResult, + GridMouseEvent, + GridRange, +} from '@deephaven/grid'; +import { ContextActionUtils } from '@deephaven/components'; +import IrisGrid from '../IrisGrid'; + +class IrisGridCopyCellMouseHandler extends GridMouseHandler { + private irisGrid: IrisGrid; + + constructor(irisGrid: IrisGrid) { + super(250); + + this.irisGrid = irisGrid; + this.cursor = null; + } + + onClick( + gridPoint: GridPoint, + grid: Grid, + event: GridMouseEvent + ): EventHandlerResult { + if ( + event.altKey && + !ContextActionUtils.isModifierKeyDown(event) && + !event.shiftKey + ) { + this.cursor = null; + if (gridPoint.columnHeaderDepth !== undefined) { + this.irisGrid.copyColumnHeader( + gridPoint.column, + gridPoint.columnHeaderDepth + ); + } else { + this.irisGrid.copyRanges([ + GridRange.makeCell(gridPoint.column, gridPoint.row), + ]); + } + return true; + } + return false; + } + + onMove( + gridPoint: GridPoint, + _grid: Grid, + event: GridMouseEvent + ): EventHandlerResult { + if ( + event.altKey && + !ContextActionUtils.isModifierKeyDown(event) && + !event.shiftKey && + gridPoint.column != null && + (gridPoint.row != null || gridPoint.columnHeaderDepth != null) + ) { + this.cursor = this.irisGrid.props.copyCursor; + return true; + } + return false; + } +} +export default IrisGridCopyCellMouseHandler; diff --git a/packages/iris-grid/src/mousehandlers/index.ts b/packages/iris-grid/src/mousehandlers/index.ts index 95c2faf112..a0bcb101a8 100644 --- a/packages/iris-grid/src/mousehandlers/index.ts +++ b/packages/iris-grid/src/mousehandlers/index.ts @@ -2,6 +2,7 @@ export { default as IrisGridCellOverflowMouseHandler } from './IrisGridCellOverf export { default as IrisGridColumnSelectMouseHandler } from './IrisGridColumnSelectMouseHandler'; export { default as IrisGridColumnTooltipMouseHandler } from './IrisGridColumnTooltipMouseHandler'; export { default as IrisGridContextMenuHandler } from './IrisGridContextMenuHandler'; +export { default as IrisGridCopyCellMouseHandler } from './IrisGridCopyCellMouseHandler'; export { default as IrisGridDataSelectMouseHandler } from './IrisGridDataSelectMouseHandler'; export { default as IrisGridFilterMouseHandler } from './IrisGridFilterMouseHandler'; export { default as IrisGridRowTreeMouseHandler } from './IrisGridRowTreeMouseHandler';