Device-agnostic drag-and-drop
One of my goals writing these games was to have the same code work on a mobile phone using a touchscreen or a desktop with a mouse. It turns out JavaScript has pointer events for exactly that:
The Pointer Events specification defines a unified hardware-agnostic framework for handling input from various devices, including mice, touchscreens, and pens/styluses. By providing a single set of events (e.g., pointerdown, pointermove, pointerup), it allows developers to support diverse input methods without writing device-specific logic for each. — W3C Specification
Using pointer events to drag and drop images turned out to be easy if you know how, which I didn’t so first went down the cul-de-sac of JavaScript’s legacy drag and drop event. So I made these notes to help others avoid that mistake.
Why not use drag and drop events?
The first snag with JavaScript’s DragEvent is it assumes a mouse, so doesn’t work on touchscreens. The next snag is that if repaced with a PointerEvent, it still messes things up so needs to be explicitly removed in the event listeners section of our code:
document.addEventListener("ondragstart", () => false);Even though I’m not using the default drag event API, Mozilla’s Kanban board makes a good starting point.
function dragstartHandler(dragEvent) {
dragEvent.target.id = "dragged-task";
dragEvent.dataTransfer.effectAllowed = "move";
dragEvent.dataTransfer.setData("task", "");
}
document.addEventListener("ondragstart", dragstartHandler);The drag event has a dataTransfer property which is a DataTransfer Object which is a DataTransferItemList of DataTransferItems which is… , uhm, completely overcomplicated crap easily replaced by a simple object literal I call dragObject.
Another reason I don’t like default drag events is if the dragged object is an image, it automagically gets resized and made transparent, whereas I want to create the illusion the original image is being moved.
My way of replacing the default drag event seemed a bit of a hack, especially using elementFromPoint to figure out the drop target, until I saw the Drag’n’Drop with mouse events example on javascript.info.
Its example uses mousedown, mousemove, and mouseup, which translate directly to pointerdown, pointermove, and pointerup.
Event-drivent programing
JavaScript would make event-driven programing really easy courtesy of Element.addEventListener(type, listener) if not for the overwhelming number of event types, many of which are nearly synonymous with very subtle differences.
For instance, my staring point for any program is something like:
function startGame() {
game.start(state);
render();
}
document.addEventListener("DOMContentLoaded", startGame);I settled on DOMContentLoaded
somewhat randomly since load
is nearly identical, except apparently DOMContentLoaded has
less delay as far as I understand.
Using either of these to launch the starting main function makes iifes unnecessary in contemporary JavaScript, though some content management systems still rewrite JavaScript files that way, which is why this site is handcoded.
Event listeners and handlers
The bottom of my main “client” script files look like this:
document.addEventListener("DOMContentLoaded", startGame);
DOM.newgame.addEventListener("click", newGame);
document.addEventListener("pointerdown", pointerdownHandler);
document.addEventListener("pointermove", pointermoveHandler);
document.addEventListener("pointerup", pointerupHandler);
document.addEventListener("pointercancel", pointerupHandler);
document.addEventListener("ondragstart", () => false);Event listeners only take one argument, an Event or a subclass of Event, which is automatically passed to the listener, and the return value is ignored. Therefore, to get data into and out of an event listener, instead of passing the data through parameters and return values, you need to create closures instead. — Mozilla’s Getting data into and out of an event listener
How pointer differ from mouse events
If you use pointer events, you should call preventDefault() to keep the mouse event from being sent as well.
What is an element?
The types of events is fairly overwhelming, so I’ve focused on just using
/**
* Data is moved from pointerup to pointerdown via this object.
* @namespace {Object} dragObject
* @property {Object} clone - HTML img element getting dragged
* @property {string} fromID - Start div's ID.
* @property {string[]} legals - IDs of legal move targets.
* @property {boolean} moving - Set to true by pointer down and false by pointer up.
* @property {number} shiftX - Pixels to offset x.
* @property {number} shiftY - Pixels to offset y.
* @property {?string} toID - End div's ID or null.
*/
const dragObject = {moving: false};Something I didn’t think of initially is that on a smartphone, people can use several fingers to drag more than one card simultaniously. There may be reasons to allow that, but since I’m trying to use the same code for a mouse or finger, a simple way to fix that was having the pointerdown handler check three things:
- dragObject.moving hasn’t been set to true by another ponterdown event.
- The dragged object is an image, which can be checked using either localName or tagName, depending on whether you prefer lowercase or uppercase.
- The dragged object isn’t a blank, which I use as
nullfor images.
Since I want to keep the original image’s size, dragObject.clone
= pointerEvent.target.cloneNode();. This replaces
pointerEvent.target, leaving it free to represent whatever
is under the dragged image — the next card in a deck or blank.
By tagging a “not convertible to JSON” type (a DOM element)
to dragObject, I lose the ability to store it in localStorage,
which is why I don’t simply add its properties to state.
The idea is dragObject holds ephemeral data which only updates the state
once a legal move is completed via pointerdown. (There
should probably be an undo option then, but I’ve not got that advanced
yet).
/**
* @function pointerdownHandler
* @listens pointerdown
*/
function pointerdownHandler(pointerEvent) {
if ( !dragObject.moving &&
pointerEvent.target.localName === "img" &&
!pointerEvent.target.src.includes("blank")
) {
dragObject.fromID = getId(pointerEvent.target);
pointerEvent.preventDefault();
dragObject.moving = true;
dragObject.toID = null;
DOM[dragObject.fromID].setPointerCapture(pointerEvent.pointerId);
dragObject.clone = pointerEvent.target.cloneNode();
dragObject.clone.style.position = "absolute";
dragObject.clone.style.left = `${DOM[dragObject.fromID].offsetLeft}px`;
dragObject.clone.style.top = `${DOM[dragObject.fromID].offsetTop}px`;
dragObject.clone.style["z-index"] = 12;
document.body.append(dragObject.clone);
dragObject.shiftX = pointerEvent.clientX - dragObject.clone.getBoundingClientRect().left;
dragObject.shiftY = pointerEvent.clientY - dragObject.clone.getBoundingClientRect().top;
dragObject.clone.style.filter = `drop-shadow(3px 3px hsl(120 100 20 / 80%))`;
pointerEvent.target.src = "./cards/blank.svg";
highlightLegals();
}
}Note at this stage I’m only changing things in the DOM, not the game state which only gets updated on the completion of a legal move.