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

uiwwsw·2025년 5월 23일
post-thumbnail

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 · 본인 소유의 글·이미지에 대한 무단 복제·재게시 및 AI 학습용 수집을 허용하지 않습니다. 개인 열람·원문 링크 공유·검색 색인과 제한적인 미리보기는 허용합니다. 법령상 이용 예외, 기존 이용 허락, 코드·제삼자 자료의 기존 라이선스는 유지합니다. 콘텐츠 이용 안내

profile
https://www.linkedin.com/in/uiwwsw/

0개의 댓글