import { Directive, ElementRef, EventEmitter, Inject, Input, NgZone, OnChanges, OnDestroy, OnInit, Optional, Output, Renderer2, SimpleChange, } from '@angular/core'; import Sortable, {Options} from 'sortablejs'; import {GLOBALS} from './globals'; import {SortablejsBindings} from './sortablejs-bindings'; import {SortablejsService} from './sortablejs.service'; export type SortableData = any | any[]; const getIndexesFromEvent = (event: SortableEvent) => { if (event.hasOwnProperty('newDraggableIndex') && event.hasOwnProperty('oldDraggableIndex')) { return { new: event.newDraggableIndex, old: event.oldDraggableIndex, }; } else { return { new: event.newIndex, old: event.oldIndex, }; } }; @Directive({ selector: '[sortablejs]', }) export class SortablejsDirective implements OnInit, OnChanges, OnDestroy { @Input() sortablejs: SortableData; // array or a FormArray @Input() sortablejsContainer: string; @Input() sortablejsOptions: Options; @Input() sortablejsCloneFunction: (item: any) => any; private sortableInstance: any; @Output() sortablejsInit = new EventEmitter(); constructor( @Optional() @Inject(GLOBALS) private globalConfig: Options, private service: SortablejsService, private element: ElementRef, private zone: NgZone, private renderer: Renderer2, ) { } ngOnInit() { if (Sortable && Sortable.create) { // Sortable does not exist in angular universal (SSR) this.create(); } } ngOnChanges(changes: { [prop in keyof SortablejsDirective]: SimpleChange }) { const optionsChange: SimpleChange = changes.sortablejsOptions; if (optionsChange && !optionsChange.isFirstChange()) { const previousOptions: Options = optionsChange.previousValue; const currentOptions: Options = optionsChange.currentValue; Object.keys(currentOptions).forEach(optionName => { if (currentOptions[optionName] !== previousOptions[optionName]) { // use low-level option setter this.sortableInstance.option(optionName, this.options[optionName]); } }); } } ngOnDestroy() { if (this.sortableInstance) { this.sortableInstance.destroy(); } } private create() { const container = this.sortablejsContainer ? this.element.nativeElement.querySelector(this.sortablejsContainer) : this.element.nativeElement; setTimeout(() => { this.sortableInstance = Sortable.create(container, this.options); this.sortablejsInit.emit(this.sortableInstance); }, 0); } private getBindings(): SortablejsBindings { if (!this.sortablejs) { return new SortablejsBindings([]); } else if (this.sortablejs instanceof SortablejsBindings) { return this.sortablejs; } else { return new SortablejsBindings([this.sortablejs]); } } private get options() { return {...this.optionsWithoutEvents, ...this.overridenOptions}; } private get optionsWithoutEvents() { return {...(this.globalConfig || {}), ...(this.sortablejsOptions || {})}; } private proxyEvent(eventName: string, ...params: any[]) { this.zone.run(() => { // re-entering zone, see https://github.com/SortableJS/angular-sortablejs/issues/110#issuecomment-408874600 if (this.optionsWithoutEvents && this.optionsWithoutEvents[eventName]) { this.optionsWithoutEvents[eventName](...params); } }); } private get isCloning() { return this.sortableInstance.options.group.checkPull(this.sortableInstance, this.sortableInstance) === 'clone'; } private clone(item: T): T { // by default pass the item through, no cloning performed return (this.sortablejsCloneFunction || (subitem => subitem))(item); } private get overridenOptions(): Options { // always intercept standard events but act only in case items are set (bindingEnabled) // allows to forget about tracking this.items changes return { onAdd: (event: SortableEvent) => { this.service.transfer = (items: any[]) => { this.getBindings().injectIntoEvery(event.newIndex, items); this.proxyEvent('onAdd', event); }; this.proxyEvent('onAddOriginal', event); }, onRemove: (event: SortableEvent) => { const bindings = this.getBindings(); if (bindings.provided) { if (this.isCloning) { this.service.transfer(bindings.getFromEvery(event.oldIndex).map(item => this.clone(item))); // great thanks to https://github.com/tauu // event.item is the original item from the source list which is moved to the target list // event.clone is a clone of the original item and will be added to source list // If bindings are provided, adding the item dom element to the target list causes artifacts // as it interferes with the rendering performed by the angular template. // Therefore we remove it immediately and also move the original item back to the source list. // (event handler may be attached to the original item and not its clone, therefore keeping // the original dom node, circumvents side effects ) this.renderer.removeChild(event.item.parentNode, event.item); this.renderer.insertBefore(event.clone.parentNode, event.item, event.clone); this.renderer.removeChild(event.clone.parentNode, event.clone); } else { this.service.transfer(bindings.extractFromEvery(event.oldIndex)); } this.service.transfer = null; } this.proxyEvent('onRemove', event); }, onUpdate: (event: SortableEvent) => { const bindings = this.getBindings(); const indexes = getIndexesFromEvent(event); bindings.injectIntoEvery(indexes.new, bindings.extractFromEvery(indexes.old)); this.proxyEvent('onUpdate', event); }, }; } } interface SortableEvent { oldIndex: number; newIndex: number; oldDraggableIndex?: number; newDraggableIndex?: number; item: HTMLElement; clone: HTMLElement; }