Minimal Drag and Drop support in Javascript
One of the things that makes front-end web development so complicated is that the web was really, really not designed for the sort of "desktop app" emulation that we demand of it these days. Comprehensive attempts to redesign browser interfaces in a more app-friendly way — Java Applets and Adobe Flash, for instance — have mostly been rejected; for various reasons (some legitimate, some... less so) we stick with Javascript and DOM-based solutions to webapp design problems. One such problem is the drag and drop interface that desktop GUI users have come to expect as standard, but which HTML struggles with.
jQuery UI allows you to attach draggable
or droppable
attributes to HTML elements like div
s to
include drag-and-drop support, but jQuery UI is pretty heavyweight if all you really want is to allow users to drag elements around and
respond to where they've been dragged. Supporting this functionality with "vanilla" (that is, nothing besides what's already included
with the browser) Javascript is low footprint and, once you make sense of the standard event interface, not too hard to support. It also
makes a handy demonstration of Javascript's partial function application support.
A very minimal drag and drop framework is illustrated in listing 1; it's demonstrated below with Sample #1, which you can drag and drop around the page.
function dragelem(evt) {
var drag = document.getElementById("drag");
drag.style.left = evt.pageX;
drag.style.top = evt.pageY;
}
function drop(evt) {
var drag = document.getElementById("drag");
window.removeEventListener("mousemove", dragelem);
window.removeEventListener("mouseup", drop);
}
function startdrag(evt) {
window.addEventListener("mousemove", dragelem);
window.addEventListener("mouseup", drop);
}
window.onload = function() {
var drag = document.getElementById("drag");
drag.addEventListener("mousedown", startdrag);
}
A couple of notes. The startdrag
function is attached to the element, but when a drag is started, the complementary
mouseup
and mousemove
functions are attached to the window rather than the div
itself.
Mousemove events are only delivered to an HTML element if the mouse is actually inside it: if you attach the listeners to the element to
be dragged, it can only be dragged down or to the right, since a drag outside its bounds is never delivered to it. Also, notice that
when the element is dragged, its CSS left
and top
are set to the window event's pageX
and
pageY
properties, to account for page scrolling. Finally, Sample #1 is absolutely positioned (otherwise I wouldn't be able
to drag it around), so I have to insert some space above the following paragraph to prevent it from overlapping.
This works, but it leaves a bit to be desired. For one thing, every time you click on the element, its upper-left corner "jumps" down to
where the mouse was clicked relative to it. Another limitation is that this approach can only work with a single element on the page: the
window listeners themselves are hardcoded to the element whose id
is drag. Fixing both of these requires a bit of
"higher-order function" sleight-of-hand.
The "jumping" problem occurs because I don't keep track of, and can't pass in, the difference between the original upper-left corner and the original click position. An obvious solution would be to use an anonymous function like listing 2:
function startdrag(evt) {
var drag = evt.target;
var diff_x = evt.pageX - evt.target.offsetLeft;
var diff_y = evt.pageY - evt.target.offsetTop;
window.addEventListener("mousemove", function(evt) {
drag.style.left = evt.pageX - diff_x;
drag.style.top = evt.pageY - diff_y;
});
}
This works, and retains the mouse position as desired, but there's a problem - I can't detach the function! The complementary
removeEventListener
function requires a function reference, but this is an anonymous function, so I can't provide one.
The solution is a pair of partially applied functions. I need to hand addEventListener
a reference to a function
that accepts a single argument of type Event
, but I also need to provide some additional context (in this case, where in
the target element the user actually clicked). The solution is to partially apply the function: provide the known arguments when the
function is created and allow the caller to provide additional arguments when it's invoked. This way, I can keep a reference to the
function to be detached.
There's one last trick, though: I can detach the mousemove
handler in the mouseup
handler this way, but I still
can't detach the mouseup
handler itself, since it doesn't exist until, of course, it's created. In this case, I can use an
oxymoronically-described "named anonymous" function: one whose name only exists inside its own body so that it can refer to itself. (Note
that I can't use this for both functions, since I have to be able to refer to the move handler outside its own body).
function dragElemFunction(drag, diff_x, diff_y) {
return function(evt) {
drag.style.left = evt.pageX - diff_x;
drag.style.top = evt.pageY - diff_y;
};
}
function dropElemFunction(dragFunction) {
return function _dropElem(evt) {
window.removeEventListener("mousemove", dragFunction);
window.removeEventListener("mouseup", _dropElem);
};
}
function startdrag(evt) {
var drag = evt.target;
var diff_x = evt.pageX - evt.target.offsetLeft;
var diff_y = evt.pageY - evt.target.offsetTop;
var dragFunction = dragElemFunction(drag, diff_x, diff_y);
var dropFunction = dropElemFunction(dragFunction);
window.addEventListener("mousemove", dragFunction);
window.addEventListener("mouseup", dropFunction);
}
This also, incidentally, solves the other problem from listing 1: the id of the actual target element isn't referenced anywhere here, so it can be reused among multiple elements.
What I've done here is to create a new function that calls another with fixed arguments. There's actually been a fixed syntax for this in
Javascript since ES 6: the bind
function. You can re-implement listing 3 with built-ins as shown in listing 4:
function drag(drag, diff_x, diff_y, evt) {
drag.style.left = evt.pageX - diff_x;
drag.style.top = evt.pageY - diff_y;
}
function startDrag(evt) {
...
var dragFunction = drag.bind(null, drag, diff_x, diff_y);
window.addEventListener("mousemove", dragFunction);
window.addEventListener("mouseup", function _drop(evt) {
window.removeEventListener("mousemove", dragFunction);
window.removeEventListener("mouseup", _drop);
});
}
The null
passed in as the first parameter to bind
is the value of the this
parameter; I could
have made this the element if I had wanted to, but I'd rather be a little bit more explicit about what's what.
Another annoyance here is that as I drag over other text, the text ends up being selected. This happens because events are propagated by
default to the element's parents. This can be fixed easily by inserting a call to preventDefault
at the end of each event
handler:
function dragElemFunction(drag, diff_x, diff_y) {
return function(evt) {
drag.style.left = evt.pageX - diff_x;
drag.style.top = evt.pageY - diff_y;
evt.preventDefault();
};
}
function dropElemFunction(dragFunction) {
return function _dropElem(evt) {
document.removeEventListener("mousemove", dragFunction, false);
document.removeEventListener("mouseup", _dropElem, false);
evt.preventDefault();
};
}
function startdrag(evt) {
var drag = evt.target;
var diff_x = evt.pageX - evt.target.offsetLeft;
var diff_y = evt.pageY - evt.target.offsetTop;
var dragFunction = dragElemFunction(drag, diff_x, diff_y);
var dropFunction = dropElemFunction(dragFunction);
document.addEventListener("mousemove", dragFunction, false);
document.addEventListener("mouseup", dropFunction, false);
evt.preventDefault();
}