드래그 앤 드랍(2): MouseEvent, TouchEvent, PointerEvent 학습과 구현

들어가기

웹에서 드래그 앤 드롭(Drag and Drop) 기능은 사용자의 직관적인 인터랙션을 제공하기 위해 매우 유용한 UI 요소이다. HTML5의 Drag and Drop API를 사용하면 간단하게 구현할 수 있지만, 이 방식은 모바일 환경에서 작동하지 않는다는 치명적인 단점이 존재한다. 따라서 보다 넓은 플랫폼 호환성과 안정성을 확보하려면, MouseEvent, TouchEvent, PointerEvent를 직접 다루는 방식으로 구현하는 것이 좋다.

각 이벤트가 어떤 상황에서 발생하며, 어떤 속성과 동작을 가지는지에 대한 이해를 한 후 직접 구현해보자

참고로 이 이벤트 객체는 모두 UIEventEvent를 상속하고 있기 때문에, 더 구체적인 속성이나 메서스가 궁금하다면 글 맨 아래에 첨부한 참고 자료를 확인하면 도움이 될 것 같다.

MouseEvent - 데스크탑의 기본 입력 이벤트

MouseEvent는 사용자가 포인팅 장치(마우스 등)를 사용해 상호작용할 때 발생하는 이벤트를 나타낸다.

주요 속성

속성명
설명

button

클릭한 마우스 버튼 번호 (0: 좌, 1: 중, 2: 우)

buttons

눌린 마우스 버튼들의 상태 (비트마스크)

altKey

Alt 키가 눌렸는지 여부 (true / false)

ctrlKey

Ctrl 키가 눌렸는지 여부

metaKey

Meta 키(Mac 기준 Command)가 눌렸는지 여부

shiftKey

Shift 키가 눌렸는지 여부

pageX, pageY

전체 문서 기준 마우스 좌표 (스크롤 고려됨)

screenX, screenY

화면 기준 마우스 포인터 좌표

clientX, clientY

뷰포트 기준 마우스 포인터의 좌표

x, y

clientX, clientY의 별칭

offsetX, offsetY

이벤트 대상 요소의 패딩 기준 내부 좌표

movementX, movementY

마지막 mousemove 이벤트 이후 포인터가 이동한 거리

relatedTarget

마우스가 방금 떠난 곳 또는 새로 들어갈 곳 (mouseover, mouseout 시 관련 요소)

주요 이벤트

이벤트 이름
설명

click

마우스를 클릭했을 때 (mousedown + mouseup)

dblclick

마우스를 빠르게 두 번 클릭할 때 발생

contextmenu

마우스 오른쪽 클릭 시 발생

mousedown

마우스 버튼을 누를 때 발생

mouseup

마우스 버튼에서 손을 뗄 때 발생

mousemove

마우스를 움직일 때 발생

mouseover

요소 위로 마우스 포인터가 올라갈 때 발생 (버블링 O)

mouseout

요소에서 마우스 포인터가 벗어날 때 발생 (버블링 O)

mouseenter

요소 위로 마우스 포인터가 올라갈 때 발생 (버블링 X)

mouseleave

요소에서 마우스 포인터가 벗어날 때 발생 (버블링 X)

TouchEvent - 모바일 기기의 터치 입력 처리

TouchEvent는 스마트폰, 태블릿 등 터치 스크린 장치의 접촉 상태 변화를 감지하는 이벤트이다.

마우스 이벤트와 다르게 여러 접촉점을 동시에 처리할 수 있다.

주요 속성

속성명
설명

altKey

이벤트 발생 시 Alt 키가 눌려 있었는지 여부 (Boolean)

ctrlKey

이벤트 발생 시 Ctrl 키가 눌려 있었는지 여부 (Boolean)

metaKey

이벤트 발생 시 Meta 키(⌘ 등)가 눌려 있었는지 여부 (Boolean)

shiftKey

이벤트 발생 시 Shift 키가 눌려 있었는지 여부 (Boolean)

touches

화면에 닿아 있는 모든 터치 지점을 담은 TouchList 객체

targetTouches

현재 이벤트 대상 요소에서 발생한 터치 지점들만 담은 TouchList 객체

changedTouches

직전 이벤트 이후 상태가 변경된 터치 지점만 담은 TouchList 객체

