Opinionated Programmer - Jo Liss's musings on enlightened software development.

Roll your own drag-and-drop handling, with help from jQuery UI

In which I show how to harness jQuery UI’s Mouse plugin to roll your own drag-and-drop handling, when Draggable is not flexible enough for you.

Overview

Sometimes you need tighter control over drag-and-drop logic than jQuery UI’s Draggable and Droppable plugins afford. For instance, when I wrote up the Solitr game, I initially used Draggable, but I ended up with an unmaintainable mess of auxiliary “drop-zone” divs, and I also didn’t find the drop logic to be flexible enough for a game.

But simply binding to mousedown and mousemove events yourself will cause a headache because you’d have to work around subtle cross-browser compatibility issues.

Luckily, jQuery UI comes with a Mouse plugin. (Incidentally, Draggable derives from this.) We can use this to handle mouseStart, mouseDrag, and mouseStop events in a way that works consistently across browsers.

Setup

It’s not possible/useful to instantiate the mouse widget directly, but we can easily subclass it to make it usable with our own custom event handlers. Simply copy and paste the following code, which registers a custommouse plugin, to get started:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$.widget('ui.custommouse', $.ui.mouse, {
  options: {
    mouseStart: function(e) {},
    mouseDrag: function(e) {},
    mouseStop: function(e) {},
    mouseCapture: function(e) { return true; }
  },
  // Forward events to custom handlers
  _mouseStart: function(e) { return this.options.mouseStart(e); },
  _mouseDrag: function(e) { return this.options.mouseDrag(e); },
  _mouseStop: function(e) { return this.options.mouseStop(e); },
  _mouseCapture: function(e) { return this.options.mouseCapture(e); }
  // Bookkeeping, inspired by Draggable
  widgetEventPrefix: 'custommouse',
  _init: function() {
    return this._mouseInit();
  },
  _create: function() {
    return this.element.addClass('ui-custommouse');
  },
  _destroy: function() {
    this._mouseDestroy();
    return this.element.removeClass('ui-custommouse');
  },
});

Now instantiate the custommouse plugin we just defined, and pass your own event handlers:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$('#containerElement').custommouse({
  mouseStart: function(e) {
    // Handle the start of a drag-and-drop sequence here ...
  },
  mouseDrag: function(e) {
    // Handle the dragging ...
  },
  mouseStop: function(e) {
    // Handle the drop ...
  },
  mouseCapture: function(e) {
    // Optional event handler: Return false here when you want to ignore a
    // drag-and-drop sequence, so the start/drag/stop events don't fire ...
    return true;
  }

  // Goodies from the Mouse plugin:
  // Minimum distance in pixels before dragging is triggered
  //distance: 1
  // Minimum time in milliseconds before dragging is triggered
  //delay: 0
});

Event Sequence

Say the user starts dragging horizontally at point 50, 50, with distance set to 10. Then the event sequence is guaranteed to be as follows.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Event         e.pageX, e.pageY  Notes
============= ================= =======================================
mouseCapture  50, 50            Subsequent events only trigger if true

    ... user drags until they reach 60, 50 ...

mouseStart    50, 50            Mouse cursor is already at 60, 50, but
                                this triggers "late" at the original
                                position, once minimum distance and
                                delay are exceeded

mouseDrag     60, 50            First mouseDrag fires event immediately
                                after mouseStart, at real cursor
                                position

    ... user keeps dragging ...

mouseDrag     63, 50

    ... and dragging ...

mouseDrag     68, 50

    ... and releases the mouse ...

mouseStop     68, 50            Perhaps this is not guaranteed to be in
                                the same position as the last mouseDrag

So much for the theory. Let me give you some practical hints on how to implement this:

Practical Hints

There are many coordinate properties on the event object, but you should use e.pageX and e.pageY, which are standardized by jQuery to return the coordinates relative to the top left corner of the entire document.

The only exception is the elementFromPoint method, which on modern browsers takes e.clientX and e.clientY and returns the element under that point.

1
2
3
4
5
mouseStart: function(e) {
  this.element = document.elementFromPoint(e.clientX, e.clientY);
  this.originalElementPosition = $(this.element).position();
  this.dragStart = { left: e.pageX, top: e.pageY };
}

Then in the mouseDrag handler, calculate the offset:

1
2
3
4
5
6
7
8
9
10
11
mouseDrag: function(e) {
  var dragOffset = {
    left: e.pageX - this.dragStart.left,
    top: e.pageY - this.dragStart.top
  };
  // Assuming the element is absolutely positioned already
  $(this.element).css({
    left: this.originalElementPosition.left + dragOffset.left,
    top: this.originalElementPosition.top + dragOffset.top
  });
}

Finally, in mouseStop, snap the element to the nearest drop point (or whatever logic you want to implement), and update the application state if necessary.

Finally

It would be sweet to handle touch events to make this work on mobile devices. Unfortunately, the Mouse plugin doesn’t support touch handling yet. I have a feeling that there will a lot of issues with inconsistent browser behavior if I try to do this myself, so I’m leaving it for now.

In any case, I hope that this post was helpful to you. If you have practical insights or alternative techniques to share (perhaps even without using jquery.ui.mouse), please leave a comment!