사용자가 수많은 파일들을 관리하는 웹사이트를 생각해보면 드래그를 통해 항목을 선택할 수 있게 하는 것은 대량 작업에 있어서 좋은 선택지가 됩니다. 네이티브 파일시스템을 생각해보세요.
아래는 네이버 드라이브 참고.
하지만 마우스 드래그를 통한 선택을 만드는 것은 생각보다 어려울 수 있습니다. 저는 위 블로그 글을 참고해서 실습을 해본 결과를 작성해보려고 합니다.
최종 데모를 잠깐 엿보세요:
그리드를 렌더링하여 데모를 만들어 보겠습니다. 0~30까지의 값을 갖는 30개 항목의 배열을 초기화할 수 있습니다.
const items = Array.from({ length: 30 }, (_, i) => i + '')
그리고 다음과 같이 div를 렌더링하여 매핑합니다.
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>
)
}
이제 간단한 격자가 생겼습니다.
📚 사전지식 1. DOMRect는 웹 브라우저에서 요소의 크기와 위치 정보를 나타내는 객체입니다. 주로 getBoundingClientRect() 메서드를 통해 얻을 수 있으며, x, y, width, height, top, right, bottom, left 속성을 포함합니다.
📚 사전지식 2. Point 이벤트와 Mouse 이벤트 차이: 마우스 이벤트는 마우스 입력만 감지하는 반면, 포인터 이벤트는 마우스, 터치, 펜 등 모든 포인팅 디바이스를 감지합니다. 현대적인 포인터 이벤트 사용을 권장하지만, 오래된 브라우저에서는 Mouse/Touch 이벤트 사용이 필요합니다.
이제 항목 그리드가 있으므로 드래그 시 "선택 사각형"을 렌더링해 보겠습니다. 이 사각형은 사용자가 무엇을 선택하는지 나타내는 지표입니다.
이 사각형을 보관하기 위한 상태를 만드는 것으로 시작하겠습니다. 웹의 지오메트리 유형이기 때문에 DOMRect 클래스를 사용하고 selectionRect라는 상태를 만듭니다.
const [selectionRect, setSelectionRect] = useState<DOMRect | null>(null)
다음으로 주변 아이템에 onPointerDown를 추가해야 합니다. 아이템을 담고 있으므로 “containerRect" 라고 부르겠습니다. 이 이벤트 핸들러는 드래그 영역을 설명하는 DOMRect를 초기화합니다.
onPointerDown={e => {
if (e.button !== 0) return
const containerRect = e.currentTarget.getBoundingClientRect()
// e.clientX, e.clientY : pointer 이벤트가 실제로 발생한 좌표
// e.currenctTarget (=containerRect): 이벤트 핸들러가 바인딩된 요소. container div 해당
// 이 둘을 빼주면 container div를 기준으로 (0,0)으로 시작해서 절대적인 좌표 계산
setSelectionRect(
new DOMRect(
e.clientX - containerRect.x,
e.clientY - containerRect.y,
0,
0,
),
)
}}
selectionRect는 container div에 절대적으로 위치하므로 해당 위치를 기준으로 저장하고 싶습니다. 이를 위해 커서의 x, y 좌표에서 컨테이너 x, y 좌표를 빼면 됩니다.
그 다음에 포인터의 다음 위치를 onPointerMove 기준으로 selectionRect 업데이트 합니다.
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), // x (좌상단 기준)
Math.min(y, selectionRect.y), // y (좌상단 기준)
Math.abs(x - selectionRect.x), // width (음수가 될 수 없음)
Math.abs(y - selectionRect.y), // height (음수가 될 수 없음)
)
setSelectionRect(nextSelectionRect)
}}
onPointerUp 이벤트 시에는 상태를 재설정합니다.
onPointerUp={() => {
setSelectionRect(null)
}}
마지막으로 selectRect를 렌더링합니다
{
selectionRect && (
<div
className="absolute border-black border-2 bg-black/30"
style={{
top: selectionRect.y,
left: selectionRect.x,
width: selectionRect.width,
height: selectionRect.height,
}}
/>
)
}
드래그 했을 때, 선택 상자가 생기는 것을 볼 수 있습니다.
겉보기에 우리의 데모는 잘 작동하는 것처럼 보이지만, 예외적인 경우도 있습니다.
DOMRect의 x, y는 드래그 시작 좌표를 나타내고 width, height는 얼마나 멀리 드래그 되었는지 나타내는 음수가 아닌 값입니다. 왼쪽과 위로 드래그할 때 음수가 될 수 없으므로 x, y는 재설정 됩니다. 그렇게 되면 어디서부터 시작되었는지 알 수 없게 되는 문제가 있죠. 따라서 벡터라는 개념이 필요합니다.
크기와 방향을 가지는 개념을 벡터라고 합니다. 벡터량은 단일 숫자로 표현할 수 없는 양입니다. 대신 방향과 크기로 구성됩니다. DOMRect는 벡터량에 매우 가깝지만 width, height를 생각하면 한 사분면으로 제한됩니다.
DOMRect 생성자는 음수인 width, height를 가지지 않기 때문에 의미 추론이 쉬운 더 나은 이름이 필요합니다. DOMRect를 사용하여 나만의 클래스를 만들어 보겠습니다. 확실히 의미가 명확해지네요.
class DOMVector {
constructor(
readonly x: number, // 시작점 X 좌표(고정)
readonly y: number, // 시작점 Y 좌표(고정)
readonly magnitudeX: number, // X축 방향의 크기(변화량)
readonly magnitudeY: number, // Y축 방향의 크기(변화량)
) {
this.x = x
this.y = y
this.magnitudeX = magnitudeX
this.magnitudeY = magnitudeY
}
// 벡터 정보를 DOMRect 객체로 변환
toDOMRect(): DOMRect {
return new DOMRect(
Math.min(this.x, this.x + this.magnitudeX), // 좌측 상단 X 좌표
Math.min(this.y, this.y + this.magnitudeY), // 좌측 상단 Y 좌표
Math.abs(this.magnitudeX), // 너비
Math.abs(this.magnitudeY), // 높이
)
}
}
그림으로 좀 더 쉽게 설명하자면 이런느낌? DOMRect는 한 사분면으로 제한되는 반면, 새로 만든 DOMVector는 여러 방향으로 해도 변화량이라는게 있으니까 상관없습니다.
다음으로 우리는 selectionRect 상태 대신 dragVector를 저장하는 새로운 상태가 필요하며, 이 상태에서 선택 항목의 DOMRect를 파생시킬 수 있습니다.
const [dragVector, setDragVector] = useState<DOMVector | null>(null)
const selectionRect = dragVector ? dragVector.toDOMRect() : null
마지막으로 DOMRect 대신 DOMVector로 생성자 호출을 대체하고 onPointMove를 업데이트합니다.
const nextDragVector = new DOMVector(
dragVector.x, // 시작점 좌표 (고정)
dragVector.y, // 시작점 좌표 (고정)
e.clientX - containerRect.x - dragVector.x, // X축 변화량
e.clientY - containerRect.y - dragVector.y, // Y축 변화량
)
setDragVector(nextDragVector)
이제 선택박스가 재설정되지 않고 모든 방향으로 렌더링됩니다.
이제 실제로 항목이 선택되어야 합니다. 각 아이템의 DOMRect을 iterating(반복) 하여 selectionRect와 교차상태인지 확인합니다.
React에서 DOMRect를 얻는 가장 일반적인 방법은 Ref를 참조하고 getBoundingClientRect을 사용해서 필요할 때 해당 참조의 DOMRect를 사용하는 것입니다. 우리의 경우, 이는 각 항목에 대한 참조 배열을 저장하는 것을 의미합니다.
ref의 데이터 구조를 저장하는 것은 항상 나에게 다루기 힘든 것처럼 보였습니다. 우리 데이터의 구조는 이미 DOM의 구조로 표현되어 있으며, 그 구조를 두 곳에서 표현할 때 구성 요소를 반복하기가 더 어려워집니다.
이 문제를 피하기 위해 RadixUI와 같은 라이브러리는 데이터 속성(dataset)을 사용하여 querySelector를 통해 관련 DOM노드를 찾습니다.
선택 아이템을 위한 상태를 만드는 것으로 시작해 보겠습니다.
// ex) { '1': true, '14': true }
const [selectedItems, setSelectedItems] = useState<Record<string, boolean>>(
{},
)
컨테이너에 대한 참조를 얻습니다
const containerRef = useRef<HTMLDivElement>(null)
그런 다음 각 항목에 data-item 속성을 추가할 수 있습니다 .
items.map(item => (
<div
data-item={item}
/>
))
이 속성은 각 항목을 고유하게 식별해야 합니다. 데모에서는 배열 내의 항목 인덱스를 사용하지만 실제 사용에서는 고유 ID를 사용하는 것이 좋습니다.
이제 updateSelectedItems 라는 함수를 만들어보겠습니다.
const updateSelectedItems = useCallback(function updateSelectedItems(
dragVector: DOMVector,
) {
/* ... */
}, [])
먼저 모든 항목을 찾습니다.
containerRef.current.querySelectorAll('[data-item]').forEach(el => {
if (containerRef.current == null || !(el instanceof HTMLElement)) return
/* ... */
})
컨테이너 div 에 대한 아이템 DOMRect의 상대적인 값을 가져옵니다 .
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)
그리고 selectionRect와의 교차점을 확인합니다
if (!intersect(dragVector.toDOMRect(), translatedItemRect)) return
if (el.dataset.item && typeof el.dataset.item === 'string') {
next[el.dataset.item] = true
}
교차 상태인지 확인하기 위한 함수를 만들어줍니다. 크게 4가지 경우만 아니면 교차 상태라고 봅니다.
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;
}
각 항목을 반복한 후에 는 로컬 상태를 selectedItems 구성요소 상태로 푸시합니다.
const next: Record<string, boolean> = {}
const containerRect = containerRef.current.getBoundingClientRect()
containerRef.current.querySelectorAll('[data-item]').forEach(el => {
/* ... */
})
setSelectedItems(next)
우리가 무엇인가를 선택했다는 것을 분명히 하기 위해, 선택한 항목의 개수를 나타내는 지표를 만들어 보겠습니다.
<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
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>
컨테이너 주변을 드래그해보세요. 이제 아이템을 선택할 수 있습니다.
선택은 잘 되는 거 같아서 좋지만, 눈에 띄는 문제점이 몇 가지 있습니다.
hover:bg-pink라는 스타일을 적용해봅니다. 그러면 포인터 이벤트랑 서로 충돌이 날 수 있습니다.
이를 해결하려면 간단히 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)
}}
setPointerCapture는 드래그 작업을 더 안정적으로 처리하기 위해 사용됩니다. 마우스/터치가 원래 요소를 벗어나도 계속해서 이벤트를 추적할 수 있고, 빠른 마우스 움직임에도 이벤트를 놓치지 않습니다.
아래와 같은 상황을 방지할 수 있습니다.
두 번째 문제인 실수로 텍스트를 선택하는 문제를 해결하려면 user-select: none을 사용하는 것이 좋습니다.
드래그와 텍스트 선택을 함께 작동시키는 것은 해결되지 않은 문제이지만, 꽤 창의적인 해결책이 있습니다. Notion에서 블록 영역 외부에서 드래그하면 드래그 선택이 시작되지만, 블록 영역 내부에서 드래그하면 텍스트 선택이 시작됩니다.
상황에 따라 비슷한 것을 하거나 다른 창의적인 해결책을 생각해 낼 수 있습니다. 텍스트 선택을 차단하는 대신 텍스트를 클릭하면 어떤 동작하도록 만들 수 있습니다.
데모로 돌아가서 컨테이너에서 select-none을 사용하여 텍스트 선택을 방지해보겠습니다.
className="... select-none"
마지막 문제는 onPointerDown이 모든 이벤트가 드래그를 위한 것이라고 가정하는 코드 때문입니다.
실제로는 사용자가 버튼을 클릭하거나 입력에 초점을 맞출 수 있습니다. 따라서 사용자가 임계 거리를 드래그한 후에만 드래그를 시작하도록 onPointerMove를 수정해봅니다.
먼저 드래그하는지 여부에 대한 상태를 만들어 보겠습니다.
const [isDragging, setIsDragging] = useState(false)
다음으로, 우리는 magnituteX와 magnituteY를 대각선 거리로 결합하여 사용자가 얼마나 이동했는지 계산할 수 있어야 합니다. 이 거리를 구하려면 피타고라스 정리를 사용하면 됩니다.
class DOMVector {
/* ... */
getDiagonalLength(): number {
return Math.sqrt(
Math.pow(this.magnitudeX, 2) + Math.pow(this.magnitudeY, 2),
)
}
}
그런 다음 onPointerMove
드래그가 10px보다 길어질 때까지 드래그 상태를 업데이트하지 않도록 할 수 있습니다.
onPointerMove={e => {
/* ... */
if (!isDragging && nextDragVector.getDiagonalLength() < 10) return
setIsDragging(true)
setDragVector(nextDragVector)
updateSelectedItems(nextDragVector)
}}
현재는 선택된 항목을 해제할 수 있는 좋은 방법이 없습니다. 이를 구현해보겠습니다.
첫번째. onPointerUp
에서 선택 항목을 지워서 포인터 선택 해제 기능을 추가 해보겠습니다.
// 드래깅이 아닌 단순 클릭인 경우 > 선택항목 초기화
if (!isDragging) {
setSelectedItems({})
setDragVector(null)
}
// 드래깅 상태에서 선택이 끝난 경우 > 선택항목은 남아있음
else {
setDragVector(null)
setIsDragging(false)
}
두번째. 사용자가 ESC를 클릭했을때 선택 내용이 지워지는 기능도 좋을 거 같습니다. 그러기 위해서 먼저 컨테이너에 초점을 맞추고 onKeyDown 이벤트를 추가해줍니다.
tabIndex={-1} // 일반적인 키보드 탭에서는 제외
onKeyDown={e => {
if (e.key === 'Escape') {
e.preventDefault()
setSelectedItems({})
setDragVector(null)
}
}}
tabIndex={-1}의 의미는 키보드 접근성과 관련된 속성입니다.
preventDefault
를 추가하면 ESC를 눌러 대화상자가 닫히거나 의도치 않은 동작이 발생하는 것을 방지할 수 있습니다.
마지막으로 컨테이너의 포커스 스타일을 업데이트하여 포커스와 선택 스타일이 서로 다르도록 할 수 있습니다.
'...focus:outline-none focus:border-dashed'
이제 어느 정도 된 거 같습니다만 만약 item이 너무 많아서 컨테이너 div 에 스크롤이 생기면 어떻게 될까요? 테스트를 해보도록 하겠습니다. 먼저 items 개수를 늘려줍니다.
const items = Array.from({ length: 300 }, (_, i) => i + '')
그리고 컨테이너를 스크롤 가능하게 만들기 위해 max-height, grid-template-colums 클래스를 사용할 수 있습니다
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',
)
}
음. 처음에는 아무 이상이 없었지만 스크롤이 된 상태에서 드래그를 해보면 엉뚱한 곳에 선택 상자가 생기는 것을 알 수 있습니다.
이러한 이유는 scroll 이 어느정도 되었는지, scroll 을 고려하지 않았기 때문입니다. onScroll 이벤트를 통해 scrollLeft, scrollTop 을 알아낸 다음, dragVector에 더해주는 작업이 필요해보입니다.
먼저, 스크롤을 벡터로 표현하는 것부터 시작해 보겠습니다.
const [scrollVector, setScrollVector] = useState<DOMVector | null>(null)
이제 우리의 드래그 상태는 두 개의 벡터에 있으므로, selectionRect를 유도할 때 두 벡터를 결합 하는 add
방법이 필요합니다
add(vector: DOMVector): DOMVector {
return new DOMVector(
this.x + vector.x,
this.y + vector.y,
this.magnitudeX + vector.magnitudeX,
this.magnitudeY + vector.magnitudeY,
)
}
다음으로, 선택 항목을 업데이트하기 위한 onScroll 이벤트 핸들러를 만들어야 합니다
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)
}}
이제 우리는 scrollVector를 포함하도록 selectionRect을 유도하는 방법을 업데이트할 수 있습니다.
const selectionRect =
dragVector && scrollVector && isDragging
? dragVector.add(scrollVector).toDOMRect()
: null
이제 스크롤 된 상태에서도 제대로 된 선택 상자와 아이템을 선택할 수 있습니다.
스크롤이 작동하지만 selectionRect가 컨테이너를 넘치는 것을 막지 못합니다.
벡터를 스크롤 영역의 경계에 고정하여 이를 수정해 보겠습니다. "clamp" 함수는 값을 특정 범위 내에 유지하기 위한 것입니다.
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),
)
}
그런 다음 컨테이너의 scrollWidth, scrollHeight와 함께 사용하여 selectionRect가 오버플로우가 발생하는 것을 방지할 수 있습니다.
dragVector
.add(scrollVector)
.clamp(
new DOMRect(
0,
0,
containerRef.current.scrollWidth,
containerRef.current.scrollHeight,
),
)
.toDOMRect()
이제 selectionRect가 넘치지 않도록 continer에 고정되었습니다.
거의 다 왔습니다. 마지막으로 사용자가 스크롤 가능한 컨테이너 가장자리로 드래그하면 자동으로 스크롤 되어야 합니다. 안타깝게도 "onDraggingCloseToTheEdge" 이벤트 핸들러는 없습니다. 사용자가 드래그할 때를 requestAnimationFrame 설정해야 사용자가 가장자리로 드래그하는지 확인할 수 있습니다.
때때로 RAF라고도 불리는 것은 브라우저가 렌더링할 때마다 무언가를 하는 API입니다. 우리의 경우 사용자가 컨테이너 가장자리에 가까이 드래그하는지 확인하는 RAF를 설정하고 싶습니다.
기능은 별거 아닌거 같은데 그 안에서도 고려해야할 내용이 많습니다. 사실 프론트엔드란 다 그런거 아닐까요? 대충 구현해도 동작은 하겠지만 사용자 입장에서 좀 더 생각해보고 개선해야하는게 더 나은 프론트엔드 개발자가 되는 길이라 생각합니다.
저도 이번에 다시 한번 깨닫게 된 거 같습니다.