AngularJS native drag and drop

Problem

I recently needed to add drag & drop functionality to an angularjs web application I’m working on, none of the existing directives did exactly what I needed so I built my own. In building the directive, I needed a service to create UUIDs, so I built one of them too.

The code presented in this post can be found on github.

Design Goals

  1. Provide a mechanism to respond to a user dragging one element onto another
  2. No dependency on external frameworks
  3. No html template
  4. Applied via attribute
  5. Use native HTML5 drag & drop api

Supported Browsers

  • IE 10
  • FF 3.5
  • Chrome 4
  • Safari 3.1
  • Opera 12

View detailed support information on Can I use

Implementation

One trap I wanted to avoid was having the directive do too much. All too often this is where good directives go bad; implementations get overly complicated when concerns aren’t properly separated. To achieve my primary goal to give page authors a hook for dealing with an element being dropped onto another element, I started with the callback signature I wanted

$scope.dropped = function(dragEl, dropEl) {....};

With my goal in mind, I consulted the internet to learn a bit about drag & drop & html5. I found a number of different references, the easiest to follow is the Drag and Drop Tutorial on HTML5 Rocks, and then I consulted MDN for gory details (starting with the draggable attribute).

Moving parts

My implementation relies on 2 directives and a service

lvl-draggable
Used to indicate an element that can be dragged
lvl-drop-target
Used to indicate an element can receive a draggable element and the callback function to fire when that occurs
uuid
A simple service for working with UUIDs

jQuery Compatibility

A reader, Ibes, found the following bug when jQuery was included on the same page as this directive.

Uncaught TypeError: Cannot call method 'setData' of undefined lvl-drag-drop.js:19
Uncaught TypeError: Cannot set property 'dropEffect' of undefined lvl-drag-drop.js:51
Uncaught TypeError: Cannot call method 'getData' of undefined lvl-drag-drop.js:74

I’m happy to say that it’s an issue with jQuery and you can resolve it by adding the following code when the page loads.

jQuery.event.props.push('dataTransfer');

Draggable directive

To make an element draggable we need to do a couple of things. First, the element must be decorated with the attribute draggable='true'. Next the DataTransfer needs to be populated. This object is used to shuttle data between elements during the drag operation. Since my api calls for the dragged element to be returned in the callback I will fill the DataTransfer object with the id of the element being dragged, but how best to ensure an element has an id?

Detour: the UUID service

So, this is a problem that has bugged me for a while, I come from a C# background and I use UUIDs often (GUIDs in the C# world). The .NET framework has a very simple API for generating GUIDs and I finally implemented a service that I can use similarly in the client.

Supported Operations

new()
Quickly generates a new UUID that is RFC4122 compliant
empty()
Returns an empty UUID (00000000-0000-0000-0000-000000000000)

Using stackoverflow I was able to find a suitable implementation in under 2 minutes, and it was just a matter of wrapping it up into an angular factory.

angular
.module('lvl.services',[])
.factory('uuid', function() {
    var svc = {
        new: function() {
            function _p8(s) {
                var p = (Math.random().toString(16)+"000000000").substr(2,8);
                return s ? "-" + p.substr(0,4) + "-" + p.substr(4,4) : p ;
            }
            return _p8() + _p8(true) + _p8(true) + _p8();
        },
        
        empty: function() {
          return '00000000-0000-0000-0000-000000000000';
        }
    };
    
    return svc;
});

The only trickiness here is on Line 7 (the rest of the code is explained in detail on the author’s blog). The line can be decomposed into the following parts

Math.random()
Returns a random # between 0 & 1
.toString(16)
Convert the number into base16
+”000000000″
Add 9 trailing 0s in case the random number generator doesn’t return enough digits
.substr(2, 8)
Grab 8 digits of the random hex number (after the decimal point)

Finishing the draggable directive

Ok, done with the service now we have a way to give unique ids to elements when we need to. So, to review, this directive will add the draggable='true' attribute to an element, ensure the element has an id, and populate the DataTransfer object with the element’s id.

var module = angular.module("lvl.directives.dragdrop", ['lvl.services']);

module.directive('lvlDraggable', ['$rootScope', 'uuid', function($rootScope, uuid) {
	    return {
	        restrict: 'A',
	        link: function(scope, el, attrs, controller) {
	            angular.element(el).attr("draggable", "true");

	            var id = angular.element(el).attr("id");
	            if (!id) {
	                id = uuid.new()
	                angular.element(el).attr("id", id);
	            }
	            
	            el.bind("dragstart", function(e) {
	                e.dataTransfer.setData('text', id);
	                $rootScope.$emit("LVL-DRAG-START");
	            });
	            
	            el.bind("dragend", function(e) {
	                $rootScope.$emit("LVL-DRAG-END");
	            });
	        }
    	}
	}]);

