From cd3b8335286b299908750c81217a5c888adb5518 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 26 Nov 2020 18:43:01 +0100 Subject: [PATCH] fix(cdk/drag-drop): not stopping drag if page is blurred Currently the only way to stop a drag sequence is via a `mouseup`/`touchend` event or by destroying the instance, however if the page loses focus while dragging the events won't be dispatched anymore and user will have to click again to stop dragging. These changes add some extra code that listens for `blur` events on the `window` and stops dragging. Fixes #17537. --- src/cdk/drag-drop/directives/drag.spec.ts | 21 +++++++++++ src/cdk/drag-drop/drag-drop-registry.spec.ts | 12 +++++++ src/cdk/drag-drop/drag-drop-registry.ts | 38 +++++++++++++++----- src/cdk/drag-drop/drag-ref.ts | 20 ++++++++++- tools/public_api_guard/cdk/drag-drop.d.ts | 7 ++-- 5 files changed, 86 insertions(+), 12 deletions(-) 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;