Search This Blog

Sunday, December 4, 2016

Drag and Drop in Angular 2 with ng2-dragula

I wanted to implement a drag and drop scenario using Angular 2 and decided to put ng2-dragula through its paces. In this post, I will take this library for a spin. The post will follow the following steps
  1. Creating a new project to utilize ng2-dragula.
  2. Configuring the project to use ng2-dragula
  3. Implementing the dragula library
  4. Adding more complex logic to the process to test dragula capabilities.
Lets go through these steps.

Creating a new project and install ng2-dragula

The first step had several tasks.. The high-level description of the tasks is as follows
  • Create a new project using angular-cli. 
  • Change into the root folder of the newly created directory
  • Install angular/material library using the node package manager (npm). Record this in the package.json using --save command.
  • Install the ng2-dragula and dragula libraries using npm. 
  • Generate a new component - myeditor that will use the dragula library
ng new ng2-4
cd ng2-4
npm install --save @angular/material
npm install --save ng2-dragula dragula
ng generate component myeditor

The big issue is that as time of writing this post, ng2-dragula expected a previous version of angular2 that shows up as unmet peer dependencies. I could not resolve it, but code seemed to work for most part.

Configuring the project to use ng2-dragula

Now that the library is installed, we need to configure the project to use ng2-dragula. This implies the following steps
  • Edit the app.module.ts file and import the DragulaModule from ng2-dragula/ng2-dragula
  • Indicate DragulaModule import in the module declarations so it is accessible to other components in the project. (Note that DragulaModule is a wrapper on a provider called DragulaService and a directive called DragulaDirective within the package).
  • Add a vendor.ts under the /src directory and add a css import

app.module.ts

import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';

import {MaterialModule} from '@angular/material';

import { AppComponent } from './app.component';
import { MyeditorComponent } from './myeditor/myeditor.component';
import { DragulaModule } from 'ng2-dragula/ng2-dragula';


