Skip to content
Colin Wren
Twitter

Adding Drag to select to your Konva app with React

JavaScript, Software Development4 min read

selecting objects via drag in reciprocal.dev
In the Reciprocal.dev editor users can select multiple Steps and Connections by holding shift and dragging a selection area on the map

During the initial development of Reciprocal.dev I added a means for a user to select entities on the map. This would allow them to see a form in which they could update the data behind the selected Step or Connection and become a core part of the UX behind building an interactive user journey map in the editor.

As the functionality of Reciprocal.dev expanded there became a need to make the UX for updating this data easier across multiple entities, as the type of data would span a number of steps in a User Journey.

I had originally introduced a means to select multiple Steps and Connections by detecting if the control key was pressed when the user clicked an entity and then would add or remove the entity from a list of selected entity based on that behaviour.

This worked well enough when I was building small maps but as I started to scale out the solution it was just not quick enough and involved too many clicks to be effective, so I started looking into how to allow the user to select Steps and Connections by dragging a selection box around them.

My original experiments didn’t go too well as the Stage in Reciprocal.dev is draggable so adding a different dragging feature on top of that proved a little difficult, but once I figured out how to capture key press events and have that update the app’s state I was happy with the results I was getting.

The implementation I settled on was to capture the start and end positions of a drag (visualised via a semi-transparent rectangle to show the user what they’re selecting) and check which objects intersected that rectangle, but there were a few considerations I had to include such as how to handle selecting a line.

Setting drag behaviour with keys

The approach I took was to have holding down the Shift key change the ‘mode’ of interaction and have the dragging behaviour differ based on that mode.

When the Shift key is not pressed then the dragging behaviour is to pan the map as detailed in a previous post — Adding zoom and panning to your react-konva stage.

When the Shift key is pressed then the dragging behaviour is to keep the map stationary and create a selection rectangle that, when the drag is released, selects all map entities that intersect with it.

In order to set the dragging mode I added a isInSelectMode parameter to my app’s state context and set up event listeners in my top level App component.

These event listeners would capture any keyDown and keyUp events and update the isInSelectMode value in state whenever the Shift key was held down or released.

1import React, { useEffect, useState } from "react";
2import { AppProvider } from "./AppContext";
3import { Map } from "./Map";
4
5export default function App() {
6 const [isInSelectMode, setIsInSelectMode] = useState(false);
7 const [selectedEntities, setSelectedEntities] = useState([]);
8
9 function handleKeyDown(e) {
10 if (!isInSelectMode && e.keyCode === 16) {
11 setIsInSelectMode(true);
12 }
13 }
14
15 function handleKeyUp(e) {
16 if (isInSelectMode && e.keyCode === 16) {
17 setIsInSelectMode(false);
18 }
19 }
20
21 useEffect(() => {
22 document.addEventListener("keydown", handleKeyDown);
23 document.addEventListener("keyup", handleKeyUp);
24
25 return function cleanUp() {
26 document.removeEventListener("keydown", handleKeyDown);
27 document.removeEventListener("keyup", handleKeyUp);
28 };
29 });
30
31 return (
32 <AppProvider
33 value={{
34 isInSelectMode,
35 setIsInSelectMode,
36 selectedEntities,
37 setSelectedEntities
38 }}
39 >
40 <Map />
41 </AppProvider>
42 );
43}
Capturing the key events at the top most level, setting the state and passing value into a context for consumption in children

On the Stage component I then used the isInSelectMode context value to control the draggable and listening props, setting them to false when isInSelectMode is true in order to stop the mouse events triggering actions within the Stage .

1import React, { useContext } from "react";
2import { Stage } from "react-konva";
3import AppContext, { AppConsumer, AppProvider } from "./AppContext";
4import { MapContent } from "./MapContent";
5
6export function Map() {
7 const { isInSelectMode } = useContext(AppContext);
8
9 return (
10 <AppConsumer>
11 {(appState) => (
12 <Stage
13 width={window.innerWidth}
14 height={window.innerHeight}
15 draggable={!isInSelectMode}
16 listening={!isInSelectMode}
17 >
18 <AppProvider value={appState}>
19 <MapContent />
20 </AppProvider>
21 </Stage>
22 )}
23 </AppConsumer>
24 );
25}
Consuming the isInSelectMode value to toggle the Stage’s dragging behaviour when the shift key is pressed

