본문 바로가기

React.js

[React.js + TypeScript] react-dnd 안쓰고 드래그앤 드랍 구현하기

반응형

간단한 재정렬 기능을 가진 todolist이다.

  1. 이동 시 해당 아이템이 마우스 커서를 따라오도록 구현:
  • draggingItem 상태를 추가하여 현재 드래그 중인 아이템을 추적합니다.
  • 드래그 중인 아이템의 복사본을 마우스 커서 위치에 표시합니다.
  1. 이동 시 커서가 e-resize 모양이 되도록 변경:
  • 각 todo 아이템에 cursor-e-resize 클래스를 추가했습니다.
  1. 메뉴 목록을 가로로 나열하고 left-align 정렬:
  • <ul> 요소에 flex flex-wrap gap-2 items-start 클래스를 적용하여 가로 정렬과 왼쪽 정렬을 구현했습니다.
  1. 추가한 글자 길이에 맞게 아이템의 너비가 fit되도록 변경:
  • <li> 요소에 inline-block 클래스를 추가하여 내용에 맞게 너비가 조정되도록 했습니다.
  1. 드래그 중인 아이템의 원래 자리를 비우도록 구현:
  • 드래그 시작 시 해당 아이템의 visibilityhidden으로 설정합니다.
  • 드래그가 끝나면 visibility를 다시 visible로 설정합니다.
  1. 실시간 정렬 미리보기 구현:
  • useEffect를 사용하여 마우스 이동을 추적합니다.
  • 마우스 위치에 따라 dragOverIndex를 실시간으로 업데이트합니다.
  • getOrderedTodos 함수를 사용하여 현재 드래그 상태에 따른 정렬된 목록을 생성합니다.
  1. 성능 최적화:
  • 불필요한 리렌더링을 방지하기 위해 draggingIndexdragOverIndex 상태를 사용합니다.
  1. 드래그 상태 관리:
  • DragState 인터페이스를 정의하여 드래그 관련 상태를 관리합니다.
  • isDragging, draggedIndex, draggedOverIndex, startPosition을 포함합니다.
  1. 마우스 이벤트 처리:
  • handleMouseDown: 마우스 다운 시 드래그 시작 위치를 기록합니다.
  • handleMouseMove: 마우스 이동 거리가 15px 이상일 때 드래그를 시작하고, 드래그 중인 아이템의 위치를 업데이트합니다.
  • handleMouseUp: 드래그가 끝났을 때 아이템 순서를 변경합니다.
  1. 드래그 헬퍼 구현:
  • 드래그 중인 아이템을 표시하는 헬퍼 요소를 추가했습니다.
  • 마우스를 따라다니도록 transform 스타일을 사용합니다.
  1. 시각적 피드백
  • 드래그 중인 아이템은 invisible 클래스를 적용하여 원래 위치에서 숨깁니다.
  1. preventDefault() 추가:
  • handleMouseDown, handleMouseMove, handleMouseUp, 그리고 전역 mouseup 이벤트 핸들러에 e.preventDefault()를 추가했습니다. 이는 텍스트 드래그를 방지하고 원치 않는 기본 동작을 막습니다.
  1. dragHelper 위치 지정 방식 변경:
  • transform을 사용하는 대신 position: absolute, top, left를 사용하여 dragHelper의 위치를 지정했습니다.
  • z-index를 1000으로 설정하여 다른 요소들 위에 표시되도록 했습니다.
  1. DragState 인터페이스 수정:
  • currentPosition을 추가하여 드래그 중인 아이템의 현재 위치를 추적합니다.
  1. 마우스 이벤트 핸들러 수정:
  • handleMouseMove에서 currentPosition을 업데이트하도록 변경했습니다.
  • dragHelper의 위치를 currentPosition을 기반으로 설정합니다.
  1. 조건부 렌더링 개선:
  • dragHelper 렌더링 조건에 currentPosition 체크를 추가하여 더 안정적으로 표시되도록 했습니다.
  1. 전역 마우스 이벤트 처리:
  • mousemove 이벤트를 전역적으로 처리하여 드래그 중 마우스가 컴포넌트를 벗어나도 동작하도록 했습니다.
  1. 드롭 위치 계산 개선:
  • getDropIndex 함수를 추가하여 마우스 위치에 따른 드롭 인덱스를 계산합니다.

추가개선점

  • 200ms throttle 추가
"use client"

import type React from "react"
import { useState, useRef, useEffect, useCallback } from "react"
import { PlusCircle } from "lucide-react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"

interface Todo {
  id: number
  text: string
}

interface DragState {
  isDragging: boolean
  draggedIndex: number | null
  startPosition: { pageX: number; pageY: number } | null
  offset: { left: number; top: number } | null
  currentPosition: { x: number; y: number } | null
  dropIndex: number | null
}

