1 | import { invariant } from '@react-dnd/invariant'
|
2 | import type {
|
3 | Backend,
|
4 | DragDropActions,
|
5 | DragDropManager,
|
6 | DragDropMonitor,
|
7 | Identifier,
|
8 | Unsubscribe,
|
9 | XYCoord,
|
10 | } from 'dnd-core'
|
11 |
|
12 | import type {
|
13 | EventName,
|
14 | TouchBackendContext,
|
15 | TouchBackendOptions,
|
16 | } from './interfaces.js'
|
17 | import { ListenerType } from './interfaces.js'
|
18 | import { OptionsReader } from './OptionsReader.js'
|
19 | import { distance, inAngleRanges } from './utils/math.js'
|
20 | import { getEventClientOffset, getNodeClientOffset } from './utils/offsets.js'
|
21 | import {
|
22 | eventShouldEndDrag,
|
23 | eventShouldStartDrag,
|
24 | isTouchEvent,
|
25 | } from './utils/predicates.js'
|
26 | import { supportsPassive } from './utils/supportsPassive.js'
|
27 |
|
28 | const 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 |
|
45 | export class TouchBackendImpl implements Backend {
|
46 | private options: OptionsReader
|
47 |
|
48 |
|
49 | private actions: DragDropActions
|
50 | private monitor: DragDropMonitor
|
51 |
|
52 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
306 |
|
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 |
|
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 |
|
348 |
|
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 |
|
368 |
|
369 |
|
370 |
|
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 |
|
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 |
|
444 | if (
|
445 | !this.monitor.isDragging() &&
|
446 |
|
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 |
|
478 | const dragOverTargetNodes: HTMLElement[] = (dragOverTargetIds || [])
|
479 | .map((key) => this.targetNodes.get(key))
|
480 | .filter((e) => !!e) as HTMLElement[]
|
481 |
|
482 |
|
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 |
|
491 | const elementsAtPointExtended: Element[] = []
|
492 | for (const nodeId in elementsAtPoint) {
|
493 |
|
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 |
|
513 | .filter((node) => dragOverTargetNodes.indexOf(node as HTMLElement) > -1)
|
514 |
|
515 | .map((node) => this._getDropTargetId(node))
|
516 |
|
517 | .filter((node) => !!node)
|
518 | .filter((id, index, ids) => ids.indexOf(id) === index) as string[]
|
519 |
|
520 |
|
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 |
|
537 | orderedDragOverTargetIds.reverse()
|
538 |
|
539 | this.actions.hover(orderedDragOverTargetIds, {
|
540 | clientOffset: clientOffset,
|
541 | })
|
542 | }
|
543 |
|
544 | |
545 |
|
546 |
|
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 | }
|