터치로 전략컨트롤 동작

This commit is contained in:
Macbook
2026-06-15 00:19:31 +09:00
parent d8529ed582
commit f35f17df36
9 changed files with 345 additions and 103 deletions
+150 -30
View File
@@ -1,7 +1,11 @@
/**
* Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그
* Desktop(Tauri) 및 터치 기기 — HTML5 DnD 대신 pointer/touch + 전체화면 overlay 드래그
*/
import { elementsUnderDragPoint } from './flowScreenProjection';
import {
bindWindowDrag,
isPrimaryPointerButton,
} from './pointerDrag';
import { isDesktop } from './platform';
export type PaletteDragPayload = Record<string, unknown> & {
@@ -40,15 +44,28 @@ const DRAG_THRESHOLD_PX = 6;
let activePayload: PaletteDragPayload | null = null;
let activePointerId: number | null = null;
let activeTouchId: number | null = null;
let overlayController: OverlayController | null = null;
let overlayElement: HTMLElement | null = null;
let listeners = new Set<PaletteDragListener>();
let dropHandler: PaletteDropHandler | null = null;
let moveRafId: number | null = null;
let pendingMove: { x: number; y: number } | null = null;
let unbindActiveDrag: (() => void) | null = null;
let pendingPointerListeners: (() => void) | null = null;
function hasCoarsePointer(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.matchMedia('(pointer: coarse)').matches;
} catch {
return false;
}
}
/** HTML5 DnD 대신 pointer/touch overlay 드래그 사용 */
export function needsPointerPaletteDrag(): boolean {
return isDesktop();
return isDesktop() || hasCoarsePointer();
}
export function subscribePaletteDrag(listener: PaletteDragListener): () => void {
@@ -88,9 +105,36 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload)
emit(event);
}
function releasePointerCapture(pointerId: number | null) {
if (pointerId == null) return;
try {
overlayElement?.releasePointerCapture(pointerId);
} catch {
/* ignore */
}
try {
if (document.body.hasPointerCapture(pointerId)) {
document.body.releasePointerCapture(pointerId);
}
} catch {
/* ignore */
}
}
function clearPendingPointerListeners() {
pendingPointerListeners?.();
pendingPointerListeners = null;
}
function cleanupDrag() {
const pointerId = activePointerId;
unbindActiveDrag?.();
unbindActiveDrag = null;
clearPendingPointerListeners();
releasePointerCapture(pointerId);
activePayload = null;
activePointerId = null;
activeTouchId = null;
pendingMove = null;
if (moveRafId != null) {
cancelAnimationFrame(moveRafId);
@@ -98,6 +142,7 @@ function cleanupDrag() {
}
document.body.classList.remove('se-palette-drag-active');
overlayController?.unmount();
overlayElement = null;
document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove());
}
@@ -140,6 +185,37 @@ export function completePaletteDragAt(clientX: number, clientY: number): boolean
return true;
}
function eventMatchesActiveInput(ev: Event): boolean {
if (!activePayload || activePointerId == null) return false;
if (ev instanceof PointerEvent) return ev.pointerId === activePointerId;
if (ev instanceof TouchEvent) {
if (activeTouchId != null) {
const touches = ev.touches.length > 0 ? ev.touches : ev.changedTouches;
return Array.from(touches).some(t => t.identifier === activeTouchId);
}
return ev.touches.length <= 1;
}
return true;
}
function bindActiveDragWindowListeners(pointerId: number) {
unbindActiveDrag?.();
unbindActiveDrag = bindWindowDrag(
(xy, ev) => {
if (!activePayload || activePointerId !== pointerId) return;
if (!eventMatchesActiveInput(ev)) return;
notifyPaletteDragMove(xy.x, xy.y);
},
(xy, ev) => {
if (!activePayload || activePointerId !== pointerId) return;
if (!eventMatchesActiveInput(ev)) return;
finishDrag('end', xy);
},
{ preventTouchScroll: true },
);
}
function captureOnOverlay(pointerId: number) {
try {
overlayElement?.setPointerCapture(pointerId);
@@ -159,12 +235,20 @@ function beginDrag(
payload: PaletteDragPayload,
xy: { x: number; y: number },
pointerId: number,
touchId: number | null = null,
) {
if (activePayload) {
cleanupDrag();
}
activePayload = payload;
activePointerId = pointerId;
activeTouchId = touchId;
document.body.classList.add('se-palette-drag-active');
bindActiveDragWindowListeners(pointerId);
overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y });
requestAnimationFrame(() => captureOnOverlay(pointerId));
emit({ phase: 'start', x: xy.x, y: xy.y, payload });
@@ -178,47 +262,83 @@ export function bindPaletteDragOverlayElement(el: HTMLElement | null) {
}
}
function startPaletteDragGesture(
startX: number,
startY: number,
pointerId: number,
touchId: number | null,
payload: PaletteDragPayload,
): void {
if (activePayload) {
cleanupDrag();
}
clearPendingPointerListeners();
let dragStarted = false;
const unbind = bindWindowDrag(
(xy, ev) => {
if (dragStarted) return;
const dx = xy.x - startX;
const dy = xy.y - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
dragStarted = true;
clearPendingPointerListeners();
if (ev.cancelable) ev.preventDefault();
beginDrag(payload, xy, pointerId, touchId);
},
() => {
if (dragStarted) return;
clearPendingPointerListeners();
},
{ preventTouchScroll: true },
);
pendingPointerListeners = unbind;
}
export function handlePalettePointerDown(
e: React.PointerEvent,
payload: PaletteDragPayload,
_label: string,
): void {
if (!needsPointerPaletteDrag()) return;
if (e.button !== 0) return;
if (!isPrimaryPointerButton(e)) return;
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
e.preventDefault();
const startX = e.clientX;
const startY = e.clientY;
const pointerId = e.pointerId;
startPaletteDragGesture(
e.clientX,
e.clientY,
e.pointerId,
null,
payload,
);
}
const onPointerMove = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return;
/** PointerEvent 미지원 환경 — touchstart 전용 진입 */
export function handlePaletteTouchStart(
e: React.TouchEvent,
payload: PaletteDragPayload,
_label: string,
): void {
if (!needsPointerPaletteDrag()) return;
if (typeof window !== 'undefined' && 'PointerEvent' in window) return;
if (e.touches.length !== 1) return;
if ((e.target as HTMLElement).closest('input, textarea, select, button')) return;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
e.preventDefault();
ev.preventDefault();
beginDrag(payload, { x: ev.clientX, y: ev.clientY }, pointerId);
};
const onPointerUp = (ev: PointerEvent) => {
if (ev.pointerId !== pointerId) return;
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
window.removeEventListener('pointercancel', onPointerCancel);
};
const onPointerCancel = onPointerUp;
window.addEventListener('pointermove', onPointerMove, { passive: false });
window.addEventListener('pointerup', onPointerUp);
window.addEventListener('pointercancel', onPointerCancel);
const touch = e.touches[0];
startPaletteDragGesture(
touch.clientX,
touch.clientY,
touch.identifier,
touch.identifier,
payload,
);
}
export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void {