Line 1: Reference the UUID service factory

Line 3 Inject the uuid service

Line 7: Add the draggable attribute to the element

Lines 9 - 13: Ensure the element has an id

Line 15: Bind the element to the dragstart event. The callback function executes when a user first drags the element.

Line 16: Populate the DataTransfer object. One issue here is that both the HTML5 tutorial and MDN set the data by specifying the content-type (text/plain), however this breaks IE. Setting the data as ‘text’ works across all the browsers.

Line 17: Fire a custom event to notify interested parties that the user has begun dragging an element

Line 20: Bind the element to the dragend event. The callback function executes when the user stops dragging an element (regardless of whether the element was dropped or not)

Line 21: Fire a custom event to notify interested parties that the user has completed the drag operation.

So the draggable directive is straight forward, but it doesn’t do too much. If you just add the x-lvl-draggable='true' attribute to an element in your page, you can drag it around, but nothing happens.

The lvl-drop-target directive

The drop target directive is responsible for firing the callback on the parent controller when a draggable object is dropped onto the element the directive is applied to.

module.directive('lvlDropTarget', ['$rootScope', 'uuid', function($rootScope, uuid) {
	    return {
	        restrict: 'A',
	        scope: {
	            onDrop: '&'
	        },
	        link: function(scope, el, attrs, controller) {
	            var id = angular.element(el).attr("id");
	            if (!id) {
	                id = uuid.new()
	                angular.element(el).attr("id", id);
	            }
	                       
	            el.bind("dragover", function(e) {
	                if (e.preventDefault) {
	                  e.preventDefault(); // Necessary. Allows us to drop.
	              }
	              
	              if(e.stopPropagation) { 
	                e.stopPropagation(); 
	              }

	              e.dataTransfer.dropEffect = 'move';
	              return false;
	            });
	            
	            el.bind("dragenter", function(e) {
	              angular.element(e.target).addClass('lvl-over');
	            });
	            
	            el.bind("dragleave", function(e) {
	              angular.element(e.target).removeClass('lvl-over');  // this / e.target is previous target element.
	            });

	            el.bind("drop", function(e) {
	              if (e.preventDefault) {
	                e.preventDefault(); // Necessary. Allows us to drop.
	              }

	              if (e.stopPropogation) {
	                e.stopPropogation(); // Necessary. Allows us to drop.
	              }

	              var data = e.dataTransfer.getData("text");
	              var dest = document.getElementById(id);
	              var src = document.getElementById(data);
	                
	              scope.onDrop({dragEl: src, dropEl: dest});
	            });

	            $rootScope.$on("LVL-DRAG-START", function() {
	              var el = document.getElementById(id);
	              angular.element(el).addClass("lvl-target");
	            });
	            
	            $rootScope.$on("LVL-DRAG-END", function() {
	              var el = document.getElementById(id);
	              angular.element(el).removeClass("lvl-target");
	              angular.element(el).removeClass("lvl-over");
	            });
	        }
    	}
	}]);

Line 1: Inject the uuid service

Line 5: Define the drop function callback. The & operator "provides a way to execute an expression in the context of the parent scope"

Lines 8 - 12: Ensure the element has an id

Lines 14 - 25: Preventing the default browser behavior allows us to drop in FF

Lines 27 - 29: Add the css class lvl-over to the element when a dragged object is hovering over it

Lines 31 - 33: Remove the css class lvl-over

Lines 35 - 49: Fires the callback when the dragged object is dropped onto this element (the drop target). First we prevent the browser from performing default actions so we can complete the drop operation. Next we retrieve the dragged element’s id from the DataTransfer object, then we retrieve the native DOM elements involved in the operation. Finally we call the function on the parent controller passing in the native dragged element along with the drop target.

Lines 51 - 54: Handle the LVL-DRAG-START event by applying the style lvl-target to this element

Lines 56-60: Handle the LVL-DRAG-END event by removing the styles lvl-over and lvl-target from the element

Styling elements

The styling requirements are minimal, and are just necessary to provide visual cues to the user.

[draggable]
This will apply to all elements decorated with the lvl-draggable attribue (or, more precisely the draggable attribute). Setting the cursor property to move is a safe bet.
lvl-target
This will apply to all elements on the page that have been decorated with the lvl-drop-target attribute while a drag operation is in process
lvl-over
This will apply to an element decorated with the lvl-drop-target attribute when a draggable object is hovering over it

Using the directives

Alright, so, it’s all done. Here’s how to use it

