Skip to content

Commit cd3b833

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 7b3006f commit cd3b833

File tree

5 files changed

+86
-12
lines changed

5 files changed

+86
-12
lines changed

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4474,6 +4474,27 @@ describe('CdkDrag', () => {
44744474
'Expected placeholder to preserve transform when dragging stops.');
44754475
}));
44764476

4477+
it('should stop dragging if the page is blurred', fakeAsync(() => {
4478+
const fixture = createComponent(DraggableInDropZone);
4479+
fixture.detectChanges();
4480+
const dragItems = fixture.componentInstance.dragItems;
4481+
4482+
expect(fixture.componentInstance.droppedSpy).not.toHaveBeenCalled();
4483+
4484+
const item = dragItems.first;
4485+
const targetRect = dragItems.toArray()[2].element.nativeElement.getBoundingClientRect();
4486+
4487+
startDraggingViaMouse(fixture, item.element.nativeElement);
4488+
dispatchMouseEvent(document, 'mousemove', targetRect.left + 1, targetRect.top + 1);
4489+
fixture.detectChanges();
4490+
4491+
dispatchFakeEvent(window, 'blur');
4492+
fixture.detectChanges();
4493+
flush();
4494+
4495+
expect(fixture.componentInstance.droppedSpy).toHaveBeenCalledTimes(1);
4496+
}));
4497+
44774498
});
44784499

44794500
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: 30 additions & 8 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,13 +59,13 @@ 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
/**
6671
* Emits when the viewport has been scrolled while the user is dragging an item.
@@ -69,10 +74,14 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
6974
*/
7075
readonly scroll: Subject<Event> = new Subject<Event>();
7176

77+
/** Emits when the page has been blurred while the user is dragging an item. */
78+
pageBlurred: Subject<void> = new Subject<void>();
79+
7280
constructor(
7381
private _ngZone: NgZone,
7482
@Inject(DOCUMENT) _document: any) {
7583
this._document = _document;
84+
this._window = (typeof window !== 'undefined' && window.addEventListener) ? window : null;
7685
}
7786

7887
/** Adds a drop container to the registry. */
@@ -137,35 +146,45 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
137146
this._globalListeners
138147
.set(isTouchEvent ? 'touchend' : 'mouseup', {
139148
handler: (e: Event) => this.pointerUp.next(e as TouchEvent | MouseEvent),
140-
options: true
149+
options: true,
150+
target: this._document
141151
})
142152
.set('scroll', {
143153
handler: (e: Event) => this.scroll.next(e),
144154
// Use capturing so that we pick up scroll changes in any scrollable nodes that aren't
145155
// the document. See https://github.com/angular/components/issues/17144.
146-
options: true
156+
options: true,
157+
target: this._document
147158
})
148159
// Preventing the default action on `mousemove` isn't enough to disable text selection
149160
// on Safari so we need to prevent the selection event as well. Alternatively this can
150161
// be done by setting `user-select: none` on the `body`, however it has causes a style
151162
// recalculation which can be expensive on pages with a lot of elements.
152163
.set('selectstart', {
153164
handler: this._preventDefaultWhileDragging,
154-
options: activeCapturingEventOptions
165+
options: activeCapturingEventOptions,
166+
target: this._document
167+
})
168+
.set('blur', {
169+
handler: () => this.pageBlurred.next(),
170+
target: this._window // Note that this event can only be bound on the window, not document
155171
});
156172

157173
// We don't have to bind a move event for touch drag sequences, because
158174
// we already have a persistent global one bound from `registerDragItem`.
159175
if (!isTouchEvent) {
160176
this._globalListeners.set('mousemove', {
161177
handler: (e: Event) => this.pointerMove.next(e as MouseEvent),
162-
options: activeCapturingEventOptions
178+
options: activeCapturingEventOptions,
179+
target: this._document
163180
});
164181
}
165182

166183
this._ngZone.runOutsideAngular(() => {
167184
this._globalListeners.forEach((config, name) => {
168-
this._document.addEventListener(name, config.handler, config.options);
185+
if (config.target) {
186+
config.target.addEventListener(name, config.handler, config.options);
187+
}
169188
});
170189
});
171190
}
@@ -230,6 +249,7 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
230249
this._clearGlobalListeners();
231250
this.pointerMove.complete();
232251
this.pointerUp.complete();
252+
this.pageBlurred.complete();
233253
}
234254

