# 제스처 표면 조합 가이드

일반 목록의 스크롤은 브라우저가 소유하게 하고, 마우스 드래그를 추가할 때는 [useDragScroll](./drag-scroll.md)을 우선합니다. 아래 조합은 콘텐츠 위치를 직접 관리하는 팬 표면을 위한 예시입니다.

## 무엇을 조합하나요?

| 목적                              | 공개 도구                                                              | 호출부 책임                       |
| --------------------------------- | ---------------------------------------------------------------------- | --------------------------------- |
| 경계 저항·속도 추적·감속 계산     | [ocGesture](./gesture.md)                                              | 좌표 변환, 샘플 시각, 입력 세션   |
| 손을 뗀 뒤 자유 감속·경계 복귀    | [createFlingAnimator](./fling.md)                                      | 위치 반영, 경계 측정, 중단과 정리 |
| 목표 위치로 이동                  | [createSpringAnimator](./spring.md)                                    | 목표 선택, 모션 설정              |
| 모션 선호 구독                    | [usePrefersReducedMotion](./motion.md#react에서-사용자-모션-설정-읽기) | 설정 변경 시 진행 중 모션 정리    |
| 전달받은 ref와 내부 측정 ref 결합 | [mergeRefs](./react-refs.md)                                           | ref callback 안정화               |

`ocGesture`는 계산을 묶는 이름이고, `create*`는 실행 객체 생성, `use*`는 React 훅입니다. 새 이름이나 공통 제스처 컴포넌트를 추가할 필요 없이 필요한 도구만 가져옵니다.

## 1차원 팬의 실행 흐름

아래는 이벤트 연결 이전의 **소비자 코드 예시**입니다. `paint`는 위치를 화면에 반영하는 함수이고, 입력 처리는 활성 pointer ID를 확인한 뒤 `begin`·`move`·`end`를 호출합니다. 경계와 초기 위치는 같은 콘텐츠 좌표계의 유한한 px 값이며 `min <= max`를 전제로 합니다.

<!-- example:pan-session -->

```ts
import { ocGesture } from '@orioncactuscorp/ui/utils/gesture';
import { createFlingAnimator } from '@orioncactuscorp/ui/utils/fling';
import type { FlingAnimator } from '@orioncactuscorp/ui/utils/fling';
import { ocSpring } from '@orioncactuscorp/ui/utils/spring';

function createPanSession(
  initial: number,
  min: number,
  max: number,
  paint: (position: number) => void,
) {
  const tracker = ocGesture.createVelocityTracker();
  const spring = ocSpring.smooth({ duration: 0.4 });
  const clamp = (value: number) => Math.min(max, Math.max(min, value));
  let position = clamp(initial);
  let animator: FlingAnimator | null = null;
  let dragging = false;
  let reducedMotion = true;

  function update(value: number) {
    position = value;
    paint(value);
  }
  function interrupt() {
    if (!animator) return;
    animator.stop();
    const current = animator.sample().position;
    animator.destroy();
    animator = null;
    update(current);
  }
  function cancel() {
    dragging = false;
    tracker.clear();
    interrupt();
    update(clamp(position));
  }

  update(position);

  return {
    begin() {
      interrupt();
      update(clamp(position));
      dragging = true;
      tracker.clear();
      tracker.push({ time: performance.now(), x: position, y: 0 });
      return position;
    },
    move(rawPosition: number) {
      if (!dragging) return;
      const shown = reducedMotion
        ? clamp(rawPosition)
        : ocGesture.rubberBandClamp(rawPosition, min, max, 100);
      update(shown);
      tracker.push({ time: performance.now(), x: shown, y: 0 });
    },
    end() {
      if (!dragging) return;
      dragging = false;
      const velocity = tracker.read(performance.now()).x;
      tracker.clear();
      if (reducedMotion) {
        update(clamp(position));
        return;
      }
      animator = createFlingAnimator({
        position,
        velocity,
        rate: ocGesture.decelerationRates.fast,
        bounds: { min, max, behavior: 'spring', spring },
        onUpdate: update,
      });
    },
    setReducedMotion(value: boolean) {
      reducedMotion = value;
      if (value) cancel();
    },
    readPosition: () => position,
    jumpTo(value: number) {
      dragging = false;
      tracker.clear();
      interrupt();
      update(clamp(value));
    },
    cancel,
    dispose() {
      dragging = false;
      tracker.clear();
      animator?.destroy();
      animator = null;
    },
  };
}
```

`begin()`이 반환한 위치에 입력 시작점으로부터의 이동량을 더해 `move()`에 전달합니다. 직전의 저항 적용 위치에 이동량을 누적하면 저항이 반복 적용되므로, 시작 위치와 원본 입력 이동량을 따로 유지하세요. CSS 확대가 있다면 `clientX` 이동량을 콘텐츠 px로 변환합니다. 이 예시는 표시 위치로 속도를 추적해 release 위치와 속도의 좌표계를 일치시킵니다. 원본 입력 속도를 쓰는 다른 정책과 혼용하지 마세요.

이 예시는 재입력 시 경계 밖 위치를 먼저 clamp하는 보수적인 정책입니다. `begin()`은 경계 안의 새 시작 위치를 반환하므로, 저항을 받은 위치가 다시 저항 계산에 들어가지 않습니다. 바운스 중 재입력에서는 즉시 경계로 이동할 수 있습니다. 초과 위치를 그대로 유지해야 하는 표면은 원본 입력 좌표와 표시 좌표를 역변환하는 별도 정책이 필요합니다.

자연 완료된 실행기에도 `stop()`·`sample()`·`destroy()`를 호출할 수 있어, 예시는 완료와 중단을 같은 `interrupt()` 경로에서 정리합니다. `onComplete`에서 새 프레임을 예약하거나 완료된 실행기를 재시작하지 않습니다.

마지막 좌표가 바뀐 `pointerup`은 `move()`로 먼저 반영한 뒤 `end()`를 호출합니다. 샘플은 `performance.now()`의 ms, 결과 속도는 px/s이므로 추가로 1000을 곱하지 않습니다. `100px` 완충 거리와 `0.4s` spring은 예시값입니다. iOS의 동작이나 모든 표면에 적합한 기본값을 보장하지 않습니다.

## 입력 세션과 수명주기

| 상황                                     | 호출부 처리                                                                                                                                   |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| 새 드래그                                | `begin()`으로 기존 관성을 중단하고 입력 시작 좌표 저장                                                                                        |
| 정상 release                             | 활성 pointer의 마지막 이동 반영 후 `end()`                                                                                                    |
| `pointercancel`·예기치 않은 capture 상실 | `cancel()`로 정리. 취소된 제스처에서 관성 시작 금지                                                                                           |
| 정상 release 후 capture 상실             | 이미 끝난 입력 세션으로 판정해 방금 시작한 플링을 취소하지 않음                                                                               |
| 다른 포인터가 추가되어 핀치로 전환       | 기존 단일 포인터 세션 취소 후 별도 핀치 owner로 인계                                                                                          |
| 콘텐츠·viewport 변경으로 경계 변경       | `readPosition()` 저장 → 입력 owner의 활성 pointer 기록·capture 해제 → `dispose()` → 새 경계로 세션 생성 및 설정 재적용. 진행 중 드래그도 취소 |
| 컴포넌트 unmount                         | `dispose()`로 프레임 정리. 정리 중 화면 상태 갱신 없음                                                                                        |

세션 생성 시 clamp된 초기 위치를 즉시 `paint`합니다. React에서는 렌더 중 세션을 만들지 말고 effect나 입력 callback에서 생성해 ref로 소유합니다. `usePrefersReducedMotion()` 값을 effect에서 `setReducedMotion()`으로 전달하고 unmount 시 `dispose()`합니다. 예시의 초기값 `true`는 설정 확인 전 자동 모션을 막습니다. 설정이 실행 중 `true`로 바뀌면 관성과 드래그를 취소하고 경계에 정리하므로, 입력 owner도 활성 pointer 기록과 capture를 함께 해제하세요.

드래그와 별도로 방향 이동·초기화 버튼 또는 키보드 조작을 제공합니다. 예를 들어 오른쪽 이동은 `session.jumpTo(session.readPosition() + 32)`로 처리합니다. `jumpTo`는 진행 중 모션과 드래그를 취소하고 유효 범위 안에서 위치를 갱신하므로 입력 owner의 활성 pointer 기록·capture도 함께 정리합니다. 단순 팬 영역에 임의의 ARIA 위젯 역할을 붙이지 말고, 실제 기능에 맞는 의미와 접근 가능한 이름을 제공하세요.

## Pointer capture와 클릭

Capture는 드래그 이벤트를 받을 owner에 설정합니다. “항상 `event.target`” 또는 “항상 배경”으로 고정하지 않습니다. 캡처 상태에 따라 click 대상이 달라질 수 있으므로, 배경 닫기와 콘텐츠 탭이 있는 표면은 입력 시작 위치·드래그 성립 여부·클릭 처리 범위를 함께 설계합니다. 키보드로 발생한 클릭까지 드래그 방지 로직으로 차단하지 마세요. [Pointer Events의 click 대상 규칙](https://www.w3.org/TR/pointerevents3/#the-click-auxclick-and-contextmenu-events)을 참고하세요.

## 브라우저 스크롤·줌과 자체 줌

- 일반 표면은 브라우저 스크롤과 pinch zoom을 보존합니다. 예를 들어 가로 조작만 소유한다면 `touch-action: pan-y pinch-zoom`을 검토하되, 브라우저가 제스처를 가져갔을 때의 `pointercancel`을 처리합니다.
- 자체 줌이 필요한 제한된 영역만 해당 입력을 소유합니다. `touch-action: none`을 페이지 전체에 적용하거나 viewport에서 사용자 확대를 막지 않습니다. `touch-action`은 입력 시작 전에 결정해야 합니다. [touch-action 계약](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/touch-action)을 참고하세요.
- 취소가 필요한 wheel/touch listener만 `{ passive: false }`로 등록하고, owner가 처리할 입력인지 판정한 뒤 `event.cancelable`이면 취소합니다. 자체 줌을 소유한다면 진입 애니메이션 중에도 그 정책이 일관되어야 합니다. 모든 `touchmove`를 무조건 막는 방식은 피합니다.
- [WebKit의 gesture 이벤트](https://developer.mozilla.org/en-US/docs/Web/API/Element/gesturechange_event)는 비표준 확장입니다. 기능 감지 후 선택적으로 지원하고 pointer/touch/wheel과 같은 물리 입력을 두 번 처리하지 않도록 세션 owner를 정합니다. 특정 플랫폼은 반드시 한 이벤트만 발생한다고 가정하지 않습니다.
- 입력 종료 후 일정 시간 동안 이벤트를 무시하는 workaround는 재현된 기기·버전과 해제 조건을 함께 기록합니다. 임의의 지연 시간을 공통 정책으로 넣지 않습니다.

## 소비자 확인 항목

- 이동 중 재입력, 취소, capture 상실, unmount 뒤에 남는 프레임이 없는지
- 배경 탭·콘텐츠 탭·드래그와 키보드 클릭이 의도대로 구분되는지
- 확대 비율·콘텐츠 크기 변경 후 새 경계와 좌표계가 일치하는지
- reduced-motion의 초기 상태와 실행 중 변경, 드래그 없는 대체 조작이 가능한지
- Chromium/WebKit 자동 검사와 별도로 실제 터치 기기·트랙패드에서 줌 보존 및 중복 이벤트를 확인했는지

자동 브라우저 검사 통과를 실기기의 모든 제스처 경로 검증으로 간주하지 않습니다.