주요 이벤트

이벤트 이름
설명

touchstart

하나 이상의 손가락이 화면에 닿았을 때 발생

touchmove

터치된 손가락이 움직였을 때 발생

touchend

손가락이 터치 표면에서 떨어졌을 때 발생

touchcancel

터치 이벤트가 시스템에 의해 취소되었을 때 발생(알림 창 등장 등 )

PointerEvent - 모든 입력 장치를 통합한 새로운 방식

PointerEvent 는 마우스, 터치, 펜 등 모든 포인팅 장치를 통합하여 처리할 수 있는 이벤트다.

크로스 플랫폼 대응이 필요할 경우, PointerEvent를 우선적으로 고려하는 것이 좋다.

주요 속성

속성명
설명

pointerId

포인터를 구별하기 위한 고유 ID. 멀티터치 등에서 포인터를 식별할 수 있음.

pointerType

"mouse", "touch", "pen" 중 하나로 포인터 종류를 나타냄.

isPrimary

해당 포인터가 기본 입력인지 여부 (true/false).

width, height

입력 지점(손가락, 펜 등)의 접촉 영역 너비와 높이 (픽셀 단위).

pressure

압력 값 (0 ~ 1 사이의 값, 마우스는 0 또는 0.5/1 등).

tiltX, tiltY

입력 장치의 기울기. -90 ~ 90 사이 값으로 주로 펜에서 사용됨.

twist

포인터의 회전 정도 (0~359도).

tangentialPressure

포인터의 측면 압력 (펜에서 사용, 일부 장치만 지원).

clientX, clientY

뷰포트를 기준으로 한 포인터 위치 (좌표).

pageX, pageY

문서 전체 기준의 포인터 위치 (스크롤 고려됨).

screenX, screenY

화면 전체 기준의 포인터 위치.

buttons

눌려진 버튼 상태를 비트마스크로 표현.

altKey, ctrlKey, metaKey, shiftKey

조합 키의 눌림 여부를 나타냄.

주요 이벤트

이벤트 이름
설명

pointerdown

포인터(마우스, 터치, 펜 등)가 눌렸을 때 발생

pointerup

포인터에서 손을 뗐을 때 발생

pointermove

포인터가 움직일 때 발생

pointercancel

시스템이 포인터 이벤트를 취소했을 때 발생

pointerover

포인터가 요소에 올라갈 때 발생 (버블링 O)

pointerout

포인터가 요소에서 벗어날 때 발생 (버블링 O)

pointerenter

포인터가 요소에 들어왔을 때 발생 (버블링 X)

pointerleave

포인터가 요소를 벗어났을 때 발생 (버블링 X)

gotpointercapture

포인터 캡처가 설정되었을 때 발생

lostpointercapture

포인터 캡처가 해제되었을 때 발생

관련 메서드

API
설명
사용 목적

setPointerCapture()

포인터 이벤트를 요소가 계속 수신하도록 설정합니다.

포인터가 요소 밖으로 나가도 이벤트를 계속 받기 위함

releasePointerCapture()

캡처한 포인터 이벤트 수신 권한을 해제합니다.

드래그 또는 인터랙션 종료 시 캡처 해제

hasPointerCapture()

해당 요소가 특정 포인터 ID의 이벤트를 캡처 중인지 확인

포인터 캡처 여부 확인 또는 조건 분기 처리

navigator.maxTouchPoints

디바이스가 동시에 감지할 수 있는 터치 포인터 수

터치 디바이스인지 여부를 판단할 때 사용

PointerEvent로 드래그앤 드랍 구현하기

PointerEvent는 MouseEvent와 ToucheEvent를 통합한 것이기 때문에 PointerEvent를 사용해서 브라우저, 모바일 둘 다 대응되는 드래그앤 드랍을 만들려고 한다. 먼저 프로젝트에 직접 적용하기 전 Drag And Drop API를 학습할 때 만들어본 간단한 예시를 PointerEvent 기반으로 다시 구현해보자

요소 옮기기

import { useState, useRef } from "react";

