/** * 마우스·터치·펜 입력을 통합한 드래그/포인터 좌표 유틸 */ export interface ClientXY { x: number; y: number; } type ReactPointerLike = | React.MouseEvent | React.TouchEvent | React.PointerEvent; /** React 합성 이벤트에서 client 좌표 추출 */ export function clientXYFromReact(e: ReactPointerLike): ClientXY { if ('touches' in e && e.touches.length > 0) { return { x: e.touches[0].clientX, y: e.touches[0].clientY }; } if ('changedTouches' in e && e.changedTouches.length > 0) { return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY }; } const pe = e as React.MouseEvent | React.PointerEvent; return { x: pe.clientX, y: pe.clientY }; } /** window/document 네이티브 이벤트에서 client 좌표 추출 */ export function clientXYFromNative(e: Event): ClientXY | null { if (e instanceof TouchEvent) { const t = e.touches[0] ?? e.changedTouches[0]; if (!t) return null; return { x: t.clientX, y: t.clientY }; } if (e instanceof MouseEvent || e instanceof PointerEvent) { return { x: e.clientX, y: e.clientY }; } return null; } export interface BindWindowDragOptions { /** 터치 드래그 중 페이지 스크롤 방지 (기본 true) */ preventTouchScroll?: boolean; /** capture 단계에서 수신 — overlay stopPropagation 우회 */ useCapture?: boolean; } /** * document capture 단계 pointer 추적 (스크롤 패널 내부 드래그 — WKWebView 대응) */ export function bindDocumentPointerDrag( pointerId: number, onMove: (xy: ClientXY) => void, onEnd: (xy: ClientXY) => void, ): () => void { const cap: AddEventListenerOptions = { capture: true, passive: false }; const move = (ev: Event) => { if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return; if (ev.cancelable) ev.preventDefault(); onMove({ x: ev.clientX, y: ev.clientY }); }; const end = (ev: Event) => { if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return; if (ev.cancelable) ev.preventDefault(); onEnd({ x: ev.clientX, y: ev.clientY }); }; document.addEventListener('pointermove', move, cap); document.addEventListener('pointerup', end, cap); document.addEventListener('pointercancel', end, cap); return () => { document.removeEventListener('pointermove', move, cap); document.removeEventListener('pointerup', end, cap); document.removeEventListener('pointercancel', end, cap); }; } /** * 드래그 시작 후 window 에 move/end 리스너 등록. * pointer + mouse + touch 모두 수신 (구형 브라우저·iOS 대응). */ export function bindWindowDrag( onMove: (xy: ClientXY, ev: Event) => void, onEnd: (xy: ClientXY, ev: Event) => void, options: BindWindowDragOptions = {}, ): () => void { const { preventTouchScroll = true, useCapture = false } = options; let lastXY: ClientXY = { x: 0, y: 0 }; const moveOpts: AddEventListenerOptions | boolean = useCapture ? { capture: true, passive: !preventTouchScroll } : { passive: !preventTouchScroll }; const endOpts: AddEventListenerOptions | boolean = useCapture ? { capture: true } : false; const move = (ev: Event) => { const xy = clientXYFromNative(ev); if (!xy) return; lastXY = xy; if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) { ev.preventDefault(); } onMove(xy, ev); }; const end = (ev: Event) => { const xy = clientXYFromNative(ev) ?? lastXY; onEnd(xy, ev); }; window.addEventListener('pointermove', move, moveOpts); window.addEventListener('pointerup', end, endOpts); window.addEventListener('pointercancel', end, endOpts); window.addEventListener('mousemove', move, moveOpts); window.addEventListener('mouseup', end, endOpts); window.addEventListener('touchmove', move, moveOpts); window.addEventListener('touchend', end, endOpts); window.addEventListener('touchcancel', end, endOpts); return () => { window.removeEventListener('pointermove', move, moveOpts); window.removeEventListener('pointerup', end, endOpts); window.removeEventListener('pointercancel', end, endOpts); window.removeEventListener('mousemove', move, moveOpts); window.removeEventListener('mouseup', end, endOpts); window.removeEventListener('touchmove', move, moveOpts); window.removeEventListener('touchend', end, endOpts); window.removeEventListener('touchcancel', end, endOpts); }; } /** 드래그 핸들(타이틀바 등)에 붙일 공통 스타일 */ export const DRAG_HANDLE_TOUCH_STYLE: React.CSSProperties = { touchAction: 'none', }; /** primary 버튼(마우스 왼쪽 / 터치)만 허용 */ export function isPrimaryPointerButton(e: { button: number; pointerType: string }): boolean { return e.button === 0; }