Skip to content

Commit 698107f

Browse files
committed
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.
1 parent 029136e commit 698107f

File tree

5 files changed

+86
-13
lines changed

5 files changed

+86
-13
lines changed

src/cdk/drag-drop/directives/drag.spec.ts

+21
Original file line numberDiff line numberDiff line change
@@ -4262,6 +4262,27 @@ describe('CdkDrag', () => {
42624262
}).toThrowError(/^cdkDropList must be attached to an element node/);
42634263
}));
42644264

4265+
it('should stop dragging if the page is blurred', fakeAsync(() => {
4266+
const fixture = createComponent(DraggableInDropZone);
4267+
fixture.detectChanges();
4268+
const dragItems = fixture.componentInstance.dragItems;
4269+
4270+
expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled();
4271+
4272+
const item = dragItems.first;
4273+
const targetRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
4274+
4275+
startDraggingViaMouse(fixture, item.element.nativeElement);
4276+
dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
4277+
fixture.detectChanges();
4278+
4279+
dispatchFakeEvent(window, 'blur');
4280+
fixture.detectChanges();
4281+
flush();
4282+
4283+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
4284+
}));
4285+
42654286
});
42664287

42674288
describe('in a connected drop container', () => {

src/cdk/drag-drop/drag-drop-registry.spec.ts

+11
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,17 @@ describe('DragDropRegistry', () => {
237237
subscription.unsubscribe();
238238
});
239239

240+
it('should dispatch an event if the window is blurred while scrolling', () => {
241+
const spy = jasmine.createSpy('blur spy');
242+
const subscription = registry.pageBlurred.subscribe(spy);
243+
244+
registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
245+
dispatchFakeEvent(window, 'blur');
246+
247+
expect(spy).toHaveBeenCalled();
248+
subscription.unsubscribe();
249+
});
250+
240251
});
241252

