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