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

Provide a mechanism to respond to a user dragging one element onto another No dependency on external frameworks No html template Applied via attribute 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