본문으로 바로가기
윤창원uiwwsw · 작은 우주

개발과 기술

두 번의 pointerdown 감지하기 — 시간·거리·기존 이벤트를 함께 다루기

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

윤창원이 벨로그에 남긴 글을 이 작은 우주에도 모았습니다. 사진은 누르면 원본 크기로 볼 수 있습니다. 원문의 전체 서식 보기 ↗

모든 글 둘러보기 →