ng-reorder
Version:
Sort elements within a list by using drag-n-drop interface without any restrictions by direction
581 lines (566 loc) • 20 kB
JavaScript
import { Injectable, Inject, NgZone, InjectionToken, Directive, SkipSelf, ElementRef, EventEmitter, Input, Output, ContentChildren, Self, NgModule } from '@angular/core';
import { Subject, Subscription } from 'rxjs';
import { DOCUMENT } from '@angular/common';
import { first, takeUntil, tap } from 'rxjs/operators';
class EventService {
constructor(_document, _zone) {
this._document = _document;
this._zone = _zone;
this.move = new Subject();
this.up = new Subject();
this.scroll = new Subject();
this._globalListeners = new Map();
}
applyGlobalListeners(event) {
const isMouse = event.type.startsWith('mouse');
const moveEvent = isMouse ? 'mousemove' : 'touchmove';
const endEvent = isMouse ? 'mouseup' : 'touchend';
this._globalListeners
.set('scroll', {
func: (e) => {
this.scroll.next(e);
},
options: true
})
.set('selectstart', {
func: (e) => {
e.preventDefault();
},
options: false
})
.set(moveEvent, {
func: (e) => {
this.move.next(e);
e.preventDefault();
},
options: { passive: false }
})
.set(endEvent, {
func: e => {
this.up.next(e);
},
options: true
});
this._zone.runOutsideAngular(() => {
this._globalListeners.forEach((handler, e) => {
this._document.addEventListener(e, handler.func, handler.options);
});
});
}
removeGlobalListeners() {
this._zone.runOutsideAngular(() => {
this._globalListeners.forEach((handler, event) => {
this._document.removeEventListener(event, handler.func, handler.options);
});
});
}
}
EventService.decorators = [
{ type: Injectable }
];
EventService.ctorParameters = () => [
{ type: HTMLElement, decorators: [{ type: Inject, args: [DOCUMENT,] }] },
{ type: NgZone }
];
/**
* @param array array to reorder
* @param begin the index which is moving in the array
* @param end the index where the array[begin] is moving to
*/
function reorderItems(array, begin, end) {
if (array.length === 0) {
return;
}
begin = fit(begin, array.length - 1);
end = fit(end, array.length - 1);
if (begin === end || end === -1 || begin === -1) {
return array;
}
const shift = begin < end ? 1 : -1;
const anchor = array[begin];
for (let i = begin; i !== end; i += shift) {
array[i] = array[i + shift];
}
array[end] = anchor;
return array;
}
/**
* @param array array to reorder
* @param a the index of first element
* @param b the index of second element
*/
function swapItems(array, a, b) {
[array[a], array[b]] = [array[b], array[a]];
return array;
}
/** To ensure to get a number not less than zero and not greater than a given max value
* @param value number to check
* @param max max value
*/
function fit(value, max) {
if (isNaN(value) || value === null) {
return -1;
}
return Math.max(0, Math.min(value, max));
}
/** Whether the event is touch or not */
function isTouchEvent(event) {
return event.type[0] === 't';
}
/** Returns { 0, 0 } point */
function createPoint() {
return { x: 0, y: 0 };
}
/**
* Returns the sum of two points
* @param first first point
* @param second second point
*/
function pointSum(first, second) {
return {
x: first.x + second.x,
y: first.y + second.y
};
}
/**
* Returns the difference between two points
* @param first first point
* @param second second point
*/
function pointDif(first, second) {
return {
x: first.x - second.x,
y: first.y - second.y
};
}
/**
* Returns point from pointer event
* @param event Mouse or Touch event
*/
function pointFromPointerEvent(event) {
const $ = isTouchEvent(event) ? event.targetTouches[0] : event;
return {
x: $.clientX,
y: $.clientY
};
}
/**
* Returns time of a transition of a speciphic property in miliseconds.
* @param element DOM element to check
* @param property required CSS property
* @param includeAll include or not the "all" property. By default true
*/
function transitionTimeOf(element, property, includeAll = true) {
const style = getComputedStyle(element);
const properties = style.transitionProperty.split(',');
// filter for the 'all' property
const target = ($) => {
if (includeAll) {
return $ === property || $ === 'all';
}
else {
return $ === property;
}
};
const foundProperty = properties.find(target);
// If no fouded property returns zero
if (!foundProperty) {
return 0;
}
const index = properties.indexOf(foundProperty);
let delay = style.transitionDelay.split(',')[index];
let duration = style.transitionDuration.split(',')[index];
// Destructuring assignment.
// Next lines check whether the values in ms and return parsed time in ms
[delay, duration] = [delay, duration].map($ => {
const k = $.toLowerCase().indexOf('ms') !== -1 ? 1 : 1000;
return parseFloat($) * k;
});
return delay + duration;
}
const DRAG_UNIT_PARENT = new InjectionToken('Parent');
class DragHandleDirective {
constructor(_parent, _host) {
this._parent = _parent;
this._host = _host;
}
/** @returns true if the hanlde is equal to an element */
is(element) {
return this._host.nativeElement === element || this._host.nativeElement.contains(element);
}
}
DragHandleDirective.decorators = [
{ type: Directive, args: [{
selector: '[dragHandle]'
},] }
];
DragHandleDirective.ctorParameters = () => [
{ type: undefined, decorators: [{ type: Inject, args: [DRAG_UNIT_PARENT,] }, { type: SkipSelf }] },
{ type: ElementRef }
];
class DragRejectorDirective {
constructor(_parent, _host) {
this._parent = _parent;
this._host = _host;
}
/** @returns true if the rejector is equal to an element */
is(element) {
return this._host.nativeElement === element || this._host.nativeElement.contains(element);
}
}
DragRejectorDirective.decorators = [
{ type: Directive, args: [{
selector: '[dragRejector]'
},] }
];
DragRejectorDirective.ctorParameters = () => [
{ type: undefined, decorators: [{ type: Inject, args: [DRAG_UNIT_PARENT,] }, { type: SkipSelf }] },
{ type: ElementRef }
];
const DRAG_COLLECTION = new InjectionToken('Container');
class DragUnitDirective {
constructor(container, eventService, _host, _zone) {
this.container = container;
this.eventService = eventService;
this._host = _host;
this._zone = _zone;
/** Emits when the element is successfully touched */
this.unitTaken = new EventEmitter();
/** Emits when the element is released */
this.unitReleased = new EventEmitter();
/** Emits when the element is moved on the page */
this.unitMoved = new EventEmitter();
// Indicate if the element is dragging or not
this._active = false;
// Indicate if the element is dropped
this._droppped = false;
/** Emits when the element is destroyed */
this._destroy = new Subject();
this._moveSubscribtion = Subscription.EMPTY;
this._upSubscription = Subscription.EMPTY;
this._scrollSubscription = Subscription.EMPTY;
this._origin = this._offset = this._scrollOrigin = this._scrollOffset = createPoint();
}
get active() {
return this._active;
}
get dropped() {
return this._droppped;
}
get disabled() {
return this._disabled;
}
ngAfterViewInit() {
['mousedown', 'touchstart'].forEach((e) => {
this._host.nativeElement.addEventListener(e, this._pointerDown.bind(this), { passive: false, capture: true });
});
}
ngOnDestroy() {
this._destroy.next();
this._destroy.complete();
}
applyTransformation(shift) {
if (shift.x === 0 && shift.y === 0) {
this._host.nativeElement.style.transform = '';
return;
}
this._host.nativeElement.style.transform = `translate(${shift.x}px, ${shift.y}px)`;
}
getRect() {
return this._host.nativeElement.getBoundingClientRect();
}
reset() {
this._host.nativeElement.style.transform = null;
this._active = false;
this._droppped = false;
this._offset = this._origin = this._scrollOrigin = this._scrollOffset = this._pointerPosition = { x: 0, y: 0 };
}
setOffset(point) {
this._offset = point;
}
_animateDroppedElement() {
return new Promise((resolve, reject) => {
this._zone.onStable.asObservable().pipe(first()).subscribe(resolve);
});
}
_initDragSequence(event) {
this.eventService.applyGlobalListeners(event);
this._scrollOrigin = { x: document.defaultView.scrollX, y: document.defaultView.scrollY };
this._moveSubscribtion = this.eventService.move
.pipe(takeUntil(this._destroy), tap((e) => {
this._zone.run(() => this._pointerMove(e));
})).subscribe();
this._upSubscription = this.eventService.up
.pipe(takeUntil(this._destroy), tap((e) => {
this._zone.run(() => this._pointerUp(e));
})).subscribe();
this._scrollSubscription = this.eventService.scroll
.pipe(takeUntil(this._destroy), tap((e) => {
this._zone.run(() => this._viewScroll());
})).subscribe();
return;
}
_viewScroll() {
const view = document.defaultView;
const currentScroll = { x: view.scrollX, y: view.scrollY };
this._scrollOffset = pointDif(currentScroll, this._scrollOrigin);
const point = pointSum(this._pointerPosition, this._scrollOffset);
this.applyTransformation(pointDif(point, this._origin));
}
_startDragSequence(event) {
if (this.container.disabled || this._disabled)
return;
event.stopPropagation();
this._initDragSequence(event);
this._origin = this._pointerPosition = pointFromPointerEvent(event);
this._active = true;
this.container.start(this, this._origin);
this.unitTaken.emit({
unit: this,
event
});
}
_stopDragSequence() {
this._moveSubscribtion.unsubscribe();
this._upSubscription.unsubscribe();
this._scrollSubscription.unsubscribe();
const delay = transitionTimeOf(this._host.nativeElement, 'transform');
setTimeout(() => {
this.container.stop(this);
this.eventService.removeGlobalListeners();
}, delay * 1.5);
}
_pointerDown(event) {
if (this.container.inAction)
return;
const target = event.target;
if (this._rejectors.length && this._rejectors.find(rejector => rejector.is(target)))
return;
else if (this._handles.length && !this._handles.find(handle => handle.is(target)))
return;
else if (true)
this._zone.run(() => this._startDragSequence(event));
return;
}
_pointerMove(event) {
this._pointerPosition = pointFromPointerEvent(event);
const point = pointSum(this._pointerPosition, this._scrollOffset);
this.applyTransformation(pointDif(point, this._origin));
this.container.moveUnitTo(this, point);
this.unitMoved.emit({
unit: this,
distance: pointDif(point, this._origin),
event
});
}
_pointerUp(event) {
this._active = false;
this.applyTransformation(this._offset);
this._droppped = true;
this._animateDroppedElement().then(this._stopDragSequence.bind(this));
this.unitReleased.emit({
unit: this,
event
});
}
}
DragUnitDirective.decorators = [
{ type: Directive, args: [{
selector: '[dragUnit]',
providers: [
{ provide: DRAG_UNIT_PARENT, useExisting: DragUnitDirective }
],
host: {
'[class.drag-unit]': 'true',
'[class.active]': 'active',
'[class.dropped]': 'dropped',
'[class.disabled]': 'disabled'
}
},] }
];
DragUnitDirective.ctorParameters = () => [
{ type: undefined, decorators: [{ type: Inject, args: [DRAG_COLLECTION,] }, { type: SkipSelf }] },
{ type: EventService, decorators: [{ type: SkipSelf }] },
{ type: ElementRef },
{ type: NgZone }
];
DragUnitDirective.propDecorators = {
_disabled: [{ type: Input, args: ['disabled',] }],
unitTaken: [{ type: Output }],
unitReleased: [{ type: Output }],
unitMoved: [{ type: Output }],
_handles: [{ type: ContentChildren, args: [DragHandleDirective, { descendants: true },] }],
_rejectors: [{ type: ContentChildren, args: [DragRejectorDirective, { descendants: true },] }]
};
class SortService {
constructor() {
this._listOfPositions = null;
this._from = null;
this._to = null;
}
cachePosition(unit) {
this._listOfPositions.push(this.getPosition(unit));
}
findIndex(point) {
for (const $ of this._listOfPositions) {
const left = point.x > $.rect.left + $.shift.x;
const right = point.x < $.rect.right + $.shift.x;
const top = point.y > $.rect.top + $.shift.y;
const bottom = point.y < $.rect.bottom + $.shift.y;
if (left && right && top && bottom) {
return this._listOfPositions.indexOf($);
}
}
return -1;
}
getPosition(unit) {
return {
unit,
rect: unit.getRect(),
shift: createPoint()
};
}
initService(container) {
this._root = container;
}
cacheAllPositions() {
this._listOfPositions = new Array();
this._root.units.forEach(unit => this.cachePosition(unit));
}
moveUnits(unit, point) {
const elements = this._listOfPositions;
const oldIndex = elements.map(i => i.unit).indexOf(unit);
const newIndex = this.findIndex(point);
if (oldIndex === -1 || newIndex === -1 || newIndex === oldIndex || this._from === null) {
return;
}
this._to = newIndex;
const newPosition = {
x: elements[newIndex].rect.left + elements[newIndex].shift.x,
y: elements[newIndex].rect.top + elements[newIndex].shift.y
};
const step = newIndex > oldIndex ? -1 : 1;
for (let current = newIndex; current !== oldIndex; current += step) {
const next = current + step;
const shift = {
x: (elements[next].rect.left + elements[next].shift.x) - (elements[current].rect.left + elements[current].shift.x),
y: (elements[next].rect.top + elements[next].shift.y) - (elements[current].rect.top + elements[current].shift.y)
};
elements[current].shift.x += shift.x;
elements[current].shift.y += shift.y;
elements[current].unit.applyTransformation(elements[current].shift);
}
elements[oldIndex].shift.x = (newPosition.x - elements[oldIndex].rect.left);
elements[oldIndex].shift.y = (newPosition.y - elements[oldIndex].rect.top);
unit.setOffset(elements[oldIndex].shift);
this._listOfPositions = elements;
this._listOfPositions = reorderItems(this._listOfPositions, oldIndex, newIndex);
}
start(units, point, from) {
this.cacheAllPositions();
this._from = from;
}
stop(unit) {
return new Promise((resolve) => {
const $ = {
collection: this._root,
unit,
previousIndex: this._from,
currentIndex: this._to
};
resolve($);
this._listOfPositions = null;
this._from = null;
this._to = null;
});
}
}
SortService.decorators = [
{ type: Injectable }
];
SortService.ctorParameters = () => [];
class DragCollectionDirective {
constructor(_zone, sortService) {
this._zone = _zone;
this.sortService = sortService;
this.dropCompleted = new EventEmitter();
this._activeItem = null;
}
get inAction() {
return this._activeItem !== null;
}
get disabled() {
return this._disabled;
}
ngAfterViewInit() {
this.sortService.initService(this);
}
clearChildren() {
this._zone.run(() => {
this.units.toArray().forEach(unit => unit.reset());
});
}
moveUnitTo(unit, point) {
this.sortService.moveUnits(unit, point);
}
start(unit, point) {
const units = this.units.toArray();
const from = units.indexOf(unit);
this.sortService.start(units, point, from);
this._activeItem = unit;
}
stop(unit) {
this.clearChildren();
this.sortService.stop(unit).then((e) => {
if (e.currentIndex !== null)
this.dropCompleted.emit(e);
});
this._activeItem = null;
}
}
DragCollectionDirective.decorators = [
{ type: Directive, args: [{
selector: '[dragCollection]',
providers: [
{ provide: DRAG_COLLECTION, useExisting: DragCollectionDirective },
EventService,
SortService
],
host: {
'[class.collection]': 'true',
'[class.in-action]': 'inAction',
'[class.disabled]': 'disabled'
}
},] }
];
DragCollectionDirective.ctorParameters = () => [
{ type: NgZone },
{ type: SortService, decorators: [{ type: Self }] }
];
DragCollectionDirective.propDecorators = {
_disabled: [{ type: Input, args: ['disabled',] }],
dropCompleted: [{ type: Output }],
units: [{ type: ContentChildren, args: [DragUnitDirective,] }]
};
class NgReorderModule {
}
NgReorderModule.decorators = [
{ type: NgModule, args: [{
declarations: [
DragCollectionDirective,
DragUnitDirective,
DragHandleDirective,
DragRejectorDirective,
],
imports: [],
exports: [DragCollectionDirective, DragUnitDirective, DragHandleDirective, DragRejectorDirective]
},] }
];
/*
* Public API Surface of ng-reorder
*/
/**
* Generated bundle index. Do not edit.
*/
export { DragCollectionDirective, DragHandleDirective, DragRejectorDirective, DragUnitDirective, NgReorderModule, createPoint, pointDif, pointFromPointerEvent, pointSum, reorderItems, DRAG_COLLECTION as ɵa, EventService as ɵc, SortService as ɵd, DRAG_UNIT_PARENT as ɵe };
//# sourceMappingURL=ng-reorder.js.map