242253
@Component({

src/cdk/drag-drop/drag-drop-registry.ts

+31-9
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const activeCapturingEventOptions = normalizePassiveListenerOptions({
2828
@Injectable({providedIn: 'root'})
2929
export class DragDropRegistry<I, C> implements OnDestroy {
3030
private _document: Document;
31+
private _window: Window | null;
3132

3233
/** Registered drop container instances. */
3334
private _dropInstances = new Set<C>();
@@ -41,28 +42,36 @@ export class DragDropRegistry<I, C> implements OnDestroy {
4142
/** Keeps track of the event listeners that we've bound to the `document`. */
4243
private _globalListeners = new Map<string, {
4344
handler: (event: Event) => void,
45+
// The target needs to be `| null` because we bind either to `window` or `document` which
46+
// aren't available during SSR. There's an injection token for the document, but not one for
47+
// window so we fall back to not binding events to it.
48+
target: EventTarget | null,
4449
options?: AddEventListenerOptions | boolean
4550
}>();
4651

4752
/**
4853
* Emits the `touchmove` or `mousemove` events that are dispatched
4954
* while the user is dragging a drag item instance.
5055
*/
51-
readonly pointerMove: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
56+
pointerMove: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
5257

5358
/**
5459
* Emits the `touchend` or `mouseup` events that are dispatched
5560
* while the user is dragging a drag item instance.
5661
*/
57-
readonly pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
62+
pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
5863

5964
/** Emits when the viewport has been scrolled while the user is dragging an item. */
60-
readonly scroll: Subject<Event> = new Subject<Event>();
65+
scroll: Subject<Event> = new Subject<Event>();
66+
67+
/** Emits when the page has been blurred while the user is dragging an item. */
68+
pageBlurred: Subject<void> = new Subject<void>();
6169

6270
constructor(
6371
private _ngZone: NgZone,
6472
@Inject(DOCUMENT) _document: any) {
6573
this._document = _document;
74+
this._window = (typeof window !== 'undefined' && window.addEventListener) ? window : null;
6675
}
6776

6877
/** Adds a drop container to the registry. */
@@ -127,35 +136,45 @@ export class DragDropRegistry<I, C> implements OnDestroy {
127136
this._globalListeners
128137
.set(isTouchEvent ? 'touchend' : 'mouseup', {
129138
handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent),
130-
options: true
139+
options: true,
140+
target: this._document
131141
})
132142
.set('scroll', {
133143
handler: (e: Event) => this.scroll.next(e),
134144
// Use capturing so that we pick up scroll changes in any scrollable nodes that aren't
135145
// the document. See https://github.com/angular/components/issues/17144.
136-
options: true
146+
options: true,
147+
target: this._document
137148
})
138149
// Preventing the default action on `mousemove` isn't enough to disable text selection
139150
// on Safari so we need to prevent the selection event as well. Alternatively this can
140151
// be done by setting `user-select: none` on the `body`, however it has causes a style
141152
// recalculation which can be expensive on pages with a lot of elements.
142153
.set('selectstart', {
143154
handler: this._preventDefaultWhileDragging,
144-
options: activeCapturingEventOptions
155+
options: activeCapturingEventOptions,
156+
target: this._document
157+
})
158+
.set('blur', {
159+
handler: () => this.pageBlurred.next(),
160+
target: this._window // Note that this event can only be bound on the window, not document
145161
});
146162

147163
// We don't have to bind a move event for touch drag sequences, because
148164
// we already have a persistent global one bound from `registerDragItem`.
149165
if (!isTouchEvent) {
150166
this._globalListeners.set('mousemove', {
151167
handler: (e: Event) => this.pointerMove.next(e as MouseEvent),
152-
options: activeCapturingEventOptions
168+
options: activeCapturingEventOptions,
169+
target: this._document
153170
});
154171
}
155172

156173
this._ngZone.runOutsideAngular(() => {
157174
this._globalListeners.forEach((config, name) => {
158-
this._document.addEventListener(name, config.handler, config.options);
175+
if (config.target) {
176+
config.target.addEventListener(name, config.handler, config.options);
177+
}
159178
});
160179
});
161180
}
@@ -181,6 +200,7 @@ export class DragDropRegistry<I, C> implements OnDestroy {
181200
this._clearGlobalListeners();
182201
this.pointerMove.complete();
183202
this.pointerUp.complete();
203+
this.pageBlurred.complete();
184204
}
185205

186206
/**
@@ -204,7 +224,9 @@ export class DragDropRegistry<I, C> implements OnDestroy {
204224
/** Clears out the global event listeners from the `document`. */
205225
private _clearGlobalListeners() {
206226
this._globalListeners.forEach((config, name) => {
207-
this._document.removeEventListener(name, config.handler, config.options);
227+
if (config.target) {
228+
config.target.removeEventListener(name, config.handler, config.options);
229+
}
208230
});
209231

210232
this._globalListeners.clear();

src/cdk/drag-drop/drag-ref.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -187,13 +187,19 @@ export class DragRef<T = any> {
187187
/** Subscription to the viewport being resized. */
188188
private _resizeSubscription = Subscription.EMPTY;
189189

190+
/** Subscription to the page being blurred. */
191+
private _blurSubscription = Subscription.EMPTY;
192+
190193
/**
191194
* Time at which the last touch event occurred. Used to avoid firing the same
192195
* events multiple times on touch devices where the browser will fire a fake
193196
* mouse event for each touch event, after a certain time.
194197
*/
195198
private _lastTouchEventTime: number;
196199

200+
/** Last pointer move event that was captured. */
201+
private _lastPointerMove: MouseEvent | TouchEvent | null;
202+
197203
/** Time at which the last dragging sequence was started. */
198204
private _dragStartTime: number;
199205

@@ -451,7 +457,7 @@ export class DragRef<T = any> {
451457
this._resizeSubscription.unsubscribe();
452458
this._parentPositions.clear();
453459
this._boundaryElement = this._rootElement = this._ownerSVGElement = this._placeholderTemplate =
454-
this._previewTemplate = this._anchor = null!;
460+
this._previewTemplate = this._anchor = this._lastPointerMove = null!;
455461
}
456462

457463
/** Checks whether the element is currently being dragged. */
@@ -537,6 +543,7 @@ export class DragRef<T = any> {
537543
this._pointerMoveSubscription.unsubscribe();
538544
this._pointerUpSubscription.unsubscribe();
539545
this._scrollSubscription.unsubscribe();
546+
this._blurSubscription.unsubscribe();
540547
}
541548

542549
/** Destroys the preview element and its ViewRef. */
@@ -633,6 +640,7 @@ export class DragRef<T = any> {
633640
const constrainedPointerPosition = this._getConstrainedPointerPosition(pointerPosition);
634641
this._hasMoved = true;
635642
this._lastKnownPointerPosition = pointerPosition;
643+
this._lastPointerMove = event;
636644
this._updatePointerDirectionDelta(constrainedPointerPosition);
637645

638646
if (this._dropContainer) {
@@ -809,6 +817,7 @@ export class DragRef<T = any> {
809817
}
810818

811819
this._hasStartedDragging = this._hasMoved = false;
820+
this._lastPointerMove = null;
812821

813822
// Avoid multiple subscriptions and memory leaks when multi touch
814823
// (isDragging check above isn't enough because of possible temporal and/or dimensional delays)
@@ -819,6 +828,15 @@ export class DragRef<T = any> {
819828
this._updateOnScroll(scrollEvent);
820829
});
821830

831+
// If the page is blurred while dragging (e.g. there was an `alert` or the browser window was
832+
// minimized) we won't get a mouseup/touchend so we need to use a different event to stop the
833+
// drag sequence. Use the last known location to figure out where the element should be dropped.
834+
this._blurSubscription = this._dragDropRegistry.pageBlurred.subscribe(() => {
835+
if (this._lastPointerMove) {
836+
this._endDragSequence(this._lastPointerMove);
837+
}
838+
});
839+
822840
if (this._boundaryElement) {
823841
this._boundaryRect = getMutableClientRect(this._boundaryElement);
824842
}

tools/public_api_guard/cdk/drag-drop.d.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,10 @@ export declare class DragDropModule {
229229
}
230230

231231
export declare class DragDropRegistry<I, C> implements OnDestroy {
232-
readonly pointerMove: Subject<TouchEvent | MouseEvent>;
233-
readonly pointerUp: Subject<TouchEvent | MouseEvent>;
234-
readonly scroll: Subject<Event>;
232+
pageBlurred: Subject<void>;
233+
pointerMove: Subject<TouchEvent | MouseEvent>;
234+
pointerUp: Subject<TouchEvent | MouseEvent>;
235+
scroll: Subject<Event>;
235236
constructor(_ngZone: NgZone, _document: any);
236237
isDragging(drag: I): boolean;
237238
ngOnDestroy(): void;

0 commit comments

Comments
 (0)