Skip to content
Colin Wren
Twitter

Adding Objects to Your Konva Stage via Drag ’n’ Drop in React

JavaScript, Software Development4 min read

phone dropping to ground
Photo by Ali Abdul Rahman on Unsplash

I’m currently in the midst of building an editor for Reciprocal.dev so that users can build their own interactive user journey maps and as part of building this functionality I needed a means of allowing users to create the steps of the user journey easily.

After conducting a couple of UX experiments I settled on a Drag ’n’ Drop approach with a fallback of having a click of the item adding a new step in the centre of the map.

When building a mapping tool the Drag ’n’ Drop approach allows users a lot more accuracy in the initial placement of the items they add to the map, meaning less clicks are needed for them to achieve their goal of building their user journey map.

The fallback allows users who don’t use a pointing device (such as users with accessibility needs) to still add steps to their map, but as there’s no means of defining a location with this method we have to make the assumption on where it should go initially.

Implementing Drag ’n’ Drop

The Konva documentation has many examples of Drag ’n’ Drop being used to populate the canvas so the approach is well documented. Essentially the process requires two elements; the element to drag and the element to receive the drop event.

Neither of these elements is within the Konva canvas but the element that receives the drop event will need to be the parent element of the Konva canvas in order for the positioning coords to match.

Implementing the Drag part

The job of the dragging element is to set information in the application state that will be used by the drop event listener to create the object that will be rendered on the canvas.

In order to set the application state, we first need to create a context that both the dragging element and the drop receiving element can access via the useContext hook.

With the context set up, we can then add the drag event listeners to the dragging element to set the state. To do this we add the draggable , onDragStart and onDragEnd props to the element we want to drag.

Please note in order to keep the code examples shorter I’ve implemented them using useState

Inside the callbacks of onDragStart and onDragEnd is where we set the state values that the drop receiving element will use, onDragStart is used to set up the values and onDragEnd is used to clear them if the user decides to cancel out of the drop.

1function onDragStart(e) {
2 setDragDropData({ x: 0, y: 0, type: 'circle' });
3 e.target.classList.add("hide");
4 }
5
6 function onDragEnd(e) {
7 setDragDropData(null);
8 e.target.classList.remove("hide");
9 }
Callbacks for onDragStart and onDragEnd that set the data used by the drop receiver

Implementing the Drop part

The job of the drop receiving element is to listen for events of the drag entering its boundary and to the user dropping the dragging element onto it.

When this drop event is triggered the callback will access the shared state, get the values set when the drag started and create the appropriate data needed to then render an object on the canvas.

There are two event listeners we need to set up to achieve this; onDragOver which listens for when the drag enters the element’s boundaries and onDrop which listens to the user releasing the drag when over the element.

1function onDragOver(e) {
2 e.preventDefault();
3 const stage = stageRef.current;
4 stage.setPointersPositions(e);
5 const scale = stage.scaleX();
6 const position = stage.getPointerPosition();
7 setDragDropData({
8 ...dragDropData,
9 x: (position.x - stage.x()) / scale,
10 y: (position.y - stage.y()) / scale,
11 });
12 }
13
14 function onDrop(e) {
15 e.preventDefault();
16 setItems([...items, dragDropData]);
17 setDragDropData(null);
18 }
In this example the onDragOver is used to update the position of the item being dragged, this can then be used to provide a preview of the dragged item

If you’re after a basic Drag ’n’ Drop UX then you can build a very basic callback for onDragOver that just calls preventDefault() on the event, but if like my implementation you’re providing the user with a preview of the final item on the map then you can use this to update the position for the preview.

The callback for the onDrop event is where you’re creating the object that will be rendered on top the stage, but you’ll need to do some calculations of the position of where the drop event happened based on the pointer’s position on the stage to ensure the correct position is used.

Giving the user a preview of the dropped item

The basic implementation of Drag ’n’ Drop described above doesn’t have the best user experience. When the user is dragging the element they see the element they are dragging, not the final object on the stage, this is fine when the element being dragged is of the same scale and appearance as the final object, but it can be jarring if not.

In order to improve the Drag ’n’ Drop experience we first need to do three things; hide the dragging element while being dragged, capture the current position of the pointer over the stage and, render a preview of the final object at that position.

Hiding the dragging element while dragging

