diff --git a/src/cdk/drag-drop/directives/drag.spec.ts b/src/cdk/drag-drop/directives/drag.spec.ts index c7643e4e135b..4408a9c46b44 100644 --- a/src/cdk/drag-drop/directives/drag.spec.ts +++ b/src/cdk/drag-drop/directives/drag.spec.ts @@ -4474,6 +4474,27 @@ describe('CdkDrag', () => { 'Expected placeholder to preserve transform when dragging stops.'); })); + it('should stop dragging if the page is blurred', fakeAsync(() => { + const fixture = createComponent(DraggableInDropZone); + fixture.detectChanges(); + const dragItems = fixture.componentInstance.dragItems; + + expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled(); + + const item = dragItems.first; + const targetRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect(); + + startDraggingViaMouse(fixture, item.element.nativeElement); + dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1); + fixture.detectChanges(); + + dispatchFakeEvent(window, 'blur'); + fixture.detectChanges(); + flush(); + + expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1); + })); + }); describe('in a connected drop container', () => { diff --git a/src/cdk/drag-drop/drag-drop-registry.spec.ts b/src/cdk/drag-drop/drag-drop-registry.spec.ts index 623fd557a4af..7b17dde08e7b 100644 --- a/src/cdk/drag-drop/drag-drop-registry.spec.ts +++ b/src/cdk/drag-drop/drag-drop-registry.spec.ts @@ -244,6 +244,18 @@ describe('DragDropRegistry', () => { subscription.unsubscribe(); }); + it('should dispatch an event if the window is blurred while scrolling', () => { + const spy = jasmine.createSpy('blur spy'); + const subscription = registry.pageBlurred.subscribe(spy); + const item = new DragItem(); + + registry.startDragging(item, createMouseEvent('mousedown')); + dispatchFakeEvent(window, 'blur'); + + expect(spy).toHaveBeenCalled(); + subscription.unsubscribe(); + }); + class DragItem { isDragging() { return this.shouldBeDragging; } constructor(public shouldBeDragging = false) { diff --git a/src/cdk/drag-drop/drag-drop-registry.ts b/src/cdk/drag-drop/drag-drop-registry.ts index 535499b5c31f..95d0e145f034 100644 --- a/src/cdk/drag-drop/drag-drop-registry.ts +++ b/src/cdk/drag-drop/drag-drop-registry.ts @@ -28,6 +28,7 @@ const activeCapturingEventOptions = normalizePassiveListenerOptions({ @Injectable({providedIn: 'root'}) export class DragDropRegistry implements OnDestroy { private _document: Document; + private _window: Window | null; /** Registered drop container instances. */ private _dropInstances = new Set(); @@ -41,6 +42,10 @@ export class DragDropRegistry implements O /** Keeps track of the event listeners that we've bound to the `document`. */ private _globalListeners = new Map void, + // The target needs to be `| null` because we bind either to `window` or `document` which + // aren't available during SSR. There's an injection token for the document, but not one for + // window so we fall back to not binding events to it. + target: EventTarget | null, options?: AddEventListenerOptions | boolean }>(); @@ -54,13 +59,13 @@ export class DragDropRegistry implements O * Emits the `touchmove` or `mousemove` events that are dispatched * while the user is dragging a drag item instance. */ - readonly pointerMove: Subject = new Subject(); + pointerMove: Subject = new Subject(); /** * Emits the `touchend` or `mouseup` events that are dispatched * while the user is dragging a drag item instance. */ - readonly pointerUp: Subject = new Subject(); + pointerUp: Subject = new Subject(); /** * Emits when the viewport has been scrolled while the user is dragging an item. @@ -69,10 +74,14 @@ export class DragDropRegistry implements O */ readonly scroll: Subject = new Subject(); + /** Emits when the page has been blurred while the user is dragging an item. */ + pageBlurred: Subject = new Subject(); + constructor( private _ngZone: NgZone, @Inject(DOCUMENT) _document: any) { this._document = _document; + this._window = (typeof window !== 'undefined' && window.addEventListener) ? window : null; } /** Adds a drop container to the registry. */ @@ -137,13 +146,15 @@ export class DragDropRegistry implements O this._globalListeners .set(isTouchEvent ? 'touchend' : 'mouseup', { handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent), - options: true + options: true, + target: this._document }) .set('scroll', { handler: (e: Event) => this.scroll.next(e), // Use capturing so that we pick up scroll changes in any scrollable nodes that aren't // the document. See https://github.com/angular/components/issues/17144. - options: true + options: true, + target: this._document }) // Preventing the default action on `mousemove` isn't enough to disable text selection // on Safari so we need to prevent the selection event as well. Alternatively this can @@ -151,7 +162,12 @@ export class DragDropRegistry implements O // recalculation which can be expensive on pages with a lot of elements. .set('selectstart', { handler: this._preventDefaultWhileDragging, - options: activeCapturingEventOptions + options: activeCapturingEventOptions, + target: this._document + }) + .set('blur', { + handler: () => this.pageBlurred.next(), + target: this._window // Note that this event can only be bound on the window, not document }); // We don't have to bind a move event for touch drag sequences, because @@ -159,13 +175,16 @@ export class DragDropRegistry implements O if (!isTouchEvent) { this._globalListeners.set('mousemove', { handler: (e: Event) => this.pointerMove.next(e as MouseEvent), - options: activeCapturingEventOptions + options: activeCapturingEventOptions, + target: this._document }); } this._ngZone.runOutsideAngular(() => { this._globalListeners.forEach((config, name) => { - this._document.addEventListener(name, config.handler, config.options); + if (config.target) { + config.target.addEventListener(name, config.handler, config.options); + } }); }); } @@ -230,6 +249,7 @@ export class DragDropRegistry implements O this._clearGlobalListeners(); this.pointerMove.complete(); this.pointerUp.complete(); + this.pageBlurred.complete(); } /** @@ -259,7 +279,9 @@ export class DragDropRegistry implements O /** Clears out the global event listeners from the `document`. */ private _clearGlobalListeners() { this._globalListeners.forEach((config, name) => { - this._document.removeEventListener(name, config.handler, config.options); + if (config.target) { + config.target.removeEventListener(name, config.handler, config.options); + } }); this._globalListeners.clear(); diff --git a/src/cdk/drag-drop/drag-ref.ts b/src/cdk/drag-drop/drag-ref.ts index db00e8fea55d..7c81213a3964 100644 --- a/src/cdk/drag-drop/drag-ref.ts +++ b/src/cdk/drag-drop/drag-ref.ts @@ -212,6 +212,9 @@ export class DragRef { /** Subscription to the viewport being resized. */ private _resizeSubscription = Subscription.EMPTY; + /** Subscription to the page being blurred. */ + private _blurSubscription = Subscription.EMPTY; + /** * Time at which the last touch event occurred. Used to avoid firing the same * events multiple times on touch devices where the browser will fire a fake @@ -219,6 +222,9 @@ export class DragRef { */ private _lastTouchEventTime: number; + /** Last pointer move event that was captured. */ + private _lastPointerMove: MouseEvent | TouchEvent | null; + /** Time at which the last dragging sequence was started. */ private _dragStartTime: number; @@ -493,7 +499,7 @@ export class DragRef { this._resizeSubscription.unsubscribe(); this._parentPositions.clear(); this._boundaryElement = this._rootElement = this._ownerSVGElement = this._placeholderTemplate = - this._previewTemplate = this._anchor = this._parentDragRef = null!; + this._previewTemplate = this._anchor = this._parentDragRef = this._lastPointerMove = null!; } /** Checks whether the element is currently being dragged. */ @@ -588,6 +594,7 @@ export class DragRef { this._pointerMoveSubscription.unsubscribe(); this._pointerUpSubscription.unsubscribe(); this._scrollSubscription.unsubscribe(); + this._blurSubscription.unsubscribe(); } /** Destroys the preview element and its ViewRef. */ @@ -689,6 +696,7 @@ export class DragRef { const constrainedPointerPosition = this._getConstrainedPointerPosition(pointerPosition); this._hasMoved = true; this._lastKnownPointerPosition = pointerPosition; + this._lastPointerMove = event; this._updatePointerDirectionDelta(constrainedPointerPosition); if (this._dropContainer) { @@ -879,6 +887,7 @@ export class DragRef { } this._hasStartedDragging = this._hasMoved = false; + this._lastPointerMove = null; // Avoid multiple subscriptions and memory leaks when multi touch // (isDragging check above isn't enough because of possible temporal and/or dimensional delays) @@ -889,6 +898,15 @@ export class DragRef { .scrolled(this._getShadowRoot()) .subscribe(scrollEvent => this._updateOnScroll(scrollEvent)); + // If the page is blurred while dragging (e.g. there was an `alert` or the browser window was + // minimized) we won't get a mouseup/touchend so we need to use a different event to stop the + // drag sequence. Use the last known location to figure out where the element should be dropped. + this._blurSubscription = this._dragDropRegistry.pageBlurred.subscribe(() => { + if (this._lastPointerMove) { + this._endDragSequence(this._lastPointerMove); + } + }); + if (this._boundaryElement) { this._boundaryRect = getMutableClientRect(this._boundaryElement); } diff --git a/tools/public_api_guard/cdk/drag-drop.d.ts b/tools/public_api_guard/cdk/drag-drop.d.ts index 7428f40d4de3..c25962c215f9 100644 --- a/tools/public_api_guard/cdk/drag-drop.d.ts +++ b/tools/public_api_guard/cdk/drag-drop.d.ts @@ -244,9 +244,10 @@ export declare class DragDropModule { export declare class DragDropRegistry implements OnDestroy { - readonly pointerMove: Subject; - readonly pointerUp: Subject; - readonly scroll: Subject; + pageBlurred: Subject; + pointerMove: Subject; + pointerUp: Subject; + scroll: Subject; constructor(_ngZone: NgZone, _document: any); isDragging(drag: I): boolean; ngOnDestroy(): void;