Skip to content

Commit 5502e6f

Browse files
crisbetommalerba
authored andcommitted
feat(drag-drop): allow connecting containers via string ids, attaching data to drop instances and consolidate global event listeners (#12315)
* Adds the ability to pass in strings to `connectedTo` when connecting multiple containers together. Previously we only accepted `CdkDrop` instances which can be inconvenient to pass while inside an `ngFor`. * Adds the ability to attach extra data to a `CdkDrag`, similarly to `CdkDrop`. This is mostly for consistency and convenience. * Introduces the `CdkDragDropRegistry` which is used to keep track of the active `CdkDrag` and `CdkDrop` instances. It also manages all of the events on the `document` in order to ensure that we only have one of each event at a given time. This will allow us to handle dragging multiple items at the same time, eventually.
1 parent 6aa1a55 commit 5502e6f

File tree

6 files changed

+507
-91
lines changed

6 files changed

+507
-91
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import {QueryList, ViewChildren, Component} from '@angular/core';
2+
import {fakeAsync, TestBed, ComponentFixture, inject} from '@angular/core/testing';
3+
import {
4+
createMouseEvent,
5+
dispatchMouseEvent,
6+
createTouchEvent,
7+
dispatchTouchEvent,
8+
} from '@angular/cdk/testing';
9+
import {CdkDragDropRegistry} from './drag-drop-registry';
10+
import {DragDropModule} from './drag-drop-module';
11+
import {CdkDrag} from './drag';
12+
import {CdkDrop} from './drop';
13+
14+
describe('DragDropRegistry', () => {
15+
let fixture: ComponentFixture<SimpleDropZone>;
16+
let testComponent: SimpleDropZone;
17+
let registry: CdkDragDropRegistry;
18+
19+
beforeEach(fakeAsync(() => {
20+
TestBed.configureTestingModule({
21+
imports: [DragDropModule],
22+
declarations: [SimpleDropZone],
23+
}).compileComponents();
24+
25+
fixture = TestBed.createComponent(SimpleDropZone);
26+
testComponent = fixture.componentInstance;
27+
fixture.detectChanges();
28+
29+
inject([CdkDragDropRegistry], (c: CdkDragDropRegistry) => {
30+
registry = c;
31+
})();
32+
}));
33+
34+
afterEach(() => {
35+
registry.ngOnDestroy();
36+
});
37+
38+
it('should be able to start dragging an item', () => {
39+
const firstItem = testComponent.dragItems.first;
40+
41+
expect(registry.isDragging(firstItem)).toBe(false);
42+
registry.startDragging(firstItem, createMouseEvent('mousedown'));
43+
expect(registry.isDragging(firstItem)).toBe(true);
44+
});
45+
46+
it('should be able to stop dragging an item', () => {
47+
const firstItem = testComponent.dragItems.first;
48+
49+
registry.startDragging(firstItem, createMouseEvent('mousedown'));
50+
expect(registry.isDragging(firstItem)).toBe(true);
51+
52+
registry.stopDragging(firstItem);
53+
expect(registry.isDragging(firstItem)).toBe(false);
54+
});
55+
56+
it('should stop dragging an item if it is removed', () => {
57+
const firstItem = testComponent.dragItems.first;
58+
59+
registry.startDragging(firstItem, createMouseEvent('mousedown'));
60+
expect(registry.isDragging(firstItem)).toBe(true);
61+
62+
registry.remove(firstItem);
63+
expect(registry.isDragging(firstItem)).toBe(false);
64+
});
65+
66+
it('should dispatch `mousemove` events after starting to drag via the mouse', () => {
67+
const spy = jasmine.createSpy('pointerMove spy');
68+
const subscription = registry.pointerMove.subscribe(spy);
69+
70+
registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
71+
dispatchMouseEvent(document, 'mousemove');
72+
73+
expect(spy).toHaveBeenCalled();
74+
75+
subscription.unsubscribe();
76+
});
77+
78+
it('should dispatch `touchmove` events after starting to drag via touch', () => {
79+
const spy = jasmine.createSpy('pointerMove spy');
80+
const subscription = registry.pointerMove.subscribe(spy);
81+
82+
registry.startDragging(testComponent.dragItems.first,
83+
createTouchEvent('touchstart') as TouchEvent);
84+
dispatchTouchEvent(document, 'touchmove');
85+
86+
expect(spy).toHaveBeenCalled();
87+
88+
subscription.unsubscribe();
89+
});
90+
91+
it('should dispatch `mouseup` events after ending the drag via the mouse', () => {
92+
const spy = jasmine.createSpy('pointerUp spy');
93+
const subscription = registry.pointerUp.subscribe(spy);
94+
95+
registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
96+
dispatchMouseEvent(document, 'mouseup');
97+
98+
expect(spy).toHaveBeenCalled();
99+
100+
subscription.unsubscribe();
101+
});
102+
103+
it('should dispatch `touchend` events after ending the drag via touch', () => {
104+
const spy = jasmine.createSpy('pointerUp spy');
105+
const subscription = registry.pointerUp.subscribe(spy);
106+
107+
registry.startDragging(testComponent.dragItems.first,
108+
createTouchEvent('touchstart') as TouchEvent);
109+
dispatchTouchEvent(document, 'touchend');
110+
111+
expect(spy).toHaveBeenCalled();
112+
113+
subscription.unsubscribe();
114+
});
115+
116+
it('should complete the pointer event streams on destroy', () => {
117+
const pointerUpSpy = jasmine.createSpy('pointerUp complete spy');
118+
const pointerMoveSpy = jasmine.createSpy('pointerMove complete spy');
119+
const pointerUpSubscription = registry.pointerUp.subscribe(undefined, undefined, pointerUpSpy);
120+
const pointerMoveSubscription =
121+
registry.pointerMove.subscribe(undefined, undefined, pointerMoveSpy);
122+
123+
registry.ngOnDestroy();
124+
125+
expect(pointerUpSpy).toHaveBeenCalled();
126+
expect(pointerMoveSpy).toHaveBeenCalled();
127+
128+
pointerUpSubscription.unsubscribe();
129+
pointerMoveSubscription.unsubscribe();
130+
});
131+
132+
it('should not throw when trying to register the same container again', () => {
133+
expect(() => registry.register(testComponent.dropInstances.first)).not.toThrow();
134+
});
135+
136+
it('should throw when trying to register a different container with the same id', () => {
137+
expect(() => {
138+
testComponent.showDuplicateContainer = true;
139+
fixture.detectChanges();
140+
}).toThrowError(/Drop instance with id \"items\" has already been registered/);
141+
});
142+
143+
it('should be able to get a drop container by its id', () => {
144+
expect(registry.getDropContainer('items')).toBe(testComponent.dropInstances.first);
145+
expect(registry.getDropContainer('does-not-exist')).toBeFalsy();
146+
});
147+
148+
it('should not prevent the default `touchmove` actions when nothing is being dragged', () => {
149+
expect(dispatchTouchEvent(document, 'touchmove').defaultPrevented).toBe(false);
150+
});
151+
152+
it('should prevent the default `touchmove` action when an item is being dragged', () => {
153+
registry.startDragging(testComponent.dragItems.first,
154+
createTouchEvent('touchstart') as TouchEvent);
155+
expect(dispatchTouchEvent(document, 'touchmove').defaultPrevented).toBe(true);
156+
});
157+
158+
});
159+
160+
@Component({
161+
template: `
162+
<cdk-drop id="items" [data]="items">
163+
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
164+
</cdk-drop>
165+
166+
<cdk-drop id="items" *ngIf="showDuplicateContainer"></cdk-drop>
167+
`
168+
})
169+
export class SimpleDropZone {
170+
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
171+
@ViewChildren(CdkDrop) dropInstances: QueryList<CdkDrop>;
172+
items = ['Zero', 'One', 'Two', 'Three'];
173+
showDuplicateContainer = false;
174+
}
Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Injectable, NgZone, OnDestroy, Inject} from '@angular/core';
10+
import {DOCUMENT} from '@angular/common';
11+
import {supportsPassiveEventListeners} from '@angular/cdk/platform';
12+
import {Subject} from 'rxjs';
13+
import {CdkDrop} from './drop';
14+
import {CdkDrag} from './drag';
15+
16+
/** Event options that can be used to bind an active event. */
17+
const activeEventOptions = supportsPassiveEventListeners() ? {passive: false} : false;
18+
19+
/** Handler for a pointer event callback. */
20+
type PointerEventHandler = (event: TouchEvent | MouseEvent) => void;
21+
22+
/**
23+
* Service that keeps track of all the `CdkDrag` and `CdkDrop` instances, and
24+
* manages global event listeners on the `document`.
25+
* @docs-private
26+
*/
27+
@Injectable({providedIn: 'root'})
28+
export class CdkDragDropRegistry implements OnDestroy {
29+
private _document: Document;
30+
31+
/** Registered `CdkDrop` instances. */
32+
private _dropInstances = new Set<CdkDrop>();
33+
34+
/** Registered `CdkDrag` instances. */
35+
private _dragInstances = new Set<CdkDrag>();
36+
37+
/** `CdkDrag` instances that are currently being dragged. */
38+
private _activeDragInstances = new Set<CdkDrag>();
39+
40+
/** Keeps track of the event listeners that we've bound to the `document`. */
41+
private _globalListeners = new Map<string, {handler: PointerEventHandler, options?: any}>();
42+
43+
/**
44+
* Emits the `touchmove` or `mousemove` events that are dispatched
45+
* while the user is dragging a `CdkDrag` instance.
46+
*/
47+
readonly pointerMove: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
48+
49+
/**
50+
* Emits the `touchend` or `mouseup` events that are dispatched
51+
* while the user is dragging a `CdkDrag` instance.
52+
*/
53+
readonly pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();
54+
55+
constructor(
56+
private _ngZone: NgZone,
57+
@Inject(DOCUMENT) _document: any) {
58+
this._document = _document;
59+
}
60+
61+
/** Adds a `CdkDrop` instance to the registry. */
62+
register(drop: CdkDrop);
63+
64+
/** Adds a `CdkDrag` instance to the registry. */
65+
register(drag: CdkDrag);
66+
67+
register(instance: CdkDrop | CdkDrag) {
68+
if (instance instanceof CdkDrop) {
69+
if (!this._dropInstances.has(instance)) {
70+
if (this.getDropContainer(instance.id)) {
71+
throw Error(`Drop instance with id "${instance.id}" has already been registered.`);
72+
}
73+
74+
this._dropInstances.add(instance);
75+
}
76+
} else {
77+
this._dragInstances.add(instance);
78+
79+
if (this._dragInstances.size === 1) {
80+
this._ngZone.runOutsideAngular(() => {
81+
// The event handler has to be explicitly active, because
82+
// newer browsers make it passive by default.
83+
this._document.addEventListener('touchmove', this._preventScrollListener,
84+
activeEventOptions);
85+
});
86+
}
87+
}
88+
}
89+
90+
/** Removes a `CdkDrop` instance from the registry. */
91+
remove(drop: CdkDrop);
92+
93+
/** Removes a `CdkDrag` instance from the registry. */
94+
remove(drag: CdkDrag);
95+
96+
remove(instance: CdkDrop | CdkDrag) {
97+
if (instance instanceof CdkDrop) {
98+
this._dropInstances.delete(instance);
99+
} else {
100+
this._dragInstances.delete(instance);
101+
this.stopDragging(instance);
102+
103+
if (this._dragInstances.size === 0) {
104+
this._document.removeEventListener('touchmove', this._preventScrollListener,
105+
activeEventOptions as any);
106+
}
107+
}
108+
}
109+
110+
/**
111+
* Starts the dragging sequence for a drag instance.
112+
* @param drag Drag instance which is being dragged.
113+
* @param event Event that initiated the dragging.
114+
*/
115+
startDragging(drag: CdkDrag, event: TouchEvent | MouseEvent) {
116+
this._activeDragInstances.add(drag);
117+
118+
if (this._activeDragInstances.size === 1) {
119+
const isTouchEvent = event.type.startsWith('touch');
120+
const moveEvent = isTouchEvent ? 'touchmove' : 'mousemove';
121+
const upEvent = isTouchEvent ? 'touchend' : 'mouseup';
122+
123+
// We explicitly bind __active__ listeners here, because newer browsers will default to
124+
// passive ones for `mousemove` and `touchmove`. The events need to be active, because we
125+
// use `preventDefault` to prevent the page from scrolling while the user is dragging.
126+
this._globalListeners
127+
.set(moveEvent, {handler: e => this.pointerMove.next(e), options: activeEventOptions})
128+
.set(upEvent, {handler: e => this.pointerUp.next(e)})
129+
.forEach((config, name) => {
130+
this._ngZone.runOutsideAngular(() => {
131+
this._document.addEventListener(name, config.handler, config.options);
132+
});
133+
});
134+
}
135+
}
136+
137+
/** Stops dragging a `CdkDrag` instance. */
138+
stopDragging(drag: CdkDrag) {
139+
this._activeDragInstances.delete(drag);
140+
141+
if (this._activeDragInstances.size === 0) {
142+
this._clearGlobalListeners();
143+
}
144+
}
145+
146+
/** Gets whether a `CdkDrag` instance is currently being dragged. */
147+
isDragging(drag: CdkDrag) {
148+
return this._activeDragInstances.has(drag);
149+
}
150+
151+
/** Gets a `CdkDrop` instance by its id. */
152+
getDropContainer<T = any>(id: string): CdkDrop<T> | undefined {
153+
return Array.from(this._dropInstances).find(instance => instance.id === id);
154+
}
155+
156+
ngOnDestroy() {
157+
this._dragInstances.forEach(instance => this.remove(instance));
158+
this._dropInstances.forEach(instance => this.remove(instance));
159+
this._clearGlobalListeners();
160+
this.pointerMove.complete();
161+
this.pointerUp.complete();
162+
}
163+
164+
/**
165+
* Listener used to prevent `touchmove` events while the element is being dragged.
166+
* This gets bound once, ahead of time, because WebKit won't preventDefault on a
167+
* dynamically-added `touchmove` listener. See https://bugs.webkit.org/show_bug.cgi?id=184250.
168+
*/
169+
private _preventScrollListener = (event: TouchEvent) => {
170+
if (this._activeDragInstances.size) {
171+
event.preventDefault();
172+
}
173+
}
174+
175+
/** Clears out the global event listeners from the `document`. */
176+
private _clearGlobalListeners() {
177+
this._globalListeners.forEach((config, name) => {
178+
this._document.removeEventListener(name, config.handler, config.options);
179+
});
180+
181+
this._globalListeners.clear();
182+
}
183+
}

0 commit comments

Comments
 (0)