235255
/**
@@ -259,7 +279,9 @@ export class DragDropRegistry<I extends {isDragging(): boolean}, C> implements O
259279
/** Clears out the global event listeners from the `document`. */
260280
private _clearGlobalListeners() {
261281
this._globalListeners.forEach((config, name) => {
262-
this._document.removeEventListener(name, config.handler, config.options);
282+
if (config.target) {
283+
config.target.removeEventListener(name, config.handler, config.options);
284+
}
263285
});
264286

265287
this._globalListeners.clear();

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

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

215+
/** Subscription to the page being blurred. */
216+
private _blurSubscription = Subscription.EMPTY;
217+
215218
/**
216219
* Time at which the last touch event occurred. Used to avoid firing the same
217220
* events multiple times on touch devices where the browser will fire a fake
218221
* mouse event for each touch event, after a certain time.
219222
*/
220223
private _lastTouchEventTime: number;
221224

225+
/** Last pointer move event that was captured. */
226+
private _lastPointerMove: MouseEvent | TouchEvent | null;
227+
222228
/** Time at which the last dragging sequence was started. */
223229
private _dragStartTime: number;
224230

@@ -493,7 +499,7 @@ export class DragRef<T = any> {
493499
this._resizeSubscription.unsubscribe();
494500
this._parentPositions.clear();
495501
this._boundaryElement = this._rootElement = this._ownerSVGElement = this._placeholderTemplate =
496-
this._previewTemplate = this._anchor = this._parentDragRef = null!;
502+
this._previewTemplate = this._anchor = this._parentDragRef = this._lastPointerMove = null!;
497503
}
498504

499505
/** Checks whether the element is currently being dragged. */
@@ -588,6 +594,7 @@ export class DragRef<T = any> {
588594
this._pointerMoveSubscription.unsubscribe();
589595
this._pointerUpSubscription.unsubscribe();
590596
this._scrollSubscription.unsubscribe();
597+
this._blurSubscription.unsubscribe();
591598
}
592599

593600
/** Destroys the preview element and its ViewRef. */
@@ -689,6 +696,7 @@ export class DragRef<T = any> {
689696
const constrainedPointerPosition = this._getConstrainedPointerPosition(pointerPosition);
690697
this._hasMoved = true;
691698
this._lastKnownPointerPosition = pointerPosition;
699+
this._lastPointerMove = event;
692700
this._updatePointerDirectionDelta(constrainedPointerPosition);
693701

694702
if (this._dropContainer) {
@@ -879,6 +887,7 @@ export class DragRef<T = any> {
879887
}
880888

881889
this._hasStartedDragging = this._hasMoved = false;
890+
this._lastPointerMove = null;
882891

883892
// Avoid multiple subscriptions and memory leaks when multi touch
884893
// (isDragging check above isn't enough because of possible temporal and/or dimensional delays)
@@ -889,6 +898,15 @@ export class DragRef<T = any> {
889898
.scrolled(this._getShadowRoot())
890899
.subscribe(scrollEvent => this._updateOnScroll(scrollEvent));
891900

901+
// If the page is blurred while dragging (e.g. there was an `alert` or the browser window was
902+
// minimized) we won't get a mouseup/touchend so we need to use a different event to stop the
903+
// drag sequence. Use the last known location to figure out where the element should be dropped.
904+
this._blurSubscription = this._dragDropRegistry.pageBlurred.subscribe(() => {
905+
if (this._lastPointerMove) {
906+
this._endDragSequence(this._lastPointerMove);
907+
}
908+
});
909+
892910
if (this._boundaryElement) {
893911
this._boundaryRect = getMutableClientRect(this._boundaryElement);
894912
}

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,10 @@ export declare class DragDropModule {
244244
export declare class DragDropRegistry<I extends {
245245
isDragging(): boolean;
246246
}, C> implements OnDestroy {
247-
readonly pointerMove: Subject<TouchEvent | MouseEvent>;
248-
readonly pointerUp: Subject<TouchEvent | MouseEvent>;
249-
readonly scroll: Subject<Event>;
247+
pageBlurred: Subject<void>;
248+
pointerMove: Subject<TouchEvent | MouseEvent>;
249+
pointerUp: Subject<TouchEvent | MouseEvent>;
250+
scroll: Subject<Event>;
250251
constructor(_ngZone: NgZone, _document: any);
251252
isDragging(drag: I): boolean;
252253
ngOnDestroy(): void;

0 commit comments

Comments
 (0)