export default function App() {
  // 박스가 위치해 있는 인덱스 (0번 영역 또는 1번 영역)
  const [boxIndex, setBoxIndex] = useState(0);

  // 드래그 중인 상태를 저장하는 ref (불리언 값으로 추적)
  const draggedRef = useRef(false);

  // 박스를 누를 때 호출됨 (드래그 시작)
  const handlePointerDown = () => {
    draggedRef.current = true;
  };

  // 포인터를 뗄 때 호출됨 (드래그 종료 및 drop 처리)
  const handlePointerUp = (index) => {
    // 드래그 중이 아니면 아무 작업도 하지 않음
    if (!draggedRef.current) return;

    // 드래그 종료 상태로 전환
    draggedRef.current = false;

    // 드롭된 위치로 박스를 이동
    setBoxIndex(index);
  };

  return (
    <div className="App">
      <div style={...}>
        {/* 박스를 담을 두 개의 영역 */}
        {[0, 1].map((_, idx) => (
          <div
            key={idx}
            onPointerUp={() => handlePointerUp(idx)} // 포인터 업 시 박스를 이 위치로 이동
            style={...}
          >
            {/* 현재 박스가 이 위치에 있을 경우만 박스를 렌더링 */}
            {boxIndex === idx && (
              <div
                onPointerDown={handlePointerDown} // 박스를 누르면 드래그 시작
                style={...}
              />
            )}
          </div>
        ))}
      </div>
    </div>
  );
}

요소 위치 바꾸기(서로 바꾸기)

export default function App() {
  // 🍎🍌🍇 아이템 리스트
  const [items, setItems] = useState([
    { id: "a", label: "🍎" },
    { id: "b", label: "🍌" },
    { id: "c", label: "🍇" },
  ]);

  // 현재 드래그 중인 아이템의 ID를 저장할 ref (상태가 아닌 이유: 리렌더링 불필요)
  const draggedIdRef = useRef(null);

  // 드래그 시작: 포인터를 누를 때 현재 아이템의 ID를 저장
  const handlePointerDown = (id) => {
    draggedIdRef.current = id;
  };

  // 드롭 시 동작: 포인터를 떼면 현재 위치에 아이템을 교체
  const handlePointerUp = (targetId) => {
    const draggedId = draggedIdRef.current;

    // 아무것도 드래그 중이 아니거나 자기 자신에 드롭한 경우 무시
    if (!draggedId || draggedId === targetId) return;

    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    const targetIndex = items.findIndex((item) => item.id === targetId);

    // 유효하지 않은 인덱스면 무시
    if (draggedIndex === -1 || targetIndex === -1) return;

    // 새 배열을 만들어 두 아이템의 위치를 교체
    const newItems = [...items];
    [newItems[draggedIndex], newItems[targetIndex]] = [
      newItems[targetIndex],
      newItems[draggedIndex],
    ];

    // 상태 업데이트 (화면 갱신)
    setItems(newItems);

    // 드래그 종료: 초기화
    draggedIdRef.current = null;
  };

  return (
    <div style={...}>
      {items.map((item) => (
        <div
          key={item.id}
          // 드롭 대상 요소: 포인터를 떼면 해당 위치로 드롭 처리
          onPointerUp={() => handlePointerUp(item.id)}
          style={...}
        >
          <div
            // 드래그 시작 요소: 포인터 누르면 드래그 시작
            onPointerDown={() => handlePointerDown(item.id)}
            style={...}
          >
            {item.label}
          </div>
        </div>
      ))}
    </div>
  );
}

요소 위치 바꾸기(사이에 끼우기)

import { useState, useRef, Fragment } from "react";

