Skip to content

Commit b2aaa7a

Browse files
authored
Handle drag and drop modifier keys more consistently across browsers (#3479)
1 parent cf60fcb commit b2aaa7a

File tree

5 files changed

+155
-27
lines changed

5 files changed

+155
-27
lines changed

packages/@react-aria/dnd/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export const DROP_OPERATION_ALLOWED = {
3232
};
3333

3434
export const EFFECT_ALLOWED = invert(DROP_OPERATION_ALLOWED);
35+
EFFECT_ALLOWED[DROP_OPERATION.all] = 'all'; // ensure we don't map to 'uninitialized'.
36+
3537
export const DROP_EFFECT = invert(DROP_OPERATION);
3638
export const DROP_EFFECT_TO_DROP_OPERATION: {[name: string]: DropOperation} = {
3739
none: 'cancel',

packages/@react-aria/dnd/src/useDrag.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ import * as DragManager from './DragManager';
1717
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, EFFECT_ALLOWED} from './constants';
1818
// @ts-ignore
1919
import intlMessages from '../intl/*.json';
20+
import {setGlobalAllowedDropOperations, useDragModality} from './utils';
2021
import {useDescription, useGlobalListeners} from '@react-aria/utils';
21-
import {useDragModality} from './utils';
2222
import {useLocalizedStringFormatter} from '@react-aria/i18n';
2323
import {writeToDataTransfer} from './utils';
2424

@@ -79,16 +79,18 @@ export function useDrag(options: DragOptions): DragResult {
7979
let items = options.getItems();
8080
writeToDataTransfer(e.dataTransfer, items);
8181

82+
let allowed = DROP_OPERATION.all;
8283
if (typeof options.getAllowedDropOperations === 'function') {
8384
let allowedOperations = options.getAllowedDropOperations();
84-
let allowed = DROP_OPERATION.none;
85+
allowed = DROP_OPERATION.none;
8586
for (let operation of allowedOperations) {
8687
allowed |= DROP_OPERATION[operation] || DROP_OPERATION.none;
8788
}
88-
89-
e.dataTransfer.effectAllowed = EFFECT_ALLOWED[allowed] || 'none';
9089
}
9190

91+
setGlobalAllowedDropOperations(allowed);
92+
e.dataTransfer.effectAllowed = EFFECT_ALLOWED[allowed] || 'none';
93+
9294
// If there is a preview option, use it to render a custom preview image that will
9395
// appear under the pointer while dragging. If not, the element itself is dragged by the browser.
9496
if (typeof options.preview?.current === 'function') {
@@ -161,6 +163,7 @@ export function useDrag(options: DragOptions): DragResult {
161163

162164
setDragging(false);
163165
removeAllGlobalListeners();
166+
setGlobalAllowedDropOperations(DROP_OPERATION.none);
164167
};
165168

166169
let onPress = (e: PressEvent) => {

packages/@react-aria/dnd/src/useDrop.ts

Lines changed: 79 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212

1313
import {DragEvent, HTMLAttributes, RefObject, useRef, useState} from 'react';
1414
import * as DragManager from './DragManager';
15-
import {DragTypes, readFromDataTransfer} from './utils';
15+
import {DragTypes, globalAllowedDropOperations, readFromDataTransfer} from './utils';
1616
import {DROP_EFFECT_TO_DROP_OPERATION, DROP_OPERATION, DROP_OPERATION_ALLOWED, DROP_OPERATION_TO_DROP_EFFECT} from './constants';
1717
import {DropActivateEvent, DropEnterEvent, DropEvent, DropExitEvent, DropMoveEvent, DropOperation, DragTypes as IDragTypes} from '@react-types/shared';
18-
import {useLayoutEffect} from '@react-aria/utils';
18+
import {isIPad, isMac, useLayoutEffect} from '@react-aria/utils';
1919
import {useVirtualDrop} from './useVirtualDrop';
2020

2121
export interface DropOptions {
@@ -55,7 +55,7 @@ export function useDrop(options: DropOptions): DropResult {
5555
y: 0,
5656
dragOverElements: new Set<Element>(),
5757
dropEffect: 'none' as DataTransfer['dropEffect'],
58-
effectAllowed: 'none' as DataTransfer['effectAllowed'],
58+
allowedOperations: DROP_OPERATION.all,
5959
dropActivateTimer: null
6060
}).current;
6161

@@ -89,7 +89,8 @@ export function useDrop(options: DropOptions): DropResult {
8989
e.preventDefault();
9090
e.stopPropagation();
9191

92-
if (e.clientX === state.x && e.clientY === state.y && e.dataTransfer.effectAllowed === state.effectAllowed) {
92+
let allowedOperations = getAllowedOperations(e);
93+
if (e.clientX === state.x && e.clientY === state.y && allowedOperations === state.allowedOperations) {
9394
e.dataTransfer.dropEffect = state.dropEffect;
9495
return;
9596
}
@@ -100,29 +101,27 @@ export function useDrop(options: DropOptions): DropResult {
100101
let prevDropEffect = state.dropEffect;
101102

102103
// Update drop effect if allowed drop operations changed (e.g. user pressed modifier key).
103-
if (e.dataTransfer.effectAllowed !== state.effectAllowed) {
104-
let allowedOperations = effectAllowedToOperations(e.dataTransfer.effectAllowed);
105-
let dropOperation = allowedOperations[0];
104+
if (allowedOperations !== state.allowedOperations) {
105+
let allowedOps = allowedOperationsToArray(allowedOperations);
106+
let dropOperation = allowedOps[0];
106107
if (typeof options.getDropOperation === 'function') {
107108
let types = new DragTypes(e.dataTransfer);
108-
dropOperation = getDropOperation(e.dataTransfer.effectAllowed, options.getDropOperation(types, allowedOperations));
109+
dropOperation = getDropOperation(allowedOperations, options.getDropOperation(types, allowedOps));
109110
}
110-
111111
state.dropEffect = DROP_OPERATION_TO_DROP_EFFECT[dropOperation] || 'none';
112112
}
113113

114114
if (typeof options.getDropOperationForPoint === 'function') {
115-
let allowedOperations = effectAllowedToOperations(e.dataTransfer.effectAllowed);
116115
let types = new DragTypes(e.dataTransfer);
117116
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
118117
let dropOperation = getDropOperation(
119-
e.dataTransfer.effectAllowed,
120-
options.getDropOperationForPoint(types, allowedOperations, state.x - rect.x, state.y - rect.y)
118+
allowedOperations,
119+
options.getDropOperationForPoint(types, allowedOperationsToArray(allowedOperations), state.x - rect.x, state.y - rect.y)
121120
);
122121
state.dropEffect = DROP_OPERATION_TO_DROP_EFFECT[dropOperation] || 'none';
123122
}
124123

125-
state.effectAllowed = e.dataTransfer.effectAllowed;
124+
state.allowedOperations = allowedOperations;
126125
e.dataTransfer.dropEffect = state.dropEffect;
127126

128127
// If the drop operation changes, update state and fire events appropriately.
@@ -162,26 +161,27 @@ export function useDrop(options: DropOptions): DropResult {
162161
return;
163162
}
164163

165-
let allowedOperations = effectAllowedToOperations(e.dataTransfer.effectAllowed);
164+
let allowedOperationsBits = getAllowedOperations(e);
165+
let allowedOperations = allowedOperationsToArray(allowedOperationsBits);
166166
let dropOperation = allowedOperations[0];
167167

168168
if (typeof options.getDropOperation === 'function') {
169169
let types = new DragTypes(e.dataTransfer);
170-
dropOperation = getDropOperation(e.dataTransfer.effectAllowed, options.getDropOperation(types, allowedOperations));
170+
dropOperation = getDropOperation(allowedOperationsBits, options.getDropOperation(types, allowedOperations));
171171
}
172172

173173
if (typeof options.getDropOperationForPoint === 'function') {
174174
let types = new DragTypes(e.dataTransfer);
175175
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
176176
dropOperation = getDropOperation(
177-
e.dataTransfer.effectAllowed,
177+
allowedOperationsBits,
178178
options.getDropOperationForPoint(types, allowedOperations, e.clientX - rect.x, e.clientY - rect.y)
179179
);
180180
}
181181

182182
state.x = e.clientX;
183183
state.y = e.clientY;
184-
state.effectAllowed = e.dataTransfer.effectAllowed;
184+
state.allowedOperations = allowedOperationsBits;
185185
state.dropEffect = DROP_OPERATION_TO_DROP_EFFECT[dropOperation] || 'none';
186186
e.dataTransfer.dropEffect = state.dropEffect;
187187

@@ -294,8 +294,66 @@ export function useDrop(options: DropOptions): DropResult {
294294
};
295295
}
296296

297-
function effectAllowedToOperations(effectAllowed: string) {
298-
let allowedOperationsBits = DROP_OPERATION_ALLOWED[effectAllowed];
297+
function getAllowedOperations(e: DragEvent) {
298+
let allowedOperations = DROP_OPERATION_ALLOWED[e.dataTransfer.effectAllowed];
299+
300+
// WebKit always sets effectAllowed to "copyMove" on macOS, and "all" on iOS, regardless of what was
301+
// set during the dragstart event: https://bugs.webkit.org/show_bug.cgi?id=178058
302+
//
303+
// Android Chrome also sets effectAllowed to "copyMove" in all cases: https://bugs.chromium.org/p/chromium/issues/detail?id=1359182
304+
//
305+
// If the drag started within the page, we can use a global variable to get the real allowed operations.
306+
// This needs to be intersected with the actual effectAllowed, which may have been filtered based on modifier keys.
307+
// Unfortunately, this means that link operations do not work at all in Safari.
308+
if (globalAllowedDropOperations) {
309+
allowedOperations &= globalAllowedDropOperations;
310+
}
311+
312+
// Chrome and Safari on macOS will automatically filter effectAllowed when pressing modifier keys,
313+
// allowing the user to switch between move, link, and copy operations. Firefox on macOS and all
314+
// Windows browsers do not do this, so do it ourselves instead. The exact keys are platform dependent.
315+
// https://ux.stackexchange.com/questions/83748/what-are-the-most-common-modifier-keys-for-dragging-objects-with-a-mouse
316+
//
317+
// Note that none of these modifiers are ever set in WebKit due to a bug: https://bugs.webkit.org/show_bug.cgi?id=77465
318+
// However, Safari does update effectAllowed correctly, so we can just rely on that.
319+
let allowedModifiers = DROP_OPERATION.none;
320+
if (isMac()) {
321+
if (e.altKey) {
322+
allowedModifiers |= DROP_OPERATION.copy;
323+
}
324+
325+
// Chrome and Safari both use the Control key for link, even though Finder uses Command + Option.
326+
// iPadOS doesn't support link operations and will not fire the drop event at all if dropEffect is set to link.
327+
// https://bugs.webkit.org/show_bug.cgi?id=244701
328+
if (e.ctrlKey && !isIPad()) {
329+
allowedModifiers |= DROP_OPERATION.link;
330+
}
331+
332+
if (e.metaKey) {
333+
allowedModifiers |= DROP_OPERATION.move;
334+
}
335+
} else {
336+
if (e.altKey) {
337+
allowedModifiers |= DROP_OPERATION.link;
338+
}
339+
340+
if (e.shiftKey) {
341+
allowedModifiers |= DROP_OPERATION.move;
342+
}
343+
344+
if (e.ctrlKey) {
345+
allowedModifiers |= DROP_OPERATION.copy;
346+
}
347+
}
348+
349+
if (allowedModifiers) {
350+
return allowedOperations & allowedModifiers;
351+
}
352+
353+
return allowedOperations;
354+
}
355+
356+
function allowedOperationsToArray(allowedOperationsBits: DROP_OPERATION) {
299357
let allowedOperations = [];
300358
if (allowedOperationsBits & DROP_OPERATION.move) {
301359
allowedOperations.push('move');
@@ -312,8 +370,7 @@ function effectAllowedToOperations(effectAllowed: string) {
312370
return allowedOperations;
313371
}
314372

315-
function getDropOperation(effectAllowed: string, operation: DropOperation) {
316-
let allowedOperationsBits = DROP_OPERATION_ALLOWED[effectAllowed];
373+
function getDropOperation(allowedOperations: DROP_OPERATION, operation: DropOperation) {
317374
let op = DROP_OPERATION[operation];
318-
return allowedOperationsBits & op ? operation : 'cancel';
375+
return allowedOperations & op ? operation : 'cancel';
319376
}

packages/@react-aria/dnd/src/utils.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {CUSTOM_DRAG_TYPE, GENERIC_TYPE, NATIVE_DRAG_TYPES} from './constants';
13+
import {CUSTOM_DRAG_TYPE, DROP_OPERATION, GENERIC_TYPE, NATIVE_DRAG_TYPES} from './constants';
1414
import {DirectoryItem, DragItem, DropItem, FileItem, DragTypes as IDragTypes} from '@react-types/shared';
1515
import {DroppableCollectionState} from '@react-stately/dnd';
1616
import {getInteractionModality, useInteractionModality} from '@react-aria/interactions';
@@ -302,3 +302,8 @@ async function *getEntries(item: FileSystemDirectoryEntry): AsyncIterable<FileIt
302302
function getEntryFile(entry: FileSystemFileEntry): Promise<File> {
303303
return new Promise((resolve, reject) => entry.file(resolve, reject));
304304
}
305+
306+
export let globalAllowedDropOperations = DROP_OPERATION.none;
307+
export function setGlobalAllowedDropOperations(o: DROP_OPERATION) {
308+
globalAllowedDropOperations = o;
309+
}

packages/@react-aria/dnd/test/dnd.test.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1079,6 +1079,67 @@ describe('useDrag and useDrop', function () {
10791079
expect(onDropExit).toHaveBeenCalledTimes(1);
10801080
});
10811081

1082+
it('should update drop operation if modifier key is pressed and browser does not update effectAllowed', () => {
1083+
let onDropEnter = jest.fn();
1084+
let onDropExit = jest.fn();
1085+
let onDragEnd = jest.fn();
1086+
let onDrop = jest.fn();
1087+
let tree = render(<>
1088+
<Draggable onDragEnd={onDragEnd} />
1089+
<Droppable onDropEnter={onDropEnter} onDropExit={onDropExit} onDrop={onDrop} />
1090+
</>);
1091+
1092+
let draggable = tree.getByText('Drag me');
1093+
let droppable = tree.getByText('Drop here');
1094+
1095+
let dataTransfer = new DataTransfer();
1096+
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
1097+
expect(dataTransfer.dropEffect).toBe('none');
1098+
1099+
fireEvent(droppable, new DragEvent('dragenter', {dataTransfer, clientX: 0, clientY: 0}));
1100+
expect(dataTransfer.dropEffect).toBe('move');
1101+
expect(droppable).toHaveAttribute('data-droptarget', 'true');
1102+
expect(onDropEnter).toHaveBeenCalledTimes(1);
1103+
1104+
fireEvent(droppable, new DragEvent('dragover', {dataTransfer, clientX: 0, clientY: 0, altKey: true}));
1105+
expect(dataTransfer.dropEffect).toBe('link');
1106+
expect(droppable).toHaveAttribute('data-droptarget', 'true');
1107+
expect(onDropExit).not.toHaveBeenCalled();
1108+
expect(onDropEnter).toHaveBeenCalledTimes(1);
1109+
});
1110+
1111+
it('should handle when browser does not set effectAllowed properly', () => {
1112+
let onDropEnter = jest.fn();
1113+
let onDropExit = jest.fn();
1114+
let onDragEnd = jest.fn();
1115+
let onDrop = jest.fn();
1116+
let getAllowedDropOperations = jest.fn().mockImplementation(() => ['copy']);
1117+
let tree = render(<>
1118+
<Draggable onDragEnd={onDragEnd} getAllowedDropOperations={getAllowedDropOperations} />
1119+
<Droppable onDropEnter={onDropEnter} onDropExit={onDropExit} onDrop={onDrop} />
1120+
</>);
1121+
1122+
let draggable = tree.getByText('Drag me');
1123+
let droppable = tree.getByText('Drop here');
1124+
1125+
let dataTransfer = new DataTransfer();
1126+
fireEvent(draggable, new DragEvent('dragstart', {dataTransfer, clientX: 0, clientY: 0}));
1127+
expect(dataTransfer.effectAllowed).toBe('copy');
1128+
1129+
// Simulate WebKit bug.
1130+
dataTransfer.effectAllowed = 'copyMove';
1131+
fireEvent(droppable, new DragEvent('dragenter', {dataTransfer, clientX: 0, clientY: 0}));
1132+
expect(dataTransfer.dropEffect).toBe('copy');
1133+
expect(droppable).toHaveAttribute('data-droptarget', 'true');
1134+
expect(onDropEnter).toHaveBeenCalledTimes(1);
1135+
1136+
dataTransfer.effectAllowed = 'copyMove';
1137+
fireEvent(droppable, new DragEvent('dragover', {dataTransfer, clientX: 0, clientY: 0, altKey: true}));
1138+
expect(dataTransfer.dropEffect).toBe('none');
1139+
expect(droppable).toHaveAttribute('data-droptarget', 'false');
1140+
expect(onDropExit).toHaveBeenCalledTimes(1);
1141+
});
1142+
10821143
it('should pass file types to getDropOperation', async () => {
10831144
let getDropOperation = jest.fn().mockImplementation(() => 'move');
10841145
let tree = render(<Droppable getDropOperation={getDropOperation} />);

0 commit comments

Comments
 (0)