Showing posts with label drag. Show all posts
Showing posts with label drag. Show all posts

Monday, June 15, 2015

Using File Drop in Web Pages

Don't Litter - Drop Files in the Right Place!
When I'm making some of the more elaborate comics (such as the fire alarm from Friday or the agile dodgeball game) I like to work out the JavaScript on my test server here on my local network. But sharing the actual comic data (positions of frogs, text bubbles, etc) was always a pain. I would copy and paste JSON from the production server into a SQL statement for my local server or vice-versa. I decided that I should make an "import data" feature directly in the editor.

It is certainly easy enough to put a text area on the screen and let me copy-and-paste in a big JSON string. But while I was doing it, I thought, "Hey, I should just be able to drop a text file in here and have it auto-populate the text area from the file contents."

And so that's what I did.

It's not really that difficult thanks to the File API stuff that's been in JavaScript for a while now. Here is a sample web page that has a single text area on it.

<!doctype html>

<html lang="en">
<head>
  <meta charset="utf-8">
  <title>File Drop</title>
</head>

<body>

  <textarea id="drophere" style="width: 200px; height: 100px;">drop a file here</textarea>

</body>

<script src="https://code.jquery.com/jquery-1.11.2.min.js"></script>

</html>

To allow dropping text files in the text area, the following JavaScript is used.

$(function() {

    $("#drophere").on(
        "dragover",
        function(e) {
            e.preventDefault();
            e.stopPropagation();
        }
    );

    $("#drophere").on(
        "dragenter",
        function(e) {
            e.preventDefault();
            e.stopPropagation();
        }
    );

    $('#drophere').on("drop", function (evt) {

        var e = evt.originalEvent;

        if (e.dataTransfer) {

            if (e.dataTransfer.files.length) {

                evt.preventDefault();
                evt.stopPropagation();

                var file = e.dataTransfer.files[0];

                if (file.type != "text/plain") {
                    console.log("wrong file type");
                } else {

                    var reader = new FileReader();
                    reader.onload = function(fevent) {
                        var txt = fevent.target.result;
                        $('#drophere').val(txt);
                    }
                    reader.readAsText(file);

                }

            }

        }

    });

});

Since I use jQuery, everything is wrapped in a function that will be called as soon as the document is fully ready. Before setting up the actual drop handler, there are two other event handlers that should be registered to prevent undesirable browser behavior.

The first, on line 3, is the ondragover event handler. This event fires constantly when an element is being drug over a drop target. All the event handler does here is prevent the default behaviors of the browser, which in the case of a text area is to move the cursor around where the drop will take place. That isn't needed in my case because I plan on replacing the entire contents of the text area when the drop occurs.

The second event handler (line 11) is for the dragenter event. This event fires once when the element being drug first enters the drop zone. Again, I am just turning off the browser default behavior in here.

The next and final event handler that I register is for the drop event. This is where the good stuff happens. Because jQuery's event object wrapper doesn't really have direct support for the dataTransfer element, the first thing I do here is get the original event object from it. That's the object I will be using for most of the subsequent processing. First I check to make sure that there is a data transfer associated with this event and that the list of files in that transfer is at least one. If those two checks pass, I once again turn off event propagation and the default browser behavior. Remember, the browser will typically load any file you drop on a page as a new document - definitely not what I want to happen!

The next step is to get the file from the list of files in the data transfer and check the type. For my purposes, I only want to accept files that are plain text. It wouldn't make sense to drop an image or something in a text area! Assuming that the file type checks out, I can finally read the contents of the file. On line 36 I create a FileReader and then set the onload event handler. This is the function that will be called with the file data (or possibly an error) once the read is complete. It will be passed an event object, in which the text can be found in the target.result field. Once this function is set up, I just call readAsText and pass in the file (line 41).

Inside the onload function, line 39, is where I set the value of the text area to the contents of the text file. You could just as easily send the file contents directly to the server at this point or do some other processing, This technique will work on other kinds of files as well - instead of reading as text you could read as a binary string or an array buffer or a data URL. See the documentation for more info!

Give my demo a try for yourself and see how convenient it is to drop text file contents in text areas. You'll probably want to add this feature anywhere you have a text area on your own web pages.

And now the obligatory link to today's comic!

Amphibian.com comic for 15 June 2015

Monday, October 27, 2014

Beware the Draggin'

One interesting thing I learned while developing 2-Player Solitaire is that there is no way to programatically cancel a drag in jQuery UI. Once a user starts dragging something, the drag has to finish. That means you have to process any drop events that might occur.

Making something draggable is really easy. Making something a drop target is really easy.

<!doctype html>

<html lang="en">
<head>
    <meta charset="utf-8">
</head>

<body>
    <div id="draggable">What a drag.</div>
    <div id="dropzone">Drop here.</div>
</body>

<script src="https://code.jquery.com/jquery-1.11.1.min.js"></script>
<script src="https://code.jquery.com/ui/1.11.1/jquery-ui.js"></script>
<script>

$("#draggable").draggable();
$("#dropzone").droppable();

</script>

</html>

