Despite many years of web development, there is one skill that I've never truly mastered: drag and drop. I've mostly relied on open source libraries and existing frameworks, but the actual mechanism has always eluded me.
I decided to do something about that. I made a "lesson plan" for learning how to do drag and drop, and I'm going to share those lessons and the outcome of them. Three things:
- This is entirely about synthetic drag-and-drop; I will not be using the HTML5 "drag and drop"
library; this will be entirely about using pointers and windows, and
ondragandondropwill not be events in our vocabulary. - My examples will be entirely within the DOM. I will be using Lit, not React. We will work directly with the events as they occur within the browser. React teaches you to treat the DOM as a hostile environment, and I'm here to tell you that the DOM is an elegant and beautiful thing and you should appreciate it for what it is.
- I'm going to use Typescript, because Typescript will be extremely helpful in discriminating between states in a drag-and-drop system.
All right? Onward!
Lesson 1. Just Drag Something.
So, this is the simplest thing we're gonna make: a web component contains a single draggable item. There are four events that constitute dragging. Once upon a time, this was much more painful because there were, in fact, seven events: three for MouseEvent, and four for TouchEvent. Those have been coalesced into PointerEvent, which takes care of all of them.
First, let's talk about states
interface Point { x: number; y: number; }
type DragState =
| { phase: 'idle' }
| { phase: 'dragging'; pointerId: number; grabOffset: Point };
There are two states in Lesson 1, dragging and, well, not-dragging. In modern systems, when we're
dragging, we need to keep track of which pointer initiated and controls the drag. On an iPad, on a
phone, on any tablet, each finger creates a new pointerId. For the moment, we only want to track
the first one.
The other field, grabOffset, records how far away you were from the upper-left corner of the
object you're doing to drag when you first clicked, because that corner is the pixel you're going to
moving; the rest of the object will move right along with it. By recording the grabOffset, you can
do the math so that the object won't "jump" so that that corner is under the tip of your mouse
pointer or finger.
One of the reasons to do synthetic drag and drop is that, unlike native drag and drop, objects being moved this way can continue to receive other events, like timer events, and change while being dragged. It can even change shape.
@customElement('drag-box')
export class DragBox extends LitElement {
static readonly styles = [Styles];
@state() private position: Point = { x: 24, y: 24 };
@state() private drag: DragState = { phase: 'idle' };
<<pointerdown>>
<<pointermove>>
<<pointerup>>
<<pointercancel>>
render() {
return html`
<div class="box"
?dragging=${this.drag.phase === 'dragging'}
style=${styleMap({
left: `${this.position.x}px`,
top: `${this.position.y}px`,
})}
@pointerdown=${this.onPointerDown}
@pointermove=${this.onPointerMove}
@pointerup=${this.onPointerUp}
@pointercancel=${this.onPointerCancel}
></div>
`;
}
}
We're going to use Lit to create a :host object containing our drag zone, and then we're going to
drag that box around inside that zone. The style object will dictate where our object is within
the zone (or even outside of it). We will use the drag state here mostly just to control how the
object looks.
Note the starting state: we're idle, and we're putting our box at a specific, absolute coordinate.
onPointerDown(ev: PointerEvent) {
if (this.drag.phase !== 'idle') return;
const target = e.currentTarget as HTMLElement;
target.setPointerCapture(e.pointerId);
this.drag = {
phase: 'dragging',
pointerId: e.pointerId,
grabOffset: {
x: e.clientX - this.position.x,
y: e.clientY - this.position.y,
},
};
}
When the user clicks on the box, we record the pointerID (because, again, we don't want random
touches confusing our implementation). And we record the difference between where we clicked and
that upper-left pixel, so we have the coordinates we need to make sure the object moves smoothly.
It really doesn't matter what the clientX/clientY pair are: as long as we have the offset, we
will know what to do with movement.
One other thing we do here is setPointerCapture. For the duration of our drag, we're saying that
the box will receive all of the pointer events. Even if the box is hidden behind something else,
or if we move off the window, or if we move the mouse so fast the browser can't keep up and our
pointer leaves the borders of the box, for the duration of the drag, the box will receive all
pointer events for this pointerId.
private onPointerMove(e: PointerEvent) {
if (this.drag.phase !== 'dragging') return;
if (e.pointerId !== this.drag.pointerId) return;
this.position = {
x: e.clientX - this.drag.grabOffset.x,
y: e.clientY - this.drag.grabOffset.y,
};
}
When the pointer moves, we want to make sure we're dragging, and that the pointer that sent our
message is our pointer. When we get this event, we take the offset we recorded and apply it to the
new clientX/clientY. Again, this math always works: it will always return a new position relative
to the previous previous position, offset just enough so that the pointer stays where it is relative
to the draggable item.
In Lit, setting an object that is decorated to be a @state triggers a refresh; Lit knows now to
schedule a re-render of the whole scene, with the new box position. On almost any modern computer,
this will happen so fast it will just look like a standard drag.
private onPointerUp(e: PointerEvent) {
if (this.drag.phase !== 'dragging') return;
if (e.pointerId !== this.drag.pointerId) return;
this.drag = { phase: 'idle' };
}
private onPointerCancel(e: PointerEvent) {
this.onPointerUp(e);
}
And these are the end events. It's about at straightforward as you can imagine: We just stop
tracking. pointerCapture is cancelled automatically on either of these events.
So what's the difference between up and cancel? The cancel Event occurs on phones and other
devices where swipe up/swipe down is used to cause scrolling. If the device's algorithm believes
you want to scroll the page, a cancel event will be sent to anything that has captured the
pointer, saying "Nope, you didn't want that. I want that."
static styles = css`
:host {
display: block;
position: relative;
height: 42rem;
width: 100%;
max-width: 41rem;
background: ivory;
border: 1px solid silver;
overflow: hidden;
}
.box {
position: absolute;
width: 4rem;
height: 4rem;
border-radius: 0.375rem;
background: dodgerblue;
cursor: grab;
touch-action: none;
user-select: none;
-webkit-user-select: none;
}
.box[dragging] {
cursor: grabbing;
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.25);
}
`;
Which is why there's that touch-action: none in there. That tells the browser: when the pointer is
within the dimensions of the box, and when the box is receiving all the pointer events, the browser
is not to interpret the touch as scroll, swipe, or cancellable.
The result is, well, not very exciting, but it is a start: