UNPKG

16.6 kBPlain TextView Raw
1import { invariant } from '@react-dnd/invariant'
2import type {
3 Backend,
4 DragDropActions,
5 DragDropManager,
6 DragDropMonitor,
7 Identifier,
8 Unsubscribe,
9 XYCoord,
10} from 'dnd-core'
11
12import type {
13 EventName,
14 TouchBackendContext,
15 TouchBackendOptions,
16} from './interfaces.js'
17import { ListenerType } from './interfaces.js'
18import { OptionsReader } from './OptionsReader.js'
19import { distance, inAngleRanges } from './utils/math.js'
20import { getEventClientOffset, getNodeClientOffset } from './utils/offsets.js'
21import {
22 eventShouldEndDrag,
23 eventShouldStartDrag,
24 isTouchEvent,
25} from './utils/predicates.js'
26import { supportsPassive } from './utils/supportsPassive.js'
27
28const eventNames: Record<ListenerType, EventName> = {
29 [ListenerType.mouse]: {
30 start: 'mousedown',
31 move: 'mousemove',
32 end: 'mouseup',
33 contextmenu: 'contextmenu',
34 },
35 [ListenerType.touch]: {
36 start: 'touchstart',
37 move: 'touchmove',
38 end: 'touchend',
39 },
40 [ListenerType.keyboard]: {
41 keydown: 'keydown',
42 },
43}
44
45export class TouchBackendImpl implements Backend {
46 private options: OptionsReader
47
48 // React-DnD Dependencies
49 private actions: DragDropActions
50 private monitor: DragDropMonitor
51
52 // Internal State
53 private static isSetUp: boolean
54 public sourceNodes: Map<Identifier, HTMLElement>
55 public sourcePreviewNodes: Map<string, HTMLElement>
56 public sourcePreviewNodeOptions: Map<string, any>
57 public targetNodes: Map<string, HTMLElement>
58 private _mouseClientOffset: Partial<XYCoord>
59 private _isScrolling: boolean
60 private listenerTypes: ListenerType[]
61 private moveStartSourceIds: string[] | undefined
62 private waitingForDelay: boolean | undefined
63 private timeout: ReturnType<typeof setTimeout> | undefined
64 private dragOverTargetIds: string[] | undefined
65 private draggedSourceNode: HTMLElement | undefined
66 private draggedSourceNodeRemovalObserver: MutationObserver | undefined
67
68 // Patch for iOS 13, discussion over #1585
69 private lastTargetTouchFallback: Touch | undefined
70
71 public constructor(
72 manager: DragDropManager,
73 context: TouchBackendContext,
74 options: Partial<TouchBackendOptions>,
75 ) {
76 this.options = new OptionsReader(options, context)
77 this.actions = manager.getActions()
78 this.monitor = manager.getMonitor()
79
80 this.sourceNodes = new Map()
81 this.sourcePreviewNodes = new Map()
82 this.sourcePreviewNodeOptions = new Map()
83 this.targetNodes = new Map()
84 this.listenerTypes = []
85 this._mouseClientOffset = {}
86 this._isScrolling = false
87
88 if (this.options.enableMouseEvents) {
89 this.listenerTypes.push(ListenerType.mouse)
90 }
91
92 if (this.options.enableTouchEvents) {
93 this.listenerTypes.push(ListenerType.touch)
94 }
95
96 if (this.options.enableKeyboardEvents) {
97 this.listenerTypes.push(ListenerType.keyboard)
98 }
99 }
100
101 /**
102 * Generate profiling statistics for the HTML5Backend.
103 */
104 public profile(): Record<string, number> {
105 return {
106 sourceNodes: this.sourceNodes.size,
107 sourcePreviewNodes: this.sourcePreviewNodes.size,
108 sourcePreviewNodeOptions: this.sourcePreviewNodeOptions.size,
109 targetNodes: this.targetNodes.size,
110 dragOverTargetIds: this.dragOverTargetIds?.length || 0,
111 }
112 }
113
114 // public for test
115 public get document(): Document | undefined {
116 return this.options.document
117 }
118
119 public setup(): void {
120 const root = this.options.rootElement
121 if (!root) {
122 return
123 }
124
125 invariant(
126 !TouchBackendImpl.isSetUp,
127 'Cannot have two Touch backends at the same time.',
128 )
129 TouchBackendImpl.isSetUp = true
130
131 this.addEventListener(root, 'start', this.getTopMoveStartHandler() as any)
132 this.addEventListener(
133 root,
134 'start',
135 this.handleTopMoveStartCapture as any,
136 true,
137 )
138 this.addEventListener(root, 'move', this.handleTopMove as any)
139 this.addEventListener(root, 'move', this.handleTopMoveCapture, true)
140 this.addEventListener(
141 root,
142 'end',
143 this.handleTopMoveEndCapture as any,
144 true,
145 )
146
147 if (this.options.enableMouseEvents && !this.options.ignoreContextMenu) {
148 this.addEventListener(
149 root,
150 'contextmenu',
151 this.handleTopMoveEndCapture as any,
152 )
153 }
154
155 if (this.options.enableKeyboardEvents) {
156 this.addEventListener(
157 root,
158 'keydown',
159 this.handleCancelOnEscape as any,
160 true,
161 )
162 }
163 }
164
165 public teardown(): void {
166 const root = this.options.rootElement
167 if (!root) {
168 return
169 }
170
171 TouchBackendImpl.isSetUp = false
172 this._mouseClientOffset = {}
173
174 this.removeEventListener(
175 root,
176 'start',
177 this.handleTopMoveStartCapture as any,
178 true,
179 )
180 this.removeEventListener(root, 'start', this.handleTopMoveStart as any)
181 this.removeEventListener(root, 'move', this.handleTopMoveCapture, true)
182 this.removeEventListener(root, 'move', this.handleTopMove as any)
183 this.removeEventListener(
184 root,
185 'end',
186 this.handleTopMoveEndCapture as any,
187 true,
188 )
189
190 if (this.options.enableMouseEvents && !this.options.ignoreContextMenu) {
191 this.removeEventListener(
192 root,
193 'contextmenu',
194 this.handleTopMoveEndCapture as any,
195 )
196 }
197
198 if (this.options.enableKeyboardEvents) {
199 this.removeEventListener(
200 root,
201 'keydown',
202 this.handleCancelOnEscape as any,
203 true,
204 )
205 }
206
207 this.uninstallSourceNodeRemovalObserver()
208 }
209
210 private addEventListener<K extends keyof EventName>(
211 subject: Node,
212 event: K,
213 handler: (e: any) => void,
214 capture = false,
215 ) {
216 const options = supportsPassive ? { capture, passive: false } : capture
217
218 this.listenerTypes.forEach(function (listenerType) {
219 const evt = eventNames[listenerType][event]
220
221 if (evt) {
222 subject.addEventListener(evt as any, handler as any, options)
223 }
224 })
225 }
226
227 private removeEventListener<K extends keyof EventName>(
228 subject: Node,
229 event: K,
230 handler: (e: any) => void,
231 capture = false,
232 ) {
233 const options = supportsPassive ? { capture, passive: false } : capture
234
235 this.listenerTypes.forEach(function (listenerType) {
236 const evt = eventNames[listenerType][event]
237
238 if (evt) {
239 subject.removeEventListener(evt as any, handler as any, options)
240 }
241 })
242 }
243
244 public connectDragSource(sourceId: string, node: HTMLElement): Unsubscribe {
245 const handleMoveStart = this.handleMoveStart.bind(this, sourceId)
246 this.sourceNodes.set(sourceId, node)
247
248 this.addEventListener(node, 'start', handleMoveStart)
249
250 return (): void => {
251 this.sourceNodes.delete(sourceId)
252 this.removeEventListener(node, 'start', handleMoveStart)
253 }
254 }
255
256 public connectDragPreview(
257 sourceId: string,
258 node: HTMLElement,
259 options: unknown,
260 ): Unsubscribe {
261 this.sourcePreviewNodeOptions.set(sourceId, options)
262 this.sourcePreviewNodes.set(sourceId, node)
263
264 return (): void => {
265 this.sourcePreviewNodes.delete(sourceId)
266 this.sourcePreviewNodeOptions.delete(sourceId)
267 }
268 }
269
270 public connectDropTarget(targetId: string, node: HTMLElement): Unsubscribe {
271 const root = this.options.rootElement
272 if (!this.document || !root) {
273 return (): void => {
274 /* noop */
275 }
276 }
277
278 const handleMove = (e: MouseEvent | TouchEvent) => {
279 if (!this.document || !root || !this.monitor.isDragging()) {
280 return
281 }
282
283 let coords
284
285 /**
286 * Grab the coordinates for the current mouse/touch position
287 */
288 switch (e.type) {
289 case eventNames.mouse.move:
290 coords = {
291 x: (e as MouseEvent).clientX,
292 y: (e as MouseEvent).clientY,
293 }
294 break
295
296 case eventNames.touch.move:
297 coords = {
298 x: (e as TouchEvent).touches[0]?.clientX || 0,
299 y: (e as TouchEvent).touches[0]?.clientY || 0,
300 }
301 break
302 }
303
304 /**
305 * Use the coordinates to grab the element the drag ended on.
306 * If the element is the same as the target node (or any of it's children) then we have hit a drop target and can handle the move.
307 */
308 const droppedOn =
309 coords != null
310 ? this.document.elementFromPoint(coords.x, coords.y)
311 : undefined
312 const childMatch = droppedOn && node.contains(droppedOn)
313
314 if (droppedOn === node || childMatch) {
315 return this.handleMove(e, targetId)
316 }
317 }
318
319 /**
320 * Attaching the event listener to the body so that touchmove will work while dragging over multiple target elements.
321 */
322 this.addEventListener(this.document.body, 'move', handleMove as any)
323 this.targetNodes.set(targetId, node)
324
325 return (): void => {
326 if (this.document) {
327 this.targetNodes.delete(targetId)
328 this.removeEventListener(this.document.body, 'move', handleMove as any)
329 }
330 }
331 }
332
333 private getSourceClientOffset = (sourceId: string): XYCoord | undefined => {
334 const element = this.sourceNodes.get(sourceId)
335 return element && getNodeClientOffset(element)
336 }
337
338 public handleTopMoveStartCapture = (e: Event): void => {
339 if (!eventShouldStartDrag(e as MouseEvent)) {
340 return
341 }
342
343 this.moveStartSourceIds = []
344 }
345
346 public handleMoveStart = (sourceId: string): void => {
347 // Just because we received an event doesn't necessarily mean we need to collect drag sources.
348 // We only collect start collecting drag sources on touch and left mouse events.
349 if (Array.isArray(this.moveStartSourceIds)) {
350 this.moveStartSourceIds.unshift(sourceId)
351 }
352 }
353
354 private getTopMoveStartHandler() {
355 if (!this.options.delayTouchStart && !this.options.delayMouseStart) {
356 return this.handleTopMoveStart
357 }
358
359 return this.handleTopMoveStartDelay
360 }
361
362 public handleTopMoveStart = (e: MouseEvent | TouchEvent): void => {
363 if (!eventShouldStartDrag(e as MouseEvent)) {
364 return
365 }
366
367 // Don't prematurely preventDefault() here since it might:
368 // 1. Mess up scrolling
369 // 2. Mess up long tap (which brings up context menu)
370 // 3. If there's an anchor link as a child, tap won't be triggered on link
371
372 const clientOffset = getEventClientOffset(e)
373 if (clientOffset) {
374 if (isTouchEvent(e)) {
375 this.lastTargetTouchFallback = e.targetTouches[0]
376 }
377 this._mouseClientOffset = clientOffset
378 }
379 this.waitingForDelay = false
380 }
381
382 public handleTopMoveStartDelay = (e: Event): void => {
383 if (!eventShouldStartDrag(e as MouseEvent)) {
384 return
385 }
386
387 const delay =
388 e.type === eventNames.touch.start
389 ? this.options.delayTouchStart
390 : this.options.delayMouseStart
391 this.timeout = setTimeout(
392 this.handleTopMoveStart.bind(this, e as any),
393 delay,
394 ) as any as ReturnType<typeof setTimeout>
395 this.waitingForDelay = true
396 }
397
398 public handleTopMoveCapture = (): void => {
399 this.dragOverTargetIds = []
400 }
401
402 public handleMove = (
403 _evt: MouseEvent | TouchEvent,
404 targetId: string,
405 ): void => {
406 if (this.dragOverTargetIds) {
407 this.dragOverTargetIds.unshift(targetId)
408 }
409 }
410
411 public handleTopMove = (e: TouchEvent | MouseEvent): void => {
412 if (this.timeout) {
413 clearTimeout(this.timeout)
414 }
415 if (!this.document || this.waitingForDelay) {
416 return
417 }
418 const { moveStartSourceIds, dragOverTargetIds } = this
419 const enableHoverOutsideTarget = this.options.enableHoverOutsideTarget
420
421 const clientOffset = getEventClientOffset(e, this.lastTargetTouchFallback)
422
423 if (!clientOffset) {
424 return
425 }
426
427 // If the touch move started as a scroll, or is is between the scroll angles
428 if (
429 this._isScrolling ||
430 (!this.monitor.isDragging() &&
431 inAngleRanges(
432 this._mouseClientOffset.x || 0,
433 this._mouseClientOffset.y || 0,
434 clientOffset.x,
435 clientOffset.y,
436 this.options.scrollAngleRanges,
437 ))
438 ) {
439 this._isScrolling = true
440 return
441 }
442
443 // If we're not dragging and we've moved a little, that counts as a drag start
444 if (
445 !this.monitor.isDragging() &&
446 // eslint-disable-next-line no-prototype-builtins
447 this._mouseClientOffset.hasOwnProperty('x') &&
448 moveStartSourceIds &&
449 distance(
450 this._mouseClientOffset.x || 0,
451 this._mouseClientOffset.y || 0,
452 clientOffset.x,
453 clientOffset.y,
454 ) > (this.options.touchSlop ? this.options.touchSlop : 0)
455 ) {
456 this.moveStartSourceIds = undefined
457
458 this.actions.beginDrag(moveStartSourceIds, {
459 clientOffset: this._mouseClientOffset,
460 getSourceClientOffset: this.getSourceClientOffset,
461 publishSource: false,
462 })
463 }
464
465 if (!this.monitor.isDragging()) {
466 return
467 }
468
469 const sourceNode = this.sourceNodes.get(
470 this.monitor.getSourceId() as string,
471 )
472 this.installSourceNodeRemovalObserver(sourceNode)
473 this.actions.publishDragSource()
474
475 if (e.cancelable) e.preventDefault()
476
477 // Get the node elements of the hovered DropTargets
478 const dragOverTargetNodes: HTMLElement[] = (dragOverTargetIds || [])
479 .map((key) => this.targetNodes.get(key))
480 .filter((e) => !!e) as HTMLElement[]
481
482 // Get the a ordered list of nodes that are touched by
483 const elementsAtPoint = this.options.getDropTargetElementsAtPoint
484 ? this.options.getDropTargetElementsAtPoint(
485 clientOffset.x,
486 clientOffset.y,
487 dragOverTargetNodes,
488 )
489 : this.document.elementsFromPoint(clientOffset.x, clientOffset.y)
490 // Extend list with parents that are not receiving elementsFromPoint events (size 0 elements and svg groups)
491 const elementsAtPointExtended: Element[] = []
492 for (const nodeId in elementsAtPoint) {
493 // eslint-disable-next-line no-prototype-builtins
494 if (!elementsAtPoint.hasOwnProperty(nodeId)) {
495 continue
496 }
497 let currentNode: Element | undefined | null = elementsAtPoint[nodeId]
498 if (currentNode != null) {
499 elementsAtPointExtended.push(currentNode)
500 }
501 while (currentNode) {
502 currentNode = currentNode.parentElement
503 if (
504 currentNode &&
505 elementsAtPointExtended.indexOf(currentNode) === -1
506 ) {
507 elementsAtPointExtended.push(currentNode)
508 }
509 }
510 }
511 const orderedDragOverTargetIds: string[] = elementsAtPointExtended
512 // Filter off nodes that arent a hovered DropTargets nodes
513 .filter((node) => dragOverTargetNodes.indexOf(node as HTMLElement) > -1)
514 // Map back the nodes elements to targetIds
515 .map((node) => this._getDropTargetId(node))
516 // Filter off possible null rows
517 .filter((node) => !!node)
518 .filter((id, index, ids) => ids.indexOf(id) === index) as string[]
519
520 // Invoke hover for drop targets when source node is still over and pointer is outside
521 if (enableHoverOutsideTarget) {
522 for (const targetId in this.targetNodes) {
523 const targetNode = this.targetNodes.get(targetId)
524 if (
525 sourceNode &&
526 targetNode &&
527 targetNode.contains(sourceNode) &&
528 orderedDragOverTargetIds.indexOf(targetId) === -1
529 ) {
530 orderedDragOverTargetIds.unshift(targetId)
531 break
532 }
533 }
534 }
535
536 // Reverse order because dnd-core reverse it before calling the DropTarget drop methods
537 orderedDragOverTargetIds.reverse()
538
539 this.actions.hover(orderedDragOverTargetIds, {
540 clientOffset: clientOffset,
541 })
542 }
543
544 /**
545 *
546 * visible for testing
547 */
548 public _getDropTargetId = (node: Element): Identifier | undefined => {
549 const keys = this.targetNodes.keys()
550 let next = keys.next()
551 while (next.done === false) {
552 const targetId = next.value
553 if (node === this.targetNodes.get(targetId)) {
554 return targetId
555 } else {
556 next = keys.next()
557 }
558 }
559 return undefined
560 }
561
562 public handleTopMoveEndCapture = (e: Event): void => {
563 this._isScrolling = false
564 this.lastTargetTouchFallback = undefined
565
566 if (!eventShouldEndDrag(e as MouseEvent)) {
567 return
568 }
569
570 if (!this.monitor.isDragging() || this.monitor.didDrop()) {
571 this.moveStartSourceIds = undefined
572 return
573 }
574
575 if (e.cancelable) e.preventDefault()
576
577 this._mouseClientOffset = {}
578
579 this.uninstallSourceNodeRemovalObserver()
580 this.actions.drop()
581 this.actions.endDrag()
582 }
583
584 public handleCancelOnEscape = (e: KeyboardEvent): void => {
585 if (e.key === 'Escape' && this.monitor.isDragging()) {
586 this._mouseClientOffset = {}
587
588 this.uninstallSourceNodeRemovalObserver()
589 this.actions.endDrag()
590 }
591 }
592
593 private installSourceNodeRemovalObserver(node: HTMLElement | undefined) {
594 this.uninstallSourceNodeRemovalObserver()
595
596 this.draggedSourceNode = node
597 this.draggedSourceNodeRemovalObserver = new MutationObserver(() => {
598 if (node && !node.parentElement) {
599 this.resurrectSourceNode()
600 this.uninstallSourceNodeRemovalObserver()
601 }
602 })
603
604 if (!node || !node.parentElement) {
605 return
606 }
607
608 this.draggedSourceNodeRemovalObserver.observe(node.parentElement, {
609 childList: true,
610 })
611 }
612
613 private resurrectSourceNode() {
614 if (this.document && this.draggedSourceNode) {
615 this.draggedSourceNode.style.display = 'none'
616 this.draggedSourceNode.removeAttribute('data-reactid')
617 this.document.body.appendChild(this.draggedSourceNode)
618 }
619 }
620
621 private uninstallSourceNodeRemovalObserver() {
622 if (this.draggedSourceNodeRemovalObserver) {
623 this.draggedSourceNodeRemovalObserver.disconnect()
624 }
625
626 this.draggedSourceNodeRemovalObserver = undefined
627 this.draggedSourceNode = undefined
628 }
629}