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와 훅은 재사용성의 우열보다 결합 방식이 다르다. 기존 컴포넌트를 감싸는 API가 편하면 HOC를, 컴포넌트 내부의 드래그 상태와 긴밀히 연결해야 한다면 훅을 선택할 수 있다. 이번 구현은 입력을 감지하는 작은 범위를 HOC로 묶은 예시다.
Assisted by AI