본문으로 바로가기
윤창원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로 묶은 이유는 기존 컴포넌트를 감싸서 이 동작을 붙이기 편해서다. 컴포넌트 안의 드래그 상태와 긴밀히 연결해야 한다면 훅이 더 편할 수 있다. 재사용성의 우열로 나누기보다는 입력을 감지하는 코드가 어느 상태와 함께 움직여야 하는지 보고 고르면 된다.

Assisted by AI

© uiwwsw · 본인 소유의 글·이미지에 대한 무단 복제·재게시를 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내

무단 복제·재게시를 허용하지 않습니다. 콘텐츠 이용 안내

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

모든 글 둘러보기 →