I was in the unfortunate position of having a virtual card table that allowed 2 players to go after the same cards to drag them around. As soon as one person started dragging, the server would place a lock on the card so that no one else could start dragging it. However, since both clients could start the drag and send the "drag start" event to the server simultaneously, one of the draggers would have to lose. The server would lock on one of the events and then asynchronously send a message to the other telling it to stop dragging. That last part turned out to be difficult.

The folks that make jQuery won't provide a "cancel" method. They have some good reasons for doing so, which you can read about on the feature request ticket. In general I agree with their reasoning, but I was trying to create a very non-standard UI. It had to give the impression that the other player just grabbed the card before you did, instead of letting you both drag it around independently and then tell one of you at the end that you were wasting your time.

What did I do? I learned that if you return false from a draggable's start or drag event handlers, it will stop the drag. In the case of the start handler, the drag never really gets to begin. Still, that didn't help me because I couldn't perform a synchronous check with the sever in that handler to see if it should allow the drag. Returning false from the drag event's handler stops the drag, but still fires the drop event if the item being dragged ends up over a droppable area. Since my intent was to make it as if the drag never really happened, that side effect was problematic.

$("#draggable").draggable({
    start: function(event, ui) {
        var ok = true; // determine if dragging is ok to start
        return ok;
    },
    drag: function(event, ui) {
        var ok = true; // determine if dragging should continue
        return ok;
    }
});

It wasn't perfect, but it was my only option. If a player started dragging a card at the same time as the other player, one of them would get shut down by the server. The server would emit an "abort drag" event to the client (via Socket.io) and the card would get a flag set on it, telling the drag event handler function that it needed to return false. The drop event handler also checked this flag, and after clearing the flag, returned immediately instead of doing the normal drop stuff. This was enough to provide the illusion that the drag never really started. And it works in my case because I don't specify a handler for the droppable's drop event - if I did, and the drag was canceled while over that area, the drop event would still be fired there. That might produce more unwanted side effects.

In the following example, I set a timer to cancel the drag 2 seconds after you start it. This provides a good example of an asynchronous even causing the cancel. Drag and drop in under 2 seconds, and you see the "normal" path. Hold on to the draggable for more than 2 seconds, and it will get cancelled on you.

$("#draggable").draggable({
    start: function(event, ui) {
        var d = this;
        d.cancelDrag = false;
        window.setTimeout(function() {
            d.cancelDrag = true;
        }, 2000);
    },
    drag: function(event, ui) {
        if (this.cancelDrag) {
            console.log("stopping the drag!");
            return false;
        }
    },
    stop: function(event, ui) {
        if (this.cancelDrag) {
            console.log("drag was cancelled, don't do anything");
            this.cancelDrag = false;
            return;
        }
        console.log("drag was completed normally");
        // do other stuff here...
    }
});

If this method doesn't suit you, there is another possibility. In my research, I came across this very interesting way to allow a drag to be canceled without firing any events on drop targets. It works by programmatically generating a mouse event which is then dispatched to the window. It tricks jQuery into thinking that the user released the mouse button (thereby stopping the drag) while the cursor was in an impossible location (thereby not over any droppable targets). In the following example, the drag is aborted by pressing the ESC key (note the keydown event checking for keyCode == 27) but you could really do the same thing from anywhere.

$( window ).keydown( function( e ){
    if( e.keyCode == 27 ) {
        var mouseMoveEvent = document.createEvent("MouseEvents");
        var offScreen = -50000;

        mouseMoveEvent.initMouseEvent(
            "mousemove", //event type : click, mousedown, mouseup, mouseover, etc.
            true, //canBubble
            false, //cancelable
            window, //event's AbstractView : should be window
            1, // detail : Event's mouse click count
            offScreen, // screenX
            offScreen, // screenY
            offScreen, // clientX
            offScreen, // clientY
            false, // ctrlKey
            false, // altKey
            false, // shiftKey
            false, // metaKey
            0, // button : 0 = click, 1 = middle button, 2 = right button
            null // relatedTarget : Only used with some event types (e.g. mouseover and mouseout).
                 // In other cases, pass null.
        );

        // Move the mouse pointer off screen so it won't be hovering a droppable
        document.dispatchEvent(mouseMoveEvent);

        // This is jQuery speak for:
        // for all ui-draggable elements who have an active draggable plugin, that
        var dragged = $(".ui-draggable:data(draggable)")
            // either are ui-draggable-dragging, or, that have a helper that is ui-draggable-dragging
            .filter(function(){return $(".ui-draggable-dragging").is($(this).add(($(this).data("draggable") || {}).helper))});

        // We need to restore this later
        var origRevertDuration = dragged.draggable("option", "revertDuration");
        var origRevertValue = dragged.draggable("option", "revert");

        dragged
            // their drag is being reverted
            .draggable("option", "revert", true)
            // no revert animation
            .draggable("option", "revertDuration", 0)
            // and the drag is forcefully ended
            .trigger("mouseup")
            // restore revert animation settings
            .draggable("option", "revertDuration", origRevertDuration)
            // restore revert value
            .draggable("option", "revert", origRevertValue);
    }
}

Cool idea. I can't take credit for that one, it was written by "eleotlecram" who I know only by his Stack Overflow profile page.

Happy dragging!

Amphibian.com comic for 27 October 2014