Skip to content

Commit cae87f9

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 27e60e8 commit cae87f9

File tree

5 files changed

+87
-13
lines changed

5 files changed

+87
-13
lines changed

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4253,6 +4253,27 @@ describe('CdkDrag', () => {
42534253
}).toThrowError(/^cdkDropList must be attached to an element node/);
42544254
}));
42554255

4256+
it('should stop dragging if the page is blurred', fakeAsync(() => {
4257+
const fixture = createComponent(DraggableInDropZone);
4258+
fixture.detectChanges();
4259+
const dragItems = fixture.componentInstance.dragItems;
4260+
4261+
expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled();
4262+
4263+
const item = dragItems.first;
4264+
const targetRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
4265+
4266+
startDraggingViaMouse(fixture, item.element.nativeElement);
4267+
dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
4268+
fixture.detectChanges();
4269+
4270+
dispatchFakeEvent(window, 'blur');
4271+
fixture.detectChanges();
4272+
flush();
4273+
4274+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
4275+
}));
4276+
42564277
});
42574278

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

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,18 @@ describe('DragDropRegistry', () => {
244244
subscription.unsubscribe();
245245
});
246246

247+
it('should dispatch an event if the window is blurred while scrolling', () => {
248+
const spy = jasmine.createSpy('blur spy');
249+
const subscription = registry.pageBlurred.subscribe(spy);
250+
const item = new DragItem();
251+
252+
registry.startDragging(item, createMouseEvent('mousedown'));
253+
dispatchFakeEvent(window, 'blur');
254+
255+
expect(spy).toHaveBeenCalled();
256+
subscription.unsubscribe();
257+
});
258+
247259
class DragItem {
248260
isDragging() { return this.shouldBeDragging; }
249261
constructor(public shouldBeDragging = false) {

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

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const activeCapturingEventOptions = normalizePassiveListenerOptions({
2828
@Injectable({providedIn: 'root'})
2929
export class DragDropRegistry<I extends {isDragging(): boolean}, 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,6 +42,10 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
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

@@ -54,21 +59,25 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
5459
* Emits the `touchmove` or `mousemove` events that are dispatched
5560
* while the user is dragging a drag item instance.
5661
*/
57-
readonly pointerMove: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
62+
pointerMove: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
5863

5964
/**
6065
* Emits the `touchend` or `mouseup` events that are dispatched
6166
* while the user is dragging a drag item instance.
6267
*/
63-
readonly pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
68+
pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
6469

6570
/** Emits when the viewport has been scrolled while the user is dragging an item. */
66-
readonly scroll: Subject<Event> = new Subject<Event>();
71+
scroll: Subject<Event> = new Subject<Event>();
72+
73+
/** Emits when the page has been blurred while the user is dragging an item. */
74+
pageBlurred: Subject<void> = new Subject<void>();
6775

6876
constructor(
6977
private _ngZone: NgZone,
7078
@Inject(DOCUMENT) _document: any) {
7179
this._document = _document;
80+
this._window = (typeof window !== 'undefined' && window.addEventListener) ? window : null;
7281
}
7382

7483
/** Adds a drop container to the registry. */
@@ -133,35 +142,45 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
133142
this._globalListeners
134143
.set(isTouchEvent ? 'touchend' : 'mouseup', {
135144
handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent),
136-
options: true
145+
options: true,
146+
target: this._document
137147
})
138148
.set('scroll', {
139149
handler: (e: Event) => this.scroll.next(e),
140150
// Use capturing so that we pick up scroll changes in any scrollable nodes that aren't
141151
// the document. See https://github.com/angular/components/issues/17144.
142-
options: true
152+
options: true,
153+
target: this._document
143154
})
144155
// Preventing the default action on `mousemove` isn't enough to disable text selection
145156
// on Safari so we need to prevent the selection event as well. Alternatively this can
146157
// be done by setting `user-select: none` on the `body`, however it has causes a style
147158
// recalculation which can be expensive on pages with a lot of elements.
148159
.set('selectstart', {
149160
handler: this._preventDefaultWhileDragging,
150-
options: activeCapturingEventOptions
161+
options: activeCapturingEventOptions,
162+
target: this._document
163+
})
164+
.set('blur', {
165+
handler: () => this.pageBlurred.next(),
166+
target: this._window // Note that this event can only be bound on the window, not document
151167
});
152168

153169
// We don't have to bind a move event for touch drag sequences, because
154170
// we already have a persistent global one bound from `registerDragItem`.
155171
if (!isTouchEvent) {
156172
this._globalListeners.set('mousemove', {
157173
handler: (e: Event) => this.pointerMove.next(e as MouseEvent),
158-
options: activeCapturingEventOptions
174+
options: activeCapturingEventOptions,
175+
target: this._document
159176
});
160177
}
161178

162179
this._ngZone.runOutsideAngular(() => {
163180
this._globalListeners.forEach((config, name) => {
164-
this._document.addEventListener(name, config.handler, config.options);
181+
if (config.target) {
182+
config.target.addEventListener(name, config.handler, config.options);
183+
}
165184
});
166185
});
167186
}
@@ -191,6 +210,7 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
191210
this._clearGlobalListeners();
192211
this.pointerMove.complete();
193212
this.pointerUp.complete();
213+
this.pageBlurred.complete();
194214
}
195215

196216
/**
@@ -220,7 +240,9 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
220240
/** Clears out the global event listeners from the `document`. */
221241
private _clearGlobalListeners() {
222242
this._globalListeners.forEach((config, name) => {
223-
this._document.removeEventListener(name, config.handler, config.options);
243+
if (config.target) {
244+
config.target.removeEventListener(name, config.handler, config.options);
245+
}
224246
});
225247

226248
this._globalListeners.clear();

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,13 +190,19 @@ export class DragRef<T = any> {
190190
/** Subscription to the viewport being resized. */
191191
private _resizeSubscription = Subscription.EMPTY;
192192

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

203+
/** Last pointer move event that was captured. */
204+
private _lastPointerMove: MouseEvent | TouchEvent | null;
205+
200206
/** Time at which the last dragging sequence was started. */
201207
private _dragStartTime: number;
202208

@@ -461,7 +467,7 @@ export class DragRef<T = any> {
461467
this._resizeSubscription.unsubscribe();
462468
this._parentPositions.clear();
463469
this._boundaryElement = this._rootElement = this._ownerSVGElement = this._placeholderTemplate =
464-
this._previewTemplate = this._anchor = null!;
470+
this._previewTemplate = this._anchor = this._lastPointerMove = null!;
465471
}
466472

467473
/** Checks whether the element is currently being dragged. */
@@ -547,6 +553,7 @@ export class DragRef<T = any> {
547553
this._pointerMoveSubscription.unsubscribe();
548554
this._pointerUpSubscription.unsubscribe();
549555
this._scrollSubscription.unsubscribe();
556+
this._blurSubscription.unsubscribe();
550557
}
551558

552559
/** Destroys the preview element and its ViewRef. */
@@ -645,6 +652,7 @@ export class DragRef<T = any> {
645652
const constrainedPointerPosition = this._getConstrainedPointerPosition(pointerPosition);
646653
this._hasMoved = true;
647654
this._lastKnownPointerPosition = pointerPosition;
655+
this._lastPointerMove = event;
648656
this._updatePointerDirectionDelta(constrainedPointerPosition);
649657

650658
if (this._dropContainer) {
@@ -825,6 +833,7 @@ export class DragRef<T = any> {
825833
}
826834

827835
this._hasStartedDragging = this._hasMoved = false;
836+
this._lastPointerMove = null;
828837

829838
// Avoid multiple subscriptions and memory leaks when multi touch
830839
// (isDragging check above isn't enough because of possible temporal and/or dimensional delays)
@@ -835,6 +844,15 @@ export class DragRef<T = any> {
835844
this._updateOnScroll(scrollEvent);
836845
});
837846

847+
// If the page is blurred while dragging (e.g. there was an `alert` or the browser window was
848+
// minimized) we won't get a mouseup/touchend so we need to use a different event to stop the
849+
// drag sequence. Use the last known location to figure out where the element should be dropped.
850+
this._blurSubscription = this._dragDropRegistry.pageBlurred.subscribe(() => {
851+
if (this._lastPointerMove) {
852+
this._endDragSequence(this._lastPointerMove);
853+
}
854+
});
855+
838856
if (this._boundaryElement) {
839857
this._boundaryRect = getMutableClientRect(this._boundaryElement);
840858
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -231,9 +231,10 @@ export declare class DragDropModule {
231231
export declare class DragDropRegistry<I extends {
232232
isDragging(): boolean;
233233
}, C> implements OnDestroy {
234-
readonly pointerMove: Subject<TouchEvent | MouseEvent>;
235-
readonly pointerUp: Subject<TouchEvent | MouseEvent>;
236-
readonly scroll: Subject<Event>;
234+
pageBlurred: Subject<void>;
235+
pointerMove: Subject<TouchEvent | MouseEvent>;
236+
pointerUp: Subject<TouchEvent | MouseEvent>;
237+
scroll: Subject<Event>;
237238
constructor(_ngZone: NgZone, _document: any);
238239
isDragging(drag: I): boolean;
239240
ngOnDestroy(): void;

0 commit comments

Comments
 (0)