Html

  <!-- include the uuid service as well as the directive -->
  <script src="script/lvl-uuid.js"></script>
  <script src="script/lvl-drag-drop.js"></script>
		
  <style>
    .lvl-over {
      border: 2px dashed black !important;
    }

    .lvl-target {
      background-color: #ddd; 
      opacity: .5;
    }

    [draggable] {
      cursor: move;
    }
  </style>

  <!-- make an element draggable -->
  <div x-lvl-draggable='true'>drag me!</div>

  <!-- create a drop target and specify a callback function>
  <div x-lvl-drop-target='true' x-on-drop='dropped(dragEl, dropEl)'>drop zone</div>

perform application logic for dropped elements in your controller.

Script

angular
  .module('myApp', ['lvl.directives.dragdrop'])
  .controller('myCtl', ['$scope', function($scope) {
    $scope.dropped = function(dragEl, dropEl) {
      // this is your application logic, do whatever makes sense
      var drag = angular.element(dragEl);
      var drop = angular.element(dropEl);

      console.log("The element " + drag.attr('id') + " has been dropped on " + drop.attr("id") + "!");
    };
  }]);

The end

I hope you find these directives useful. I welcome any questions, comments, or suggestions.

~~~jason

25 thoughts on “AngularJS native drag and drop

  1. Pingback: AngularJS Highlights – Week Ending 15 September 2013 | SyntaxSpectrum
  2. Thanks very much for the examples, Jason. This definitely feels very much like the “angular” way of doing things, even though I’m still trying to wrap my head around some of the details.

    I tried to use your approach for another toy project I’m working on, where the drop targets are rather large divs with a number of child elements. Unfortunately, when I drag my draggables over the child elements, the browser fires the ‘dragleave’ event on my parent div before calling ‘dragenter’ on the child. I gather this is expected behavior from the browser, but it means I lose the ‘lvl-over’ class for the parent. And yet, I only really wanted the larger parent be a viable target drop area. Do you have suggestions on how one might account for this?

    (There seems to be a nice jQuery solution here: http://stackoverflow.com/questions/10867506/dragleave-of-parent-element-fires-when-dragging-over-children-elements but I can’t quite wrap my head around how to do something similar with your lvlDropTarget directive.)

    • First I would look to see if there is a way to simplify the HTML to prevent the nesting and still provide the functionality you are looking for. Maybe elements positioned absolutely would work.

      • I don’t have time to work on this now, but, I would be happy to accept a pull request. The problem is that the javascript touch events are wired up.

    • Yes, you just need to think about the UI implications – for instance, dragging & dropping a button may be difficult b/c a user would expect the mouse click to fire the button, not start a drag operation. It’s doable, but some elements lend themselves to drag/drop operations better than others.

  3. This is very interesting. I’ve been working with this code, and I can see how this could be adapted to help in some useful applications.

    • I haven’t tried,but I’m not surprised it doesn’t work. I’d try to narrow down where it’s failing, and then look for a polyfill for that functionality.

  4. Hi, i’m trying to use your cool directive for a soccer manager app. There is a list of players and you can drag the players and drop them in the field, to compose a formation.

    Now, when I add x-lvl-draggable=”true” to a the directive works fine. But when i use it on a the dropped dragEl is null… have you an idea on why this happens?

    Thank you really much 🙂

    Piero

    • I’m not sure I understand the issue. You are trying to make the same element both draggable and a drop target? I guess that could work, your best best would be to make a failing test to narrow down the problem.

  5. Angular Newbie question : Loved the demo what would be the easiest way to inject the html of source into target on drop?

    • I think you can add the source as a child element of the target using angular’s jqlite implementation using either append() or after()

  6. Thanks for the directives, a few suggestions for updates:

    If I generate the draggables using angular, setting the ID with angular (eg. ng-attr-id=”foo-{{$index}}” or id=”bar-{{$index}}”) then the following line:
    var id = angular.element(el).attr(“id”);

    will actually give the value =”foo-{$index}” and not “foo-0”, “foo-1” etc., as you’d expect. Instead, you can use:
    var id = attrs.id; // the attrs from the function parameters

    Second suggestion, currently some security measures has been added since you wrote the post, so this part:
    var data = e.dataTransfer.getData(“text”);
    var dest = document.getElementById(id);
    var src = document.getElementById(data);

    scope.onDrop({dragEl: src, dropEl: dest});

    results in: Error: [$parse:isecdom] http://errors.angularjs.org/1.5.0/$parse/isecdom?p0=dropped(dragEl%2CdropEl)

    the error comes from passing on the elements which is unfortunate, because it’s a really handy place to pass on the elements. The only useful solution I found so far is to pass on the IDs rather than the elements, this has a slight advantage, since the dataTransfer could be modified to other functionality, if desired:

    var data = e.dataTransfer.getData(“text”);
    scope.onDrop({dragEl: data, dropEl: id});

    I hope you find it useful, and thanks for a good start to get my things running 🙂

  7. Pingback: Angular — drag and drop? – Just another WordPress site

Leave a reply to jason Cancel reply