@NgModule({
  declarations: [
    AppComponent,
    MyeditorComponent
  ],
  imports: [
    BrowserModule,
    FormsModule,
    HttpModule,
    MaterialModule.forRoot(),
    DragulaModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Here is a screenshot from my environment



This vendor.ts file only contains an import of a css class as per the documentation but the css import was the most erratic part of the whole process, and I am not sure I got it completely right. Still, I followed the steps in the documentation.

vendor.ts

import 'dragula/dist/dragula.css';

Here is the screenshot from my environment.


Implementing the dragula library

This is the fun part. Implementing dragula is fairly easy. It requires a some css classes to be added to the component css. In the html we simply define the div tags that will define the different boxes and add the dragula directive. The code takes it from there.

Here are the high level steps:

  • In the component html file, we define a simple html that contains an outer div tag which has a class called "wrapper" and 4 inner divs each that have a css class called "container".
  • Each of the div.container elements implements the [dragula]  directive with a specific name - "editor-bag" that indicates that their contents can be dragged within them or between other div.containers which implement the same directive with same bag name.

myeditor.component.html


<div class="wrapper">
  <div class="container master" [dragula]="'editor-bag'">
    <div class="A Z">A</div>
    <div class="B Z">B</div>
    <div class="C Z">C</div>
    <div class="D Z">D</div>
    <div class="E Z">E</div>
  </div>
  <div class="container" [dragula]="'editor-bag'">
    <div class="A Z">A</div>
  </div> 
  <div class="container" [dragula]="'editor-bag'">
  </div> 
  <div class="container" [dragula]="'editor-bag'">
  </div> 
</div>

The accompanying component css class adds some rendering logic including some of the behavior prescribed by the dragula folks. Important things to note are

  • css classes "wrapper" and "container" both implement display:float
  • css class Z ensures that all boxes labeled A through E are rectangles of a certain size and can be dragged around.

myeditor.component.css



*, *:before, *:after {
  -webkit-box-sizing: inherit;
  -moz-box-sizing: inherit;
  box-sizing: inherit;
}

.promo {
  margin-bottom: 0;
  font-style: italic;
  padding: 10px;
  background-color: #ff4020;
  border-bottom: 5px solid #c00;
}

.parent {
  background-color: rgba(255, 255, 255, 0.2);
  margin: 50px 0;
  padding: 20px;
}

.gh-fork {
  position: fixed;
  top: 0;
  right: 0;
  border: 0;
}

.wrapper {
  display: flex;
  background-color: #942A57;
  flex-flow: row wrap;
}
.container {
  display: flex;
  background-color: rgba(255, 255, 255, 0.2);
  width: 100%;
  min-height: 150px;
  min-width: 500px;
  margin-left: 50px;
  margin-right: 50px;
  margin-top:20px;
  margin-bottom:20px;
}
.container:nth-child(odd) {
  background-color: rgba(0, 0, 0, 0.2);
}

.container div,
.gu-mirror {
  margin: 10px;
  padding: 10px;
  background-color: rgba(0, 0, 0, 0.2);
  transition: opacity 0.4s ease-in-out;
}
.container div {
  cursor: move;
  cursor: grab;
  cursor: -moz-grab;
  cursor: -webkit-grab;
}
.gu-mirror {
  cursor: grabbing;
  cursor: -moz-grabbing;
  cursor: -webkit-grabbing;
}
.container .ex-moved {
  background-color: #e74c3c;
}
.container.ex-over {
  background-color: rgba(255, 255, 255, 0.3);
}
.handle {
  padding: 0 5px;
  margin-right: 5px;
  background-color: rgba(0, 0, 0, 0.4);
  cursor: move;
}
nested-repeat-example .container span {
  display: block;
  padding: 8px;
}
.container.master {
    background-color:rgba(155,155,155,1);
    color: white;
}

.Z {
  width: 75px;
  height: 125px;
  background-color: floralwhite;
}

This allows the boxes located in the first row to be moved around to any of the rows in the screen and their relative position to each other can also be modified.

The logic in the app.component.ts is fairly simple as its just the container for the myeditor component.

app.component.ts

<h1>
  {{title}}
</h1>
<myeditor></myeditor>

Finally, app.component.css includes remaining css classes.


body {
  background-color: #FFF9C4;
  margin: 0 auto;
 
}

html, body {
  -webkit-box-sizing: border-box;
  -moz-box-sizing: border-box;
  box-sizing: border-box;
}

*, *:before, *:after {
  -webkit-box-sizing: inherit;
  -moz-box-sizing: inherit;
  box-sizing: inherit;
}

body, input, button {
  font-family: Georgia, Helvetica;
  font-size: 17px;
  color: #ecf0f1;
}

h1 {
  text-align: center;
  background-color: #AC5C7E;
  margin-top: 20px;
  margin-bottom: 0;
  padding: 10px;
}

h3 {
  background-color: rgba(255, 255, 255, 0.2);
  border-bottom: 5px solid #A13462;
  text-align: center;
  padding: 10px;
}

h3 div {
  margin-bottom: 10px;
}

Adding more complex logic

Naturally, this was not complex enough logic. I wanted to extend the logic to ensure..
  • The boxes in the top row (that also implements the css class "master") can be cloned to any other rows, but boxes in top row can never be re-arranged.
  • Boxes in the bottom rows can be re-arranged among themselves but cannot be transferred back to the top row.
  • There are some rules that are followed in positioning the boxes relative to each other.
    • Box A can only be one at the start of the row
    • Box B and D can be anywhere in the row, in any number or any sequence.
    • Box B or D cannot follow Box C.
    • Box C or E can be the last boxes in the row, but there can be no other box after box E.
I decide to implement the logic using different events thrown by the dragula library. I translated the above rules to a setOptions method called within the constructor of the component class.



import { Component, OnInit, Input } from '@angular/core';
import {DragulaService, DragulaDirective} from 'ng2-dragula/ng2-dragula';


@Component({
  selector: 'myeditor',
  templateUrl: './myeditor.component.html',
  styleUrls: ['./myeditor.component.css']
})
export class MyeditorComponent implements OnInit {

  static _debug:boolean = false;
  _debug:boolean = MyeditorComponent._debug;

  static _siblingMap: Map<string, AllowedSiblings> ;

    A_prev: string[] = [];
    A_next: string[] = ["B","C","D","E"];
    B_prev: string[] = ["A","B","D"];
    B_next: string[] = ["B","C","D","E"];
    C_prev: string[] = ["A","B","D"];
    C_next: string[] = ["E"];
    D_prev  = ["A","B","D"];
    D_next: string[] = ["B","C","D","E"];
    E_prev  = ["A","B","C","D"];
    E_next: string[] = [];

  constructor(private dragulaService: DragulaService) { 
    if(MyeditorComponent._siblingMap == null)
        {
          this.setupSiblingMap();
        }
    dragulaService.setOptions('editor-bag',{
      isContainer: function(el) {
        return false;
      },
      moves: function(el, container, handle) {
        return true;//handle.classList.contains('master');
      },
      accepts: function(el, target, source, sibling) {
        var fn_debug = true;
        var acceptAll = false;
        if(!acceptAll)
        {
          if(this._debug || fn_debug) {
            console.log("accepts() start el, target, source, sibling");
            console.log({el,target,source,sibling});
          }
          if(target.classList.contains('master')){
            return false;
          }
          if(sibling==null) {
            return (target.children.length == 0);
          }
          var name:string = el.innerText;
          return MyeditorComponent.areAllowedSiblings(name,sibling);
        }
        return acceptAll;
      },
      invalid: function (el, handle) {
        return false; // don't prevent any drags from initiating by default
      },
      direction: 'vertical',             // Y axis is considered when determining where an element would be dropped
      copy: function(el,source) {
        if(this._debug) {
          console.log("copy() start");
          console.log(el);
          console.log(source);
          console.log("copy() stop");
        }
        return source.classList.contains('master');
      },                       // elements are moved by default, not copied
      copySortSource: false,             // elements in copy-source containers can be reordered
      revertOnSpill: false,              // spilling will put the element back where it was dragged from, if this is true
      removeOnSpill: true,              // 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
    })
  }

  ngOnInit() {
    
     this.dragulaService.drag.subscribe((value:any) => {
         if(this._debug) {
          console.log("drag start");
          console.log(value);
          console.log("drag stop");
          console.log(`drag: ${value[0]}`);
         }
         this.onDrag(value.slice(1));
    });

    this.dragulaService.drop.subscribe((value:any) => {
      console.log(`drop: ${value[0]}`);
      this.onDrop(value.slice(1));
    });
    
    this.dragulaService.over.subscribe((value:any) => {
       if(this._debug) { console.log(`over: ${value[0]}`);}
      this.onOver(value.slice(1));
    });
    
    this.dragulaService.out.subscribe((value:any) => {
       if(this._debug) {console.log(`out: ${value[0]}`);}
      this.onOut(value.slice(1));
    });
  }

  private hasClass(el:any, name:string):any {
    return new RegExp('(?:^|\\s+)' + name + '(?:\\s+|$)').test(el.className);
  }

  private addClass(el:any, name:string):void {
    if (!this.hasClass(el, name)) {
      el.className = el.className ? [el.className, name].join(' ') : name;
    }
  }

  private removeClass(el:any, name:string):void {
    if (this.hasClass(el, name)) {
      el.className = el.className.replace(new RegExp('(?:^|\\s+)' + name + '(?:\\s+|$)', 'g'), ' ');
    }
  }

  private onDrag(args:any):void {
    let [e] = args;
    this.removeClass(e, 'ex-moved');
  }

  private onDrop(args:any):void {
    let [e] = args;
    this.addClass(e, 'ex-moved');
  }

  private onOver(args:any):void {
    let [el] = args;
    this.addClass(el, 'ex-over');
  }

  private onOut(args:any):void {
    let [el] = args;
    this.removeClass(el, 'ex-over');
  }

  private static areAllowedSiblings(name:string,sibling:any):boolean {
    // return true;
    var fn_debug: boolean = false;
    var isValid:boolean = true;
  
    var nextSibling = sibling.nextSibling;
    var prevSibling = sibling.previousSibling;
    var allowedSiblings: AllowedSiblings = null;
    var debugMsg = "";
    if( MyeditorComponent._siblingMap!=null) {
      debugMsg+=("1:"+isValid);
      if(isValid) {
          allowedSiblings = MyeditorComponent._siblingMap.get(name);
          isValid = (allowedSiblings!=null);
          if(this._debug || fn_debug) {
            console.log("allowedSiblings");
            console.log(allowedSiblings);
          }
          debugMsg+=(",2:"+isValid);
      }
      if(isValid) {
          if(this._debug || fn_debug) {
            console.log("nextSibling");
            console.log(nextSibling);
          }
        isValid = MyeditorComponent.isAllowedSibling(nextSibling,allowedSiblings.nextSiblings);
        debugMsg+=(",3:"+isValid);
      }
      if(isValid) {
          if(this._debug || fn_debug) {
            console.log("prevSibling");
            console.log(prevSibling);
          }
        isValid = MyeditorComponent.isAllowedSibling(prevSibling,allowedSiblings.prevSiblings);
        debugMsg+=(",4:"+isValid);
      }
    }
    if(this._debug || fn_debug) {
      console.log("isValid:"+ debugMsg);
    }
    fn_debug = false;
    return isValid;
  }

  private static isAllowedSibling(siblingNode, allowedSiblingArray: string[]):boolean {
    var isValid:boolean = false;
    if(siblingNode == null)
    {
      return true;
    }
    if(siblingNode.nodeName == "#text")
    {
      return true;
    }
    if (allowedSiblingArray.indexOf(siblingNode.innerText)>-1)
    {
      isValid=true;
    }
    return isValid;
  }

  private setupSiblingMap() {
    MyeditorComponent._siblingMap = new Map<string, AllowedSiblings>();
  
    MyeditorComponent._siblingMap.set("A", new AllowedSiblings("A",this.A_prev,this.A_next ));
    MyeditorComponent._siblingMap.set("B", new AllowedSiblings("B",this.B_prev,this.B_next ));
    MyeditorComponent._siblingMap.set("C", new AllowedSiblings("C",this.C_prev,this.C_next ));
    MyeditorComponent._siblingMap.set("D", new AllowedSiblings("D",this.D_prev,this.D_next ));
    MyeditorComponent._siblingMap.set("E", new AllowedSiblings("E",this.E_prev,this.E_next ));
    // if(this._debug) {
    if(true) {
      console.log(MyeditorComponent._siblingMap);
    }
  }
}

export class AllowedSiblings {
  @Input() name:string;
  @Input() prevSiblings: string[];
  @Input() nextSiblings: string[];

  constructor(name:string,
              prevSiblings: string[],
              nextSiblings: string[]) {
    this.name=name;
    this.prevSiblings=prevSiblings;
    this.nextSiblings=nextSiblings;
  }

}

The behavior was only partly implemented correctly. Only the following rules seem to be working.
  • The boxes in the top row (that also implements the css class "master") can be cloned to any other rows, but boxes in top row can never be re-arranged.
  • Boxes in the bottom rows can be re-arranged among themselves but cannot be transferred back to the top row.
  • There are some rules that are followed in positioning the boxes relative to each other.
    • Box A can only be one at the start of the row
    • Box B and D can be anywhere in the row, in any number or any sequence.
    • Box C or E can be the last boxes in the row, but there can be no other box after box E.
These are the errors in the code:

  • No Box can be added after Box A in second row
  • Box C and E cannot be added to any row
Here is a screenshot of the application. The highlighted cells are the ones that were added/ moved.


I will continue to tweak the code and post updates as I fix more errors, or add any explanation.

No comments: