[실습] Drag to Select

기운찬곰·2일 전
0

프론트개발이모저모

목록 보기
20/20

출처(참고): 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개의 댓글

관련 채용 정보