Skip to content

Commit 680f015

Browse files
authored
fix: No context menu item for paste in an input table (#2341)
- Added paste as a context menu option in IrisGrid - Added methods to check for and request clipboard permissions, which are called when clicking paste in context menu - For browsers that support the `clipboard-read` permission (e.g. Chrome), clicking paste results in the same behaviour as pressing the keyboard shortcut, provided the user has accepted the request for permission - For browsers that don't support the `clipboard-read` permission (e.g. Firefox and Safari), a modal is shown to explain the reason and to suggest using the keyboard shortcut instead Additional related issues fixed - Show relevant warning when attempting to paste too many columns into input table - Fixed copy and pasting between Deephaven tables on Firefox. The issue is caused by tabs in pasted content being converted to `    ` instead of `    ` which we were expecting before. Was able to reproduce former behaviour on an older build of Firefox (91.0), meaning this discrepancy is likely caused by an update to Firefox (unsure when exactly)
1 parent 35d1138 commit 680f015

15 files changed

Lines changed: 350 additions & 7 deletions

packages/grid/src/Grid.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1552,6 +1552,10 @@ class Grid extends PureComponent<GridProps, GridState> {
15521552

15531553
const edits: EditOperation[] = [];
15541554
ranges.forEach(range => {
1555+
if ((range.startColumn ?? 0) + tableWidth > columnCount) {
1556+
throw new PasteError('Pasted content would overflow columns.');
1557+
}
1558+
15551559
for (let x = 0; x < tableWidth; x += 1) {
15561560
for (let y = 0; y < tableHeight; y += 1) {
15571561
edits.push({

packages/grid/src/key-handlers/PasteKeyHandler.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,13 +66,13 @@ describe('table parsing', () => {
6666

6767
const TEXT_TABLE_FIREFOX = (
6868
<>
69-
A&nbsp;&nbsp; &nbsp;B&nbsp;&nbsp; &nbsp;C
69+
A&nbsp;&nbsp;&nbsp; B&nbsp;&nbsp;&nbsp; C
7070
<br />
71-
1&nbsp;&nbsp; &nbsp;2&nbsp;&nbsp; &nbsp;3
71+
1&nbsp;&nbsp;&nbsp; 2&nbsp;&nbsp;&nbsp; 3
7272
</>
7373
);
7474

75-
const SINGLE_ROW_FIREFOX = <>A&nbsp;&nbsp; &nbsp;B&nbsp;&nbsp; &nbsp;C</>;
75+
const SINGLE_ROW_FIREFOX = <>A&nbsp;&nbsp;&nbsp; B&nbsp;&nbsp;&nbsp; C</>;
7676

7777
function testTable(jsx: JSX.Element, expectedValue: string[][]) {
7878
const element = makeElementFromJsx(jsx);

packages/grid/src/key-handlers/PasteKeyHandler.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,9 @@ export function parseValueFromNodes(nodes: NodeListOf<ChildNode>): string[][] {
3939
if (text.length > 0) {
4040
// When Chrome pastes a table from text, it preserves the tab characters
4141
// In Firefox, it breaks it into a combination of non-breaking spaces and spaces
42-
result.push(text.split(/\t|\u00a0\u00a0 \u00a0/));
42+
result.push(text.split(/\t|\u00a0\u00a0\u00a0 /));
4343
}
4444
});
45-
4645
return result;
4746
}
4847

@@ -62,7 +61,7 @@ export function parseValueFromElement(
6261
// If there's only one row and it doesn't contain a tab, then just treat it as a regular value
6362
const { childNodes } = element;
6463
const hasTabChar = text.includes('\t');
65-
const hasFirefoxTab = text.includes('\u00a0\u00a0 \u00a0');
64+
const hasFirefoxTab = text.includes('\u00a0\u00a0\u00a0 ');
6665
if (
6766
hasTabChar &&
6867
childNodes.length !== 0 &&

packages/iris-grid/src/IrisGrid.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ import {
194194
import type ColumnHeaderGroup from './ColumnHeaderGroup';
195195
import { IrisGridThemeContext } from './IrisGridThemeProvider';
196196
import { isMissingPartitionError } from './MissingPartitionError';
197+
import { NoPastePermissionModal } from './NoPastePermissionModal';
197198

198199
const log = Log.module('IrisGrid');
199200

@@ -442,6 +443,8 @@ export interface IrisGridState {
442443
toastMessage: JSX.Element | null;
443444
frozenColumns: readonly ColumnName[];
444445
showOverflowModal: boolean;
446+
showNoPastePermissionModal: boolean;
447+
noPastePermissionError: string;
445448
overflowText: string;
446449
overflowButtonTooltipProps: CSSProperties | null;
447450
expandCellTooltipProps: CSSProperties | null;
@@ -624,6 +627,8 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
624627
this.handleCrossColumnSearch = this.handleCrossColumnSearch.bind(this);
625628
this.handleRollupChange = this.handleRollupChange.bind(this);
626629
this.handleOverflowClose = this.handleOverflowClose.bind(this);
630+
this.handleCloseNoPastePermissionModal =
631+
this.handleCloseNoPastePermissionModal.bind(this);
627632
this.getColumnBoundingRect = this.getColumnBoundingRect.bind(this);
628633
this.handleGotoRowSelectedRowNumberChanged =
629634
this.handleGotoRowSelectedRowNumberChanged.bind(this);
@@ -870,6 +875,8 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
870875
toastMessage: null,
871876
frozenColumns,
872877
showOverflowModal: false,
878+
showNoPastePermissionModal: false,
879+
noPastePermissionError: '',
873880
overflowText: '',
874881
overflowButtonTooltipProps: null,
875882
expandCellTooltipProps: null,
@@ -3853,6 +3860,19 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
38533860
});
38543861
}
38553862

3863+
handleOpenNoPastePermissionModal(errorMessage: string): void {
3864+
this.setState({
3865+
showNoPastePermissionModal: true,
3866+
noPastePermissionError: errorMessage,
3867+
});
3868+
}
3869+
3870+
handleCloseNoPastePermissionModal(): void {
3871+
this.setState({
3872+
showNoPastePermissionModal: false,
3873+
});
3874+
}
3875+
38563876
getColumnBoundingRect(): DOMRect {
38573877
const { metrics, shownColumnTooltip } = this.state;
38583878
assertNotNull(metrics);
@@ -4273,6 +4293,8 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
42734293
frozenColumns,
42744294
columnHeaderGroups,
42754295
showOverflowModal,
4296+
showNoPastePermissionModal,
4297+
noPastePermissionError,
42764298
overflowText,
42774299
overflowButtonTooltipProps,
42784300
expandCellTooltipProps,
@@ -4998,6 +5020,11 @@ class IrisGrid extends Component<IrisGridProps, IrisGridState> {
49985020
</div>
49995021
</SlideTransition>
50005022
<ContextActions actions={this.contextActions} />
5023+
<NoPastePermissionModal
5024+
isOpen={showNoPastePermissionModal}
5025+
onClose={this.handleCloseNoPastePermissionModal}
5026+
errorMessage={noPastePermissionError}
5027+
/>
50015028
</div>
50025029
);
50035030
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import {
2+
Button,
3+
GLOBAL_SHORTCUTS,
4+
Modal,
5+
ModalBody,
6+
ModalFooter,
7+
ModalHeader,
8+
} from '@deephaven/components';
9+
10+
export type NoPastePermissionModalProps = {
11+
isOpen: boolean;
12+
onClose: () => void;
13+
errorMessage: string;
14+
};
15+
16+
export function NoPastePermissionModal({
17+
isOpen,
18+
onClose,
19+
errorMessage,
20+
}: NoPastePermissionModalProps): JSX.Element {
21+
const pasteShortcutText = GLOBAL_SHORTCUTS.PASTE.getDisplayText();
22+
return (
23+
<Modal isOpen={isOpen} toggle={onClose} centered>
24+
<ModalHeader closeButton={false}>No Paste Permission</ModalHeader>
25+
<ModalBody>
26+
<p>{errorMessage}</p>
27+
<p>You can still use {pasteShortcutText} to paste.</p>
28+
</ModalBody>
29+
<ModalFooter>
30+
<Button kind="primary" onClick={onClose}>
31+
Dismiss
32+
</Button>
33+
</ModalFooter>
34+
</Modal>
35+
);
36+
}

packages/iris-grid/src/mousehandlers/IrisGridContextMenuHandler.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,14 @@ import {
4040
import Log from '@deephaven/log';
4141
import type { DebouncedFunc } from 'lodash';
4242
import {
43+
ClipboardPermissionsDeniedError,
44+
ClipboardUnavailableError,
4345
TextUtils,
4446
assertNotEmpty,
4547
assertNotNaN,
4648
assertNotNull,
4749
copyToClipboard,
50+
readFromClipboard,
4851
} from '@deephaven/utils';
4952
import {
5053
DateTimeFormatContextMenu,
@@ -477,6 +480,31 @@ class IrisGridContextMenuHandler extends GridMouseHandler {
477480
});
478481
}
479482

483+
actions.push({
484+
title: 'Paste',
485+
group: IrisGridContextMenuHandler.GROUP_COPY,
486+
order: 50,
487+
action: async () => {
488+
try {
489+
const text = await readFromClipboard();
490+
const items = text.split('\n').map(row => row.split('\t'));
491+
await grid.pasteValue(items);
492+
} catch (err) {
493+
if (err instanceof ClipboardUnavailableError) {
494+
irisGrid.handleOpenNoPastePermissionModal(
495+
'For security reasons your browser does not allow access to your clipboard on click.'
496+
);
497+
} else if (err instanceof ClipboardPermissionsDeniedError) {
498+
irisGrid.handleOpenNoPastePermissionModal(
499+
'Requested clipboard permissions have not been granted, please grant them and try again.'
500+
);
501+
} else {
502+
throw err;
503+
}
504+
}
505+
},
506+
});
507+
480508
actions.push({
481509
title: 'View Cell Contents',
482510
group: IrisGridContextMenuHandler.GROUP_VIEW_CONTENTS,

packages/utils/src/ClipboardUtils.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
1-
import { copyToClipboard } from './ClipboardUtils';
1+
import { copyToClipboard, readFromClipboard } from './ClipboardUtils';
2+
import {
3+
ClipboardPermissionsDeniedError,
4+
ClipboardUnavailableError,
5+
} from './errors';
6+
import { checkPermission } from './PermissionUtils';
27

38
document.execCommand = jest.fn();
49

10+
jest.mock('./PermissionUtils', () => ({
11+
checkPermission: jest.fn(),
12+
}));
13+
514
describe('Clipboard', () => {
615
describe('writeText', () => {
716
beforeEach(() => jest.resetAllMocks());
@@ -55,4 +64,113 @@ describe('Clipboard', () => {
5564
await expect(document.execCommand).toHaveBeenCalledWith('copy');
5665
});
5766
});
67+
68+
describe('readFromClipboard', () => {
69+
beforeEach(() => jest.resetAllMocks());
70+
71+
it('should throw unavailable error if clipboard is undefined', async () => {
72+
Object.assign(navigator, {
73+
clipboard: undefined,
74+
});
75+
76+
await expect(readFromClipboard()).rejects.toThrow(
77+
ClipboardUnavailableError
78+
);
79+
});
80+
81+
it('should throw unavailable error if PermissionState is null', async () => {
82+
(checkPermission as jest.Mock).mockResolvedValue(null);
83+
84+
await expect(readFromClipboard()).rejects.toThrow(
85+
ClipboardUnavailableError
86+
);
87+
});
88+
89+
it('should return text if PermissionState is granted', async () => {
90+
Object.assign(navigator, {
91+
clipboard: {
92+
readText: jest.fn().mockResolvedValueOnce('text from clipboard'),
93+
},
94+
});
95+
96+
(checkPermission as jest.Mock).mockResolvedValueOnce('granted');
97+
98+
await expect(readFromClipboard()).resolves.toBe('text from clipboard');
99+
});
100+
101+
it('should throw denied error if PermissionState is denied', async () => {
102+
Object.assign(navigator, {
103+
clipboard: {
104+
readText: jest.fn(),
105+
},
106+
});
107+
108+
(checkPermission as jest.Mock).mockResolvedValue('denied');
109+
110+
await expect(readFromClipboard()).rejects.toThrow(
111+
ClipboardPermissionsDeniedError
112+
);
113+
});
114+
115+
it('should return text if permission prompt accepted', async () => {
116+
const mockClipboard = {
117+
readText: jest
118+
.fn()
119+
.mockRejectedValueOnce(new Error('Missing permission'))
120+
.mockResolvedValueOnce('text from clipboard'),
121+
};
122+
123+
Object.assign(navigator, {
124+
clipboard: mockClipboard,
125+
});
126+
127+
(checkPermission as jest.Mock)
128+
.mockResolvedValueOnce('prompt')
129+
.mockResolvedValue('granted');
130+
131+
await expect(readFromClipboard()).resolves.toBe('text from clipboard');
132+
expect(checkPermission).toHaveBeenCalledTimes(2);
133+
expect(mockClipboard.readText).toHaveBeenCalledTimes(2);
134+
});
135+
136+
it('should throw denied error if permission prompt denied', async () => {
137+
const mockClipboard = {
138+
readText: jest
139+
.fn()
140+
.mockRejectedValueOnce(new Error('Missing permission')),
141+
};
142+
143+
Object.assign(navigator, {
144+
clipboard: mockClipboard,
145+
});
146+
147+
(checkPermission as jest.Mock)
148+
.mockResolvedValueOnce('prompt')
149+
.mockResolvedValue('denied');
150+
151+
await expect(readFromClipboard()).rejects.toThrow(
152+
ClipboardPermissionsDeniedError
153+
);
154+
expect(checkPermission).toHaveBeenCalledTimes(2);
155+
expect(mockClipboard.readText).toHaveBeenCalledTimes(1);
156+
});
157+
158+
it('should throw denied error if permission prompt closed', async () => {
159+
const mockClipboard = {
160+
readText: jest.fn().mockRejectedValue(new Error('Missing permission')),
161+
};
162+
163+
Object.assign(navigator, {
164+
clipboard: mockClipboard,
165+
});
166+
167+
(checkPermission as jest.Mock).mockResolvedValue('prompt');
168+
169+
await expect(readFromClipboard()).rejects.toThrow(
170+
ClipboardPermissionsDeniedError
171+
);
172+
expect(checkPermission).toHaveBeenCalledTimes(2);
173+
expect(mockClipboard.readText).toHaveBeenCalledTimes(1);
174+
});
175+
});
58176
});

0 commit comments

Comments
 (0)