export default function App() {
  // 드래그 가능한 아이템들의 상태 관리
  const [items, setItems] = useState([
    { id: "a", label: "🍎" },
    { id: "b", label: "🍌" },
    { id: "c", label: "🍇" },
  ]);

  // 현재 드래그 중인 아이템의 ID를 저장
  const draggedIdRef = useRef(null);

  // 드래그 중인 아이템이 놓일 예상 위치 인덱스
  const [insertionIndex, setInsertionIndex] = useState(null);

  // 드래그 상태 초기화 함수
  const reset = () => {
    draggedIdRef.current = null;
    setInsertionIndex(null);
  };

  // 드래그 시작 시 호출되는 함수 (해당 아이템의 ID 저장)
  const handlePointerDown = (e, id) => {
    draggedIdRef.current = id;
  };

  // 드래그 중에 아이템 위를 지나갈 때 호출됨
  // 마우스 위치를 기준으로 삽입선 위치 결정
  const handlePointerMove = (event, index) => {
    const draggedId = draggedIdRef.current;
    if (draggedId === null) return;

    // 현재 드래그 중인 아이템의 인덱스 찾기
    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    if (draggedIndex === -1) return;

    // 현재 마우스가 위치한 아이템의 요소 정보 가져오기
    const rect = event.currentTarget.getBoundingClientRect();

    // 요소 위에서 마우스의 상대적인 Y 위치 계산
    const offsetY = event.clientY - rect.top;

    // 마우스 위치가 요소의 상반부면 현재 위치로, 하반부면 다음 위치로 설정
    setInsertionIndex(offsetY < rect.height / 2 ? index : index + 1);
  };

  // 드래그가 종료되었을 때 실행되는 함수
  const handlePointerUp = () => {
    const draggedId = draggedIdRef.current;
    if (draggedId == null || insertionIndex == null) return;

    // 드래그 중인 아이템의 현재 인덱스 확인
    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    if (draggedIndex === -1) return;

    const updatedItems = [...items];

    // 드래그된 아이템 제거
    const [draggedItem] = updatedItems.splice(draggedIndex, 1);

    // 삽입 위치가 원래 위치보다 뒤라면 인덱스를 하나 줄여야 정확한 위치로 이동됨
    let targetIndex = insertionIndex;
    if (draggedIndex < insertionIndex) targetIndex--;

    // 드래그된 아이템을 새로운 위치에 삽입
    updatedItems.splice(targetIndex, 0, draggedItem);

    // 새로운 아이템 배열로 상태 업데이트
    setItems(updatedItems);

    // 드래그 상태 초기화
    reset();
  };

  // 드래그 중 커서가 드래그 가능한 영역을 벗어나면 상태 초기화
  const handlePointerLeave = () => {
    reset();
  };

  // 삽입선 컴포넌트: 드래그 중인 아이템이 어디로 들어갈지 시각적으로 표시
  function InsertionLine({ isVisible }) {
    return (
      <div style={...} />
    );
  }

  return (
    <div
      // 드래그가 끝났을 때, 영역 벗어났을 때 전체 영역에서 처리
      onPointerUp={handlePointerUp}
      onPointerLeave={handlePointerLeave}
      style={...}
    >
      {/* 첫 번째 위치에 대한 삽입선 표시 */}
      <InsertionLine isVisible={insertionIndex === 0} />

      {/* 각 아이템과 그 사이에 삽입선 렌더링 */}
      {items.map((item, index) => (
        <Fragment key={item.id}>
          {/* 드래그 가능한 아이템 */}
          <div
            onPointerDown={(e) => handlePointerDown(e, item.id)} // 드래그 시작
            onPointerMove={(e) => handlePointerMove(e, index)} // 드래그 중
            style={...}
          >
            <div style={{ fontSize: "20px" }}>
              {item.label} Item {item.id}
            </div>
          </div>

          {/* 현재 아이템 다음 위치에 삽입선 표시 여부 */}
          <InsertionLine isVisible={insertionIndex === index + 1} />
        </Fragment>
      ))}
    </div>
  );
}잔ㅅ

잔상 띄우기

Pointer 이벤트 기반 드래그 구현에서는 기본 Drag API처럼 자동으로 잔상이 생성되지 않는다. 따라서 사용자에게 시각적 피드백을 제공하려면, 드래그 중인 아이템을 따라다니는 잔상(고스트) 요소를 직접 만들어야 한다.

아래와 같은 로직으로 구현 가능할 듯 하다:

  1. 요소를 누르면 (pointerdown)

    • 드래그할 아이템의 ID와 시작 좌표를 저장한다.

  2. 마우스를 움직이면 (pointermove)

    • 처음 누른 지점에서 일정 거리 이상 움직였을 때만 드래그로 간주하여 드래그 잔상(ghost 요소)을 생성한다.

    • 드래그 중이라면 마우스 위치에 따라 잔상을 이동시킨다.

  3. 마우스를 놓으면 (pointerup)

    • 드래그 잔상을 없앤다.

