import { useRef, RefObject } from 'react';
import throttle from 'lodash.throttle';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
const DRAG_PRESS_DELAY = 500 as const;
const THROTTLE_DELAY = 150 as const;
/**
* useDraggable
* Enables press-and-hold dragging on the given element ref.
* - Hold mouse down for DRAG_PRESS_DELAY ms to activate drag.
* - Moves the element by directly setting style.left/top.
* - Cleans up event listeners on unmount.
*/
export function useDraggable(ref: RefObject<HTMLDivElement | null>) {
const isDraggable = useRef(false);
const isDragging = useRef(false);
const timer = useRef<number | null>(null);
const startPos = useRef({ x: 0, y: 0 });
const boxRect = useRef<DOMRect | null>(null);
const isDragged = useRef(false);
// `useLayoutEffect`를 사용한 이유 [docs/useDraggable.md] -> 이후 useGSAP로 대체
useGSAP(
(_context, contextSafe) => {
const element = ref.current;
if (!element) return;
if (!contextSafe) return;
// Mouse move handler
const onMouseMove = contextSafe((event: MouseEvent) => {
if (!isDraggable.current || !boxRect.current) return;
isDragging.current = true;
const dx = event.clientX - startPos.current.x;
const dy = event.clientY - startPos.current.y;
element.style.left = `${boxRect.current.left + dx}px`;
element.style.top = `${boxRect.current.top + dy}px`;
});
// Mouse down handler (throttled)
const onMouseDown = throttle(
contextSafe((event: MouseEvent) => {
// start press timer for drag activation
isDraggable.current = false;
timer.current = window.setTimeout(() => {
isDraggable.current = true;
timer.current = null;
isDragged.current = true;
}, DRAG_PRESS_DELAY);
// record initial positions
boxRect.current = element.getBoundingClientRect();
startPos.current = { x: event.clientX, y: event.clientY };
gsap.to(ref.current, {
scale: 1.03,
duration: 0.5,
delay: 0.2,
cursor: 'grabbing',
onInterrupt: () => {
gsap.set(ref.current, { scale: 1 });
},
overwrite: true,
});
document.addEventListener('mousemove', onMouseMove);
}),
THROTTLE_DELAY,
);
// Mouse up handler
const onMouseUp = contextSafe(() => {
isDraggable.current = false;
isDragging.current = false;
document.removeEventListener('mousemove', onMouseMove);
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
gsap.to(ref.current, {
scale: 1,
duration: 0.5,
delay: 0.2,
cursor: 'pointer',
overwrite: true,
onInterrupt: () => {
gsap.set(ref.current, { scale: 1 });
},
});
});
// Attach listeners
element.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
// Cleanup
return () => {
element.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('mousemove', onMouseMove);
if (timer.current) {
clearTimeout(timer.current);
timer.current = null;
}
// Cancel any pending throttle
onMouseDown.cancel();
};
},
{ dependencies: [ref], scope: ref },
);
return { isDragging: isDragging, isDragged: isDragged };
}
아래에 지금 구현을 더 매끄럽고 고성능으로 다듬을 수 있는 몇 가지 포인트를 비교 중심으로 정리해 보았습니다.
항목 | **기존 (**mousedown 등) | **제안 (**pointerdown 등) |
---|---|---|
지원 디바이스 | 오직 마우스 | 마우스·터치·펜 모두 한 번에 지원 |
코드량·중복 | 별도 touchstart/touchmove 필요 | 단일 이벤트로 통합 → 핸들러 등록·해제 로직 절감 |
브라우저 호환성 | 비교적 넓음 | IE11 제외한 최신 브라우저에서 모두 지원 |
→ 강한 의견: 멀티터치 대응이 필요하다면 pointer* 계열로 전환하세요. 등록·해제 로직이 한 번으로 줄어들어 코드도 더 간결해집니다.
항목 | 기존 (style.left/top) | 제안 (transform: translate3d) |
---|---|---|
레이아웃 재계산(Layout) | 매 프레임마다 발생 → 성능 저하 위험 | GPU 가속, 레이아웃 변경 없음 → 페인트·컴포지트 위주 처리 |
부드러움 | 간헐적 깜박임·저하 | 훨씬 부드러운 움직임 보장 |
코드 변경 | element.style.left = ... 그대로 | element.style.transform = 'translate3d(dx,dy,0)' 적용 필요 |
→ 추천: 드래그 시에는 translate 쪽이 표준입니다. CSS 로 will-change: transform; 을 걸어두면 더 부드러워집니다.