UNPKG

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