1. 요소를 누르면 (pointerdown) 드래그할 아이템의 ID와 시작 좌표를 저장

 const handlePointerDown = (e, id) => {
    draggedIdRef.current = id;
    dragStartPosRef.current = { x: e.pageX, y: e.pageY };
 };

2. 마우스를 움직이면 (pointermove) 드래그일 때만 잔상 생성/이동


  const handlePointerMove = (event, index) => {
    const draggedId = draggedIdRef.current;
    if (!draggedId) return;

    // pointermove 이벤트 좌표와 요소 눌렀던 좌표로 거리 계산하여 특정 거리 이상일 때만 드래그로 판단, 잔상 생성  
    if (!isDraggingRef.current) {
      const dx = event.pageX - dragStartPosRef.current.x;
      const dy = event.pageY - dragStartPosRef.current.y;
      const distance = Math.sqrt(dx * dx + dy * dy);

      if (distance <= 5) return;

      isDraggingRef.current = true;

      const ghost = event.currentTarget.cloneNode(true);
      ghost.style.position = "fixed";
      ghost.style.top = `${event.pageY}px`;
      ghost.style.left = `${event.pageX}px`;
      ghost.style.pointerEvents = "none";
      ghost.style.opacity = "0.8";
      ghost.style.zIndex = "1000";
      ghost.style.transform = "translate(-50%, -50%)";
      ghost.style.width = `${event.currentTarget.offsetWidth}px`;

      document.body.appendChild(ghost);
      ghostRef.current = ghost;
    }

		// 드래그 상태이고 잔상이 있다면 잔상 위치 갱신 
    if (isDraggingRef.current && ghostRef.current) {
      ghostRef.current.style.left = `${event.pageX}px`;
      ghostRef.current.style.top = `${event.pageY}px`;
    }

		// 기존 삽입할 인덱스 계산 로직 
    const rect = event.currentTarget.getBoundingClientRect();
    const offsetY = event.clientY - rect.top;
    setInsertionIndex(offsetY < rect.height / 2 ? index : index + 1);
  };

3. 놓으면 (pointerup) 초기화 하면서 잔상 제거

  const reset = () => {
    draggedIdRef.current = null;
    setInsertionIndex(null);
    isDraggingRef.current = false;

    if (ghostRef.current) {
      document.body.removeChild(ghostRef.current);
      ghostRef.current = null;
    }
  };
  
  const handlePointerUp = () => {
    if (!isDraggingRef.current) {
      reset(); 
      return;
    }

    const draggedId = draggedIdRef.current;
    if (draggedId == null || insertionIndex == null) {
      reset();
      return;
    }

    const draggedIndex = items.findIndex((item) => item.id === draggedId);
    if (draggedIndex === -1) return;

    const updatedItems = [...items];
    const [draggedItem] = updatedItems.splice(draggedIndex, 1);

    let targetIndex = insertionIndex;
    if (draggedIndex < insertionIndex) targetIndex--;

    updatedItems.splice(targetIndex, 0, draggedItem);
    setItems(updatedItems);

    reset(); // 초기화하면서 잔상 제거 
  };

영역 벗어나면 유지 안되는 문제 해결

그런데 위의 로직대로 구현하면 문제가 있다. 드래그 영역에서 벗어나게되면 위 gif처럼 드래그 요소가 유지 되지 않는다는점이다. 이를 해결하려면 이벤트를 전역에 부착시켜야한다.

1. 전역에 이벤트 부착

useEffect(() => {
  ...
  document.addEventListener("pointermove", handlePointerMove);
  document.addEventListener("pointerup", handlePointerUp);

  return () => {
    document.removeEventListener("pointermove", handlePointerMove);
    document.removeEventListener("pointerup", handlePointerUp);
  };
}, [items]);

2. 드래그 가능 영역이 아니면 드래그 불가능

드래그 중 커서가 컨테이너의 경계를 벗어나면 삽입 불가능하기 때문에insertionIndexnull로 설정한다.

const containerRect = container.getBoundingClientRect();
if (
  event.clientX < containerRect.left ||
  event.clientX > containerRect.right ||
  event.clientY < containerRect.top ||
  event.clientY > containerRect.bottom
) {
  setInsertionIndex(null); // 영역 벗어남 → 삽입선 제거
  return;
}

