Skip to content

Commit c33baef

Browse files
authored
Drag and drop bug fixes (#3391)
1 parent f4c916e commit c33baef

File tree

18 files changed

+387
-358
lines changed

18 files changed

+387
-358
lines changed

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ export function beginDragging(target: DragTarget, stringFormatter: LocalizedStri
7575
getDragModality() === 'keyboard' ||
7676
(getDragModality() === 'touch' && getInteractionModality() === 'virtual')
7777
) {
78-
dragSession.next();
78+
let target = dragSession.findNearestDropTarget();
79+
dragSession.setCurrentDropTarget(target);
7980
}
8081
});
8182

@@ -411,6 +412,25 @@ class DragSession {
411412
}
412413
}
413414

415+
findNearestDropTarget(): DropTarget {
416+
let dragTargetRect = this.dragTarget.element.getBoundingClientRect();
417+
418+
let minDistance = Infinity;
419+
let nearest = null;
420+
for (let dropTarget of this.validDropTargets) {
421+
let rect = dropTarget.element.getBoundingClientRect();
422+
let dx = rect.left - dragTargetRect.left;
423+
let dy = rect.top - dragTargetRect.top;
424+
let dist = (dx * dx) + (dy * dy);
425+
if (dist < minDistance) {
426+
minDistance = dist;
427+
nearest = dropTarget;
428+
}
429+
}
430+
431+
return nearest;
432+
}
433+
414434
setCurrentDropTarget(dropTarget: DropTarget, item?: DroppableItem) {
415435
if (dropTarget !== this.currentDropTarget) {
416436
if (this.currentDropTarget && typeof this.currentDropTarget.onDropExit === 'function') {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {Collection, DropTarget, DropTargetDelegate, Node} from '@react-types/shared';
2+
import {RefObject} from 'react';
3+
4+
export class ListDropTargetDelegate implements DropTargetDelegate {
5+
private collection: Collection<Node<unknown>>;
6+
private ref: RefObject<HTMLElement>;
7+
8+
constructor(collection: Collection<Node<unknown>>, ref: RefObject<HTMLElement>) {
9+
this.collection = collection;
10+
this.ref = ref;
11+
}
12+
13+
getDropTargetFromPoint(x: number, y: number, isValidDropTarget: (target: DropTarget) => boolean): DropTarget {
14+
if (this.collection.size === 0) {
15+
return;
16+
}
17+
18+
let rect = this.ref.current.getBoundingClientRect();
19+
x += rect.x;
20+
y += rect.y;
21+
22+
let elements = this.ref.current.querySelectorAll('[data-key]');
23+
let elementMap = new Map<string, HTMLElement>();
24+
for (let item of elements) {
25+
if (item instanceof HTMLElement) {
26+
elementMap.set(item.dataset.key, item);
27+
}
28+
}
29+
30+
let items = [...this.collection];
31+
let low = 0;
32+
let high = items.length;
33+
while (low < high) {
34+
let mid = Math.floor((low + high) / 2);
35+
let item = items[mid];
36+
let element = elementMap.get(String(item.key));
37+
let rect = element.getBoundingClientRect();
38+
39+
if (y < rect.top) {
40+
high = mid;
41+
} else if (y > rect.bottom) {
42+
low = mid + 1;
43+
} else {
44+
let target: DropTarget = {
45+
type: 'item',
46+
key: item.key,
47+
dropPosition: 'on'
48+
};
49+
50+
if (isValidDropTarget(target)) {
51+
// Otherwise, if dropping on the item is accepted, try the before/after positions if within 5px
52+
// of the top or bottom of the item.
53+
if (y <= rect.top + 5 && isValidDropTarget({...target, dropPosition: 'before'})) {
54+
target.dropPosition = 'before';
55+
} else if (y >= rect.bottom - 5 && isValidDropTarget({...target, dropPosition: 'after'})) {
56+
target.dropPosition = 'after';
57+
}
58+
} else {
59+
// If dropping on the item isn't accepted, try the target before or after depending on the y position.
60+
let midY = rect.top + rect.height / 2;
61+
if (y <= midY && isValidDropTarget({...target, dropPosition: 'before'})) {
62+
target.dropPosition = 'before';
63+
} else if (y >= midY && isValidDropTarget({...target, dropPosition: 'after'})) {
64+
target.dropPosition = 'after';
65+
}
66+
}
67+
68+
return target;
69+
}
70+
}
71+
72+
let item = items[Math.min(low, items.length - 1)];
73+
let element = elementMap.get(String(item.key));
74+
rect = element.getBoundingClientRect();
75+
76+
if (Math.abs(y - rect.top) < Math.abs(y - rect.bottom)) {
77+
return {
78+
type: 'item',
79+
key: item.key,
80+
dropPosition: 'before'
81+
};
82+
}
83+
84+
return {
85+
type: 'item',
86+
key: item.key,
87+
dropPosition: 'after'
88+
};
89+
}
90+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type {DragPreviewProps} from './DragPreview';
1818
export type {DragOptions, DragResult} from './useDrag';
1919
export type {DropOptions, DropResult} from './useDrop';
2020
export type {ClipboardProps, ClipboardResult} from './useClipboard';
21+
export type {DropTargetDelegate} from '@react-types/shared';
2122

2223
export {useDrag} from './useDrag';
2324
export {useDrop} from './useDrop';
@@ -27,3 +28,4 @@ export {useDropIndicator} from './useDropIndicator';
2728
export {useDraggableItem} from './useDraggableItem';
2829
export {useClipboard} from './useClipboard';
2930
export {DragPreview} from './DragPreview';
31+
export {ListDropTargetDelegate} from './ListDropTargetDelegate';

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

Lines changed: 107 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,24 @@ import {useVirtualDrop} from './useVirtualDrop';
2020

2121
export interface DropOptions {
2222
ref: RefObject<HTMLElement>,
23+
/**
24+
* A function returning the drop operation to be performed when items matching the given types are dropped
25+
* on the drop target.
26+
*/
2327
getDropOperation?: (types: IDragTypes, allowedOperations: DropOperation[]) => DropOperation,
2428
getDropOperationForPoint?: (types: IDragTypes, allowedOperations: DropOperation[], x: number, y: number) => DropOperation,
29+
/** Handler that is called when a valid drag enters the drop target. */
2530
onDropEnter?: (e: DropEnterEvent) => void,
31+
/** Handler that is called when a valid drag is moved within the drop target. */
2632
onDropMove?: (e: DropMoveEvent) => void,
27-
// When the user hovers over the drop target for a period of time.
28-
// typically opens that item. macOS/iOS call this "spring loading".
33+
/**
34+
* Handler that is called after a valid drag is held over the drop target for a period of time.
35+
* This typically opens the item so that the user can drop within it.
36+
*/
2937
onDropActivate?: (e: DropActivateEvent) => void,
38+
/** Handler that is called when a valid drag exits the drop target. */
3039
onDropExit?: (e: DropExitEvent) => void,
40+
/** Handler that is called when a valid drag is dropped on the drop target. */
3141
onDrop?: (e: DropEvent) => void
3242
}
3343

@@ -43,34 +53,86 @@ export function useDrop(options: DropOptions): DropResult {
4353
let state = useRef({
4454
x: 0,
4555
y: 0,
46-
dragEnterCount: 0,
56+
dragOverElements: new Set<Element>(),
4757
dropEffect: 'none' as DataTransfer['dropEffect'],
58+
effectAllowed: 'none' as DataTransfer['effectAllowed'],
4859
dropActivateTimer: null
4960
}).current;
5061

62+
let fireDropEnter = (e: DragEvent) => {
63+
setDropTarget(true);
64+
65+
if (typeof options.onDropEnter === 'function') {
66+
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
67+
options.onDropEnter({
68+
type: 'dropenter',
69+
x: e.clientX - rect.x,
70+
y: e.clientY - rect.y
71+
});
72+
}
73+
};
74+
75+
let fireDropExit = (e: DragEvent) => {
76+
setDropTarget(false);
77+
78+
if (typeof options.onDropExit === 'function') {
79+
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
80+
options.onDropExit({
81+
type: 'dropexit',
82+
x: e.clientX - rect.x,
83+
y: e.clientY - rect.y
84+
});
85+
}
86+
};
87+
5188
let onDragOver = (e: DragEvent) => {
5289
e.preventDefault();
5390
e.stopPropagation();
5491

55-
if (e.clientX === state.x && e.clientY === state.y) {
92+
if (e.clientX === state.x && e.clientY === state.y && e.dataTransfer.effectAllowed === state.effectAllowed) {
5693
e.dataTransfer.dropEffect = state.dropEffect;
5794
return;
5895
}
5996

6097
state.x = e.clientX;
6198
state.y = e.clientY;
6299

100+
let prevDropEffect = state.dropEffect;
101+
102+
// 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];
106+
if (typeof options.getDropOperation === 'function') {
107+
let types = new DragTypes(e.dataTransfer);
108+
dropOperation = getDropOperation(e.dataTransfer.effectAllowed, options.getDropOperation(types, allowedOperations));
109+
}
110+
111+
state.dropEffect = DROP_OPERATION_TO_DROP_EFFECT[dropOperation] || 'none';
112+
}
113+
63114
if (typeof options.getDropOperationForPoint === 'function') {
64115
let allowedOperations = effectAllowedToOperations(e.dataTransfer.effectAllowed);
65116
let types = new DragTypes(e.dataTransfer);
66117
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
67-
let dropOperation = options.getDropOperationForPoint(types, allowedOperations, state.x - rect.x, state.y - rect.y);
118+
let dropOperation = getDropOperation(
119+
e.dataTransfer.effectAllowed,
120+
options.getDropOperationForPoint(types, allowedOperations, state.x - rect.x, state.y - rect.y)
121+
);
68122
state.dropEffect = DROP_OPERATION_TO_DROP_EFFECT[dropOperation] || 'none';
69123
}
70124

125+
state.effectAllowed = e.dataTransfer.effectAllowed;
71126
e.dataTransfer.dropEffect = state.dropEffect;
72127

73-
if (typeof options.onDropMove === 'function') {
128+
// If the drop operation changes, update state and fire events appropriately.
129+
if (state.dropEffect === 'none' && prevDropEffect !== 'none') {
130+
fireDropExit(e);
131+
} else if (state.dropEffect !== 'none' && prevDropEffect === 'none') {
132+
fireDropEnter(e);
133+
}
134+
135+
if (typeof options.onDropMove === 'function' && state.dropEffect !== 'none') {
74136
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
75137
options.onDropMove({
76138
type: 'dropmove',
@@ -95,8 +157,8 @@ export function useDrop(options: DropOptions): DropResult {
95157

96158
let onDragEnter = (e: DragEvent) => {
97159
e.stopPropagation();
98-
state.dragEnterCount++;
99-
if (state.dragEnterCount > 1) {
160+
state.dragOverElements.add(e.target as Element);
161+
if (state.dragOverElements.size > 1) {
100162
return;
101163
}
102164

@@ -105,52 +167,55 @@ export function useDrop(options: DropOptions): DropResult {
105167

106168
if (typeof options.getDropOperation === 'function') {
107169
let types = new DragTypes(e.dataTransfer);
108-
dropOperation = options.getDropOperation(types, allowedOperations);
109-
}
110-
111-
if (dropOperation !== 'cancel') {
112-
setDropTarget(true);
170+
dropOperation = getDropOperation(e.dataTransfer.effectAllowed, options.getDropOperation(types, allowedOperations));
113171
}
114172

115173
if (typeof options.getDropOperationForPoint === 'function') {
116174
let types = new DragTypes(e.dataTransfer);
117175
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
118-
dropOperation = options.getDropOperationForPoint(types, allowedOperations, e.clientX - rect.x, e.clientY - rect.y);
176+
dropOperation = getDropOperation(
177+
e.dataTransfer.effectAllowed,
178+
options.getDropOperationForPoint(types, allowedOperations, e.clientX - rect.x, e.clientY - rect.y)
179+
);
119180
}
120181

182+
state.x = e.clientX;
183+
state.y = e.clientY;
184+
state.effectAllowed = e.dataTransfer.effectAllowed;
121185
state.dropEffect = DROP_OPERATION_TO_DROP_EFFECT[dropOperation] || 'none';
122186
e.dataTransfer.dropEffect = state.dropEffect;
123187

124-
if (typeof options.onDropEnter === 'function' && dropOperation !== 'cancel') {
125-
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
126-
options.onDropEnter({
127-
type: 'dropenter',
128-
x: e.clientX - rect.x,
129-
y: e.clientY - rect.y
130-
});
188+
if (dropOperation !== 'cancel') {
189+
fireDropEnter(e);
131190
}
132-
133-
state.x = e.clientX;
134-
state.y = e.clientY;
135191
};
136192

137193
let onDragLeave = (e: DragEvent) => {
138194
e.stopPropagation();
139-
state.dragEnterCount--;
140-
if (state.dragEnterCount > 0) {
195+
196+
// We would use e.relatedTarget to detect if the drag is still inside the drop target,
197+
// but it is always null in WebKit. https://bugs.webkit.org/show_bug.cgi?id=66547
198+
// Instead, we track all of the targets of dragenter events in a set, and remove them
199+
// in dragleave. When the set becomes empty, we've left the drop target completely.
200+
// We must also remove any elements that are no longer in the DOM, because dragleave
201+
// events will never be fired for these. This can happen, for example, with drop
202+
// indicators between items, which disappear when the drop target changes.
203+
204+
state.dragOverElements.delete(e.target as Element);
205+
for (let element of state.dragOverElements) {
206+
if (!e.currentTarget.contains(element)) {
207+
state.dragOverElements.delete(element);
208+
}
209+
}
210+
211+
if (state.dragOverElements.size > 0) {
141212
return;
142213
}
143214

144-
if (typeof options.onDropExit === 'function' && state.dropEffect !== 'none') {
145-
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
146-
options.onDropExit({
147-
type: 'dropexit',
148-
x: e.clientX - rect.x,
149-
y: e.clientY - rect.y
150-
});
215+
if (state.dropEffect !== 'none') {
216+
fireDropExit(e);
151217
}
152218

153-
setDropTarget(false);
154219
clearTimeout(state.dropActivateTimer);
155220
};
156221

@@ -180,17 +245,8 @@ export function useDrop(options: DropOptions): DropResult {
180245
}, 0);
181246
}
182247

183-
if (typeof options.onDropExit === 'function') {
184-
let rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
185-
options.onDropExit({
186-
type: 'dropexit',
187-
x: e.clientX - rect.x,
188-
y: e.clientY - rect.y
189-
});
190-
}
191-
192-
state.dragEnterCount = 0;
193-
setDropTarget(false);
248+
state.dragOverElements.clear();
249+
fireDropExit(e);
194250
clearTimeout(state.dropActivateTimer);
195251
};
196252

@@ -255,3 +311,9 @@ function effectAllowedToOperations(effectAllowed: string) {
255311

256312
return allowedOperations;
257313
}
314+
315+
function getDropOperation(effectAllowed: string, operation: DropOperation) {
316+
let allowedOperationsBits = DROP_OPERATION_ALLOWED[effectAllowed];
317+
let op = DROP_OPERATION[operation];
318+
return allowedOperationsBits & op ? operation : 'cancel';
319+
}

0 commit comments

Comments
 (0)