export default function TodoList() {
  const [todos, setTodos] = useState<Todo[]>([])
  const [newTodo, setNewTodo] = useState("")
  const [dragState, setDragState] = useState<DragState>({
    isDragging: false,
    draggedIndex: null,
    startPosition: null,
    offset: null,
    currentPosition: null,
    dropIndex: null,
  })
  const [tempTodos, setTempTodos] = useState<Todo[]>([])
  const listRef = useRef<HTMLUListElement>(null)
  const dragHelperRef = useRef<HTMLDivElement>(null)
  const lastDropIndexUpdateTime = useRef<number>(0)

  const addTodo = () => {
    if (newTodo.trim() !== "") {
      setTodos([...todos, { id: Date.now(), text: newTodo }])
      setNewTodo("")
    }
  }

  const handleMouseDown = (e: React.MouseEvent<HTMLLIElement>, index: number) => {
    e.preventDefault()
    const target = e.currentTarget
    const rect = target.getBoundingClientRect()
    setDragState({
      isDragging: false,
      draggedIndex: index,
      startPosition: { pageX: e.pageX, pageY: e.pageY },
      offset: { left: rect.left, top: rect.top },
      currentPosition: null,
      dropIndex: null,
    })
    setTempTodos([...todos])
    lastDropIndexUpdateTime.current = Date.now()
  }

  const handleMouseMove = useCallback(
    (e: React.MouseEvent<HTMLLIElement> | MouseEvent) => {
      e.preventDefault()
      if (dragState.startPosition && dragState.offset && dragState.draggedIndex !== null) {
        const currentPosition = {
          x: e.clientX - (dragState.startPosition.pageX - dragState.offset.left),
          y: e.clientY - (dragState.startPosition.pageY - dragState.offset.top),
        }

        setDragState((prev) => ({ ...prev, isDragging: true, currentPosition }))

        const currentTime = Date.now()
        if (currentTime - lastDropIndexUpdateTime.current >= 200) {
          // 0.2 seconds throttle
          const dropIndex = getDropIndex(e.clientX, e.clientY)
          if (dropIndex !== dragState.dropIndex) {
            setDragState((prev) => ({ ...prev, dropIndex }))
            const newTempTodos = [...todos]
            const [draggedItem] = newTempTodos.splice(dragState.draggedIndex, 1)
            newTempTodos.splice(dropIndex, 0, draggedItem)
            setTempTodos(newTempTodos)
            lastDropIndexUpdateTime.current = currentTime
          }
        }
      }
    },
    [dragState, todos],
  )

  const handleMouseUp = useCallback(
    (e: React.MouseEvent<HTMLLIElement> | MouseEvent) => {
      e.preventDefault()
      if (dragState.isDragging) {
        setTodos(tempTodos)
      }
      setDragState({
        isDragging: false,
        draggedIndex: null,
        startPosition: null,
        offset: null,
        currentPosition: null,
        dropIndex: null,
      })
    },
    [dragState.isDragging, tempTodos],
  )

  const getDropIndex = (x: number, y: number): number => {
    if (!listRef.current) return -1
    const items = Array.from(listRef.current.children) as HTMLLIElement[]
    return items.findIndex((item) => {
      const rect = item.getBoundingClientRect()
      return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom
    })
  }

  useEffect(() => {
    document.body.style.cursor = dragState.isDragging ? "ew-resize" : "default"
  }, [dragState.isDragging])

  useEffect(() => {
    document.addEventListener("mousemove", handleMouseMove)
    document.addEventListener("mouseup", handleMouseUp)
    return () => {
      document.removeEventListener("mousemove", handleMouseMove)
      document.removeEventListener("mouseup", handleMouseUp)
    }
  }, [handleMouseMove, handleMouseUp])

  return (
    <div className="max-w-4xl mx-auto mt-10 p-6 bg-white rounded-lg shadow-md">
      <h1 className="text-2xl font-bold mb-4">Todo List</h1>
      <div className="flex mb-4">
        <Input
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="Add a new todo"
          className="flex-grow mr-2"
        />
        <Button onClick={addTodo}>
          <PlusCircle className="w-4 h-4 mr-2" />
          Add
        </Button>
      </div>
      <ul ref={listRef} className="flex flex-wrap gap-2 items-start">
        {(dragState.isDragging ? tempTodos : todos).map((todo, index) => (
          <li
            key={todo.id}
            onMouseDown={(e) => handleMouseDown(e, index)}
            className={`p-2 bg-gray-100 rounded transition-colors inline-block ${
              dragState.isDragging && index === dragState.dropIndex ? "invisible" : ""
            }`}
          >
            {todo.text}
          </li>
        ))}
      </ul>
      {dragState.isDragging && dragState.draggedIndex !== null && dragState.currentPosition && (
        <div
          ref={dragHelperRef}
          className="fixed pointer-events-none bg-gray-200 p-2 rounded opacity-80"
          style={{
            position: "absolute",
            top: `${dragState.currentPosition.y}px`,
            left: `${dragState.currentPosition.x}px`,
            zIndex: 1000,
          }}
        >
          {todos[dragState.draggedIndex].text}
        </div>
      )}
    </div>
  )
}

참고

 

drag and drop 기능 구현과 최적화까지

drag and drop 직접 구현해본 적이 있나요?

velog.io

 

반응형