DEV Community

Andreas Bolz
Andreas Bolz

Posted on

Roll your own SVG drag and drop in VueJS

Drag and drop interfaces are a staple of the modern web. If you work on the frontend it won't be long before you encounter a UX/UI problem that screams for one. Good libraries are usually not hard to find, but the fit with your exact usecase might not perfect. Especially when we have a modern Javascript framework like VueJS at hand, rolling your own often ends up being easier than adapting other peoples work.

Setup

<div id="app">
  <svg ref="box" class="box" width="500" height="500">
    <rect width="100%" height="100%" fill="white" stroke="black" stroke-width="5"/>
    <rect
      width="100"
      height="100"
      class="square"
      fill="red"
      x="100"
      y="100"
    />
  </svg>
</div>

As you can see we define a square 500*500 SVG element. The first <rect/> simply serves to highlight the bounds of this space. The second <rect/> is the thing we want to drag and drop around. SVG elements are positioned relative to the coordinate system of their parent SVG through their x and y coordinate attributes. The starting position of our square is set to x="100" y="100".

That is cool but it doesn't do anything interesting yet. In order to make this little red square draggable al we have to do is correctly update these x and y coordinates by processing the information captured through a set of three related events: mousedown, mousemove and mouseup.

Before we can do that lets do some setup work that binds these coordinates to a data property in a Vue instance. We will go ahead and register a set of event handlers on the square already.

<div id="app">
  <svg ref="box" class="box" width="500" height="500">
    <rect width="100%" height="100%" fill="white" stroke="black" stroke-width="5"/>
    <rect
      width="100"
      height="100"
      class="square"
      fill="red"
      :x="square.x"
      :y="square.y"
      @mousedown="drag"
      @mouseup="drop"
    />
  </svg>
</div>
const app = new Vue({
  el: '#app',
  data() {
    return {
      square: {
        x: 100,
        y: 100,
      },
    }
  },
  methods: {
    drag() {},
    drop() {},
    move() {}
  }
})

Cool! Now here comes the interesting part. Remember, our aim is basically to let the square follow along with the position of the cursor between the moment we click (mousedown), and the moment we release (mouseup). In other words we can use these events to register/deregister an event handler on the svg that gets called on mousemove. Then all we have to do is get the coordinates out of those mousemove events and update the x and y data properties on our square. Sounds easy enough, looks easy enough:

methods: {
  drag() {
    this.$refs.box.addEventListener('mousemove', this.move)
  },
  drop() {
    this.$refs.box.removeEventListener('mousemove', this.move)
  },
  move(event) {
    this.square.x = event.offsetX;
    this.square.y = event.offsetY;
  }
}

Now the mousemove event captures a number of different x and y coordinates and they are each relative to a particular object in the document. By far the easiest for this usecase are offsetX and offsetY. Because, according to MDN:

The offsetX read-only property of the MouseEvent interface provides the offset in the X coordinate of the mouse pointer between that event and the padding edge of the target node.

This means that these numbers give us exactly the distance in pixels to the left and top of the bounding svg. Which is exactly what the x and y properties on our rect express.

Great. This should work. Try it out...

Hmm. That works. Kinda. But not really. As we can see as soon as we start dragging the square jumps so that its top left corner corresponds with our mousecursor. On top of that there is now no way to let go of the square because the mouseup event wont fire as the cursor is right on the edge of the element.

Luckily this is quite easily solved by capturing the distance between the top-left of the square and the location of our initial mousedown. In order to do this we add two now properties to our data object: dragOffsetX and dragOffsetY, and we set them accordingly in our drag() and drop() methods. The result looks as follows:

const app = new Vue({
  el: '#app',
  data() {
    return {
      square: {
        x: 100,
        y: 100,
      },
      dragOffsetX: null,
      dragOffsetY: null
    }
  },
  computed: {
    cursor() {
      return `cursor: ${this.dragOffsetX ? 'grabbing' : 'grab'}`
    },
  },
  methods: {
    drag({offsetX, offsetY}) {
      this.dragOffsetX = offsetX - this.square.x;
      this.dragOffsetY = offsetY - this.square.y;
      this.$refs.box.addEventListener('mousemove', this.move)
    },
    drop() {
      this.dragOffsetX = this.dragOffsetY = null;
      this.$refs.box.removeEventListener('mousemove', this.move)
    },
    move({offsetX, offsetY}) {
      this.square.x = offsetX - this.dragOffsetX;
      this.square.y = offsetY - this.dragOffsetY;
    }
  }
})

Based on the value in dragOffsetX we define a convenient computed property that tells us if we are currently dragging, which will allow us to set the cursor property on the red square for a nice UI feel.

    <rect
      width="100"
      height="100"
      class="square"
      fill="red"
      :x="square.x"
      :y="square.y"
      :style="cursor"
      @mousedown="drag"
      @mouseup="drop"
    />

Beautiful...

Top comments (0)