Adding Drag to select to your Konva app with React
— JavaScript, Software Development — 4 min read
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.
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
.
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.
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.
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.
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.