Giving the user feedback when creating selection

When the user is dragging and the isInSelectMode flag is true then the start and end positions of that drag will be used to calculate an area of the map that’s been selected and then another calculation is done to figure out which entities on the map intersect that area. Anything that intersects that area is then considered ‘selected’.

All of this is great but the user needs some form of feedback on where the drag started from and what the area they’re creating looks like. So I added a semi-transparent Rect to the Stage that would act as a ‘selection preview’ and have its position based on the top-left most point of the selection area and its dimensions based on bottom-right most point.

Basing the position and dimensions on these values allows for a consistent behaviour as the user may end their drag at a point higher or lower on either axis than the starting point.

In order to capture the events needed to show the selection area and to capture the data behind it I added onMouseDown , onMouseMove and onMouseUp callbacks to the Stage component and updated component state to keep track of the update to the selection area.

1import React, { useContext, useState } from "react";
2import { Rect, Stage, Layer } from "react-konva";
3import AppContext, { AppConsumer, AppProvider } from "./AppContext";
4import { MapContent } from "./MapContent";
5
6export function Map() {
7 const { isInSelectMode } = useContext(AppContext);
8 const [selection, setSelection] = useState(null);
9
10 function handleMouseDown(e) {
11 e.evt.preventDefault();
12 if (e.evt.shiftKey && selection === null) {
13 const stage = e.target.getStage();
14 const { x: pointerX, y: pointerY } = stage.getPointerPosition();
15 const pos = {
16 x: pointerX - stage.x(),
17 y: pointerY - stage.y()
18 };
19 setSelection({
20 startX: pos.x,
21 startY: pos.y,
22 endX: pos.x,
23 endY: pos.y,
24 x: pos.x,
25 y: pos.y,
26 width: 0,
27 height: 0
28 });
29 }
30 }
31
32 function handleMouseMove(e) {
33 if (e.evt.shiftKey && selection !== null) {
34 const stage = e.target.getStage();
35 const { x: pointerX, y: pointerY } = stage.getPointerPosition();
36 const pos = {
37 x: pointerX - stage.x(),
38 y: pointerY - stage.y()
39 };
40 setSelection({
41 ...selection,
42 endX: pos.x,
43 endY: pos.y,
44 x: Math.min(selection.startX, pos.x),
45 y: Math.min(selection.startY, pos.y),
46 width: Math.abs(selection.startX - pos.x),
47 height: Math.abs(selection.startY - pos.y)
48 });
49 }
50 }
51
52 function handleMouseUp(e) {
53 e.evt.preventDefault();
54 if (selection !== null) {
55 // Calculate the selection and update app state
56 setSelection(null);
57 }
58 }
59
60 const selectionPreview =
61 selection !== null ? (
62 <Rect
63 fill="rgba(86, 204, 242, 0.1)"
64 stroke="#2d9cdb"
65 x={selection.x}
66 y={selection.y}
67 width={selection.width}
68 height={selection.height}
69 />
70 ) : null;
71
72 return (
73 <AppConsumer>
74 {(appState) => (
75 <Stage
76 width={window.innerWidth}
77 height={window.innerHeight}
78 draggable={!isInSelectMode}
79 listening={!isInSelectMode}
80 onMouseDown={handleMouseDown}
81 onMouseMove={handleMouseMove}
82 onMouseUp={handleMouseUp}
83 >
84 <AppProvider value={appState}>
85 <MapContent />
86 </AppProvider>
87 <Layer>
88 {selectionPreview}
89 </Layer>
90 </Stage>
91 )}
92 </AppConsumer>
93 );
94}
Handling mouse events on the stage to show a selection preview

Calculating what’s been selected

On releasing the mouse after creating a selection area there needs to be a calculation to figure out which of the entities on the map are inside of the selection area so we can mark them as selected.

While researching other implementations of selecting objects I found that some implementations use the selection preview’s Rect element to do this as Konva provides a getIntersection method that returns a boolean of if a particular point falls within a shapes bounds.

My implementation however didn’t use this approach as I found a helper class in the Util module of konva that makes it easier to calculate if two rectangles intersect.

1// Steps have this shape
2// export const steps = [
3// {
4// key: "step-1",
5// x: 100,
6// y: 100,
7// width: 100,
8// height: 200
9// },
10// ];
11
12export function getSelectedEntities(selectionArea, data) {
13 const selectedSteps = data.steps.filter((step) =>
14 Konva.Util.haveIntersection(selectionArea, step)
15 );
16 return selectedSteps.map((step) => step.key)
17}
A subset of the getSelectedEntities function showing how the step selection is calculated

While this approach worked well for my shape-based entities it didn’t work too well for my path based ones.

I had originally attempted to calculate the intersection by creating a rectangle from the top-left and bottom-right most points of the overall line but this led to unintended selections when there were large paths that had steps within that rectangle so I switched to calculating each edge in the path as a rectangle instead.

To do this, I did a reduction over the path commands and used the next command to calculate which axis the edge would be on and returned a rectangle that was the same dimensions as that edge, but 10px wide.

I then mapped over each edge when calculating the selection areas intersection. This approach meant that the selection would only be triggered when the selection area had actually interesected with the line and led to a more accurate selection experience.

1// Connections have this shape
2// {
3// key: "connection-1",
4// x: 0,
5// y: 0,
6// commands: [
7// {
8// command: "M",
9// x: 180,
10// y: 280
11// },
12// {
13// command: "L",
14// x: 250,
15// y: 280
16// },
17// ]
18// },
19
20function getEdgeRects(connection) {
21 return connection.commands.reduce((edges, point, index) => {
22 if (index === connection.commands.length - 1) {
23 return edges;
24 }
25 const nextPoint = connection.commands[index + 1];
26 const sameX = point.x === nextPoint.x;
27 const sameY = point.y === nextPoint.y;
28 if (!(sameX && sameY) && (sameX || sameY)) {
29 const minX = Math.min(point.x, nextPoint.x);
30 const maxX = Math.max(point.x, nextPoint.x);
31 const minY = Math.min(point.y, nextPoint.y);
32 const maxY = Math.max(point.y, nextPoint.y);
33 if (sameX) {
34 edges.push({
35 x: minX,
36 y: minY - 5,
37 width: 10,
38 height: Math.abs(maxY) - Math.abs(minY)
39 });
40 } else {
41 edges.push({
42 x: minX - 5,
43 y: minY,
44 width: Math.abs(maxX) - Math.abs(minX),
45 height: 10
46 });
47 }
48 }
49 return edges;
50 }, []);
51}
52
53export function getSelectedEntities(selectionArea, data) {
54 const selectedConnections = data.connections.filter((connection) => {
55 const edgeRects = getEdgeRects(connection);
56 return edgeRects.reduce((result, edge) => {
57 if (Konva.Util.haveIntersection(selectionArea, edge)) {
58 result = true;
59 }
60 return result;
61 }, false);
62 });
63 return selectedConnections.map((connection) => connection.key)
64}
A subset of the getSelectedEntities function showing how the calculation of a path being selected based on it’s edges

Summary

I like this implementation, as it makes it easy for the user to pan and zoom the Stage as they would normally, but then when they hold shift key they can only drag the selection which prevents accidentally panning or zooming.

I’m especially happy with the approach taken to calculate if a path has been selected as it’s accurate and performant enough to make selecting paths in a very messy, interconnected map a doddle.

I’ve created a demo based on the implementation for you to play with below, you will need to open the running app in a new window for the keyboard events to work. Feel free to fork it and edit the code for your needs.

An example app that shows how the selection preview and selection calculations work