opf/openproject

View on GitHub
frontend/src/app/shared/helpers/drag-and-drop/drag-and-drop.service.ts

Summary

Maintainability
C
1 day
Test Coverage
import {
  Inject, Injectable, Injector, OnDestroy,
} from '@angular/core';
import { DOCUMENT } from '@angular/common';
import { DomAutoscrollService } from 'core-app/shared/helpers/drag-and-drop/dom-autoscroll.service';
import { findIndex, reinsert } from 'core-app/shared/helpers/drag-and-drop/drag-and-drop.helpers';

export interface DragMember {
  dragContainer:HTMLElement;
  scrollContainers:HTMLElement[];
  /** Whether this element moves */
  moves:(element:HTMLElement, fromContainer:HTMLElement, handle:HTMLElement, sibling?:HTMLElement|null) => boolean;
  /** Move element in container */
  onMoved:(element:HTMLElement, target:any, source:HTMLElement, sibling:HTMLElement|null) => void;
  /** Add element to this container */
  onAdded:(element:HTMLElement, target:any, source:HTMLElement, sibling:HTMLElement|null) => Promise<boolean>;
  /** Remove element from this container */
  onRemoved:(element:HTMLElement, target:any, source:HTMLElement, sibling:HTMLElement|null) => void;

  /** Move this container accepts elements */
  accepts?:(row:HTMLElement, container:any) => boolean;

  /** Callback when the element got cloned */
  onCloned?:(clone:HTMLElement, original:HTMLElement) => void;

  /** Callback when the shadow element got inserted into a container */
  onShadowInserted?:(row:HTMLElement) => void;

  /** Callback when the shadow element got inserted into a container */
  onCancel?:(element:HTMLElement) => void;
}

@Injectable()
export class DragAndDropService implements OnDestroy {
  public drake:dragula.Drake|null = null;

  public members:DragMember[] = [];

  private autoscroll:any;

  private escapeListener = (evt:KeyboardEvent) => {
    if (this.drake && evt.key === 'Escape') {
      this.drake.cancel(true);
    }
  };

  constructor(@Inject(DOCUMENT) private document:Document,
    readonly injector:Injector) {
    this.document.documentElement.addEventListener('keydown', this.escapeListener);
  }

  ngOnDestroy():void {
    this.document.documentElement.removeEventListener('keydown', this.escapeListener);

    if (this.autoscroll) {
      this.autoscroll.destroy();
    }

    if (this.drake) {
      this.drake.destroy();
    }
  }

  public remove(container:HTMLElement) {
    if (this.initialized) {
      _.remove(this.drake!.containers, (el) => el === container);
      _.remove(this.members, (el) => el.dragContainer === container);
    }
  }

  public member(container:HTMLElement):DragMember|undefined {
    return _.find(this.members, (el) => el.dragContainer === container);
  }

  public get initialized() {
    return this.drake !== null;
  }

  public register(member:DragMember) {
    this.members.push(member);
    const { scrollContainers } = member;

    if (this.autoscroll) {
      this.autoscroll.add(scrollContainers);
    } else {
      this.setupAutoscroll(scrollContainers);
    }

    const { dragContainer } = member;
    if (this.drake === null) {
      this.initializeDrake([dragContainer]);
    } else {
      this.drake.containers.push(dragContainer);
    }
  }

  public addScrollContainer(el:Element) {
    if (this.autoscroll) {
      this.autoscroll.add(el);
    } else {
      this.setupAutoscroll([el]);
    }
    this.autoscroll.setOuterScrollContainer(el);
  }

  protected setupAutoscroll(containers:Element[]) {
    // Setup autoscroll
    this.autoscroll = new DomAutoscrollService(
      containers,
      {
        margin: 100,
        maxSpeed: 10,
        scrollWhenOutside: true,
        autoScroll: () => this.drake && this.drake.dragging,
      },
    );
  }

  /**
   * Retrieve a member from the container, if one exists.
   * @param container
   */
  protected getMember(container:Element):DragMember|undefined {
    return this.members.find((member) => member.dragContainer === container);
  }

  protected initializeDrake(containers:Element[]) {
    this.drake = dragula(containers, {
      moves: (el:any, container:any, handle:any, sibling:any) => {
        const member = this.getMember(container);
        return member ? member.moves(el, container, handle, sibling) : false;
      },
      accepts: (el:any, container:any) => {
        const member = this.getMember(container);
        return (member && member.accepts) ? member.accepts(el, container) : true;
      },
      invalid: () => false,
      direction: 'vertical', // Y axis is considered when determining where an element would be dropped
      copy: false, // elements are moved by default, not copied
      revertOnSpill: true, // spilling will put the element back where it was dragged from, if this is true
      removeOnSpill: false, // spilling will `.remove` the element, if this is true
      mirrorContainer: document.body, // set the element that gets mirror elements appended
      ignoreInputTextSelection: true, // allows users to select input text, see details below
    });

    this.drake.on('drag', (el:HTMLElement) => {
      // eslint-disable-next-line no-param-reassign
      el.dataset.sourceIndex = findIndex(el).toString();
    });

    this.drake.on('over', (_, container:HTMLElement) => {
      const zone = container.closest('.drop-zone');
      if (zone) {
        zone.classList.add('-dragged-over');
      }
    });

    this.drake.on('out', (_, container:HTMLElement) => {
      const zone = container.closest('.drop-zone');
      if (zone) {
        zone.classList.remove('-dragged-over');
      }
    });

    this.drake.on('cloned', (clone:HTMLElement, original:HTMLElement) => {
      const member = this.member(original.parentElement!);
      if (member && member.onCloned) {
        member.onCloned(clone, original);
      }
    });

    this.drake.on('drop', (el:HTMLElement, target:HTMLElement, source:HTMLElement, sibling:HTMLElement) => {
      try {
        void this.handleDrop(el, target, source, sibling);
      } catch (e) {
        console.error('Failed to handle drop of %O, %O', el, e);
      }
    });

    this.drake.on('shadow', (shadowElement:HTMLElement, container:HTMLElement) => {
      const member = this.member(container);
      if (member && member.onShadowInserted) {
        member.onShadowInserted(shadowElement);
      }
    });

    this.drake.on('cancel', (el:HTMLElement, container:HTMLElement) => {
      const member = this.member(container);
      if (member && member.onCancel) {
        member.onCancel(el);
      }
    });
  }

  private async handleDrop(el:HTMLElement, target:HTMLElement, source:HTMLElement, sibling:HTMLElement|null) {
    const to = this.member(target);
    const from = this.member(source);

    if (!(to && from)) {
      return;
    }

    if (to === from) {
      to.onMoved(el, target, source, sibling);
      return;
    }

    const result = await to.onAdded(el, target, source, sibling);
    if (result) {
      from.onRemoved(el, target, source, sibling);
    } else {
      // Restore element in from container
      reinsert(el, el.dataset.sourceIndex || -1, source);
    }
  }
}