How to implement Drag and Drop from React to SVG (d3)
By: Kinneret Zin
Tags: Drag And Drop, SVG, D3js, React, JavaScript

When creating a topology builder (or any type of graph/layout that is based on a group of nodes that are drawn on one canvas), one of the most common operations will be to drag a node from a list of building blocks and drop it onto that canvas.

We can drop a node on to the canvas itself and it will be drawn based on the dropped [x,y] position, or we can drop it in a specific drop point, or even inside another node.

I have implemented such a system a few times in the past, and it always came to a point where I needed the building blocks (list of nodes that I drag into the canvas) to be regular HTML nodes and not SVG. The main reason was that I wanted them to be a part of the app framework and be able to interact with the app itself. For example, They are generated from some list I already have in my models, there are some more operations on the nodes (like settings\edit) or they need to behave like DOM behaves (for example, change of layout because of screen-size or listen to some DOM events), etc.

Defining drag and drop relation in SVG is pretty straight forward, and defining drag and drop relation in HTML is pretty simple as well. However, combining the two is a bit tricky.

I will demonstrate how to implement this using React and D3, and I assume that you are somewhat familiar with them both.

The Draggable component

We will start by defining the Draggable component. The Draggable component can wrap any component and will add to it the drag and drop capabilities.

Each Draggable has a drag object. The drag object is the data that is related to the block we are wrapping. For example, if I create drag objects out of a list of employees (boring example, I know…) then the drag object will be the employee data. Based on this data we will draw the block. Let's say we draw a circle and inside it the employee’s name, and an icon representing the department she belongs to.

In our example, the drag object will have a name and a color.

In addition to the drag object, we will also want to save the offset of the mouse cursor from the edge of the drag object. As seen from the screenshot, when you grab an object and start dragging it, the cursor is somewhere inside the dragged component. When we will drop it, we will want to have the coordinates of the top\left corner of the dragged object. However, the drag events always relate to the mouse location and not to the drag object location. We will calculate this offset and use it later to calculate the correct drop point.

To calculate the offset we will first use getBoundingClientRect from the current target. We will simply reduce the left from the event current X position - e.clientX and the top from the current Y position - e.clientY.

Please note that if you have margins on that block you will need to reduce the margin size from the calculation (e.g. e.clientX-currentTargetRect.left-5)

For the drag wrapper we will define two event handlers:

onDragStart:

Runs when we start the drag operations. In this callback, we want to save our drag data. We will later use this drag data when dropping the node so we can draw it properly.

The drag data will consist of the drag object and the offset that we described above. The data will be saved by our passed handler onDragStart which we call at the end of the method.

onDragEnd:

In this callback, we will simply disable propagation to enable drop. We will also call the caller’s callback for onDragEnd.

The Draggable blocks

We will define a list of blocks that we want to drag and use the Draggable wrapper for each of them. We will give each block color and text (e.g. the drag object):

As you can see the DraggableBlocks are simply a list of draggable components. We use a list of blocks data and iterate on them in order to render it. Each draggable maps to one draggable object from the data list.

Please note that on a real system, this data list will probably come from an API or configuration.

Our callback for onDragStart will just call the setDragObject callback that we got. This callback will make sure to save the drag object so we can use it later when dropping.

Saving the drag data

On a real system, we will probably use some models to save our data. It can be Mobx, Redux, or any other model of your choice.

For the sake of this example, I will simplify things by saving it directly on the App.js state. I will “bubble” it up the components tree using the appropriate callback and eventually will save it on the main app component.

The drag data will be then passed to the SVG container so it can get the correct drop information when a dragged component is dropped in the SVG container.

The App.js component looks like this:

The SVG Component

Now let’s add our SVG container. We will later want to be able to drop those blocks inside the SVG container and let the SVG draw the blocks exactly where it was dropped.

The SVG is our drop area. We will define some drop callbacks to handle the drag and drop operation. Currently, we will add\remove a “drag-over” class on the SVG container when a dragged object is dragged over the SVG (onDragOver) and will remove it when the block exits the SVG container (onDragLeave) or when its dropped (onDrop).

In this example, this class will add\remove a dashed red border to the SVG container. Usually, this is used to give the user some feedback on the validity of the drop point. It can be by the border color, by container color, or even by adding some text saying “Drop Here”.

Dropping the dragged component

In order to implement the “drop” we will need to first get the drag data, calculate its new location and then add it to the nodes list. Later I will show how we will take this node list and draw it.

Let's look at onDrop:

The onDrop callback first calculates the new drop point on the SVG. It uses a helper method to convert the DOM coordinates that we get from the event to SVG coordinates. The reason that we need this is that the SVG has a different coordinate system then the DOM. Moreover, SVGs can be zoomed in and out (I didn't implement it in this example, but its usually an ability that we will want to add to our drawing), and can be “scrolled” — moved from side to side in a drag-like method of the canvas itself.

I will not elaborate more on convertCoordinatesDOMtoSVG, I will just add that if we have a container inside our SVG that its the scrollable\zoomable container, we will need to change

return pt.matrixTransform(svg.node().getScreenCTM().inverse());

to

return pt.matrixTransform(container.node().getScreenCTM().inverse());

The second thing that onDrop will do is add the new node’s information to the list of nodes, including a generated id (on a real app we will probably use some utility to create a unique UUID), the drag data, and the new location we just calculated.

What is the list of nodes?

The list of nodes is just an array containing all the nodes we have dropped in the SVG container.

On a real app we will probably get some initial list of nodes from an API and have some initial drawing of the nodes, and dragging new nodes will only be for expanding this graph\diagram.

Now that we have all the right information about the nodes, we can draw them. In order to draw the nodes on the SVG, I am using another class called SVGDrawer.

SVGDrawer.draw(nodes)

I call this method every time a node is dropped. Also, you will see in the full example, that I will also call it on initial load to draw the nodes I already have in the list — those that in theory I will get from an API.

Drawing the nodes:

Drawing the nodes is performed using d3. Once we have the coordinates, there is nothing special to do here.

Here is the SVG drawer:

Please notice the use of ‘enter’ here. When using “join”, the first argument is a callback for ‘enter’ which means all the new nodes matching the criteria (selection + data), the second is ‘update’ and the third is ‘exist’ — delete.

When we use enter only, it means that this code will only run on new nodes, meaning it will not re-draw nodes that were already in the list and were drawn before. Let's say I already dropped 10 nodes in the container, and I now add another one. The ‘enter’ will only run on that new added one and will not redraw the first 10 nodes.

So this is the final version of SVGArea:

You can see here that we simply put a <svg> tag, and all the drawing is done in the SVGDrawer class. Notice that I call draw method from two places: onDrop and on initial load (useEffect with [] as the second argument).

All done!

At this point, we have a working example. We can drag a node, and drop it. The dropped node will be drawn exactly where it was dropped.

What’s next?

In addition to simply adding a node on a canvas, sometimes we will want to snap that dropped node into a grid or allow dropping this node inside of an existing node, or only allow dropping in a specific area\s.

So how can we detect that we are dragging a node over another node? How can we set only specific drop points?

I will try to cover those scenarios in my next post.

You can find the complete code in my Github repo here, or CodeSandBox: