프로젝트 중 Drag&Drop이 필요할 일이 생겼다. 처음엔 라이브러리를 사용해서 커스터마이징하려 했는데 vue에는 마땅한 라이브러리가 없었고...(리액트였으면 react-dnd 사용했을 것 같다.) 요구사항이 많지 않아서 직접 구현하게 됐다.
원래 짠건 vue지만 react로 다시 만들어보자.
onChange(updatedOrder: number[])DropArea와 DropItem 파일을 생성한다.
// DropArea.tsx
import './droparea.scss';
import React, { useEffect, useRef } from 'react';
type Props = {
children?: React.ReactNode;
direction?: 'horizontal' | 'vertical';
};
export const DropArea = ({ children, direction = 'vertical' }: Props) => {
const areaRef = useRef<HTMLUListElement>(null);
useEffect(() => {});
return (
<ul ref={areaRef} className={'droparea ' + direction}>
{children}
</ul>
);
};
// droparea.scss
.droparea {
position: relative;
display: flex;
width: fit-content;
height: fit-content;
flex-wrap: wrap;
gap: 10px;
padding: 10px;
background-color: grey;
height: 300px;
width: 300px;
&.vertical {
flex-direction: column;
}
}
// DragItem.tsx
import "./dragitem.scss";
type Props = {
id: string;
children?: React.ReactNode;
};
export const DragItem = ({ id, children }: Props) => {
return (
<li id={id} className="dragitem">
{children}
</li>
);
};
// dragitem.scss
.dragitem {
display: flex;
width: fit-content;
height: fit-content;
&.ghost {
opacity: 0.8;
pointer-events: none;
}
}
// App.tsx 일부
<DropArea>
{list.map((el, idx) => (
<DragItem key={el} id={el}>
<div
style={{
width: '100px',
height: '50px',
border: '1px dashed black',
borderRadius: '5px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '5px 0',
}}
>
<button draggable='true'>drag</button>
<span>{idx}</span>
</div>
</DragItem>
))}
</DropArea>
DragItem의 draggable=true로 설정된 영역에 드래그 핸들러를 추가한다.
// DropArea.tsx
const handleDragStart = (ev: DragEvent) => {};
const handleDragEnd = (ev: DragEvent) => {};
useEffect(() => {
if (areaRef.current) {
const dragItems = [...areaRef.current.querySelectorAll('.dragitem')];
dragItems.forEach((el) => {
const draggable = el.querySelector('[draggable=true]');
if (draggable instanceof HTMLElement) {
draggable.addEventListener('dragstart', handleDragStart);
draggable.addEventListener('dragend', handleDragEnd);
}
});
}
});
draggable=true 요소를 드래그하면 해당 요소가 ghost image로 커서에 딸려나온다. 하지만 우리는 draggable=true인 부분이 해당 요소 그 자체가 아니므로 고스트 이미지를 따로 추가해줘야 한다.
고스트 이미지 추가를 위해 사용할 메서드는 setDragImage이다.
ev.dataTransfer.setDragImage(Element, x, y);
여기서 이미지를 추가하는게 아니라 HTMLElement를 추가하므로

HTMLElement를 클론을 통해 만든 후에 appendChild는 해주되 화면에서 보이지 않도록 한다.
const makeCustomGhost = (target: Element) => {
const ghost = target.cloneNode(true) as HTMLElement;
ghost.classList.add('ghost');
// 화면에서 가려주기 위해 스타일을 추가
ghost.style.position = 'absolute';
ghost.style.top = '-1000px';
ghost.style.left = '-1000px';
return ghost;
};
// draggable을 기준으로 부모 element 찾기
const getDragItem = (target: Element) => {
if (areaRef.current && target) {
const dragItems = [...areaRef.current.querySelectorAll('.dragitem')];
const dragItem = dragItems.find((el) => el.contains(target));
return dragItem;
}
return undefined;
};
const handleDragStart = (ev: DragEvent) => {
const draggable = ev.currentTarget as Element;
const dragItem = getDragItem(draggable);
if (!dragItem) return;
const ghost = makeCustomGhost(dragItem);
ghostRef.current = ghost;
areaRef.current?.appendChild(ghost);
ev.dataTransfer?.setDragImage(ghost, 0, 0);
};
const handleDragEnd = (ev: DragEvent) => {
const draggable = ev.currentTarget as Element;
const dragItem = getDragItem(draggable);
if (!dragItem) return;
dragItem.classList.remove('dragging');
};
여기까지 하면 드래그는 되는데 커서 위치와 포인터가 살짝 어색하다.

위치를 조정하고 커서를 변경해보자
setDragImage에서 offset을 (0,0)으로 잡아서 발생한다. offset을 요소의 크기에 맞게 수정해주자.
const getOffset = (draggable: Element, item: Element) => {
const draggableRect = draggable.getBoundingClientRect();
const itemRect = item.getBoundingClientRect();
return {
x: Math.ceil(
draggableRect.width / 2 + draggableRect.left - itemRect.left
),
y: Math.ceil(draggableRect.height / 2 + draggableRect.top - itemRect.top),
};
};
const handleDragStart = (ev: DragEvent) => {
const draggable = ev.currentTarget as Element;
const dragItem = getDragItem(draggable);
if (!dragItem) return;
const ghost = makeCustomGhost(dragItem);
ghostRef.current = ghost;
const { x, y } = getOffset(draggable, dragItem);
// 나중에 해제해야되기 때문에 저장
areaRef.current?.appendChild(ghost);
ev.dataTransfer?.setDragImage(ghost, x, y);
};