3. 드래그된 아이템이 삽입될 위치(인덱스) 계산

마우스가 컨테이너 안에 있을 경우에는, 아이템들의 위치를 계산하여 어느 위치에 삽입할지 결정한다.

마우스 커서의 Y 좌표가 각 아이템의 세로 중앙(중간점)보다 위에 있는 경우, 해당 아이템 앞에 삽입선을 표시한다. 조건을 만족하는 가장 첫 번째 아이템 앞 위치를 삽입될 위치로 설정하며, 그렇지 않으면 마지막을 삽입 위치로 설정한다.

for (let i = 0; i < items.length; i++) {
  const { id } = items[i];
  const child = itemRefs.current[id];
  if (!child) continue;

  const rect = child.getBoundingClientRect();
  if (event.clientY < rect.top + rect.height / 2) {
    setInsertionIndex(i); // 삽입될 인덱스 설정 
    return;
  }
}
setInsertionIndex(items.length); // 리스트 맨 아래로

여기까지 완성된 전체 코드는 아래와 같다.

import { useState, useRef, Fragment, useEffect } from "react";

export default function App() {
  // 아이템 상태 관리 (순서 바뀔 때마다 업데이트됨)
  const [items, setItems] = useState([
    { id: "a", label: "🍎" },
    { id: "b", label: "🍌" },
    { id: "c", label: "🍇" },
  ]);

  // 드래그 대상이 되는 전체 컨테이너 참조
  const containerRef = useRef(null);

  // 삽입선을 보여줄 위치 인덱스 (0~n 사이, null이면 안 보임)
  const [insertionIndex, setInsertionIndex] = useState(null);
  const insertionIndexRef = useRef(insertionIndex); // 최신 값을 외부 이벤트에서 참조하기 위해 ref로도 저장

  // 드래그 중인 아이템 id 저장
  const draggedIdRef = useRef(null);

  // 따라다니는 고스트 요소 (cloneNode로 만든 시각적 대체 요소)
  const ghostRef = useRef(null);

  // 포인터가 처음 눌렸을 때의 위치 저장
  const dragStartPosRef = useRef({ x: 0, y: 0 });

  // 현재 드래그가 시작되었는지 여부 저장
  const isDraggingRef = useRef(false);

  // 각 아이템 DOM 요소에 접근하기 위한 ref 객체
  const itemRefs = useRef({});

  // insertionIndex가 바뀔 때마다 ref 값도 동기화
  useEffect(() => {
    insertionIndexRef.current = insertionIndex;
  }, [insertionIndex]);

  useEffect(() => {
    // 전역으로 포인터 이동 감지 (드래그 도중 화면 밖으로 나가도 동작 유지하기 위해)
    const handlePointerMove = (event) => {
      const draggedId = draggedIdRef.current;
      if (!draggedId) return;

      // 드래그 판별: 일정 거리 이상 움직인 경우에만 시작
      if (!isDraggingRef.current) {
        const dx = event.pageX - dragStartPosRef.current.x;
        const dy = event.pageY - dragStartPosRef.current.y;
        const distance = Math.sqrt(dx * dx + dy * dy);
        if (distance <= 5) return;

        isDraggingRef.current = true;

        // 고스트 요소 생성 및 스타일링
        const draggedElement = itemRefs.current[draggedId];
        if (!draggedElement) return;

        const ghost = draggedElement.cloneNode(true);
        ghost.style.position = "fixed";
        ghost.style.top = `${event.pageY}px`;
        ghost.style.left = `${event.pageX}px`;
        ghost.style.pointerEvents = "none";
        ghost.style.opacity = "0.8";
        ghost.style.zIndex = "1000";
        ghost.style.transform = "translate(-50%, -50%)";
        ghost.style.width = `${draggedElement.offsetWidth}px`;

        document.body.appendChild(ghost);
        ghostRef.current = ghost;
      }

      // 고스트 위치 계속 업데이트
      if (ghostRef.current) {
        ghostRef.current.style.left = `${event.pageX}px`;
        ghostRef.current.style.top = `${event.pageY}px`;
      }

      const container = containerRef.current;
      if (!container) return;

      // 컨테이너 밖으로 나간 경우 삽입선 숨김
      const containerRect = container.getBoundingClientRect();
      if (
        event.clientX < containerRect.left ||
        event.clientX > containerRect.right ||
        event.clientY < containerRect.top ||
        event.clientY > containerRect.bottom
      ) {
        setInsertionIndex(null);
        return;
      }

      // 커서 위치 기준으로 어느 아이템 앞에 삽입선 표시할지 결정
      for (let i = 0; i < items.length; i++) {
        const { id } = items[i];
        const child = itemRefs.current[id];
        if (!child) continue;

        const rect = child.getBoundingClientRect();
        // 커서가 아이템의 세로 중앙보다 위에 있으면, 그 아이템 앞에 삽입선 표시
        if (event.clientY < rect.top + rect.height / 2) {
          setInsertionIndex(i);
          return;
        }
      }

      // 어떤 조건에도 해당되지 않으면, 마지막 아이템 뒤에 삽입선 표시
      setInsertionIndex(items.length);
    };

    // 포인터 해제 시 드롭 처리
    const handlePointerUp = () => {
      if (!isDraggingRef.current) return reset();

      const draggedId = draggedIdRef.current;
      const index = insertionIndexRef.current;

      if (draggedId == null || index == null) return reset();

      const draggedIndex = items.findIndex((item) => item.id === draggedId);
      if (draggedIndex === -1) return reset();

      const updatedItems = [...items];
      const [draggedItem] = updatedItems.splice(draggedIndex, 1);

      // 드래그된 항목이 원래보다 뒤로 이동한 경우, 삽입 위치 보정
      let targetIndex = index;
      if (draggedIndex < index) targetIndex--;

      updatedItems.splice(targetIndex, 0, draggedItem);
      setItems(updatedItems);

      reset();
    };

    // 전역 이벤트 등록 (영역 벗어나도 동작하게 하기 위해)
    document.addEventListener("pointermove", handlePointerMove);
    document.addEventListener("pointerup", handlePointerUp);
    return () => {
      document.removeEventListener("pointermove", handlePointerMove);
      document.removeEventListener("pointerup", handlePointerUp);
    };
  }, [items]);

  // 드래그 상태 초기화
  const reset = () => {
    draggedIdRef.current = null;
    setInsertionIndex(null);
    isDraggingRef.current = false;

    if (ghostRef.current) {
      document.body.removeChild(ghostRef.current);
      ghostRef.current = null;
    }
  };

  // 드래그 시작 시 포인터 위치 기록
  const handlePointerDown = (e, id) => {
    e.preventDefault(); // prevent text selection 등 방지
    draggedIdRef.current = id;
    dragStartPosRef.current = { x: e.pageX, y: e.pageY };
  };

  // 삽입선 표시 컴포넌트
  function InsertionLine({ isVisible }) {
    return (
      <div
        style={{
          height: "2px",
          background: isVisible ? "#0066ff" : "transparent",
          transition: "background 0.2s",
          margin: "4px 0",
        }}
      />
    );
  }

  return (
    <div
      ref={containerRef}
      style={{
        padding: "20px",
        background: "#f5f5f5",
        borderRadius: "8px",
        maxWidth: "400px",
      }}
    >
      {/* 맨 앞에 삽입선 표시 */}
      <InsertionLine isVisible={insertionIndex === 0} />

      {/* 아이템 목록 렌더링 + 각 뒤에 삽입선 표시 */}
      {items.map((item, index) => (
        <Fragment key={item.id}>
          <div
            ref={(el) => (itemRefs.current[item.id] = el)}
            onPointerDown={(e) => handlePointerDown(e, item.id)}
            style={{
              padding: "15px",
              background: "white",
              cursor: "grab",
              userSelect: "none",
              borderRadius: "6px",
              boxShadow: "0 1px 4px rgba(0,0,0,0.1)",
              margin: "4px 0",
            }}
          >
            <div style={{ fontSize: "20px" }}>
              {item.label} Item {item.id}
            </div>
          </div>
          <InsertionLine isVisible={insertionIndex === index + 1} />
        </Fragment>
      ))}
    </div>
  );
}

참고 자료

https://developer.mozilla.org/ko/docs/Web/API/MouseEvent

https://developer.mozilla.org/en-US/docs/Web/API/TouchEvent

https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent

Last updated