UNPKG

22 kBJavaScriptView Raw
1import { EnterLeaveCounter } from './EnterLeaveCounter';
2import { isFirefox } from './BrowserDetector';
3import { getNodeClientOffset, getEventClientOffset, getDragPreviewOffset, } from './OffsetUtils';
4import { createNativeDragSource, matchNativeItemType, } from './NativeDragSources';
5import * as NativeTypes from './NativeTypes';
6import { OptionsReader } from './OptionsReader';
7export class HTML5BackendImpl {
8 constructor(manager, globalContext) {
9 this.sourcePreviewNodes = new Map();
10 this.sourcePreviewNodeOptions = new Map();
11 this.sourceNodes = new Map();
12 this.sourceNodeOptions = new Map();
13 this.dragStartSourceIds = null;
14 this.dropTargetIds = [];
15 this.dragEnterTargetIds = [];
16 this.currentNativeSource = null;
17 this.currentNativeHandle = null;
18 this.currentDragSourceNode = null;
19 this.altKeyPressed = false;
20 this.mouseMoveTimeoutTimer = null;
21 this.asyncEndDragFrameId = null;
22 this.dragOverTargetIds = null;
23 this.getSourceClientOffset = (sourceId) => {
24 const source = this.sourceNodes.get(sourceId);
25 return (source && getNodeClientOffset(source)) || null;
26 };
27 this.endDragNativeItem = () => {
28 if (!this.isDraggingNativeItem()) {
29 return;
30 }
31 this.actions.endDrag();
32 if (this.currentNativeHandle) {
33 this.registry.removeSource(this.currentNativeHandle);
34 }
35 this.currentNativeHandle = null;
36 this.currentNativeSource = null;
37 };
38 this.isNodeInDocument = (node) => {
39 // Check the node either in the main document or in the current context
40 return Boolean(node &&
41 this.document &&
42 this.document.body &&
43 document.body.contains(node));
44 };
45 this.endDragIfSourceWasRemovedFromDOM = () => {
46 const node = this.currentDragSourceNode;
47 if (this.isNodeInDocument(node)) {
48 return;
49 }
50 if (this.clearCurrentDragSourceNode()) {
51 this.actions.endDrag();
52 }
53 };
54 this.handleTopDragStartCapture = () => {
55 this.clearCurrentDragSourceNode();
56 this.dragStartSourceIds = [];
57 };
58 this.handleTopDragStart = (e) => {
59 if (e.defaultPrevented) {
60 return;
61 }
62 const { dragStartSourceIds } = this;
63 this.dragStartSourceIds = null;
64 const clientOffset = getEventClientOffset(e);
65 // Avoid crashing if we missed a drop event or our previous drag died
66 if (this.monitor.isDragging()) {
67 this.actions.endDrag();
68 }
69 // Don't publish the source just yet (see why below)
70 this.actions.beginDrag(dragStartSourceIds || [], {
71 publishSource: false,
72 getSourceClientOffset: this.getSourceClientOffset,
73 clientOffset,
74 });
75 const { dataTransfer } = e;
76 const nativeType = matchNativeItemType(dataTransfer);
77 if (this.monitor.isDragging()) {
78 if (dataTransfer && typeof dataTransfer.setDragImage === 'function') {
79 // Use custom drag image if user specifies it.
80 // If child drag source refuses drag but parent agrees,
81 // use parent's node as drag image. Neither works in IE though.
82 const sourceId = this.monitor.getSourceId();
83 const sourceNode = this.sourceNodes.get(sourceId);
84 const dragPreview = this.sourcePreviewNodes.get(sourceId) || sourceNode;
85 if (dragPreview) {
86 const { anchorX, anchorY, offsetX, offsetY, } = this.getCurrentSourcePreviewNodeOptions();
87 const anchorPoint = { anchorX, anchorY };
88 const offsetPoint = { offsetX, offsetY };
89 const dragPreviewOffset = getDragPreviewOffset(sourceNode, dragPreview, clientOffset, anchorPoint, offsetPoint);
90 dataTransfer.setDragImage(dragPreview, dragPreviewOffset.x, dragPreviewOffset.y);
91 }
92 }
93 try {
94 // Firefox won't drag without setting data
95 dataTransfer?.setData('application/json', {});
96 }
97 catch (err) {
98 // IE doesn't support MIME types in setData
99 }
100 // Store drag source node so we can check whether
101 // it is removed from DOM and trigger endDrag manually.
102 this.setCurrentDragSourceNode(e.target);
103 // Now we are ready to publish the drag source.. or are we not?
104 const { captureDraggingState } = this.getCurrentSourcePreviewNodeOptions();
105 if (!captureDraggingState) {
106 // Usually we want to publish it in the next tick so that browser
107 // is able to screenshot the current (not yet dragging) state.
108 //
109 // It also neatly avoids a situation where render() returns null
110 // in the same tick for the source element, and browser freaks out.
111 setTimeout(() => this.actions.publishDragSource(), 0);
112 }
113 else {
114 // In some cases the user may want to override this behavior, e.g.
115 // to work around IE not supporting custom drag previews.
116 //
117 // When using a custom drag layer, the only way to prevent
118 // the default drag preview from drawing in IE is to screenshot
119 // the dragging state in which the node itself has zero opacity
120 // and height. In this case, though, returning null from render()
121 // will abruptly end the dragging, which is not obvious.
122 //
123 // This is the reason such behavior is strictly opt-in.
124 this.actions.publishDragSource();
125 }
126 }
127 else if (nativeType) {
128 // A native item (such as URL) dragged from inside the document
129 this.beginDragNativeItem(nativeType);
130 }
131 else if (dataTransfer &&
132 !dataTransfer.types &&
133 ((e.target && !e.target.hasAttribute) ||
134 !e.target.hasAttribute('draggable'))) {
135 // Looks like a Safari bug: dataTransfer.types is null, but there was no draggable.
136 // Just let it drag. It's a native type (URL or text) and will be picked up in
137 // dragenter handler.
138 return;
139 }
140 else {
141 // If by this time no drag source reacted, tell browser not to drag.
142 e.preventDefault();
143 }
144 };
145 this.handleTopDragEndCapture = () => {
146 if (this.clearCurrentDragSourceNode()) {
147 // Firefox can dispatch this event in an infinite loop
148 // if dragend handler does something like showing an alert.
149 // Only proceed if we have not handled it already.
150 this.actions.endDrag();
151 }
152 };
153 this.handleTopDragEnterCapture = (e) => {
154 this.dragEnterTargetIds = [];
155 const isFirstEnter = this.enterLeaveCounter.enter(e.target);
156 if (!isFirstEnter || this.monitor.isDragging()) {
157 return;
158 }
159 const { dataTransfer } = e;
160 const nativeType = matchNativeItemType(dataTransfer);
161 if (nativeType) {
162 // A native item (such as file or URL) dragged from outside the document
163 this.beginDragNativeItem(nativeType, dataTransfer);
164 }
165 };
166 this.handleTopDragEnter = (e) => {
167 const { dragEnterTargetIds } = this;
168 this.dragEnterTargetIds = [];
169 if (!this.monitor.isDragging()) {
170 // This is probably a native item type we don't understand.
171 return;
172 }
173 this.altKeyPressed = e.altKey;
174 if (!isFirefox()) {
175 // Don't emit hover in `dragenter` on Firefox due to an edge case.
176 // If the target changes position as the result of `dragenter`, Firefox
177 // will still happily dispatch `dragover` despite target being no longer
178 // there. The easy solution is to only fire `hover` in `dragover` on FF.
179 this.actions.hover(dragEnterTargetIds, {
180 clientOffset: getEventClientOffset(e),
181 });
182 }
183 const canDrop = dragEnterTargetIds.some((targetId) => this.monitor.canDropOnTarget(targetId));
184 if (canDrop) {
185 // IE requires this to fire dragover events
186 e.preventDefault();
187 if (e.dataTransfer) {
188 e.dataTransfer.dropEffect = this.getCurrentDropEffect();
189 }
190 }
191 };
192 this.handleTopDragOverCapture = () => {
193 this.dragOverTargetIds = [];
194 };
195 this.handleTopDragOver = (e) => {
196 const { dragOverTargetIds } = this;
197 this.dragOverTargetIds = [];
198 if (!this.monitor.isDragging()) {
199 // This is probably a native item type we don't understand.
200 // Prevent default "drop and blow away the whole document" action.
201 e.preventDefault();
202 if (e.dataTransfer) {
203 e.dataTransfer.dropEffect = 'none';
204 }
205 return;
206 }
207 this.altKeyPressed = e.altKey;
208 this.actions.hover(dragOverTargetIds || [], {
209 clientOffset: getEventClientOffset(e),
210 });
211 const canDrop = (dragOverTargetIds || []).some((targetId) => this.monitor.canDropOnTarget(targetId));
212 if (canDrop) {
213 // Show user-specified drop effect.
214 e.preventDefault();
215 if (e.dataTransfer) {
216 e.dataTransfer.dropEffect = this.getCurrentDropEffect();
217 }
218 }
219 else if (this.isDraggingNativeItem()) {
220 // Don't show a nice cursor but still prevent default
221 // "drop and blow away the whole document" action.
222 e.preventDefault();
223 }
224 else {
225 e.preventDefault();
226 if (e.dataTransfer) {
227 e.dataTransfer.dropEffect = 'none';
228 }
229 }
230 };
231 this.handleTopDragLeaveCapture = (e) => {
232 if (this.isDraggingNativeItem()) {
233 e.preventDefault();
234 }
235 const isLastLeave = this.enterLeaveCounter.leave(e.target);
236 if (!isLastLeave) {
237 return;
238 }
239 if (this.isDraggingNativeItem()) {
240 this.endDragNativeItem();
241 }
242 };
243 this.handleTopDropCapture = (e) => {
244 this.dropTargetIds = [];
245 e.preventDefault();
246 if (this.isDraggingNativeItem()) {
247 this.currentNativeSource?.loadDataTransfer(e.dataTransfer);
248 }
249 this.enterLeaveCounter.reset();
250 };
251 this.handleTopDrop = (e) => {
252 const { dropTargetIds } = this;
253 this.dropTargetIds = [];
254 this.actions.hover(dropTargetIds, {
255 clientOffset: getEventClientOffset(e),
256 });
257 this.actions.drop({ dropEffect: this.getCurrentDropEffect() });
258 if (this.isDraggingNativeItem()) {
259 this.endDragNativeItem();
260 }
261 else {
262 this.endDragIfSourceWasRemovedFromDOM();
263 }
264 };
265 this.handleSelectStart = (e) => {
266 const target = e.target;
267 // Only IE requires us to explicitly say
268 // we want drag drop operation to start
269 if (typeof target.dragDrop !== 'function') {
270 return;
271 }
272 // Inputs and textareas should be selectable
273 if (target.tagName === 'INPUT' ||
274 target.tagName === 'SELECT' ||
275 target.tagName === 'TEXTAREA' ||
276 target.isContentEditable) {
277 return;
278 }
279 // For other targets, ask IE
280 // to enable drag and drop
281 e.preventDefault();
282 target.dragDrop();
283 };
284 this.options = new OptionsReader(globalContext);
285 this.actions = manager.getActions();
286 this.monitor = manager.getMonitor();
287 this.registry = manager.getRegistry();
288 this.enterLeaveCounter = new EnterLeaveCounter(this.isNodeInDocument);
289 }
290 /**
291 * Generate profiling statistics for the HTML5Backend.
292 */
293 profile() {
294 return {
295 sourcePreviewNodes: this.sourcePreviewNodes.size,
296 sourcePreviewNodeOptions: this.sourcePreviewNodeOptions.size,
297 sourceNodeOptions: this.sourceNodeOptions.size,
298 sourceNodes: this.sourceNodes.size,
299 dragStartSourceIds: this.dragStartSourceIds?.length || 0,
300 dropTargetIds: this.dropTargetIds.length,
301 dragEnterTargetIds: this.dragEnterTargetIds.length,
302 dragOverTargetIds: this.dragOverTargetIds?.length || 0,
303 };
304 }
305 // public for test
306 get window() {
307 return this.options.window;
308 }
309 get document() {
310 return this.options.document;
311 }
312 setup() {
313 if (this.window === undefined) {
314 return;
315 }
316 if (this.window.__isReactDndBackendSetUp) {
317 throw new Error('Cannot have two HTML5 backends at the same time.');
318 }
319 this.window.__isReactDndBackendSetUp = true;
320 this.addEventListeners(this.window);
321 }
322 teardown() {
323 if (this.window === undefined) {
324 return;
325 }
326 this.window.__isReactDndBackendSetUp = false;
327 this.removeEventListeners(this.window);
328 this.clearCurrentDragSourceNode();
329 if (this.asyncEndDragFrameId) {
330 this.window.cancelAnimationFrame(this.asyncEndDragFrameId);
331 }
332 }
333 connectDragPreview(sourceId, node, options) {
334 this.sourcePreviewNodeOptions.set(sourceId, options);
335 this.sourcePreviewNodes.set(sourceId, node);
336 return () => {
337 this.sourcePreviewNodes.delete(sourceId);
338 this.sourcePreviewNodeOptions.delete(sourceId);
339 };
340 }
341 connectDragSource(sourceId, node, options) {
342 this.sourceNodes.set(sourceId, node);
343 this.sourceNodeOptions.set(sourceId, options);
344 const handleDragStart = (e) => this.handleDragStart(e, sourceId);
345 const handleSelectStart = (e) => this.handleSelectStart(e);
346 node.setAttribute('draggable', 'true');
347 node.addEventListener('dragstart', handleDragStart);
348 node.addEventListener('selectstart', handleSelectStart);
349 return () => {
350 this.sourceNodes.delete(sourceId);
351 this.sourceNodeOptions.delete(sourceId);
352 node.removeEventListener('dragstart', handleDragStart);
353 node.removeEventListener('selectstart', handleSelectStart);
354 node.setAttribute('draggable', 'false');
355 };
356 }
357 connectDropTarget(targetId, node) {
358 const handleDragEnter = (e) => this.handleDragEnter(e, targetId);
359 const handleDragOver = (e) => this.handleDragOver(e, targetId);
360 const handleDrop = (e) => this.handleDrop(e, targetId);
361 node.addEventListener('dragenter', handleDragEnter);
362 node.addEventListener('dragover', handleDragOver);
363 node.addEventListener('drop', handleDrop);
364 return () => {
365 node.removeEventListener('dragenter', handleDragEnter);
366 node.removeEventListener('dragover', handleDragOver);
367 node.removeEventListener('drop', handleDrop);
368 };
369 }
370 addEventListeners(target) {
371 // SSR Fix (https://github.com/react-dnd/react-dnd/pull/813
372 if (!target.addEventListener) {
373 return;
374 }
375 target.addEventListener('dragstart', this.handleTopDragStart);
376 target.addEventListener('dragstart', this.handleTopDragStartCapture, true);
377 target.addEventListener('dragend', this.handleTopDragEndCapture, true);
378 target.addEventListener('dragenter', this.handleTopDragEnter);
379 target.addEventListener('dragenter', this.handleTopDragEnterCapture, true);
380 target.addEventListener('dragleave', this.handleTopDragLeaveCapture, true);
381 target.addEventListener('dragover', this.handleTopDragOver);
382 target.addEventListener('dragover', this.handleTopDragOverCapture, true);
383 target.addEventListener('drop', this.handleTopDrop);
384 target.addEventListener('drop', this.handleTopDropCapture, true);
385 }
386 removeEventListeners(target) {
387 // SSR Fix (https://github.com/react-dnd/react-dnd/pull/813
388 if (!target.removeEventListener) {
389 return;
390 }
391 target.removeEventListener('dragstart', this.handleTopDragStart);
392 target.removeEventListener('dragstart', this.handleTopDragStartCapture, true);
393 target.removeEventListener('dragend', this.handleTopDragEndCapture, true);
394 target.removeEventListener('dragenter', this.handleTopDragEnter);
395 target.removeEventListener('dragenter', this.handleTopDragEnterCapture, true);
396 target.removeEventListener('dragleave', this.handleTopDragLeaveCapture, true);
397 target.removeEventListener('dragover', this.handleTopDragOver);
398 target.removeEventListener('dragover', this.handleTopDragOverCapture, true);
399 target.removeEventListener('drop', this.handleTopDrop);
400 target.removeEventListener('drop', this.handleTopDropCapture, true);
401 }
402 getCurrentSourceNodeOptions() {
403 const sourceId = this.monitor.getSourceId();
404 const sourceNodeOptions = this.sourceNodeOptions.get(sourceId);
405 return {
406 dropEffect: this.altKeyPressed ? 'copy' : 'move',
407 ...(sourceNodeOptions || {}),
408 };
409 }
410 getCurrentDropEffect() {
411 if (this.isDraggingNativeItem()) {
412 // It makes more sense to default to 'copy' for native resources
413 return 'copy';
414 }
415 return this.getCurrentSourceNodeOptions().dropEffect;
416 }
417 getCurrentSourcePreviewNodeOptions() {
418 const sourceId = this.monitor.getSourceId();
419 const sourcePreviewNodeOptions = this.sourcePreviewNodeOptions.get(sourceId);
420 return {
421 anchorX: 0.5,
422 anchorY: 0.5,
423 captureDraggingState: false,
424 ...(sourcePreviewNodeOptions || {}),
425 };
426 }
427 isDraggingNativeItem() {
428 const itemType = this.monitor.getItemType();
429 return Object.keys(NativeTypes).some((key) => NativeTypes[key] === itemType);
430 }
431 beginDragNativeItem(type, dataTransfer) {
432 this.clearCurrentDragSourceNode();
433 this.currentNativeSource = createNativeDragSource(type, dataTransfer);
434 this.currentNativeHandle = this.registry.addSource(type, this.currentNativeSource);
435 this.actions.beginDrag([this.currentNativeHandle]);
436 }
437 setCurrentDragSourceNode(node) {
438 this.clearCurrentDragSourceNode();
439 this.currentDragSourceNode = node;
440 // A timeout of > 0 is necessary to resolve Firefox issue referenced
441 // See:
442 // * https://github.com/react-dnd/react-dnd/pull/928
443 // * https://github.com/react-dnd/react-dnd/issues/869
444 const MOUSE_MOVE_TIMEOUT = 1000;
445 // Receiving a mouse event in the middle of a dragging operation
446 // means it has ended and the drag source node disappeared from DOM,
447 // so the browser didn't dispatch the dragend event.
448 //
449 // We need to wait before we start listening for mousemove events.
450 // This is needed because the drag preview needs to be drawn or else it fires an 'mousemove' event
451 // immediately in some browsers.
452 //
453 // See:
454 // * https://github.com/react-dnd/react-dnd/pull/928
455 // * https://github.com/react-dnd/react-dnd/issues/869
456 //
457 this.mouseMoveTimeoutTimer = setTimeout(() => {
458 return (this.window &&
459 this.window.addEventListener('mousemove', this.endDragIfSourceWasRemovedFromDOM, true));
460 }, MOUSE_MOVE_TIMEOUT);
461 }
462 clearCurrentDragSourceNode() {
463 if (this.currentDragSourceNode) {
464 this.currentDragSourceNode = null;
465 if (this.window) {
466 this.window.clearTimeout(this.mouseMoveTimeoutTimer || undefined);
467 this.window.removeEventListener('mousemove', this.endDragIfSourceWasRemovedFromDOM, true);
468 }
469 this.mouseMoveTimeoutTimer = null;
470 return true;
471 }
472 return false;
473 }
474 handleDragStart(e, sourceId) {
475 if (e.defaultPrevented) {
476 return;
477 }
478 if (!this.dragStartSourceIds) {
479 this.dragStartSourceIds = [];
480 }
481 this.dragStartSourceIds.unshift(sourceId);
482 }
483 handleDragEnter(e, targetId) {
484 this.dragEnterTargetIds.unshift(targetId);
485 }
486 handleDragOver(e, targetId) {
487 if (this.dragOverTargetIds === null) {
488 this.dragOverTargetIds = [];
489 }
490 this.dragOverTargetIds.unshift(targetId);
491 }
492 handleDrop(e, targetId) {
493 this.dropTargetIds.unshift(targetId);
494 }
495}