We can re-use the onDragStart and onDragEnd hooks here to control the visibility of the element when the user drags it. I did this by adding and removing a class to the element in the respective callbacks and then adding a CSS rule that tells the browser to render the element off canvas.

1.card.hide {
2 opacity: 0;
3}
The .hide class is added to the dragged element by the onDragStart callback

Showing the preview on the stage

As mentioned previously the onDragOver callback can be used to capture the position of the pointer while it’s inside the boundary of the drop receiving element and update values in a state that can then be used for the position of the preview.

For the preview itself I added a new layer to the Konva Stage that listens to changes in the preview position values in state and renders an object on that layer if they are set.

In order to clean up properly if the user cancels the drag or drops outside of the drop receiving element we need to be able to set this position value to null so we don’t end up with objects in the preview layer when the user isn’t dragging anything.

1import React, { useState, useRef } from "react";
2import { Stage, Layer, Circle, Rect } from "react-konva";
3
4function getItem(item) {
5 if (item.type === "circle") {
6 return <Circle x={item.x} y={item.y} fill="red" radius={20} />;
7 }
8 return <Rect x={item.x} y={item.y} fill="green" width={20} height={20} />;
9}
10
11function getPreview(data) {
12 if (data === null) {
13 return null;
14 }
15 return getItem(data);
16}
17
18const App = () => {
19 const [dragDropData, setDragDropData] = useState(null);
20 const stageRef = useRef(null);
21
22 const previewItem = getPreview(dragDropData);
23
24 function onDragStart(e, type) {
25 setDragDropData({ x: 0, y: 0, type });
26 e.target.classList.add("hide");
27 }
28
29 function onDragEnd(e) {
30 setDragDropData(null);
31 e.target.classList.remove("hide");
32 }
33
34 function onDragOver(e) {
35 e.preventDefault();
36 const stage = stageRef.current;
37 stage.setPointersPositions(e);
38 const scale = stage.scaleX();
39 const position = stage.getPointerPosition();
40 setDragDropData({
41 ...dragDropData,
42 x: (position.x - stage.x()) / scale,
43 y: (position.y - stage.y()) / scale,
44 });
45 }
46
47 function onDrop(e) {
48 e.preventDefault()
49 setDragDropData(null);
50 }
51
52 return (
53 <div className="main-container">
54 <div className="drag-container">
55 <p>Drag the item below onto the stage to add it</p>
56 <div
57 class="card"
58 draggable
59 onDragStart={(e) => onDragStart(e, "circle")}
60 onDragEnd={onDragEnd}
61 >
62 <div class="card-body">
63 <h5 class="card-title">Add Circle</h5>
64 <p class="card-text">A circle is an amazing object to add</p>
65 </div>
66 </div>
67 <div
68 class="card"
69 draggable
70 onDragStart={(e) => onDragStart(e, "rectangle")}
71 onDragEnd={onDragEnd}
72 >
73 <div class="card-body">
74 <h5 class="card-title">Add Rectangle</h5>
75 <p class="card-text">A rectangle is an amazing object to add</p>
76 </div>
77 </div>
78 </div>
79 <div className="drop-container" onDragOver={onDragOver} onDrop={onDrop}>
80 <Stage
81 ref={stageRef}
82 width={window.innerWidth - 300}
83 height={window.innerHeight}
84 >
85 <Layer>
86 {previewItem}
87 </Layer>
88 </Stage>
89 </div>
90 </div>
91 );
92};
A condensed version of the code to render a drag preview on the stage

Supporting non-pointer users

Not every user will be using a pointer so we need a means to allow them to add items to the stage without dragging them in. This is an easy problem to solve as we can simply add an onClick event listener to the dragging element that will just add the object to the app state in the centre of the stage.

1function addItem(e) {
2 e.preventDefault();
3 const stage = stageRef.current;
4 setItems([
5 ...items,
6 {
7 x: stage.width() / 2,
8 y: stage.height() / 2,
9 type: 'circle'
10 }
11 ]);
12 }
If you’re project has zooming you may need to get the scale for the stage and use that when calculating the centre point

Demo

You can view a demo of the Drag ’n’ Drop implementation below along with the source code.

A simple demo showing the drag ’n’ drop functionality in a single file

Summary

By giving users a means to drag elements onto the stage we allow them to accurately add items at the positions they want. For an application like Reciprocal.dev that I’m building, which relies on a user building a map of items, this makes for a far better UX.