/** * 마우스·터치·펜 입력을 통합한 드래그/포인터 좌표 유틸 */ 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; } /** * 드래그 시작 후 window 에 move/end 리스너 등록. * pointer + mouse + touch 모두 수신 (구형 브라우저·iOS 대응). */ export function bindWindowDrag( onMove: (xy: ClientXY, ev: Event) => void, onEnd: () => void, options: BindWindowDragOptions = {}, ): () => void { const { preventTouchScroll = true } = options; const move = (ev: Event) => { const xy = clientXYFromNative(ev); if (!xy) return; if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) { ev.preventDefault(); } onMove(xy, ev); }; const end = () => onEnd(); window.addEventListener('pointermove', move); window.addEventListener('pointerup', end); window.addEventListener('pointercancel', end); window.addEventListener('mousemove', move); window.addEventListener('mouseup', end); window.addEventListener('touchmove', move, { passive: !preventTouchScroll }); window.addEventListener('touchend', end); window.addEventListener('touchcancel', end); return () => { window.removeEventListener('pointermove', move); window.removeEventListener('pointerup', end); window.removeEventListener('pointercancel', end); window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', end); window.removeEventListener('touchmove', move); window.removeEventListener('touchend', end); window.removeEventListener('touchcancel', end); }; } /** 드래그 핸들(타이틀바 등)에 붙일 공통 스타일 */ export const DRAG_HANDLE_TOUCH_STYLE: React.CSSProperties = { touchAction: 'none', }; /** primary 버튼(마우스 왼쪽 / 터치)만 허용 */ export function isPrimaryPointerButton(e: { button: number; pointerType: string }): boolean { return e.button === 0; }