144 lines
4.7 KiB
TypeScript
144 lines
4.7 KiB
TypeScript
/**
|
|
* 마우스·터치·펜 입력을 통합한 드래그/포인터 좌표 유틸
|
|
*/
|
|
|
|
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;
|
|
}
|