projects/angular-draggable-droppable/src/lib/draggable.directive.ts
Selector | [mwlDraggable] |
Inputs |
Outputs |
autoScroll | |
Type : literal type
|
|
Default value : {
margin: 20,
}
|
|
dragActiveClass | |
Type : string
|
|
The css class to apply when the element is being dragged |
dragAxis | |
Type : DragAxis
|
|
Default value : { x: true, y: true }
|
|
The axis along which the element is draggable |
dragCursor | |
Type : string
|
|
Default value : ''
|
|
The cursor to use when hovering over a draggable element |
dragSnapGrid | |
Type : SnapGrid
|
|
Default value : {}
|
|
Snap all drags to an x / y grid |
dropData | |
Type : any
|
|
an object of data you can pass to the drop event |
ghostDragEnabled | |
Type : boolean
|
|
Default value : true
|
|
Show a ghost element that shows the drag when dragging |
ghostElementAppendTo | |
Type : HTMLElement
|
|
The element the ghost element will be appended to. Default is next to the dragged element |
ghostElementTemplate | |
Type : TemplateRef<any>
|
|
An ng-template to be inserted into the parent element of the ghost element. It will overwrite any child nodes. |
showOriginalElementWhileDragging | |
Type : boolean
|
|
Default value : false
|
|
Show the original element when ghostDragEnabled is true |
touchStartLongPress | |
Type : literal type
|
|
Amount of milliseconds to wait on touch devices before starting to drag the element (so that you can scroll the page by touching a draggable element) |
validateDrag | |
Type : ValidateDrag
|
|
Allow custom behaviour to control when the element is dragged |
dragEnd | |
Type : EventEmitter
|
|
Called after the element is dragged |
dragging | |
Type : EventEmitter
|
|
Called when the element is being dragged |
dragPointerDown | |
Type : EventEmitter
|
|
Called when the element can be dragged along one axis and has the mouse or pointer device pressed on it |
dragStart | |
Type : EventEmitter
|
|
Called when the element has started to be dragged. Only called after at least one mouse or touch move event. If you call $event.cancelDrag$.emit() it will cancel the current drag |
ghostElementCreated | |
Type : EventEmitter
|
|
Called after the ghost element has been created |
import {
Directive,
OnInit,
ElementRef,
Renderer2,
Output,
EventEmitter,
Input,
OnDestroy,
OnChanges,
NgZone,
SimpleChanges,
Inject,
TemplateRef,
ViewContainerRef,
Optional,
} from '@angular/core';
import {
Subject,
Observable,
merge,
ReplaySubject,
combineLatest,
fromEvent,
} from 'rxjs';
import {
map,
mergeMap,
takeUntil,
take,
takeLast,
pairwise,
share,
filter,
count,
startWith,
} from 'rxjs/operators';
import { CurrentDragData, DraggableHelper } from './draggable-helper.provider';
import { DOCUMENT } from '@angular/common';
import autoScroll from '@mattlewis92/dom-autoscroller';
import { DraggableScrollContainerDirective } from './draggable-scroll-container.directive';
import { addClass, removeClass } from './util';
export interface Coordinates {
x: number;
y: number;
}
export interface DragAxis {
x: boolean;
y: boolean;
}
export interface SnapGrid {
x?: number;
y?: number;
}
export interface DragPointerDownEvent extends Coordinates {}
export interface DragStartEvent {
cancelDrag$: ReplaySubject<void>;
}
export interface DragMoveEvent extends Coordinates {}
export interface DragEndEvent extends Coordinates {
dragCancelled: boolean;
}
export interface ValidateDragParams extends Coordinates {
transform: {
x: number;
y: number;
};
}
export type ValidateDrag = (params: ValidateDragParams) => boolean;
export interface PointerEvent {
clientX: number;
clientY: number;
event: MouseEvent | TouchEvent;
}
export interface TimeLongPress {
timerBegin: number;
timerEnd: number;
}
export interface GhostElementCreatedEvent {
clientX: number;
clientY: number;
element: HTMLElement;
}
@Directive({
selector: '[mwlDraggable]',
})
export class DraggableDirective implements OnInit, OnChanges, OnDestroy {
/**
* an object of data you can pass to the drop event
*/
@Input() dropData: any;
/**
* The axis along which the element is draggable
*/
@Input() dragAxis: DragAxis = { x: true, y: true };
/**
* Snap all drags to an x / y grid
*/
@Input() dragSnapGrid: SnapGrid = {};
/**
* Show a ghost element that shows the drag when dragging
*/
@Input() ghostDragEnabled: boolean = true;
/**
* Show the original element when ghostDragEnabled is true
*/
@Input() showOriginalElementWhileDragging: boolean = false;
/**
* Allow custom behaviour to control when the element is dragged
*/
@Input() validateDrag: ValidateDrag;
/**
* The cursor to use when hovering over a draggable element
*/
@Input() dragCursor: string = '';
/**
* The css class to apply when the element is being dragged
*/
@Input() dragActiveClass: string;
/**
* The element the ghost element will be appended to. Default is next to the dragged element
*/
@Input() ghostElementAppendTo: HTMLElement;
/**
* An ng-template to be inserted into the parent element of the ghost element. It will overwrite any child nodes.
*/
@Input() ghostElementTemplate: TemplateRef<any>;
/**
* Amount of milliseconds to wait on touch devices before starting to drag the element (so that you can scroll the page by touching a draggable element)
*/
@Input() touchStartLongPress: { delay: number; delta: number };
/*
* Options used to control the behaviour of auto scrolling: https://www.npmjs.com/package/dom-autoscroller
*/
@Input() autoScroll: {
margin:
| number
| { top?: number; left?: number; right?: number; bottom?: number };
maxSpeed?:
| number
| { top?: number; left?: number; right?: number; bottom?: number };
scrollWhenOutside?: boolean;
} = {
margin: 20,
};
/**
* Called when the element can be dragged along one axis and has the mouse or pointer device pressed on it
*/
@Output() dragPointerDown = new EventEmitter<DragPointerDownEvent>();
/**
* Called when the element has started to be dragged.
* Only called after at least one mouse or touch move event.
* If you call $event.cancelDrag$.emit() it will cancel the current drag
*/
@Output() dragStart = new EventEmitter<DragStartEvent>();
/**
* Called after the ghost element has been created
*/
@Output() ghostElementCreated = new EventEmitter<GhostElementCreatedEvent>();
/**
* Called when the element is being dragged
*/
@Output() dragging = new EventEmitter<DragMoveEvent>();
/**
* Called after the element is dragged
*/
@Output() dragEnd = new EventEmitter<DragEndEvent>();
/**
* @hidden
*/
pointerDown$ = new Subject<PointerEvent>();
/**
* @hidden
*/
pointerMove$ = new Subject<PointerEvent>();
/**
* @hidden
*/
pointerUp$ = new Subject<PointerEvent>();
private eventListenerSubscriptions: {
mousemove?: () => void;
mousedown?: () => void;
mouseup?: () => void;
mouseenter?: () => void;
mouseleave?: () => void;
touchstart?: () => void;
touchmove?: () => void;
touchend?: () => void;
touchcancel?: () => void;
} = {};
private ghostElement: HTMLElement | null;
private destroy$ = new Subject<void>();
private timeLongPress: TimeLongPress = { timerBegin: 0, timerEnd: 0 };
private scroller: { destroy: () => void };
/**
* @hidden
*/
constructor(
private element: ElementRef<HTMLElement>,
private renderer: Renderer2,
private draggableHelper: DraggableHelper,
private zone: NgZone,
private vcr: ViewContainerRef,
@Optional() private scrollContainer: DraggableScrollContainerDirective,
@Inject(DOCUMENT) private document: any
) {}
ngOnInit(): void {
this.checkEventListeners();
const pointerDragged$: Observable<any> = this.pointerDown$.pipe(
filter(() => this.canDrag()),
mergeMap((pointerDownEvent: PointerEvent) => {
// fix for https://github.com/mattlewis92/angular-draggable-droppable/issues/61
// stop mouse events propagating up the chain
if (pointerDownEvent.event.stopPropagation && !this.scrollContainer) {
pointerDownEvent.event.stopPropagation();
}
// hack to prevent text getting selected in safari while dragging
const globalDragStyle: HTMLStyleElement =
this.renderer.createElement('style');
this.renderer.setAttribute(globalDragStyle, 'type', 'text/css');
this.renderer.appendChild(
globalDragStyle,
this.renderer.createText(`
body * {
-moz-user-select: none;
-ms-user-select: none;
-webkit-user-select: none;
user-select: none;
}
`)
);
requestAnimationFrame(() => {
this.document.head.appendChild(globalDragStyle);
});
const startScrollPosition = this.getScrollPosition();
const scrollContainerScroll$ = new Observable((observer) => {
const scrollContainer = this.scrollContainer
? this.scrollContainer.elementRef.nativeElement
: 'window';
return this.renderer.listen(scrollContainer, 'scroll', (e) =>
observer.next(e)
);
}).pipe(
startWith(startScrollPosition),
map(() => this.getScrollPosition())
);
const currentDrag$ = new Subject<CurrentDragData>();
const cancelDrag$ = new ReplaySubject<void>();
if (this.dragPointerDown.observers.length > 0) {
this.zone.run(() => {
this.dragPointerDown.next({ x: 0, y: 0 });
});
}
const dragComplete$ = merge(
this.pointerUp$,
this.pointerDown$,
cancelDrag$,
this.destroy$
).pipe(share());
const pointerMove = combineLatest([
this.pointerMove$,
scrollContainerScroll$,
]).pipe(
map(([pointerMoveEvent, scroll]) => {
return {
currentDrag$,
transformX: pointerMoveEvent.clientX - pointerDownEvent.clientX,
transformY: pointerMoveEvent.clientY - pointerDownEvent.clientY,
clientX: pointerMoveEvent.clientX,
clientY: pointerMoveEvent.clientY,
scrollLeft: scroll.left,
scrollTop: scroll.top,
target: pointerMoveEvent.event.target,
};
}),
map((moveData) => {
if (this.dragSnapGrid.x) {
moveData.transformX =
Math.round(moveData.transformX / this.dragSnapGrid.x) *
this.dragSnapGrid.x;
}
if (this.dragSnapGrid.y) {
moveData.transformY =
Math.round(moveData.transformY / this.dragSnapGrid.y) *
this.dragSnapGrid.y;
}
return moveData;
}),
map((moveData) => {
if (!this.dragAxis.x) {
moveData.transformX = 0;
}
if (!this.dragAxis.y) {
moveData.transformY = 0;
}
return moveData;
}),
map((moveData) => {
const scrollX = moveData.scrollLeft - startScrollPosition.left;
const scrollY = moveData.scrollTop - startScrollPosition.top;
return {
...moveData,
x: moveData.transformX + scrollX,
y: moveData.transformY + scrollY,
};
}),
filter(
({ x, y, transformX, transformY }) =>
!this.validateDrag ||
this.validateDrag({
x,
y,
transform: { x: transformX, y: transformY },
})
),
takeUntil(dragComplete$),
share()
);
const dragStarted$ = pointerMove.pipe(take(1), share());
const dragEnded$ = pointerMove.pipe(takeLast(1), share());
dragStarted$.subscribe(({ clientX, clientY, x, y }) => {
if (this.dragStart.observers.length > 0) {
this.zone.run(() => {
this.dragStart.next({ cancelDrag$ });
});
}
this.scroller = autoScroll(
[
this.scrollContainer
? this.scrollContainer.elementRef.nativeElement
: this.document.defaultView,
],
{
...this.autoScroll,
autoScroll() {
return true;
},
}
);
addClass(this.renderer, this.element, this.dragActiveClass);
if (this.ghostDragEnabled) {
const rect = this.element.nativeElement.getBoundingClientRect();
const clone = this.element.nativeElement.cloneNode(
true
) as HTMLElement;
if (!this.showOriginalElementWhileDragging) {
this.renderer.setStyle(
this.element.nativeElement,
'visibility',
'hidden'
);
}
if (this.ghostElementAppendTo) {
this.ghostElementAppendTo.appendChild(clone);
} else {
this.element.nativeElement.parentNode!.insertBefore(
clone,
this.element.nativeElement.nextSibling
);
}
this.ghostElement = clone;
this.document.body.style.cursor = this.dragCursor;
this.setElementStyles(clone, {
position: 'fixed',
top: `${rect.top}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
height: `${rect.height}px`,
cursor: this.dragCursor,
margin: '0',
willChange: 'transform',
pointerEvents: 'none',
});
if (this.ghostElementTemplate) {
const viewRef = this.vcr.createEmbeddedView(
this.ghostElementTemplate
);
clone.innerHTML = '';
viewRef.rootNodes
.filter((node) => node instanceof Node)
.forEach((node) => {
clone.appendChild(node);
});
dragEnded$.subscribe(() => {
this.vcr.remove(this.vcr.indexOf(viewRef));
});
}
if (this.ghostElementCreated.observers.length > 0) {
this.zone.run(() => {
this.ghostElementCreated.emit({
clientX: clientX - x,
clientY: clientY - y,
element: clone,
});
});
}
dragEnded$.subscribe(() => {
clone.parentElement!.removeChild(clone);
this.ghostElement = null;
this.renderer.setStyle(
this.element.nativeElement,
'visibility',
''
);
});
}
this.draggableHelper.currentDrag.next(currentDrag$);
});
dragEnded$
.pipe(
mergeMap((dragEndData) => {
const dragEndData$ = cancelDrag$.pipe(
count(),
take(1),
map((calledCount) => ({
...dragEndData,
dragCancelled: calledCount > 0,
}))
);
cancelDrag$.complete();
return dragEndData$;
})
)
.subscribe(({ x, y, dragCancelled }) => {
this.scroller.destroy();
if (this.dragEnd.observers.length > 0) {
this.zone.run(() => {
this.dragEnd.next({ x, y, dragCancelled });
});
}
removeClass(this.renderer, this.element, this.dragActiveClass);
currentDrag$.complete();
});
merge(dragComplete$, dragEnded$)
.pipe(take(1))
.subscribe(() => {
requestAnimationFrame(() => {
this.document.head.removeChild(globalDragStyle);
});
});
return pointerMove;
}),
share()
);
merge(
pointerDragged$.pipe(
take(1),
map((value) => [, value])
),
pointerDragged$.pipe(pairwise())
)
.pipe(
filter(([previous, next]) => {
if (!previous) {
return true;
}
return previous.x !== next.x || previous.y !== next.y;
}),
map(([previous, next]) => next)
)
.subscribe(
({
x,
y,
currentDrag$,
clientX,
clientY,
transformX,
transformY,
target,
}) => {
if (this.dragging.observers.length > 0) {
this.zone.run(() => {
this.dragging.next({ x, y });
});
}
requestAnimationFrame(() => {
if (this.ghostElement) {
const transform = `translate3d(${transformX}px, ${transformY}px, 0px)`;
this.setElementStyles(this.ghostElement, {
transform,
'-webkit-transform': transform,
'-ms-transform': transform,
'-moz-transform': transform,
'-o-transform': transform,
});
}
});
currentDrag$.next({
clientX,
clientY,
dropData: this.dropData,
target,
});
}
);
}
ngOnChanges(changes: SimpleChanges): void {
if (changes.dragAxis) {
this.checkEventListeners();
}
}
ngOnDestroy(): void {
this.unsubscribeEventListeners();
this.pointerDown$.complete();
this.pointerMove$.complete();
this.pointerUp$.complete();
this.destroy$.next();
}
private checkEventListeners(): void {
const canDrag: boolean = this.canDrag();
const hasEventListeners: boolean =
Object.keys(this.eventListenerSubscriptions).length > 0;
if (canDrag && !hasEventListeners) {
this.zone.runOutsideAngular(() => {
this.eventListenerSubscriptions.mousedown = this.renderer.listen(
this.element.nativeElement,
'mousedown',
(event: MouseEvent) => {
this.onMouseDown(event);
}
);
this.eventListenerSubscriptions.mouseup = this.renderer.listen(
'document',
'mouseup',
(event: MouseEvent) => {
this.onMouseUp(event);
}
);
this.eventListenerSubscriptions.touchstart = this.renderer.listen(
this.element.nativeElement,
'touchstart',
(event: TouchEvent) => {
this.onTouchStart(event);
}
);
this.eventListenerSubscriptions.touchend = this.renderer.listen(
'document',
'touchend',
(event: TouchEvent) => {
this.onTouchEnd(event);
}
);
this.eventListenerSubscriptions.touchcancel = this.renderer.listen(
'document',
'touchcancel',
(event: TouchEvent) => {
this.onTouchEnd(event);
}
);
this.eventListenerSubscriptions.mouseenter = this.renderer.listen(
this.element.nativeElement,
'mouseenter',
() => {
this.onMouseEnter();
}
);
this.eventListenerSubscriptions.mouseleave = this.renderer.listen(
this.element.nativeElement,
'mouseleave',
() => {
this.onMouseLeave();
}
);
});
} else if (!canDrag && hasEventListeners) {
this.unsubscribeEventListeners();
}
}
private onMouseDown(event: MouseEvent): void {
if (event.button === 0) {
if (!this.eventListenerSubscriptions.mousemove) {
this.eventListenerSubscriptions.mousemove = this.renderer.listen(
'document',
'mousemove',
(mouseMoveEvent: MouseEvent) => {
this.pointerMove$.next({
event: mouseMoveEvent,
clientX: mouseMoveEvent.clientX,
clientY: mouseMoveEvent.clientY,
});
}
);
}
this.pointerDown$.next({
event,
clientX: event.clientX,
clientY: event.clientY,
});
}
}
private onMouseUp(event: MouseEvent): void {
if (event.button === 0) {
if (this.eventListenerSubscriptions.mousemove) {
this.eventListenerSubscriptions.mousemove();
delete this.eventListenerSubscriptions.mousemove;
}
this.pointerUp$.next({
event,
clientX: event.clientX,
clientY: event.clientY,
});
}
}
private onTouchStart(event: TouchEvent): void {
let startScrollPosition: any;
let isDragActivated: boolean;
let hasContainerScrollbar: boolean;
if (this.touchStartLongPress) {
this.timeLongPress.timerBegin = Date.now();
isDragActivated = false;
hasContainerScrollbar = this.hasScrollbar();
startScrollPosition = this.getScrollPosition();
}
if (!this.eventListenerSubscriptions.touchmove) {
const contextMenuListener = fromEvent<Event>(
this.document,
'contextmenu'
).subscribe((e) => {
e.preventDefault();
});
const touchMoveListener = fromEvent<TouchEvent>(
this.document,
'touchmove',
{
passive: false,
}
).subscribe((touchMoveEvent) => {
if (
this.touchStartLongPress &&
!isDragActivated &&
hasContainerScrollbar
) {
isDragActivated = this.shouldBeginDrag(
event,
touchMoveEvent,
startScrollPosition
);
}
if (
!this.touchStartLongPress ||
!hasContainerScrollbar ||
isDragActivated
) {
touchMoveEvent.preventDefault();
this.pointerMove$.next({
event: touchMoveEvent,
clientX: touchMoveEvent.targetTouches[0].clientX,
clientY: touchMoveEvent.targetTouches[0].clientY,
});
}
});
this.eventListenerSubscriptions.touchmove = () => {
contextMenuListener.unsubscribe();
touchMoveListener.unsubscribe();
};
}
this.pointerDown$.next({
event,
clientX: event.touches[0].clientX,
clientY: event.touches[0].clientY,
});
}
private onTouchEnd(event: TouchEvent): void {
if (this.eventListenerSubscriptions.touchmove) {
this.eventListenerSubscriptions.touchmove();
delete this.eventListenerSubscriptions.touchmove;
if (this.touchStartLongPress) {
this.enableScroll();
}
}
this.pointerUp$.next({
event,
clientX: event.changedTouches[0].clientX,
clientY: event.changedTouches[0].clientY,
});
}
private onMouseEnter(): void {
this.setCursor(this.dragCursor);
}
private onMouseLeave(): void {
this.setCursor('');
}
private canDrag(): boolean {
return this.dragAxis.x || this.dragAxis.y;
}
private setCursor(value: string): void {
if (!this.eventListenerSubscriptions.mousemove) {
this.renderer.setStyle(this.element.nativeElement, 'cursor', value);
}
}
private unsubscribeEventListeners(): void {
Object.keys(this.eventListenerSubscriptions).forEach((type) => {
(this as any).eventListenerSubscriptions[type]();
delete (this as any).eventListenerSubscriptions[type];
});
}
private setElementStyles(
element: HTMLElement,
styles: { [key: string]: string }
) {
Object.keys(styles).forEach((key) => {
this.renderer.setStyle(element, key, styles[key]);
});
}
private getScrollElement() {
if (this.scrollContainer) {
return this.scrollContainer.elementRef.nativeElement;
} else {
return this.document.body;
}
}
private getScrollPosition() {
if (this.scrollContainer) {
return {
top: this.scrollContainer.elementRef.nativeElement.scrollTop,
left: this.scrollContainer.elementRef.nativeElement.scrollLeft,
};
} else {
return {
top: window.pageYOffset || this.document.documentElement.scrollTop,
left: window.pageXOffset || this.document.documentElement.scrollLeft,
};
}
}
private shouldBeginDrag(
event: TouchEvent,
touchMoveEvent: TouchEvent,
startScrollPosition: { top: number; left: number }
): boolean {
const moveScrollPosition = this.getScrollPosition();
const deltaScroll = {
top: Math.abs(moveScrollPosition.top - startScrollPosition.top),
left: Math.abs(moveScrollPosition.left - startScrollPosition.left),
};
const deltaX =
Math.abs(
touchMoveEvent.targetTouches[0].clientX - event.touches[0].clientX
) - deltaScroll.left;
const deltaY =
Math.abs(
touchMoveEvent.targetTouches[0].clientY - event.touches[0].clientY
) - deltaScroll.top;
const deltaTotal = deltaX + deltaY;
const longPressConfig = this.touchStartLongPress;
if (
deltaTotal > longPressConfig.delta ||
deltaScroll.top > 0 ||
deltaScroll.left > 0
) {
this.timeLongPress.timerBegin = Date.now();
}
this.timeLongPress.timerEnd = Date.now();
const duration =
this.timeLongPress.timerEnd - this.timeLongPress.timerBegin;
if (duration >= longPressConfig.delay) {
this.disableScroll();
return true;
}
return false;
}
private enableScroll() {
if (this.scrollContainer) {
this.renderer.setStyle(
this.scrollContainer.elementRef.nativeElement,
'overflow',
''
);
}
this.renderer.setStyle(this.document.body, 'overflow', '');
}
private disableScroll() {
/* istanbul ignore next */
if (this.scrollContainer) {
this.renderer.setStyle(
this.scrollContainer.elementRef.nativeElement,
'overflow',
'hidden'
);
}
this.renderer.setStyle(this.document.body, 'overflow', 'hidden');
}
private hasScrollbar(): boolean {
const scrollContainer = this.getScrollElement();
const containerHasHorizontalScroll =
scrollContainer.scrollWidth > scrollContainer.clientWidth;
const containerHasVerticalScroll =
scrollContainer.scrollHeight > scrollContainer.clientHeight;
return containerHasHorizontalScroll || containerHasVerticalScroll;
}
}