Skip to content

feat(drag-drop): allow connecting containers via string ids, attaching data to drop instances and consolidate global event listeners #12315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/cdk-experimental/drag-drop/drag-drop-registry.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import {QueryList, ViewChildren, Component} from '@angular/core';
import {fakeAsync, TestBed, ComponentFixture, inject} from '@angular/core/testing';
import {
createMouseEvent,
dispatchMouseEvent,
createTouchEvent,
dispatchTouchEvent,
} from '@angular/cdk/testing';
import {CdkDragDropRegistry} from './drag-drop-registry';
import {DragDropModule} from './drag-drop-module';
import {CdkDrag} from './drag';
import {CdkDrop} from './drop';

describe('DragDropRegistry', () => {
let fixture: ComponentFixture<SimpleDropZone>;
let testComponent: SimpleDropZone;
let registry: CdkDragDropRegistry;

beforeEach(fakeAsync(() => {
TestBed.configureTestingModule({
imports: [DragDropModule],
declarations: [SimpleDropZone],
}).compileComponents();

fixture = TestBed.createComponent(SimpleDropZone);
testComponent = fixture.componentInstance;
fixture.detectChanges();

inject([CdkDragDropRegistry], (c: CdkDragDropRegistry) => {
registry = c;
})();
}));

afterEach(() => {
registry.ngOnDestroy();
});

it('should be able to start dragging an item', () => {
const firstItem = testComponent.dragItems.first;

expect(registry.isDragging(firstItem)).toBe(false);
registry.startDragging(firstItem, createMouseEvent('mousedown'));
expect(registry.isDragging(firstItem)).toBe(true);
});

it('should be able to stop dragging an item', () => {
const firstItem = testComponent.dragItems.first;

registry.startDragging(firstItem, createMouseEvent('mousedown'));
expect(registry.isDragging(firstItem)).toBe(true);

registry.stopDragging(firstItem);
expect(registry.isDragging(firstItem)).toBe(false);
});

it('should stop dragging an item if it is removed', () => {
const firstItem = testComponent.dragItems.first;

registry.startDragging(firstItem, createMouseEvent('mousedown'));
expect(registry.isDragging(firstItem)).toBe(true);

registry.remove(firstItem);
expect(registry.isDragging(firstItem)).toBe(false);
});

it('should dispatch `mousemove` events after starting to drag via the mouse', () => {
const spy = jasmine.createSpy('pointerMove spy');
const subscription = registry.pointerMove.subscribe(spy);

registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
dispatchMouseEvent(document, 'mousemove');

expect(spy).toHaveBeenCalled();

subscription.unsubscribe();
});

it('should dispatch `touchmove` events after starting to drag via touch', () => {
const spy = jasmine.createSpy('pointerMove spy');
const subscription = registry.pointerMove.subscribe(spy);

registry.startDragging(testComponent.dragItems.first,
createTouchEvent('touchstart') as TouchEvent);
dispatchTouchEvent(document, 'touchmove');

expect(spy).toHaveBeenCalled();

subscription.unsubscribe();
});

it('should dispatch `mouseup` events after ending the drag via the mouse', () => {
const spy = jasmine.createSpy('pointerUp spy');
const subscription = registry.pointerUp.subscribe(spy);

registry.startDragging(testComponent.dragItems.first, createMouseEvent('mousedown'));
dispatchMouseEvent(document, 'mouseup');

expect(spy).toHaveBeenCalled();

subscription.unsubscribe();
});

it('should dispatch `touchend` events after ending the drag via touch', () => {
const spy = jasmine.createSpy('pointerUp spy');
const subscription = registry.pointerUp.subscribe(spy);

registry.startDragging(testComponent.dragItems.first,
createTouchEvent('touchstart') as TouchEvent);
dispatchTouchEvent(document, 'touchend');

expect(spy).toHaveBeenCalled();

subscription.unsubscribe();
});

it('should complete the pointer event streams on destroy', () => {
const pointerUpSpy = jasmine.createSpy('pointerUp complete spy');
const pointerMoveSpy = jasmine.createSpy('pointerMove complete spy');
const pointerUpSubscription = registry.pointerUp.subscribe(undefined, undefined, pointerUpSpy);
const pointerMoveSubscription =
registry.pointerMove.subscribe(undefined, undefined, pointerMoveSpy);

registry.ngOnDestroy();

expect(pointerUpSpy).toHaveBeenCalled();
expect(pointerMoveSpy).toHaveBeenCalled();

pointerUpSubscription.unsubscribe();
pointerMoveSubscription.unsubscribe();
});

it('should not throw when trying to register the same container again', () => {
expect(() => registry.register(testComponent.dropInstances.first)).not.toThrow();
});

it('should throw when trying to register a different container with the same id', () => {
expect(() => {
testComponent.showDuplicateContainer = true;
fixture.detectChanges();
}).toThrowError(/Drop instance with id \"items\" has already been registered/);
});

it('should be able to get a drop container by its id', () => {
expect(registry.getDropContainer('items')).toBe(testComponent.dropInstances.first);
expect(registry.getDropContainer('does-not-exist')).toBeFalsy();
});

it('should not prevent the default `touchmove` actions when nothing is being dragged', () => {
expect(dispatchTouchEvent(document, 'touchmove').defaultPrevented).toBe(false);
});

it('should prevent the default `touchmove` action when an item is being dragged', () => {
registry.startDragging(testComponent.dragItems.first,
createTouchEvent('touchstart') as TouchEvent);
expect(dispatchTouchEvent(document, 'touchmove').defaultPrevented).toBe(true);
});

});

@Component({
template: `
<cdk-drop id="items" [data]="items">
<div *ngFor="let item of items" cdkDrag>{{item}}</div>
</cdk-drop>

<cdk-drop id="items" *ngIf="showDuplicateContainer"></cdk-drop>
`
})
export class SimpleDropZone {
@ViewChildren(CdkDrag) dragItems: QueryList<CdkDrag>;
@ViewChildren(CdkDrop) dropInstances: QueryList<CdkDrop>;
items = ['Zero', 'One', 'Two', 'Three'];
showDuplicateContainer = false;
}
183 changes: 183 additions & 0 deletions src/cdk-experimental/drag-drop/drag-drop-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Injectable, NgZone, OnDestroy, Inject} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {supportsPassiveEventListeners} from '@angular/cdk/platform';
import {Subject} from 'rxjs';
import {CdkDrop} from './drop';
import {CdkDrag} from './drag';

