Created
December 14, 2016 10:45
-
-
Save jimitndiaye/63909447f3c08b2e5e9bfd6e1c675545 to your computer and use it in GitHub Desktop.
Angular2 directives supporting nested drag and drop
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Injectable } from '@angular/core'; | |
@Injectable() | |
export class DndContext { | |
dropEffect = 'none'; | |
isDragging = false; | |
itemType = undefined; | |
dragData: any = undefined; | |
// stopDragOver= new Map<string, () => void>(); | |
stopDragOver: () => void = undefined; | |
current: { stopDragOver: () => void }; | |
} | |
export interface DndDragEvent { | |
dragData?: any; | |
dropEffect?: string; | |
event: MouseEvent; | |
} | |
export interface DndDropEvent { | |
/** | |
* The original drop event sent by the browser | |
*/ | |
event: DragEvent; | |
/** | |
* The position in the drop target at which the element would be dropped. | |
*/ | |
index: number; | |
/** | |
* The transferred data | |
*/ | |
data: any; | |
/** | |
* The type of element (if provided on the source element) | |
*/ | |
type: string; | |
/** | |
* Indicates whether the element was dragged from an external source. | |
*/ | |
external: boolean; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Directive, | |
Input, | |
Output, | |
EventEmitter, | |
ElementRef, | |
ChangeDetectorRef, | |
HostBinding, | |
HostListener, | |
Renderer, | |
NgZone, | |
Injectable | |
} from '@angular/core'; | |
import { | |
isPresent, | |
isBlank, | |
isString, | |
isFunction | |
} from '@angular/core/src/facade/lang'; | |
import { DndContext, DndDragEvent } from './context'; | |
export class DragImage { | |
constructor( | |
public imageElement: string | HTMLElement, | |
public x_offset: number = 0, | |
public y_offset: number = 0) { | |
if (isString(this.imageElement)) { | |
// Create real image from string source | |
let imgScr: string = <string>this.imageElement; | |
this.imageElement = new HTMLImageElement(); | |
(<HTMLImageElement>this.imageElement).src = imgScr; | |
} | |
} | |
} | |
@Directive({ | |
selector: '[tkDraggable]' | |
}) | |
export class DraggableDirective { | |
/** | |
* Whether the object is draggable. Default is true. | |
*/ | |
@Input() | |
@HostBinding('attr.draggable') | |
set dragEnabled(enabled: boolean) { | |
this.disabled = !enabled; | |
} | |
get dragEnabled(): boolean { | |
return !this.disabled; | |
} | |
/** | |
* The data to be transferred to the drop target. | |
* Can be any JS object. | |
*/ | |
@Input() tkDraggable: any; | |
/** | |
* Drag effect | |
*/ | |
@Input() dragEffect: 'copy' | 'move' | 'none'; | |
/** | |
* Use this attribute if you have different kinds of items in your application and you want to | |
* limit which items can be dropped into which lists | |
*/ | |
@Input() draggedItemType: string; | |
/** | |
* This class will be added to the element while the it is being dragged. It will affect | |
* both the element you see while dragging and the source element that stays at its position. | |
* Do not try to hide the source element with this class as it will about the drag operation. | |
*/ | |
@Input() dragClass: string; | |
/** | |
* This class will be added to the source element after the drag operation is started, meaning | |
* it only affects the original element that is still at it's source position and | |
* not the "element" that the user is dragging. | |
*/ | |
@Input() dragSourceClass: string; | |
@Input() dragImage: string | DragImage | Function; | |
/** | |
* Event raised when the element was moved. Usually you will remove your element | |
* from the original list by handling this event since this directive does not do | |
* that for you automatically. The original dragend event will be provided as output. | |
*/ | |
@Output() onMoved: EventEmitter<DndDragEvent> = new EventEmitter<DndDragEvent>(); | |
/** | |
* Same as the targetMoved event except that it is raised when the element is copied | |
* instead of moved. Payload will be the original dragend event. | |
*/ | |
@Output() onCopied: EventEmitter<DndDragEvent> = new EventEmitter<DndDragEvent>(); | |
/** | |
* Event raised if the element was clicked but not dragged. The original click event | |
* will be provided in the payload. | |
*/ | |
@Output() onSelected: EventEmitter<DndDragEvent> = new EventEmitter<DndDragEvent>(); | |
/** | |
* Event raised when the element is dragged. The original dragstart event | |
* will be privided as the output data. | |
*/ | |
@Output() onDragStart: EventEmitter<DndDragEvent> = new EventEmitter<DndDragEvent>(); | |
/** | |
* Event raised when the drag operation is ended. Output will be | |
* the original dragend event and the dropEffect | |
*/ | |
@Output() onDragEnd: EventEmitter<DndDragEvent> = new EventEmitter<DndDragEvent>(); | |
/** | |
* Event raised when the element was dragged but the operation was canceled | |
* and the element was not dropped. The original dragend event will be provided | |
* as output. | |
*/ | |
@Output() onDragCanceled: EventEmitter<DndDragEvent> = new EventEmitter<DndDragEvent>(); | |
private disabled: boolean = false; | |
private element: HTMLElement; | |
private defaultCursor: string; | |
constructor( | |
element: ElementRef, | |
private cdr: ChangeDetectorRef, | |
private renderer: Renderer, | |
private zone: NgZone, | |
private context: DndContext) { | |
this.element = element.nativeElement; | |
this.dragEnabled = true; | |
} | |
/** | |
* When the drag operation is started we have to prepare the dataTransfer object, | |
* which is the primary way we communicate with the target element | |
*/ | |
@HostListener('dragstart', ['$event']) | |
handleDragStart(dragEvent: DragEvent) { | |
console.debug('DND: Drag start', dragEvent); | |
// Check whether the element is draggable, since dragstart might be triggered on a child. | |
if (this.dragEnabled === false) return true; | |
// Initialize global state | |
this.context.dropEffect = 'none'; | |
this.context.isDragging = true; | |
this.context.dragData = this.tkDraggable; | |
this.context.itemType = this.draggedItemType || undefined; | |
// Add CSS classes. | |
const draggedElementClass = this.dragClass; | |
const sourceElementClass = this.dragSourceClass; | |
if (draggedElementClass) { | |
this.renderer.setElementClass(this.element, draggedElementClass, true); | |
} | |
if (sourceElementClass) { | |
setTimeout(() => { | |
this.zone.run( | |
() => this.renderer.setElementClass( | |
this.element, sourceElementClass, true)); | |
}, 0); | |
} | |
// Try setting a proper drag image if triggered on a dnd-handle (won't work in IE). | |
if (dragEvent.dataTransfer) { | |
dragEvent.dataTransfer.setData('text', ''); | |
// Change drag effect | |
dragEvent.dataTransfer.effectAllowed = this.dragEffect || ''; | |
// Change drag image | |
const setDragImage = (image: any, xOffset: number = 0, yOffset: number = 0) => | |
(dragEvent.dataTransfer as any).setDragImage(image, xOffset, yOffset); | |
if (isPresent(setDragImage) && isFunction(setDragImage)) { | |
if (isPresent(this.dragImage)) { | |
if (isString(this.dragImage)) { | |
setDragImage( | |
this.createImage(<string>this.dragImage)); | |
} else if (isFunction(this.dragImage)) { | |
setDragImage((this.dragImage as Function)()); | |
} else { | |
let img: DragImage = <DragImage>this.dragImage; | |
setDragImage( | |
img.imageElement, img.x_offset, img.y_offset); | |
} | |
} else { | |
setDragImage(this.element, 0, 0); | |
} | |
} | |
// // Change drag cursor | |
// const cursor = this.dragCursor || this.config.dragCursor; | |
// if (cursor) { | |
// this.context.dragCursor = this.element.style.cursor; | |
// this.element.style.cursor = cursor; | |
// } else { | |
// this.element.style.cursor = this._defaultCursor; | |
// } | |
} | |
// Raise the dragstart event | |
this.onDragStart.next(<DndDragEvent>{ event: dragEvent }); | |
// Prevent triggering event in parent elements | |
if (isFunction(dragEvent.stopPropagation)) | |
dragEvent.stopPropagation(); | |
console.debug('DND: Drag start complete', dragEvent); | |
} | |
/** | |
* The dragend event is triggered when the element is dropped or when the drag operation | |
* is aborted (e.g. hitting the escape button). Depending on the executed action we will | |
* invoke the callbacks specified with the targetCopied and targetMoved events | |
*/ | |
@HostListener('dragend', ['$event']) | |
handleDragEnd(dragEvent: DragEvent) { | |
console.debug('DND: Drag end', dragEvent); | |
/** | |
* Invoke callbacks. Usually we would use event.dataTransferEffect.dropEffect to determine | |
* the used effect, but Chrome has not implemented that field correctly. On Windows it is | |
* always set to 'none', while Chrome on Linux sometimes sets it to something else when it | |
* is supposed to send 'none' (drag operation aborted). | |
*/ | |
const dropEffect = this.context.dropEffect; | |
switch (dropEffect) { | |
case 'move': | |
this.onMoved.emit({ event: dragEvent, dragData: this.context.dragData }); | |
break; | |
case 'copy': | |
this.onCopied.emit({ event: dragEvent, dragData: this.context.dragData }); | |
break; | |
case 'none': | |
this.onDragCanceled.emit({ event: dragEvent, dragData: this.context.dragData }); | |
break; | |
default: | |
break; | |
} | |
this.onDragEnd.emit(<DndDragEvent>{ | |
event: dragEvent, | |
dragData: this.context.dragData, | |
dropEffect | |
}); | |
// Clean up | |
const draggingClass = this.dragClass; | |
const dragSourceClass = this.dragSourceClass; | |
if (draggingClass) { | |
this.renderer.setElementClass(this.element, draggingClass, false); | |
} | |
if (dragSourceClass) { | |
setTimeout(() => { | |
this.zone.run( | |
() => this.renderer.setElementClass(this.element, dragSourceClass, false)); | |
}, 0); | |
} | |
this.context.isDragging = false; | |
// Prevent triggering event in parent elements | |
if (isFunction(dragEvent.stopPropagation)) | |
dragEvent.stopPropagation(); | |
console.debug('DND: Drag end complete'); | |
} | |
@HostListener('click', ['$event']) | |
handleClick(event: MouseEvent) { | |
this.onSelected.emit({ event }); | |
// Prevent triggering event in parent elements | |
event.stopPropagation(); | |
} | |
/** | |
* Create Image element with specified url string | |
*/ | |
private createImage(src: string) { | |
let img: HTMLImageElement = new HTMLImageElement(); | |
img.src = src; | |
return img; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { | |
Directive, | |
Input, | |
Output, | |
EventEmitter, | |
ElementRef, | |
ChangeDetectorRef, | |
HostBinding, | |
HostListener, | |
Renderer, | |
NgZone, | |
ViewContainerRef, | |
TemplateRef, | |
OnInit, | |
AfterViewInit, | |
ViewChild, | |
EmbeddedViewRef, | |
ComponentRef, | |
ViewRef, | |
Host, | |
Optional, | |
AfterContentInit, | |
forwardRef, | |
Inject | |
} from '@angular/core'; | |
import { getDOM } from '@angular/platform-browser/src/dom/dom_adapter'; | |
import { | |
isPresent, | |
isBlank, | |
isString, | |
isFunction | |
} from '@angular/core/src/facade/lang'; | |
import { DndContext, DndDropEvent } from './context'; | |
class DropTargetPlaceholderView { | |
viewRef: EmbeddedViewRef<Object>; | |
constructor( | |
public viewContainer: ViewContainerRef, private template: TemplateRef<Object>) { } | |
get index() { | |
return this.viewContainer.indexOf(this.viewRef); | |
} | |
create(): void { this.viewRef = this.viewContainer.createEmbeddedView(this.template); } | |
destroy(): void { | |
if (isPresent(this.viewRef)) { | |
this.viewContainer.clear(); | |
this.viewRef = undefined; | |
} | |
} | |
contains(node: Node): boolean { | |
if (!isPresent(this.viewRef) || !isPresent(this.viewRef.rootNodes)) { | |
return false; | |
} | |
const nodes = this.viewRef.rootNodes as Node[]; | |
return nodes.includes(node) || nodes.some(n => n.contains(node)); | |
} | |
} | |
@Directive({ | |
selector: '[tkDropTarget]' | |
}) | |
export class DropTargetDirective { | |
/** | |
* Whether the object allows drop events. Default is false. | |
*/ | |
@Input() disabled = false; | |
/** | |
* Optional array of allowed item types. When used, only items a matching type will be droppable | |
*/ | |
@Input() allowedTypes: Array<string>; | |
/** | |
* Optional. When true, the list accepts drops from sources outside the current browser tab. | |
* Note that this will allow dropping arbitrary text into the list, thus it is highly | |
* recommended to provide a validator function via 'allowDrop' | |
* Furthermore, the elementType of external sources cannot be determinded, so do not rely | |
* on restrictions based on allowedTypes | |
*/ | |
@Input() allowExternalSources: boolean = false; | |
/** | |
* When true, the positioning algorithm will use the left and right halfs of the list | |
* items instead of the upper and lower halfs. | |
*/ | |
@Input() horizontal: boolean = false; | |
/** | |
* An optional function that is invoked when an element is dropped on the list | |
* that determines if the drop operation should be allowed. | |
*/ | |
@Input() allowDrop: (event: DndDropEvent) => boolean; | |
/** | |
* CSS class to be set on the list element when an element is dragged over it. | |
*/ | |
@Input() dragOverClass: string; | |
/** | |
* Event raised on successful drop | |
*/ | |
@Output() onDrop: EventEmitter<DndDropEvent> = new EventEmitter<DndDropEvent>(); | |
private placeHolder: DropTargetPlaceholderView; | |
constructor( | |
private element: ElementRef, | |
private renderer: Renderer, | |
private context: DndContext | |
// @Optional() @Host() @Inject(forwardRef(() => DropTargetDirective)) | |
// private parent: DropTargetDirective, | |
// @Optional() @Host() @Inject(forwardRef(() => DropTargetPlaceholderDirective)) | |
// private parent: DropTargetPlaceholderDirective, | |
) { | |
} | |
/** | |
* The dragenter event is fired when a dragged element or text selection enters a valid drop | |
* target. According to the spec, we either need to have a dropzone attribute or listen on | |
* dragenter events and call preventDefault(). It should be noted though that no browser seems | |
* to enforce this behaviour. | |
*/ | |
@HostListener('dragenter', ['$event']) | |
private handleDragEnter(event: DragEvent) { | |
if (!this.isDropAllowed(event)) { | |
return true; | |
} | |
event.preventDefault(); | |
} | |
/** | |
* The dragover event is triggered "every few hundred milliseconds" while an element | |
* is being dragged over our target, or over a child element. | |
*/ | |
@HostListener('dragover', ['$event']) | |
private handleDragOver(event: DragEvent) { | |
if (!this.isDropAllowed(event)) { | |
// event.dataTransfer.effectAllowed = 'none'; | |
event.dataTransfer.dropEffect = 'none'; | |
return true; | |
} | |
const listNode = this.element.nativeElement as HTMLElement; | |
if (isPresent(this.placeHolder)) { | |
// Make sure the placeholder is shown, | |
// which is especially important if the list is empty. | |
if (!this.placeHolder.viewRef) { | |
this.placeHolder.create(); | |
// In nested lists, the parent list might be showing a placeholder | |
// that we have to remove. | |
// if (isPresent(this.context.stopDragOver)) { | |
// const stopDragOver = this.context.stopDragOver; | |
// setTimeout(() => stopDragOver(), 0); | |
// } | |
// this.context.stopDragOver = this.stopDragOver; | |
if (isPresent(this.context.current)) { | |
this.context.current.stopDragOver(); | |
} | |
this.context.current = this; | |
} | |
// const actions = []; | |
// this.context.stopDragOver.forEach((stopDragOver, id) => { | |
// if (id !== this.id && isPresent(stopDragOver)) { | |
// actions.push(stopDragOver); | |
// } | |
// }); | |
// actions.forEach(action => action()); | |
// this.context.stopDragOver[this.id] = this.stopDragOver; | |
if (event.target !== listNode) { | |
// Try to find the node direct directly below the list node. | |
var listItemNode = event.target as Node; | |
while (listItemNode.parentNode != listNode && isPresent(listItemNode.parentNode)) { | |
listItemNode = listItemNode.parentNode; | |
} | |
if (listItemNode.parentNode == listNode | |
&& !this.placeHolder.contains(listItemNode) | |
&& listItemNode instanceof HTMLElement) { | |
// If the mouse pointer is in the upper half of the list item element, | |
// we position the placeholder before the list item, otherwise after it. | |
const listItemElement = listItemNode as HTMLElement; | |
var rect = listItemElement.getBoundingClientRect(); | |
const isFirstHalf = this.horizontal | |
? event.clientX < rect.left + rect.width / 2 | |
: event.clientY < rect.top + rect.height / 2; | |
this.placeHolder.destroy(); | |
listNode.insertBefore(this.placeHolderNode, | |
isFirstHalf ? listItemNode : listItemNode.nextSibling); | |
this.placeHolder.create(); | |
} | |
} | |
} | |
// at this point we invoke the callback, which can still disallow the drop. | |
// We can't do this earlier because we need the index of the placeholder | |
const dragOverEvent = this.createDropEvent(event); | |
if (this.allowDrop && !this.allowDrop(dragOverEvent)) { | |
this.stopDragOver(); | |
console.debug('DND: Drop disabled'); | |
return true; | |
} | |
if (this.dragOverClass) { | |
this.renderer.setElementClass(this.element.nativeElement, this.dragOverClass, true); | |
} | |
event.preventDefault(); | |
if (isFunction(event.stopPropagation)) | |
event.stopPropagation(); | |
// console.debug('DND: Drag over complete', event); | |
return false; | |
} | |
@HostListener('drop', ['$event']) | |
private handleDrop(event: DragEvent) { | |
console.debug('DND: Drop', event); | |
if (!this.isDropAllowed(event)) return true; | |
// The default behavior in Firefox is to interpret the dropped element as URL and | |
// forword to it. We want to prevent that even if our drop is aborted. | |
event.preventDefault(); | |
const targetIndex = this.placeHolderIndex; | |
console.log(`Dropping item to index ${targetIndex}`, event.target); | |
const dropEvent = this.createDropEvent(event, targetIndex); | |
if (this.allowDrop && !this.allowDrop(dropEvent)) { | |
this.stopDragOver(); | |
console.debug('DND: Drop disabled'); | |
return true; | |
} | |
this.onDrop.next(dropEvent); | |
// In Chrome on Windows the dropEffect will always be none... | |
// We have to determine the actual effect manually from the allowed effects | |
if (event.dataTransfer.dropEffect === 'none') { | |
if (event.dataTransfer.effectAllowed === 'copy' || | |
event.dataTransfer.effectAllowed === 'move') { | |
this.context.dropEffect = event.dataTransfer.effectAllowed; | |
} else { | |
this.context.dropEffect = event.ctrlKey ? 'copy' : 'move'; | |
} | |
} else { | |
this.context.dropEffect = event.dataTransfer.dropEffect; | |
} | |
// Clean up | |
this.stopDragOver(); | |
if (isFunction(event.stopPropagation)) | |
event.stopPropagation(); | |
return false; | |
} | |
/** | |
* We have to remove the placeholder when the element is no longer dragged over our list. The | |
* problem is that the dragleave event is not only fired when the element leaves our list, | |
* but also when it leaves a child element -- so practically it's fired all the time. As a | |
* workaround we wait a few milliseconds and then check if the dndDragover class was added | |
* again. If it is there, dragover must have been called in the meantime, i.e. the element | |
* is still dragging over the list. If you know a better way of doing this, please tell me! | |
*/ | |
@HostListener('dragleave', ['$event']) | |
private handleDragLeave(event: DragEvent) { | |
// const document = this.renderer.selectRootElement('document') as HTMLDocument; | |
const document = getDOM().defaultDoc(); | |
const target = document.elementFromPoint(event.clientX, event.clientY); | |
const container = this.element.nativeElement as HTMLElement; | |
if (!container.contains(target)) { | |
this.stopDragOver(); | |
} | |
} | |
private get placeHolderNode() { | |
return this.placeHolder.viewContainer.element.nativeElement as Node; | |
} | |
private get placeHolderIndex() { | |
if (isPresent(this.placeHolder) && isPresent(this.placeHolder.viewRef)) { | |
const listNode = this.element.nativeElement as HTMLElement; | |
for (let i = 0; i < listNode.children.length; i++) { | |
const childNode = listNode.children.item(i); | |
if (this.placeHolder.contains(childNode)) | |
return i; | |
} | |
} | |
return -1; | |
} | |
private createDropEvent(event: DragEvent, index: number = undefined) { | |
return { | |
event, | |
external: !this.context.isDragging, | |
index: index !== undefined ? index : this.placeHolderIndex, | |
data: this.context.isDragging | |
? this.context.dragData | |
: event.dataTransfer.getData(event.dataTransfer.types[0]), | |
type: this.context.isDragging ? this.context.itemType : undefined | |
}; | |
} | |
public stopDragOver() { | |
if (isPresent(this.placeHolder)) { | |
this.placeHolder.destroy(); | |
} | |
if (this.dragOverClass) { | |
this.renderer.setElementClass(this.element.nativeElement, this.dragOverClass, false); | |
} | |
console.debug('DND: Stopping drag over'); | |
// this.context.stopDragOver.delete(this.id); | |
} | |
/** | |
* Checks various conditions that must be fulfilled for a drop to be allowed | |
*/ | |
private isDropAllowed(event: DragEvent) { | |
// Disallow all drops if globally disabled | |
if (this.disabled) return false; | |
// Disallow drop from external source unless it's allowed explicitly. | |
if (!this.context.isDragging && !this.allowExternalSources) return false; | |
// Check mimetype. Usually we would use a custom drag type instead of Text, but IE doesn't | |
// support that. | |
// if (!this.hasTextMimetype(event.dataTransfer.types)) return false; | |
// Now check the dnd-allowed-types against the type of the incoming element. For drops from | |
// external sources we don't know the type, so it will need to be checked via dnd-drop. | |
if (this.allowedTypes && this.allowedTypes.length && this.context.isDragging) { | |
if (this.allowedTypes.indexOf(this.context.itemType) === -1) { | |
return false; | |
} | |
} | |
return true; | |
} | |
/** @internal */ | |
_registerPlaceholder(template: TemplateRef<Object>, viewContainer: ViewContainerRef) { | |
if (isPresent(this.placeHolder)) { | |
console.error('A placeholder has already bean registered. Only one tkDropTargetPlaceholder may be used'); | |
return; | |
} | |
console.debug('Registering drop-target placeholder'); | |
this.placeHolder = new DropTargetPlaceholderView(viewContainer, template); | |
} | |
// /** | |
// * Checks whether the mouse pointer is in the first half of the given target element. | |
// * | |
// * In Chrome we can just use offsetY, but in Firefox we have to use layerY, which only | |
// * works if the child element has position relative. In IE the events are only triggered | |
// * on the listNode instead of the listNodeItem, therefore the mouse positions are | |
// * relative to the parent element of targetNode. | |
// */ | |
// isMouseInFirstHalf(event: DragEvent, targetNode: Node, relativeToParent: boolean) { | |
// var mousePointer = this.horizontal ? (event.offsetX || event.layerX) | |
// : (event.offsetY || event.layerY); | |
// var targetSize = this.horizontal ? targetNode.offsetWidth : targetNode.offsetHeight; | |
// var targetPosition = this.horizontal ? targetNode.offsetLeft : targetNode.offsetTop; | |
// targetPosition = relativeToParent ? targetPosition : 0; | |
// return mousePointer < targetPosition + targetSize / 2; | |
// } | |
} | |
@Directive({ | |
selector: '[tkDropTargetPlaceholder]' | |
}) | |
export class DropTargetPlaceholderDirective { | |
constructor( | |
template: TemplateRef<Object>, | |
viewContainer: ViewContainerRef, | |
@Host() public dropTarget: DropTargetDirective | |
) { | |
dropTarget._registerPlaceholder(template, viewContainer); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Here's a snippet of production code using the drag/drop directives:
This was written using Angular 2.1.0. I'm not quite up to date any changes in syntax in more recent versions of Angular, if any. It is for a WYSIWYG designer allowing drag and drop of nested components. We needed to support arbitrarily deep nesting while allowing drag and drop to and from containers at any level. The key things to note are:
tkDropTarget
directive is used on the enclosingul
to mark it as a drop zone.horizontal
property on that directive is a boolean used to indicate whether the children are arranged horizontally or vertically - this is used to calculate placeholder placement. In this instance it is bound to a property. If not specified, horizontal is assumed to be false, i.e items are arranged vertically.allowedTypes
property is used to filter which items are allowed to be dropped to in this zone.dragOverClass
is an optional property that allows you to toggle a CSS class when an item is being dragged over the drop zone e.g toggle a border color etc.allowDrop
andonDrop
events validate drop items and process dropped items respectively.ul
I use anngFor
to list a bunch of draggableli
using thetkDraggable
directive. These represent the direct children of the container. Marking them as draggable allows you to use drag and drop to reposition items within the container or even move them from one container to another.dragClass
is used to toggle a CSS class on the dragged itemdragSourceClass
is used to toggle a CSS class on the dragged item's original positiontkComponentOutlet
is very similar tongComponentOutlet
) for embedding a component which may itself be a container, thus resulting in nested lists. In my case, any nested containers reuse this same template above resulting in a form of recursion.ul
has anli
marked withtkDropTargetPlaceholder
which basically defines the template for the placeholder element shown during drag and dropIf you have any further questions on individual directive properties there are comments in the original gist for every input property.
The relevant CSS classes for the snippet above are as follows: