diff --git a/.changeset/use-fixed-event-listeners.md b/.changeset/use-fixed-event-listeners.md new file mode 100644 index 00000000..2328a4dd --- /dev/null +++ b/.changeset/use-fixed-event-listeners.md @@ -0,0 +1,5 @@ +--- +'@dnd-kit/dom': patch +--- + +Use fixed event listeners in PointerSensor to address race conditions preventing `pointermove` and `pointerup` events from firing when document changes during a drag. diff --git a/packages/dom/src/core/sensors/pointer/PointerSensor.ts b/packages/dom/src/core/sensors/pointer/PointerSensor.ts index c6683aab..7bd5043c 100644 --- a/packages/dom/src/core/sensors/pointer/PointerSensor.ts +++ b/packages/dom/src/core/sensors/pointer/PointerSensor.ts @@ -56,6 +56,8 @@ export class PointerSensor extends Sensor< protected initialCoordinates: Coordinates | undefined; + protected source: Draggable | undefined = undefined; + #clearTimeout: CleanupFunction | undefined; constructor( @@ -67,6 +69,14 @@ export class PointerSensor extends Sensor< this.handleCancel = this.handleCancel.bind(this); this.handlePointerUp = this.handlePointerUp.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); + + effect(() => { + const unbindGlobal = this.bindGlobal(options ?? {}); + + return () => { + unbindGlobal(); + }; + }); } public bind(source: Draggable, options = this.options) { @@ -92,6 +102,48 @@ export class PointerSensor extends Sensor< return unbind; } + protected bindGlobal(options: PointerSensorOptions) { + const documents = new Set(); + + for (const draggable of this.manager.registry.draggables.value) { + if (draggable.element) { + documents.add(getDocument(draggable.element)); + } + } + + for (const droppable of this.manager.registry.droppables.value) { + if (droppable.element) { + documents.add(getDocument(droppable.element)); + } + } + + const unbindFns = Array.from(documents).map((doc) => + this.listeners.bind(doc, [ + { + type: 'pointermove', + listener: (event: PointerEvent) => + this.handlePointerMove(event, doc, options), + }, + { + type: 'pointerup', + listener: this.handlePointerUp, + options: { + capture: true, + }, + }, + { + // Cancel activation if there is a competing Drag and Drop interaction + type: 'dragstart', + listener: this.handleDragStart, + }, + ]) + ); + + return () => { + unbindFns.forEach((unbind) => unbind()); + }; + } + protected handlePointerDown( event: PointerEvent, source: Draggable, @@ -106,11 +158,6 @@ export class PointerSensor extends Sensor< ) { return; } - const {target} = event; - const isNativeDraggable = - isHTMLElement(target) && - target.draggable && - target.getAttribute('draggable') === 'true'; const offset = getFrameTransform(source.element); @@ -119,6 +166,8 @@ export class PointerSensor extends Sensor< y: event.clientY * offset.scaleY + offset.y, }; + this.source = source; + const {activationConstraints} = options; const constraints = typeof activationConstraints === 'function' @@ -145,32 +194,10 @@ export class PointerSensor extends Sensor< } } - const ownerDocument = getDocument(event.target); - - const unbindListeners = this.listeners.bind(ownerDocument, [ - { - type: 'pointermove', - listener: (event: PointerEvent) => - this.handlePointerMove(event, source, options), - }, - { - type: 'pointerup', - listener: this.handlePointerUp, - options: { - capture: true, - }, - }, - { - // Cancel activation if there is a competing Drag and Drop interaction - type: 'dragstart', - listener: isNativeDraggable ? this.handleCancel : preventDefault, - }, - ]); - const cleanup = () => { - setTimeout(unbindListeners); this.#clearTimeout?.(); this.initialCoordinates = undefined; + this.source = undefined; }; this.cleanup.add(cleanup); @@ -178,15 +205,27 @@ export class PointerSensor extends Sensor< protected handlePointerMove( event: PointerEvent, - source: Draggable, + doc: Document, options: PointerSensorOptions ) { + if (!this.source) { + return; + } + + const ownerDocument = + this.source.element && getDocument(this.source.element); + + // Event may have duplicated between documents if user is bubbling events + if (doc !== ownerDocument) { + return; + } + const coordinates = { x: event.clientX, y: event.clientY, }; - const offset = getFrameTransform(source.element); + const offset = getFrameTransform(this.source.element); coordinates.x = coordinates.x * offset.scaleX + offset.x; coordinates.y = coordinates.y * offset.scaleY + offset.y; @@ -210,7 +249,7 @@ export class PointerSensor extends Sensor< const {activationConstraints} = options; const constraints = typeof activationConstraints === 'function' - ? activationConstraints(event, source) + ? activationConstraints(event, this.source) : activationConstraints; const {distance, delay} = constraints ?? {}; @@ -222,7 +261,7 @@ export class PointerSensor extends Sensor< return this.handleCancel(); } if (exceedsDistance(delta, distance.value)) { - return this.handleStart(source, event); + return this.handleStart(this.source, event); } } @@ -234,6 +273,10 @@ export class PointerSensor extends Sensor< } private handlePointerUp(event: PointerEvent) { + if (!this.source) { + return; + } + // Prevent the default behaviour of the event event.preventDefault(); event.stopPropagation(); @@ -304,6 +347,25 @@ export class PointerSensor extends Sensor< this.cleanup.add(unbind); } + protected handleDragStart(event: DragEvent) { + const {target} = event; + + if (!isElement(target)) { + return; + } + + const isNativeDraggable = + isHTMLElement(target) && + target.draggable && + target.getAttribute('draggable') === 'true'; + + if (isNativeDraggable) { + this.handleCancel(); + } else { + preventDefault(event); + } + } + protected handleCancel() { const {dragOperation} = this.manager;