Tuesday, November 6, 2012

Dropping into Orion: HTML5 Drag and Drop

One of our goals for the next Orion release (2.0) is to make it a lot easier to get your stuff in and out of Orion. We've always supported uploading files, using SFTP, and cloning git repositories, but one could argue that the non-git scenarios haven't always been...errrrr...very usable. After fighting with Orion too much to get some image files replaced in a web site I'm playing with, I figured it was worth the time to implement support for dropping files from the desktop into Orion.

There are already a number of good articles and examples out there to follow, and I'm sure some of this discussion will be obsolete soon enough, as browser support evolves. In this post, I'll focus on Orion's role as the drop target for desktop files and folders, and some finer details such as feature detection, drop effects, and some notable browser differences.

Good starting points

A quick search on "HTML5 drag and drop" will give you plenty of articles and examples to start with, many of them over three years old. Here are a few that were key to my understanding.

Devil in the details

Your first clue that you'll need to do a little digging is the second sentence in the spec.
"This specification does not define exactly what a drag-and-drop operation actually is."
The sentence is actually referring to the UI gesture, and the fact that it might not be the traditional mouse down, move, mouse up sequence. But I think it's a good mantra to keep in mind anyway. Your code and the user's browser ultimately control the operation semantics.

What can my browser do?

Detecting what's implemented is important for setting user expectation. I checked Modernizr to see what code is used to detect drag and drop support, and then quickly learned that detecting drag and drop support is not enough. For example, IE9 implements drag and drop, but not the file API that is needed to do desktop file drag and drop. You need to check for both. The Modernizr feature detect directory is a great place to browse the feature detection code. Using both of those checks, I can see if file drag/drop is supported.

var supportsFileDragDrop = 
   (('draggable' in myNode ) || ('ondragstart' in myNode && 'ondrop' in myNode )) &&
        !!(window.File && window.FileList && window.FileReader);

The Orion navigator logs the lack of drag and drop support to the console in this case, and doesn't bother trying to hook up any events.

What events to hook

By default, only certain kinds of DOM elements recognize drag and drop. You need to hook four events to tell the browser that you want your DOM element to be a drop target. One of the initial brain-benders in this API is that since the default behavior is NOT to be a drop target, you have to hook some events and prevent the default behavior and event propagation in order to cause the desired behavior to happen. This takes a little getting used to and has caused some major ranting about this API. The snippets below show you how to hook the events and minimally prevent the default browser processing. (These snippets are purposefully library independent, so perhaps a bit more verbose.)

  • dragenter is where you tell the browser that your node wants to be a drop target. As mentioned, you do this by preventing the default browser behavior. You might also want to set a class on your node to indicate that it is a drop target.
    node.addEventListener("dragenter", function(evt) {
       node.classList.add("dropTarget");
       evt.preventDefault();
       evt.stopPropagation();
    }, false); 
    
  • dragover is where you continue to express your undying loyalty to the idea of being a drop target. Again, you want to prevent the default event handling and bubbling. It sounds redundant, but this event gives you the opportunity in more complex scenarios to stop the drag if some state changes in your application.
    node.addEventListener("dragover", function(evt) {
       evt.preventDefault();
       evt.stopPropagation();
    }, false); 
    
  • drop is where the work for drop actually gets done. More on this soon enough.
  • dragleave gives you a chance to clean up when the drag operation is done. For example, if you added a class when the drag began, you can remove it when the drag is done.
    node.addEventListener("dragleave", function(evt) {
       node.classList.remove("dropTarget");
       evt.preventDefault();
       evt.stopPropagation();
    }, false); 
    

Handling dropped files

The dataTransfer property in the various drag events is where you can figure out what work to do. If your browser supports file drag and drop, this property should contain a list of files that are being dragged (or have been dropped.) You can use this to figure out if you want to accept the files or not. One caveat here is that folders will be in this list, also, and must be handled differently. In this snippet, we reverse engineer a way to tell if a file is indeed a folder.

node.addEventListener("drop", function(evt) {
   if (evt.dataTransfer.files && evt.dataTransfer.files.length > 0) {
      for (var i=0; i < evt.dataTransfer.files.length; i++) {
         var file = evt.dataTransfer.files[i];
         // this test is reverse engineered test for folders
         // see http://www.w3.org/TR/FileAPI/#file
         if (!file.length && (!file.type || file.type === "")) {
            window.console.log("Skipping directory " + file.name);
         } else {
     dropFile(file); // upload this file to the Orion server
         }
      }
   } 
   evt.preventDefault();
   evt.stopPropagation();
}, false);

