Draggable objects

 from Red Blob Games
7 Nov 2018

On many of my pages I want to drag objects around. I use d3.drag for this when I’m using d3.js. Examples:

Sometimes though I’m not using d3.js. Over the years I cobbled together my own functions, which I copied and pasted from one project to another. In 2017 I turned this into a library. Over the years I kept adding random features to it as I needed them. Now in late 2018, I wanted to redesign its interface to be both easier to use and amenable to extension.

Draggable v1 handles SVG coordinates, so it’s mapped the pixel coordinates into the SVG coordinate space automatically. It doesn’t translate through CSS transforms.

I want to design Draggable v2 to have similar features but a nicer interface.

 1  Move SVG shape#

{
    let svg = document.querySelector("#example-svg-move-1");
    let g = svg.querySelector("g");
    makeDraggable(svg, g,
              (begin, current, state) => {
                 g.setAttribute('transform', `translate(${current.x}, ${current.y})`);
              });
}

If you play with this though you’ll notice that it “snaps” to the mouse location. The fix is to remember where you started the drag, and remember the offset between that and the center. Then apply that same offset later. The state parameter is there to let you remember per-drag state like the offset. Try dragging from the corner of the object and see how it behaves differently than above:

{
    let svg = document.querySelector("#example-svg-move-2");
    let g = svg.querySelector("g");
    let pos = {x: 0, y: 0};
    makeDraggable(svg, g,
              (begin, current, state) => {
                 if (!state) {
                     return {dx: pos.x - begin.x, dy: pos.y - begin.y};
                 }
                 pos.x = current.x + state.dx;
                 pos.y = current.y + state.dy;
                 g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`);
                 return state;
              });
}

There are some things I don’t like here:

  1. The state is being returned and passed around instead of using a more conventional way of storing state. The reason is that there’s no per drag operation object where I can store that state.
  2. The handler has to check whether state is defined in order to figure out whether the drag started. And then it does something different.

What if I use Javascript’s features to my advantage? In Javascript, this is not bound to the object where the method is defined but instead where the method is called. This confuses everyone. But it turns to be useful! If I create a new object representing each drag operation, I can use that to store the state.

{
    let svg = document.querySelector("#example-svg-move-3");
    let g = svg.querySelector("g");
    let pos = {x: 0, y: 0};
    new Draggable({
      el: g,
      parent: svg,
      start(event) {
        this.state = {dx: pos.x - event.x, dy: pos.y - event.y};
      },
      drag(event) {
        pos.x = event.x + this.state.dx;
        pos.y = event.y + this.state.dy;
        g.setAttribute('transform', `translate(${pos.x}, ${pos.y})`);
      }
    });
}

 2  Change marker during drag operation#

Another thing I didn’t need initially but added later, in an ugly way, was detecting the end-drag operation. In this example I set the state during the drag and then reset it.

{
    let svg = document.querySelector("#example-start-end-1");
    let g = svg.querySelector("g");
    let circle = g.querySelector("circle");
    makeDraggable(svg, g,
              (begin, current, state) => {
                 if (!state) { circle.setAttribute('fill', "yellow"); }
                 g.setAttribute('transform', `translate(${current.x}, ${current.y})`);
              })
    .onDragEnd(() => {
       circle.setAttribute('fill', "black");
    });
}

With the new interface, I think it’s a bit cleaner and easier to read:

{
    let svg = document.querySelector("#example-start-end-2");
    let g = svg.querySelector("g");
    let circle = g.querySelector("circle");
    new Draggable({
      el: g,
      parent: svg,
      start(_event) {
        circle.setAttribute('fill', "yellow");
      },
      drag(event) {
        g.setAttribute('transform', `translate(${event.x}, ${event.y})`);
      },
      end(_event) {
        circle.setAttribute('fill', "black");
      }
    });
}

 3  Paint on canvas#

Let’s try a different type of operation. Instead of moving something, use the position to draw on a canvas. Because the dom object isn’t moving, we don’t need a separate reference element.

{
    let canvas = document.querySelector("#example-canvas-1");
    let ctx = canvas.getContext('2d');
    makeDraggable(canvas, canvas,
              (begin, current, state) => {
                ctx.fillRect(current.x-1, current.y-1, 3, 3);
              });
}
{
    let canvas = document.querySelector("#example-canvas-2");
    let ctx = canvas.getContext('2d');
    new Draggable({
      el: canvas,
      drag(event) {
          ctx.fillRect(event.x-1, event.y-1, 3, 3);
      }
    });
}

 4  Remove event handlers#

Let’s try removing the event handlers after moving a certain distance. With the old interface, I have to keep a reference to the returned value.

{
    let canvas = document.querySelector("#example-cleanup-1");
    let ctx = canvas.getContext('2d');
    let count = 100;
    let draggable = makeDraggable(canvas, canvas,
              (begin, current, state) => {
                ctx.fillRect(current.x-1, current.y-1, 3, 3);
                if (--count <= 0) {
                  console.log('cleanup example: count is ', count);
                  draggable.cleanup();
                }
              });
}

With the new interface, this points to an object with an uninstall method.

{
    let canvas = document.querySelector("#example-cleanup-2");
    let ctx = canvas.getContext('2d');
    let count = 100;
    new Draggable({
        el: canvas,
        drag(event) {
            ctx.fillRect(event.x-1, event.y-1, 3, 3);
            if (--count <= 0) {
                console.log('cleanup example: count is ', count);
                this.uninstall();
            }
        }
    });
}

 5  Right mouse drag#

The code doesn’t allow this right now. I think I’d implement it by having a default filter that determines whether mouseDown returns immediately, and then you can pass in your own filter.

 6  Cancel current drag#

Not implemented.

 7  Vue integration#

A directive can integrate Draggable into Vue. However, because this is normally bound to the Vue component, I pass the drag operation as a separate parameter:

Vue.directive('draggable', {
    bind(el, binding) {
        Vue.nextTick(() => {
            // Have to wait for next tick so that ownerSVGElement is set
            const props = binding.value;
            el.__draggable__ = new Draggable({
                reference: el.ownerSVGElement,
                el: el,
                start(event) { props.start && props.start(this, event); },
                drag(event) { props.drag && props.drag(this, event); },
                end(event) { props.end && props.end(this, event); },
            });
        });
    },
    unbind(el, binding) {
        el.__draggable__.uninstall();
        delete el.__draggable__;
    }
});

The use would be something like:

<my-component v-draggable="{start, drag}"/>

and then it’ll call the start, drag methods on your component with operation, event as parameters.

 8  lit-html integration#

A directive can do this, but it’s not a great match. I don’t know the best way to do this in lit-html.

const draggable = directive(handlers => part => {
    new Draggable({
        el: part.element,
        start(event) { handlers.start && handlers.start(this, event); },
        drag(event) { handlers.drag && handlers.drag(this, event); },
        end(event) { handlers.end && handlers.end(this, event); },
    });
};

The use would be something like:

<my-component @="draggable({start, drag})"/>

and then it’ll call the start, drag methods on your component with operation, event as parameters.

I don’t know the proper way to remove these event handlers.

 9  Touch vs click#

The event object has a mouse_button or touch_identifier that tells you whether it was a mouse drag or a touch drag, and which button was used.

 10  Multitouch#

I treat each finger as a separate drag and don’t have a way to act on a set of fingers at once.

 11  Initial experiences#

I have used Draggable v2 in several real projects. The interface does seem cleaner. However the use of this interferes with existing uses of this, so I occasionally have to use the that = this trick.

Email me , or tweet @redblobgames, or comment: