This past summer, I led a project at Makeswift to rework our file manager.
Makeswift is a website builder and many of our users have hundreds of files.
To manage hundreds of anything you need bulk operations, but bulk operations aren't helpful if selecting things is cumbersome,
so drag selection was a key part of my vision for making Makeswift's file manager feel native.
But creating drag selection was harder than I thought it would be.
There was something fundamentally wrong with how I was representing user interactions in state.
In this post, we'll recreate drag selection, and along the way I'll share what I learned about state management that dramatically simplified the solution.
Now that we have a grid of items, let's render a "selection rectangle" on drag.
This rectangle is the indicator of what a user is selecting.
Let's start by creating state to hold this rectangle.
We'll use the DOMRect class since it's the geometry type of the web, and we'll call this state selectionRect.
Next we need to add an onPointerDown to the div surrounding our items.
We'll call this div the "container div" since it contains our items.
This event handler will initialize a DOMRect describing the area of our drag.
Since the selectionRect will be positioned absolutely to the container div, we want to store it relative to the container's position.
We do this by subtracting the container's x/y coordinates from our cursor's x/y coordinates.
Since we only want to start drag events from the left pointer button, we can early return when e.button !== 0.
Then in onPointerMove, we update our selectionRect based on the next position of the pointer.
This new x/y position is also relative to the container, so we offset selectionRect's position based on the container.
In onPointerUp we reset our state.
And finally we render the selectionRect.
On drag we now have a DOMRect representing our selection area.
From a cursory look, our demo seems to be working, but there is an edge case.
The x and y of our DOMRect together represent the start of a drag, and height and width are non negative values that together represent how far has been dragged.
When we drag left and up we have to reset the x and y of our DOMRect since width and height can't be negative.
This causes our starting point to reset.
The root issue is height and width are expressions of magnitude without direction.
We need a datatype that expresses both the magnitude and direction of the user's action.
This concept of magnitude + direction is called a vector [1].
A vector quantity is one that can't be expressed as a single number.
Instead it is comprised of direction and magnitude.
DOMRects are so close to being vector quantities but the names width and height limit your thinking to one quadrant.
The DOMRect constructor doesn't throw when you pass negative width and height values,
but names are important.
Having better names will make reasoning about this interaction easier.
Let's create our own DOMVector class with x, y, magnitudeX, and magnitudeY.
Next we need to update our selectionRect state to store a dragVector, and at render time we can derive the DOMRect of our selection from this state.
I generally try to avoid components that derive values on render.
I think this is why I have tried to store drag interactions as DOMRects for so long.
A DOMRect is what should be rendered, but a DOMRect is a lossy form of storing the data, so this derivation can't be avoided.
Finally, we can replace our DOMRect constructor calls with DOMVector constructor calls,
and update our onPointerMove to calculate magnitudeX and magnitudeY instead of width and height.
Our selection rect is now being rendered in all directions without being reset.
Now that we are drawing the selectionRect, we need to actually select things.
We'll do this by iterating each item's DOMRect to see if it intersects with our selectionRect.
The most common way of getting a DOMRect in React is by storing a ref and using getBoundingClientRect on that ref when you need its DOMRect.
In our case, this would mean storing an array of refs to each item.
Storing a data structure of refs has always seemed unwieldy to me.
The structure of our data is already expressed in the structure of the DOM,
and when you represent that structure in two places, your component becomes harder to iterate.
To avoid this issue, libraries like RadixUI use data attributes and querySelector to find the related DOM node at event time.
This is what we'll be doing as well.
Let's start by creating state for selection
and getting a ref to the container div.
Then we can add a data-item attribute to each item.
This attribute should uniquely identify each item.
For the demo, we will use the index of the item within our array, but in the Makeswift's files manager these boxes are actual files and folders, so we used actual ids.
Now let's create a helper function called updateSelectedItems.
This function finds all items,
get's their DOMRect relative to the container,
and checks for intersection with the selectionRect.
Once updateSelectedItems has looped through each of the items it pushes the local state to the selectedItems component state.
To make it obvious that we selected something, let's create an indicator for the number of selected items.
And update the items to have different styles when they are selected.
Try dragging around our container. Our items are now selectable.
To solve our second issue of accidental text selection I recommend using user-select: none.
When I originally wrote this blog post I had a fancy way of trying to guess if the user was selecting text or items.
But accross browsers the behavior wasn't consistent, and I decided to simplify this section.
Making drag and text selection work together is an unsolved problem, but there are some pretty creative solutions.
In Notion, if you drag from outside the block area, a drag selection is started, but if you drag from inside the block area, a text selection is started.
Depending on your situation you may be able to do something similar or come up with another creative solution.
In Makeswift, I ended up blocking text selection, but clicking your selected file's name opens a rename option where you can copy your file's name.
Back in our demo, let's use select-none on the container to prevent text selection.
Our final issue is due to the code assuming all onPointerDown events are for dragging.
In reality, the user might be clicking a button or focusing an input.
So let's start dragging in our onPointerMove and only after the user has dragged a threshold distance.
First let's create some state for if we are dragging or not.
Next, we need to be able to calculate how far the user has travelled by combining the magnitudeX and magnitudeY into a diagonal distance.
We can use Pythagorean theorem [2] to find this distance.
And then we can update the onPointerMove to not update our drag state until the drag is longer than 10pxs.
These little bits of polish have really added up, and our interaction is looking much better.
At this point, there isn't a good way to deselect items.
Let's add pointer deselection by clearing selection in onPointerUp when there isn't a current event.
It would also be great to clear selection when the user clicks "Escape."
For that, we'll need to focus the container in our onPointerMove.
Then we'll add an onKeyDown for "Escape" that clears the selection.
Without a tabIndex our container is not focusable and using -1 prevents our container from being in the tab order.
Adding preventDefault() will prevent the escape key press from closing any dialogs or resulting in unintentional behavior.
And finally we can update the focus styles of the container so our focus and selection styles are distinct.
Trying to update drag selection to work in a scrollable region was the forcing function that made me create DOMVector.
Up to that point I had created a dragStartPoint ref to prevent my starting point from being reset.
But scroll events don't include clientX and clientY, so I couldn't easily derive my selectionRect in my onScroll event handler.
My only option was to cache the last pointer event so I could still update selectionRect within onScroll.
My state was a collection of refs, derived state, and a cached event. Not very approachable.
Switching to vectors fixed these issues.
Let's start by representing our scroll as a vector.
We want to separate scrollVector from dragVector so that we can update them indepentantly in onScroll and onPointerMove.
This keeps the math simple in both, and ensures we don't need the pointer's position in onScroll.
Our drag state is now in two vectors, so we need a way to add them together when deriving our selectionRect.
We can create an add method on DragVector.
Next we need to create an onScroll event handler to update the scrollVector and our selection.
Now we can update how we derive our selectionRect to include our scrollVector.
And finally, to make the container scrollable, we can use classes for max-height and grid-template-columns
At this point scroll is working, but nothing is preventing our selectionRect from overflowing the container.
The selectionRect overflows, so the scrollable area grows, so the selectionRect grows and overflows in a cycle.
Let's fix this by clamping the vector to the bounds of the scroll area.
A "clamp" function is for keeping a value within certain bounds.
Most of the time you are clamping a number but the concept also works for clamping our DOMVector to a DOMRect.
Let's add a clamp method to DOMVector.
Then we can use it with the scrollWidth and scrollHeight of our container to prevent the selectionRect from causing overflow.
The selectionRect is now clamped to prevent overflowing the container.
There is one feature of text selection that we are still missing.
When a user drags to the edge of our scrollable container it should scroll automatically.
Unfortunately, there isn't a "onDraggingCloseToTheEdge" event handler.
We'll need to setup a requestAnimationFrame when the user is dragging so that we can check if they are dragging to the edge.
requestAnimationFrame, sometimes called RAF, is an API for doing something every time your browser renders.
In our case we want to setup a RAF that checks if the user is dragging close to the container's edge.
Our demo does this for each side, but we'll focus on the logic for auto scrolling down to keep things simple.
We'll start by creating a useEffect to sets up our RAF.
Within this RAF, we need to find the pointer's position relative to the container.
Even though useEffect isn't an event handler with access to clientX and clientY, we can still get our pointer's position by calculating the terminal point of our dragVector.
Let's create a method on DOMVector for finding the terminal point.
And we can use this terminal point to decide whether or not to auto scroll.
When your pointer is within the container and not within 20px of the edge, this value is greater than 20px, so we won't scroll.
Otherwise, it's less than 20px, so we scroll.
If we should scroll down, then we set a variable called top to a positive value.
If the pointer is 19px from the edge of the container and the container has a height of 100px then this value is 1 (20 - 100 + 81 = 1).
As you approach the edge this value increases.
If you are 1px from the edge of the container with a 100px height then this value is 19px (20 - 100 + 99 = 19).
This allows the user to control the speed of the scroll, while capping the value at 15px to prevent over-scrolling.
In the last part of our RAF, we use the calculated top value to scroll the container.
Our drag selection now autoscrolls.
selectable area
0
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
I would like to formally propose -TheLad naming convention for functions local to a useEffect.
The limited scope and the random nature of useEffect functions makes them the perfect spot to be a goof ball.
In actuality though my big takeaways from writing this post are that vectors are a great way to model user interaction,
and more broadly speaking, having rich data types can greatly improve the readability of your code.