2026-09-10: 설명과 코드 예시를 보완했습니다. 아래 예시는 글의 설계 의도를 전달하기 위한 것이며, 원 프로젝트에 반영된 변경 내역과는 구분합니다.
크기 조정 핸들을 두 번 누르면 원래 크기로 돌아가게 하고 싶었다. 이때 두 입력을 얼마나 가까운 간격으로 받아들일지 직접 정할 수 있도록 작은 로직을 실험했다.
이 기능은 두 번째 pointerdown 시점에 반응한다. 두 번 누르고 떼는 동작을 확인하는 dblclick과 의미가 같지는 않다. 터치에서 dblclick이 전혀 동작하지 않는다고 일반화할 수도 없다. 여기서는 마우스·터치·펜의 입력을 공통된 포인터 이벤트 정보로 다룬다. MDN: Pointer events
어떤 두 입력을 묶을까
빠르게 두 번 눌렀다고 같은 동작은 아니다. 서로 떨어진 곳을 눌렀을 수도 있다. 이 예시는 같은 대상과 같은 포인터 종류를 먼저 확인하고, 지정한 시간과 거리 안에 들어온 두 입력만 묶는다. 기본 포인터의 주 버튼 입력만 받고, 기존 핸들러가 preventDefault()로 취소했다면 추가 동작을 실행하지 않는다.
import {
useRef,
type ComponentType,
type PointerEvent,
type PointerEventHandler,
} from 'react';
type PointerProps = {
onPointerDown?: PointerEventHandler<HTMLElement>;
};
type DoubleProps = {
onDoublePointerDown?: PointerEventHandler<HTMLElement>;
doubleDelay?: number;
doubleDistance?: number;
};
type Point = {
time: number;
x: number;
y: number;
pointerType: string;
target: EventTarget;
};
export function withDoublePointerDown<P extends PointerProps>(
Wrapped: ComponentType<P>,
) {
function WithDoublePointerDown({
onDoublePointerDown,
onPointerDown,
doubleDelay = 300,
doubleDistance = 24,
...props
}: P & DoubleProps) {
const previous = useRef<Point | null>(null);
const handlePointerDown = (event: PointerEvent<HTMLElement>) => {
onPointerDown?.(event);
if (event.defaultPrevented || !event.isPrimary || event.button !== 0) {
previous.current = null;
return;
}
const current: Point = {
time: event.timeStamp,
x: event.clientX,
y: event.clientY,
pointerType: event.pointerType,
target: event.target,
};
const last = previous.current;
const elapsed = last ? current.time - last.time : Infinity;
const distance = last
? Math.hypot(current.x - last.x, current.y - last.y)
: Infinity;
if (last && last.target === current.target &&
last.pointerType === current.pointerType &&
elapsed >= 0 && elapsed <= doubleDelay &&
distance <= doubleDistance) {
previous.current = null;
onDoublePointerDown?.(event);
} else {
previous.current = current;
}
};
return <Wrapped {...(props as P)} onPointerDown={handlePointerDown} />;
}
WithDoublePointerDown.displayName =
`WithDoublePointerDown(${Wrapped.displayName || Wrapped.name || 'Component'})`;
return WithDoublePointerDown;
}
Wrapped는 받은 onPointerDown을 실제 DOM 요소에 전달해야 한다. HOC는 컴포넌트 바깥에서 한 번 만든다. 렌더 중 매번 만들면 다른 컴포넌트 타입이 되어 상태가 초기화될 수 있다.
// Line은 onPointerDown을 내부 DOM 핸들에 전달하는 프로젝트 컴포넌트다.
const LineWithDouble = withDoublePointerDown(Line);
<LineWithDouble
doubleDelay={300}
doubleDistance={24}
onDoublePointerDown={handleResetResize}
/>
시간은 밀리초, 거리는 CSS 픽셀 기준이며 둘 다 유한한 0 이상의 값을 전달한다. ref 전달이나 임의의 컴포넌트 전용 prop까지 자동으로 해결하는 HOC는 아니다.
제품에 붙일 때 확인할 점
현재 코드는 pointerup, 이동 경로, pointercancel을 추적하지 않는다. 따라서 첫 입력이 드래그였는지, 두 번째 입력이 취소됐는지까지 판별하는 제스처 인식기로 볼 수는 없다. 그런 구분이 필요하면 포인터의 시작·이동·종료를 함께 관리해야 한다.
브라우저의 스크롤이나 확대 동작도 이 코드만으로 결정되지 않는다. 적용 영역에서 실제 기기 입력을 확인하고, 초기화 버튼처럼 키보드로 실행할 수 있는 경로도 제공해야 한다.
HOC로 묶은 이유는 기존 컴포넌트를 감싸서 이 동작을 붙이기 편해서다. 컴포넌트 안의 드래그 상태와 긴밀히 연결해야 한다면 훅이 더 편할 수 있다. 재사용성의 우열로 나누기보다는 입력을 감지하는 코드가 어느 상태와 함께 움직여야 하는지 보고 고르면 된다.
Assisted by AI
© uiwwsw · 본인 소유의 글·이미지에 대한 무단 복제·재게시를 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내