(번역/실습) Drag to Select

기운찬곰·2025년 3월 10일
0

출처(참고): https://www.joshuawootonn.com/react-drag-to-select

사용자가 수많은 파일들을 관리하는 웹사이트를 생각해보면 드래그를 통해 항목을 선택할 수 있게 하는 것은 대량 작업에 있어서 좋은 선택지가 됩니다. 네이티브 파일시스템을 생각해보세요.

아래는 네이버 드라이브 참고.

하지만 마우스 드래그를 통한 선택을 만드는 것은 생각보다 어려울 수 있습니다. 저는 위 블로그 글을 참고해서 실습을 해본 결과를 작성해보려고 합니다.

최종 데모를 잠깐 엿보세요:

  • 마우스 드래그를 어느쪽으로 하든 적절히 반응해야 함
  • 마우스 드래그가 화면 영역을 벗어나도 스크롤 되면서 드래그 되어야 함.
  • 키보드 ESC 동작 같은 접근성 고려. (다만 드래그하여 선택하는 것은 접근성 호환이 100% 어려움으로 추가적인 키보드 다중 선택이 고려되어야 합니다. 해당 프로젝트는 그것까지 고려하지 않는다는 것을 미리 알립니다.)

기본 마크업

그리드를 렌더링하여 데모를 만들어 보겠습니다. 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>

컨테이너 주변을 드래그해보세요. 이제 아이템을 선택할 수 있습니다.

Drag and Drop Polish

선택은 잘 되는 거 같아서 좋지만, 눈에 띄는 문제점이 몇 가지 있습니다.

드래그하는 동안 포인터 이벤트 방지 (setPointerCapture)

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는 드래그 작업을 더 안정적으로 처리하기 위해 사용됩니다. 마우스/터치가 원래 요소를 벗어나도 계속해서 이벤트를 추적할 수 있고, 빠른 마우스 움직임에도 이벤트를 놓치지 않습니다.

아래와 같은 상황을 방지할 수 있습니다.

  • 드래그 중 마우스가 다른 요소 위로 이동했을 때 이벤트가 끊기는 현상
  • 드래그 중 텍스트나 다른 요소가 선택되는 현상
  • 드래그 중 다른 요소의 hover 효과가 발생하는 현상

텍스트 선택 방지 user-select: none

두 번째 문제인 실수로 텍스트를 선택하는 문제를 해결하려면 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}의 의미는 키보드 접근성과 관련된 속성입니다.

  • 해당 요소를 프로그래밍 방식으로는 포커스가 가능하게 만듭니다
  • 하지만 일반적인 키보드 탭 순서에서는 제외됩니다
  • 즉, 사용자가 Tab 키로 이동할 때는 건너뛰지만, JavaScript로 .focus()를 호출하면 포커스를 받을 수 있습니다

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를 설정하고 싶습니다.

나의 후기

기능은 별거 아닌거 같은데 그 안에서도 고려해야할 내용이 많습니다. 사실 프론트엔드란 다 그런거 아닐까요? 대충 구현해도 동작은 하겠지만 사용자 입장에서 좀 더 생각해보고 개선해야하는게 더 나은 프론트엔드 개발자가 되는 길이라 생각합니다.

저도 이번에 다시 한번 깨닫게 된 거 같습니다.

profile
배움을 좋아합니다. 새로운 것을 좋아합니다.

0개의 댓글