/** Event options that can be used to bind an active event. */
const activeEventOptions = supportsPassiveEventListeners() ? {passive: false} : false;

/** Handler for a pointer event callback. */
type PointerEventHandler = (event: TouchEvent | MouseEvent) => void;

/**
* Service that keeps track of all the `CdkDrag` and `CdkDrop` instances, and
* manages global event listeners on the `document`.
* @docs-private
*/
@Injectable({providedIn: 'root'})
export class CdkDragDropRegistry implements OnDestroy {
private _document: Document;

/** Registered `CdkDrop` instances. */
private _dropInstances = new Set<CdkDrop>();

/** Registered `CdkDrag` instances. */
private _dragInstances = new Set<CdkDrag>();

/** `CdkDrag` instances that are currently being dragged. */
private _activeDragInstances = new Set<CdkDrag>();

/** Keeps track of the event listeners that we've bound to the `document`. */
private _globalListeners = new Map<string, {handler: PointerEventHandler, options?: any}>();

/**
* Emits the `touchmove` or `mousemove` events that are dispatched
* while the user is dragging a `CdkDrag` instance.
*/
readonly pointerMove: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();

/**
* Emits the `touchend` or `mouseup` events that are dispatched
* while the user is dragging a `CdkDrag` instance.
*/
readonly pointerUp: Subject<TouchEvent | MouseEvent> = new Subject<TouchEvent | MouseEvent>();

constructor(
private _ngZone: NgZone,
@Inject(DOCUMENT) _document: any) {
this._document = _document;
}

/** Adds a `CdkDrop` instance to the registry. */
register(drop: CdkDrop);

/** Adds a `CdkDrag` instance to the registry. */
register(drag: CdkDrag);

register(instance: CdkDrop | CdkDrag) {
if (instance instanceof CdkDrop) {
if (!this._dropInstances.has(instance)) {
if (this.getDropContainer(instance.id)) {
throw Error(`Drop instance with id "${instance.id}" has already been registered.`);
}

this._dropInstances.add(instance);
}
} else {
this._dragInstances.add(instance);

if (this._dragInstances.size === 1) {
this._ngZone.runOutsideAngular(() => {
// The event handler has to be explicitly active, because
// newer browsers make it passive by default.
this._document.addEventListener('touchmove', this._preventScrollListener,
activeEventOptions);
});
}
}
}

/** Removes a `CdkDrop` instance from the registry. */
remove(drop: CdkDrop);

/** Removes a `CdkDrag` instance from the registry. */
remove(drag: CdkDrag);

remove(instance: CdkDrop | CdkDrag) {
if (instance instanceof CdkDrop) {
this._dropInstances.delete(instance);
} else {
this._dragInstances.delete(instance);
this.stopDragging(instance);

if (this._dragInstances.size === 0) {
this._document.removeEventListener('touchmove', this._preventScrollListener,
activeEventOptions as any);
}
}
}

/**
* Starts the dragging sequence for a drag instance.
* @param drag Drag instance which is being dragged.
* @param event Event that initiated the dragging.
*/
startDragging(drag: CdkDrag, event: TouchEvent | MouseEvent) {
this._activeDragInstances.add(drag);

if (this._activeDragInstances.size === 1) {
const isTouchEvent = event.type.startsWith('touch');
const moveEvent = isTouchEvent ? 'touchmove' : 'mousemove';
const upEvent = isTouchEvent ? 'touchend' : 'mouseup';

// We explicitly bind __active__ listeners here, because newer browsers will default to
// passive ones for `mousemove` and `touchmove`. The events need to be active, because we
// use `preventDefault` to prevent the page from scrolling while the user is dragging.
this._globalListeners
.set(moveEvent, {handler: e => this.pointerMove.next(e), options: activeEventOptions})
.set(upEvent, {handler: e => this.pointerUp.next(e)})
.forEach((config, name) => {
this._ngZone.runOutsideAngular(() => {
this._document.addEventListener(name, config.handler, config.options);
});
});
}
}

/** Stops dragging a `CdkDrag` instance. */
stopDragging(drag: CdkDrag) {
this._activeDragInstances.delete(drag);

if (this._activeDragInstances.size === 0) {
this._clearGlobalListeners();
}
}

/** Gets whether a `CdkDrag` instance is currently being dragged. */
isDragging(drag: CdkDrag) {
return this._activeDragInstances.has(drag);
}

/** Gets a `CdkDrop` instance by its id. */
getDropContainer<T = any>(id: string): CdkDrop<T> | undefined {
return Array.from(this._dropInstances).find(instance => instance.id === id);
}

ngOnDestroy() {
this._dragInstances.forEach(instance => this.remove(instance));
this._dropInstances.forEach(instance => this.remove(instance));
this._clearGlobalListeners();
this.pointerMove.complete();
this.pointerUp.complete();
}

/**
* Listener used to prevent `touchmove` events while the element is being dragged.
* This gets bound once, ahead of time, because WebKit won't preventDefault on a
* dynamically-added `touchmove` listener. See https://bugs.webkit.org/show_bug.cgi?id=184250.
*/
private _preventScrollListener = (event: TouchEvent) => {
if (this._activeDragInstances.size) {
event.preventDefault();
}
}

/** Clears out the global event listeners from the `document`. */
private _clearGlobalListeners() {
this._globalListeners.forEach((config, name) => {
this._document.removeEventListener(name, config.handler, config.options);
});

this._globalListeners.clear();
}
}
Loading