Handling folders

Dragging files is useful, but if you have a folder structure you want to drag into the Orion navigator, you want more than files. You want the folder structure to be replicated on the Orion server. Fortunately, this is a common requirement and the Drag and Drop Entry API allows you to traverse folder structures. At the time of this writing, this specification is only supported on Chrome (since Chrome 21). We check for the file entry API in the drop event handler before reverting back to the file list.

node.addEventListener("drop", function(evt) {
   node.classList.remove("dragOver");
   // webkit supports testing for and traversing directories
   if (evt.dataTransfer.items && evt.dataTransfer.items.length > 0) {
      for (var i=0; i < evt.dataTransfer.items.length; i++) {
         var entry = null;
         if (typeof evt.dataTransfer.items[i].getAsEntry === "function") {
            entry = evt.dataTransfer.items[i].getAsEntry();
         } else if (typeof evt.dataTransfer.items[i].webkitGetAsEntry === "function") {
            entry = evt.dataTransfer.items[i].webkitGetAsEntry();
         }
         if (entry) {
            dropFileEntry(entry, null, targetFolder);
         } else {
            window.console.log("Error processing " + evt.dataTransfer.items[i]);
         }
      }
   } else {
      // entries not supported, do the file list handling.... 
   }
}, false);
The dropFileEntry function checks to see if the file is a folder. It will traverse the folder contents if needed, creating Orion server directories along the way. This code is a bit more complex, as it is using Orion's deferred file API's, but I think it's better to show the real thing than to contrive an example. Here we already have some json data representing the target folder object on the server. For simplicity, we log messages to the console rather than reporting in the UI.
function dropFileEntry(entry, path, target) {
   path = path || "";
   if (entry.isFile) {
      // can't drop files directly into workspace.
      if (target.Location.indexOf('/workspace') === 0){ //$NON-NLS-0$
         window.console.log("You cannot copy files directly into the workspace.  Create a folder first.");
      } else {
         entry.file(function(file) {
            performDrop(target, file);
         });
      }
   } else if (entry.isDirectory) {
      var dirReader = entry.createReader();
      // create a folder and then update the UI and traverse the folder content using our new folder as target
      fileClient.createFolder(target.Location, entry.name).then(function(subFolder) {
         explorer.changedItem(target, true);
         dirReader.readEntries(function(entries) {
            for (var i=0; i < entries.length; i++) {
               dropFileEntry(entries[i], path + entry.name + "/", subFolder);
            }
         });
      });
   }
}

Drop effects

One annoyance with my initial implementation was that the icon shown during drag said "Move". But uploading a file or folder to a server is most definitely a copy. The API supports this, but not all browsers do yet. To specify that a copy is going to happen, you need to set the dropEffect in both the "dragenter" and "dragover" events. (Setting it only the "dragenter" was not enough. I tried.) I also added a check on the effectAllowed flag to ensure that "copy" is supported by the drag source.

if (evt.dataTransfer.effectAllowed === "all" ||  
   evt.dataTransfer.effectAllowed === "uninitialized" || 
   evt.dataTransfer.effectAllowed.indexOf("copy") >= 0) {  
      // only supported in Chrome.
      evt.dataTransfer.dropEffect = "copy"; 
      node.classList.add("dragOver"); 
}   
This gives me a proper copy icon on Chrome.
Unfortunately we have a misleading "move" icon on Firefox. That issue is tracked in bugzilla.

Bleeding edge

If you've stumbled upon this post and it's not early November 2012, make sure you check all the spec and proposal links in this article to understand the state of the art for both the spec and for browser support. My goal here is to show you some code you can work with, but more importantly show you the places to check for the latest information, and also how to check to see what your browser can do. When searching for the latest and greatest, you'll want to be specific, not just about drag and drop, but about the File API and the Drag Entry API.

4 comments:

  1. Unfortunately your browser support detection method would deny Safari 5 users (window.FileReader is undefined in Safari) but Safari 5 DOES support file drag and drop.

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete
  3. if (!file.length && (!file.type || file.type === "")) does not work if the file does not have extension.

    ReplyDelete