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.
Here is a sneak peak at the final demo:
And this is what the interaction feels like:
Basic markup
Let's start building our demo by rendering a grid of items.
-
We can initialize a array of 30 items with values from 0 to 30
const items = Array.from({ length: 30 }, (_, i) => i + '')
-
And then map through them rendering divs like so:
items.map(item => ( <div className={clsx( 'border-2 size-10 border-black flex justify-center items-center', )} key={item} > {item} </div> ))
const items = Array.from({ length: 30 }, (_, i) => i + '')
function Root() {
return (
<div>
<div className="px-2 border-2 border-black">selectable area</div>
<div className="relative z-0 grid grid-cols-8 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5">
{items.map(item => (
<div
className="border-2 size-10 border-black flex justify-center items-center"
key={item}
>
{item}
</div>
))}
</div>
</div>
)
}
This gives us a simple grid.
Drawing the selection box
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 stateselectionRect
.const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
-
Next we need to add an
onPointerDown
to thediv
surrounding our items. We'll call thisdiv
the "containerdiv
" since it contains our items. This event handler will initialize aDOMRect
describing the area of our drag.onPointerDown={e => { if (e.button !== 0) return const containerRect = e.currentTarget.getBoundingClientRect() setSelectionRect( new DOMRect( e.clientX - containerRect.x, e.clientY - containerRect.y, 0, 0, ), ) }}
Since the
selectionRect
will be positioned absolutely to the containerdiv
, we want to store it relative to the container's position. We do this by subtracting the container'sx
/y
coordinates from our cursor'sx
/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 ourselectionRect
based on the next position of the pointer.onPointerMove={e => { if (selectionRect == null) return const containerRect = e.currentTarget.getBoundingClientRect() const x = e.clientX - containerRect.x const y = e.clientY - containerRect.y const nextSelectionRect = new DOMRect( Math.min(x, selectionRect.x), Math.min(y, selectionRect.y), Math.abs(x - selectionRect.x), Math.abs(y - selectionRect.y), ) setSelectionRect(nextSelectionRect) }}
This new
x
/y
position is also relative to the container, so we offsetselectionRect
's position based on the container. -
In
onPointerUp
we reset our state.onPointerUp={() => { setSelectionRect(null) }}
-
And finally we render the
selectionRect
.{ selectionRect && ( <div className="absolute border-black border-2 bg-black/30" style={{ top: selectionRect.y, left: selectionRect.x, width: selectionRect.width, height: selectionRect.height, }} /> ) }
import { useState } from 'react'
const items = Array.from({ length: 30 }, (_, i) => i + '')
function Root() {
const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
return (
<div>
<div className="px-2 border-2 border-black">selectable area</div>
<div
onPointerDown={e => {
if (e.button !== 0) return
const containerRect = e.currentTarget.getBoundingClientRect()
setSelectionRect(
new DOMRect(
e.clientX - containerRect.x,
e.clientY - containerRect.y,
0,
0,
),
)
}}
onPointerMove={e => {
if (selectionRect == null) return
const containerRect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - containerRect.x
const y = e.clientY - containerRect.y
const nextSelectionRect = new DOMRect(
Math.min(x, selectionRect.x),
Math.min(y, selectionRect.y),
Math.abs(x - selectionRect.x),
Math.abs(y - selectionRect.y),
)
setSelectionRect(nextSelectionRect)
}}
onPointerUp={() => {
setSelectionRect(null)
}}
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5"
>
{items.map(item => (
<div
className="border-2 size-10 border-black flex justify-center items-center"
key={item}
>
{item}
</div>
))}
{selectionRect && (
<div
className="absolute border-black border-2 bg-black/30"
style={{
top: selectionRect.y,
left: selectionRect.x,
width: selectionRect.width,
height: selectionRect.height,
}}
/>
)}
</div>
</div>
)
}
On drag we now have a DOMRect
representing our selection area.
Using a Vector
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.
DOMRect
s 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 withx
,y
,magnitudeX
, andmagnitudeY
.class DOMVector { constructor( readonly x: number, readonly y: number, readonly magnitudeX: number, readonly magnitudeY: number, ) { this.x = x this.y = y this.magnitudeX = magnitudeX this.magnitudeY = magnitudeY } toDOMRect(): DOMRect { return new DOMRect( Math.min(this.x, this.x + this.magnitudeX), Math.min(this.y, this.y + this.magnitudeY), Math.abs(this.magnitudeX), Math.abs(this.magnitudeY), ) } }
-
Next we need to update our
selectionRect
state to store adragVector
, and at render time we can derive theDOMRect
of our selection from this state.const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null) const [dragVector, setDragVector] = useState<DOMVector | null>(null) const selectionRect = dragVector ? dragVector.toDOMRect() : null
I generally try to avoid components that derive values on render. I think this is why I have tried to store drag interactions as
DOMRect
s for so long. ADOMRect
is what should be rendered, but aDOMRect
is a lossy form of storing the data, so this derivation can't be avoided. -
Finally, we can replace our
DOMRect
constructor calls withDOMVector
constructor calls, and update ouronPointerMove
to calculatemagnitudeX
andmagnitudeY
instead ofwidth
andheight
.const x = e.clientX - containerRect.x const y = e.clientY - containerRect.y const nextSelectionRect = new DOMRect( Math.min(x, selectionRect.x), Math.min(y, selectionRect.y), Math.abs(x - selectionRect.x), Math.abs(y - selectionRect.y), ) setSelectionRect(nextSelectionRect) const nextDragVector = new DOMVector( dragVector.x, dragVector.y, e.clientX - containerRect.x - dragVector.x, e.clientY - containerRect.y - dragVector.y, ) setDragVector(nextDragVector)
/* ... */
class DOMVector {
constructor(
readonly x: number,
readonly y: number,
readonly magnitudeX: number,
readonly magnitudeY: number,
) {
this.x = x
this.y = y
this.magnitudeX = magnitudeX
this.magnitudeY = magnitudeY
}
toDOMRect(): DOMRect {
return new DOMRect(
Math.min(this.x, this.x + this.magnitudeX),
Math.min(this.y, this.y + this.magnitudeY),
Math.abs(this.magnitudeX),
Math.abs(this.magnitudeY),
)
}
}
function Root() {
const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
const [dragVector, setDragVector] = useState<DOMVector | null>(null)
const selectionRect = dragVector ? dragVector.toDOMRect() : null
return (
<div>
<div className="px-2 border-2 border-black">selectable area</div>
<div
onPointerDown={e => {
if (e.button !== 0) return
const containerRect = e.currentTarget.getBoundingClientRect()
setSelectionRect(
setDragVector(
new DOMVector(
e.clientX - containerRect.x,
e.clientY - containerRect.y,
0,
0,
),
)
}}
onPointerMove={e => {
if (selectionRect == null) return
if (dragVector == null) return
const containerRect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - containerRect.x
const y = e.clientY - containerRect.y
const nextSelectionRect = new DOMRect(
Math.min(x, selectionRect.x),
Math.min(y, selectionRect.y),
Math.abs(x - selectionRect.x),
Math.abs(y - selectionRect.y),
)
setSelectionRect(nextSelectionRect)
const nextDragVector = new DOMVector(
dragVector.x,
dragVector.y,
e.clientX - containerRect.x - dragVector.x,
e.clientY - containerRect.y - dragVector.y,
)
setDragVector(nextDragVector)
}}
onPointerUp={() => {
setSelectionRect(null)
setDragVector(null)
}}
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5"
>
{/* ... */}
</div>
</div>
)
}
Our selection rect is now being rendered in all directions without being reset.
Intersection State
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
const [selectedItems, setSelectedItems] = useState<Record<string, boolean>>( {}, )
and getting a ref to the container
div
.const containerRef = useRef<HTMLDivElement>(null)
-
Then we can add a
data-item
attribute to each item.items.map(item => ( <div data-item={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
id
s. -
Now let's create a helper function called
updateSelectedItems
.const updateSelectedItems = useCallback(function updateSelectedItems( dragVector: DOMVector, ) { /* ... */ }, [])
This function finds all items,
containerRef.current.querySelectorAll('[data-item]').forEach(el => { if (containerRef.current == null || !(el instanceof HTMLElement)) return /* ... */ })
get's their
DOMRect
relative to the container,const itemRect = el.getBoundingClientRect() const x = itemRect.x - containerRect.x const y = itemRect.y - containerRect.y const translatedItemRect = new DOMRect(x, y, itemRect.width, itemRect.height)
and checks for intersection with the
selectionRect
.if (!intersect(dragVector.toDOMRect(), translatedItemRect)) return if (el.dataset.item && typeof el.dataset.item === 'string') { next[el.dataset.item] = true }
-
Once
updateSelectedItems
has looped through each of the items it pushes the local state to theselectedItems
component state.const next: Record<string, boolean> = {} const containerRect = containerRef.current.getBoundingClientRect() containerRef.current.querySelectorAll('[data-item]').forEach(el => { /* ... */ }) setSelectedItems(next)
-
To make it obvious that we selected something, let's create an indicator for the number of selected items.
<div className="flex flex-row justify-between"> <div className="px-2 border-2 border-black">selectable area</div> {Object.keys(selectedItems).length > 0 && ( <div className="px-2 border-2 border-black"> count: {Object.keys(selectedItems).length} </div> )} </div>
-
And update the items to have different styles when they are selected.
<div data-item={item} className={clsx( 'border-2 size-10 border-black flex justify-center items-center', selectedItems[item] ? 'bg-black text-white' : 'bg-white text-black', )} key={item} > {item} </div>
import { useRef, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
import clsx from 'clsx'
/* ... */
function intersect(rect1: DOMRect, rect2: DOMRect) {
if (rect1.right < rect2.left || rect2.right < rect1.left) return false
if (rect1.bottom < rect2.top || rect2.bottom < rect1.top) return false
return true
}
function Root() {
const [dragVector, setDragVector] = useState<DOMVector | null>(null)
const [selectedItems, setSelectedItems] = useState<Record<string, boolean>>(
{},
)
const containerRef = useRef<HTMLDivElement>(null)
const updateSelectedItems = useCallback(function updateSelectedItems(
dragVector: DOMVector,
) {
if (containerRef.current == null) return
const next: Record<string, boolean> = {}
const containerRect = containerRef.current.getBoundingClientRect()
containerRef.current.querySelectorAll('[data-item]').forEach(el => {
if (containerRef.current == null || !(el instanceof HTMLElement)) return
const itemRect = el.getBoundingClientRect()
const x = itemRect.x - containerRect.x
const y = itemRect.y - containerRect.y
const translatedItemRect = new DOMRect(
x,
y,
itemRect.width,
itemRect.height,
)
if (!intersect(dragVector.toDOMRect(), translatedItemRect)) return
if (el.dataset.item && typeof el.dataset.item === 'string') {
next[el.dataset.item] = true
}
})
setSelectedItems(next)
}, [])
const selectionRect = dragVector ? dragVector.toDOMRect() : null
return (
<div>
<div className="flex flex-row justify-between">
<div className="px-2 border-2 border-black">selectable area</div>
{Object.keys(selectedItems).length > 0 && (
<div className="px-2 border-2 border-black">
count: {Object.keys(selectedItems).length}
</div>
)}
</div>
<div
ref={containerRef}
onPointerMove={e => {
/* ... */
updateSelectedItems(nextDragVector)
}}
/* ... */
>
{items.map(item => (
<div
data-item={item}
className={clsx(
'border-2 size-10 border-black flex justify-center items-center',
selectedItems[item]
? 'bg-black text-white'
: 'bg-white text-black',
)}
key={item}
>
{item}
</div>
))}
{/* ... */}
</div>
</div>
)
}
Try dragging around our container. Our items are now selectable.
Drag and drop polish
Selection is working, which is great, but there are three glaring issues.
-
Our pointer is triggering pointer events, which you can see below because of the temporary
hover:bg-pink
class I added. -
Our drag is triggering text selection.
-
And our drag is triggered on click.
Preventing pointer events during drag with setPointerCapture
To solve the first issue we can simply use setPointerCapture
.
onPointerDown={e => {
if (e.button !== 0) return
const containerRect =
e.currentTarget.getBoundingClientRect()
setDragVector(
new DOMVector(
e.clientX - containerRect.x,
e.clientY - containerRect.y,
0,
0,
),
)
e.currentTarget.setPointerCapture(e.pointerId)
}}
This tells the browser: "Until this pointer cycle is complete only trigger pointer events from this element."
In our case, setPointerCapture
prevents the hover styles from being applied during a drag.
Preventing text selection with user-select: none
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.
className =
'relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5'
'relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none -translate-y-0.5',
Preventing premature drags by adding a threshold
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.
const [isDragging, setIsDragging] = useState(false)
-
Next, we need to be able to calculate how far the user has travelled by combining the
magnitudeX
andmagnitudeY
into a diagonal distance. We can use Pythagorean theorem [2] to find this distance.class DOMVector { /* ... */ getDiagonalLength(): number { return Math.sqrt( Math.pow(this.magnitudeX, 2) + Math.pow(this.magnitudeY, 2), ) } }
-
And then we can update the
onPointerMove
to not update our drag state until the drag is longer than10px
s.onPointerMove={e => { /* ... */ if (!isDragging && nextDragVector.getDiagonalLength() < 10) return setIsDragging(true) setDragVector(nextDragVector) updateSelectedItems(nextDragVector) }}
class DOMVector {
/* ... */
getDiagonalLength(): number {
return Math.sqrt(
Math.pow(this.magnitudeX, 2) + Math.pow(this.magnitudeY, 2),
)
}
}
/* ... */
function Root() {
/* ... */
const [isDragging, setIsDragging] = useState(false)
/* ... */
const selectionRect = dragVector ? dragVector.toDOMRect() : null
const selectionRect = dragVector && isDragging ? dragVector.toDOMRect() : null
return (
<div>
{/* ... */}
<div
ref={containerRef}
onPointerMove={e => {
/* ... */
if (!isDragging && nextDragVector.getDiagonalLength() < 10) return
setIsDragging(true)
setDragVector(nextDragVector)
updateSelectedItems(nextDragVector)
}}
onPointerUp={() => {
setDragVector(null)
setIsDragging(false)
}}
>
{/* ... */}
</div>
</div>
)
}
These little bits of polish have really added up, and our interaction is looking much better.
Adding deselection
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.if (!isDragging) { setSelectedItems({}) setDragVector(null) } else { setDragVector(null) setIsDragging(false) }
-
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
.containerRef.current?.focus()
-
Then we'll add an
onKeyDown
for "Escape" that clears the selection.tabIndex={-1} onKeyDown={e => { if (e.key === 'Escape') { e.preventDefault() setSelectedItems({}) setDragVector(null) } }}
Without a
tabIndex
our container is not focusable and using-1
prevents our container from being in the tab order. AddingpreventDefault()
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.
'relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black -translate-y-0.5' 'relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black focus:outline-none focus:border-dashed -translate-y-0.5'
/* ... */
function Root() {
/* ... */
return (
<div>
{/* ... */}
<div
ref={containerRef}
onPointerDown={e => {
/* ... */
}}
onPointerMove={e => {
/* ... */
selection?.removeAllRanges()
setIsDragging(true)
containerRef.current?.focus()
setDragVector(nextDragVector)
updateSelectedItems(nextDragVector)
}}
onPointerUp={() => {
if (!isDragging) {
setSelectedItems({})
setDragVector(null)
} else {
setDragVector(null)
setIsDragging(false)
}
}}
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none -translate-y-0.5"
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none focus:outline-none focus:border-dashed -translate-y-0.5"
tabIndex={-1}
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault()
setSelectedItems({})
setDragVector(null)
}
}}
>
{/* ... */}
</div>
</div>
)
}
Deselection is now operational.
Scrolling
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.
const [scrollVector, setScrollVector] = useState<DOMVector | null>(null)
We want to separate
scrollVector
fromdragVector
so that we can update them indepentantly inonScroll
andonPointerMove
.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 ourselectionRect
.add(vector: DOMVector): DOMVector { return new DOMVector( this.x + vector.x, this.y + vector.y, this.magnitudeX + vector.magnitudeX, this.magnitudeY + vector.magnitudeY, ) }
We can create an
add
method onDragVector
. -
Next we need to create an
onScroll
event handler to update thescrollVector
and our selection.onScroll={e => { if (dragVector == null || scrollVector == null) return const { scrollLeft, scrollTop } = e.currentTarget const nextScrollVector = new DOMVector( scrollVector.x, scrollVector.y, scrollLeft - scrollVector.x, scrollTop - scrollVector.y, ) setScrollVector(nextScrollVector) updateSelectedItems(dragVector, nextScrollVector) }}
-
Now we can update how we derive our
selectionRect
to include ourscrollVector
.const selectionRect = dragVector && isDragging ? dragVector.toDOMRect() : null const selectionRect = dragVector && scrollVector && isDragging ? dragVector.add(scrollVector).toDOMRect() : null
-
And finally, to make the container scrollable, we can use classes for
max-height
andgrid-template-columns
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none focus:outline-none focus:border-dashed -translate-y-0.5" className={ clsx( 'relative max-h-96 overflow-auto z-10 grid grid-cols-[repeat(20,min-content)] gap-4 p-4', 'border-2 border-black select-none -translate-y-0.5 focus:outline-none focus:border-dashed', ) }
and render a bunch more items to cause overflow.
const items = Array.from({ length: 30 }, (_, i) => i + '') const items = Array.from({ length: 300 }, (_, i) => i + '')
const items = Array.from({ length: 30 }, (_, i) => i + '')
const items = Array.from({ length: 300 }, (_, i) => i + '')
class DOMVector {
/* ... */
add(vector: DOMVector): DOMVector {
return new DOMVector(
this.x + vector.x,
this.y + vector.y,
this.magnitudeX + vector.magnitudeX,
this.magnitudeY + vector.magnitudeY,
)
}
}
function Root() {
const [scrollVector, setScrollVector] = useState<DOMVector | null>(null)
/* ... */
const updateSelectedItems = useCallback(function updateSelectedItems(
dragVector: DOMVector,
scrollVector: DOMVector,
) {
if (containerRef.current == null) return
const next: Record<string, boolean> = {}
const containerRect = containerRef.current.getBoundingClientRect()
containerRef.current.querySelectorAll('[data-item]').forEach(el => {
if (containerRef.current == null || !(el instanceof HTMLElement)) return
const itemRect = el.getBoundingClientRect()
const x = itemRect.x - containerRect.x
const y = itemRect.y - containerRect.y
const x = itemRect.x - containerRect.x + containerRef.current.scrollLeft
const y = itemRect.y - containerRect.y + containerRef.current.scrollTop
const translatedItemRect = new DOMRect(
x,
y,
itemRect.width,
itemRect.height,
)
if (
!intersect(dragVector.add(scrollVector).toDOMRect(), translatedItemRect)
)
return
if (el.dataset.item && typeof el.dataset.item === 'string') {
next[el.dataset.item] = true
}
})
setSelectedItems(next)
}, [])
const selectionRect = dragVector && isDragging ? dragVector.toDOMRect() : null
const selectionRect =
dragVector && scrollVector && isDragging
?
dragVector.add(scrollVector).toDOMRect()
: null
return (
<div>
{/* ... */}
<div
ref={containerRef}
onScroll={e => {
if (dragVector == null || scrollVector == null) return
const { scrollLeft, scrollTop } = e.currentTarget
const nextScrollVector = new DOMVector(
scrollVector.x,
scrollVector.y,
scrollLeft - scrollVector.x,
scrollTop - scrollVector.y,
)
setScrollVector(nextScrollVector)
updateSelectedItems(dragVector, nextScrollVector)
}}
onPointerDown={e => {
/* ... */
setScrollVector(
new DOMVector(
e.currentTarget.scrollLeft,
e.currentTarget.scrollTop,
0,
0,
),
)
}}
onPointerMove={e => {
if (dragVector == null) return
if (dragVector == null || scrollVector == null) return
/* ... */
updateSelectedItems(nextDragVector)
updateSelectedItems(nextDragVector, scrollVector)
}}
onPointerUp={() => {
/* ... */
setScrollVector(null)
}}
onKeyDown={e => {
/* ... */
setScrollVector(null)
}}
className="relative z-0 grid grid-cols-6 sm:grid-cols-10 gap-4 p-4 border-2 border-black select-none focus:outline-none focus:border-dashed -translate-y-0.5"
className={clsx(
'relative max-h-96 overflow-auto z-10 grid grid-cols-[repeat(20,min-content)] gap-4 p-4',
'border-2 border-black select-none -translate-y-0.5 focus:outline-none focus:border-dashed',
)}
>
{/* ... */}
</div>
</div>
)
}
You can now scroll to select more items.
Preventing scroll overflow
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 aDOMRect
. Let's add aclamp
method toDOMVector
.clamp(vector: DOMRect): DOMVector { return new DOMVector( this.x, this.y, Math.min(vector.width - this.x, this.magnitudeX), Math.min(vector.height - this.y, this.magnitudeY), ) }
-
Then we can use it with the
scrollWidth
andscrollHeight
of our container to prevent theselectionRect
from causing overflow.dragVector .add(scrollVector) .clamp( new DOMRect( 0, 0, containerRef.current.scrollWidth, containerRef.current.scrollHeight, ), ) .toDOMRect()
import { useCallback, useState } from 'react'
import { useCallback, useRef, useState } from 'react'
class DOMVector {
clamp(vector: DOMRect): DOMVector {
return new DOMVector(
this.x,
this.y,
Math.min(vector.width - this.x, this.magnitudeX),
Math.min(vector.height - this.y, this.magnitudeY),
)
}
}
function Root() {
const containerRef = useRef<HTMLDivElement>(null)
const selectionRect =
dragVector && scrollVector && isDragging
dragVector && scrollVector && isDragging && containerRef.current
? dragVector
.add(scrollVector)
.clamp(
new DOMRect(
0,
0,
containerRef.current.scrollWidth,
containerRef.current.scrollHeight,
),
)
.toDOMRect()
: null
return {
<div
/* ... */
ref={containerRef}
>
{/* ... */}
</div>
}
}
The selectionRect
is now clamped to prevent overflowing the container.
Auto scrolling
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.useEffect(() => { if (!isDragging) return let handle = requestAnimationFrame(scrollTheLad) return () => cancelAnimationFrame(handle) function scrollTheLad() { /* ... */ handle = requestAnimationFrame(scrollTheLad) } }, [isDragging, dragVector, updateSelectedItems])
-
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 toclientX
andclientY
, we can still get our pointer's position by calculating the terminal point of ourdragVector
.Let's create a method on
DOMVector
for finding the terminal point.toTerminalPoint(): DOMPoint { return new DOMPoint(this.x + this.magnitudeX, this.y + this.magnitudeY) }
And we can use this terminal point to decide whether or not to auto scroll.
const currentPointer = dragVector.toTerminalPoint() const containerRect = containerRef.current.getBoundingClientRect() const shouldScrollDown = containerRect.height - currentPointer.y < 20
When your pointer is within the container and not within
20px
of the edge, this value is greater than20px
, so we won't scroll. Otherwise, it's less than20px
, so we scroll. -
If we should scroll down, then we set a variable called
top
to a positive value.const top = shouldScrollDown ? clamp(20 - containerRect.height + currentPointer.y, 0, 15) : // other cases
If the pointer is
19px
from the edge of the container and the container has a height of100px
then this value is1
(20 - 100 + 81 = 1
).As you approach the edge this value increases.
If you are
1px
from the edge of the container with a100px
height then this value is19px
(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.containerRef.current.scrollBy({ left, top, })
class DOMVector {
/* ... */
toTerminalPoint(): DOMPoint {
return new DOMPoint(this.x + this.magnitudeX, this.y + this.magnitudeY)
}
}
/* ... */
function Root(){
/* ... */
useEffect(() => {
if (!isDragging || containerRef.current == null) return
let handle = requestAnimationFrame(scrollTheLad)
return () => cancelAnimationFrame(handle)
function clamp(num: number, min: number, max: number) {
return Math.min(Math.max(num, min), max)
}
function scrollTheLad() {
if (containerRef.current == null || dragVector == null) return
const currentPointer = dragVector.toTerminalPoint()
const containerRect = containerRef.current.getBoundingClientRect()
const shouldScrollRight =
containerRect.width - currentPointer.x < 20
const shouldScrollLeft = currentPointer.x < 20
const shouldScrollDown =
containerRect.height - currentPointer.y < 20
const shouldScrollUp = currentPointer.y < 20
const left = shouldScrollRight
? clamp(20 - containerRect.width + currentPointer.x, 0, 15)
: shouldScrollLeft
? -1 * clamp(20 - currentPointer.x, 0, 15)
: undefined
const top = shouldScrollDown
? clamp(20 - containerRect.height + currentPointer.y, 0, 15)
: shouldScrollUp
? -1 * clamp(20 - currentPointer.y, 0, 15)
: undefined
if (top === undefined && left === undefined) {
handle = requestAnimationFrame(scrollTheLad)
return
}
containerRef.current.scrollBy({
left,
top,
})
handle = requestAnimationFrame(scrollTheLad)
}
}, [isDragging, dragVector, updateSelectedItems])
return (/* ... */)
}
Our drag selection now autoscrolls.
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.
That's all for this one folks 👋