Skip to content

Instantly share code, notes, and snippets.

@jimitndiaye
Created December 14, 2016 10:45
Show Gist options
  • Select an option

  • Save jimitndiaye/63909447f3c08b2e5e9bfd6e1c675545 to your computer and use it in GitHub Desktop.

Select an option

Save jimitndiaye/63909447f3c08b2e5e9bfd6e1c675545 to your computer and use it in GitHub Desktop.

Revisions

  1. jimitndiaye created this gist Dec 14, 2016.
    41 changes: 41 additions & 0 deletions context.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,41 @@
    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;
    }
    278 changes: 278 additions & 0 deletions draggable.directive.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,278 @@
    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;
    }

    }
    376 changes: 376 additions & 0 deletions drop-target.directive.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,376 @@
    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);
    }
    }