터치로 전략컨트롤 동작

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
@@ -1,6 +1,7 @@
import React from 'react';
import {
handlePalettePointerDown,
handlePaletteTouchStart,
needsPointerPaletteDrag,
shouldSuppressPaletteClick,
writePaletteHtmlDragData,
@@ -55,6 +56,10 @@ export default function PaletteChip({
handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label);
};
const onTouchStart = (e: React.TouchEvent) => {
handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label);
};
const handleClick = () => {
if (shouldSuppressPaletteClick()) return;
if (onSelect) onSelect();
@@ -83,6 +88,7 @@ export default function PaletteChip({
className={`se-palette-card ${color ? `se-palette-card--${color}` : 'se-palette-card--ind'}${composite ? ' se-palette-card--composite' : ''}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart}
onPointerDown={onPointerDown}
onTouchStart={onTouchStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
role="button"
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
bindPaletteDragOverlayElement,
@@ -6,53 +6,67 @@ import {
notifyPaletteDragEnd,
notifyPaletteDragMove,
registerPaletteDragOverlay,
subscribePaletteDrag,
type PaletteDragOverlaySession,
} from '../../utils/paletteDragSession';
import { clientXYFromReact } from '../../utils/pointerDrag';
/**
* Tauri WebView — 드래그 overlay (라벨 위치는 ref 갱신, move 마다 React re-render 없음)
* pointer/touch 드래그 overlay (sessionRef 로 stale closure 방지, window touch 백업과 연동)
*/
export default function PaletteDragOverlay() {
const [session, setSession] = useState<PaletteDragOverlaySession | null>(null);
const overlayRef = useRef<HTMLDivElement | null>(null);
const sessionRef = useRef<PaletteDragOverlaySession | null>(null);
const labelRef = useRef<HTMLDivElement | null>(null);
sessionRef.current = session;
useEffect(() => {
registerPaletteDragOverlay({
mount: next => {
sessionRef.current = next;
setSession(next);
requestAnimationFrame(() => {
const label = labelRef.current;
if (label) {
label.style.left = `${next.x}px`;
label.style.top = `${next.y}px`;
}
});
},
unmount: () => setSession(null),
bindElement: el => {
overlayRef.current = el as HTMLDivElement | null;
unmount: () => {
sessionRef.current = null;
setSession(null);
bindPaletteDragOverlayElement(null);
},
bindElement: () => { /* ref callback 에서 처리 */ },
});
return () => registerPaletteDragOverlay(null);
}, []);
useLayoutEffect(() => {
bindPaletteDragOverlayElement(overlayRef.current);
return () => bindPaletteDragOverlayElement(null);
}, [session]);
useEffect(() => {
return subscribePaletteDrag(ev => {
if (ev.phase !== 'move' && ev.phase !== 'start') return;
const label = labelRef.current;
if (!label) return;
label.style.left = `${ev.x}px`;
label.style.top = `${ev.y}px`;
});
}, []);
useLayoutEffect(() => {
if (!session || !overlayRef.current) return;
try {
overlayRef.current.setPointerCapture(session.pointerId);
} catch {
/* ignore */
const overlayRefCallback = useCallback((el: HTMLDivElement | null) => {
bindPaletteDragOverlayElement(el);
const s = sessionRef.current;
if (el && s) {
try {
el.setPointerCapture(s.pointerId);
} catch {
/* window backup in paletteDragSession */
}
const label = labelRef.current;
if (label) {
label.style.left = `${s.x}px`;
label.style.top = `${s.y}px`;
}
}
}, [session]);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent) => {
if (!session || e.pointerId !== session.pointerId) return;
const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
e.preventDefault();
e.stopPropagation();
const label = labelRef.current;
@@ -61,31 +75,60 @@ export default function PaletteDragOverlay() {
label.style.top = `${e.clientY}px`;
}
notifyPaletteDragMove(e.clientX, e.clientY);
}, [session]);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent) => {
if (!session || e.pointerId !== session.pointerId) return;
const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
e.preventDefault();
e.stopPropagation();
notifyPaletteDragEnd(e.clientX, e.clientY);
}, [session]);
}, []);
const onPointerCancel = useCallback((e: React.PointerEvent) => {
if (!session || e.pointerId !== session.pointerId) return;
const s = sessionRef.current;
if (!s || e.pointerId !== s.pointerId) return;
e.preventDefault();
e.stopPropagation();
notifyPaletteDragCancel(e.clientX, e.clientY);
}, [session]);
}, []);
const onTouchMove = useCallback((e: React.TouchEvent) => {
const s = sessionRef.current;
if (!s || e.touches.length !== 1) return;
e.preventDefault();
const { x, y } = clientXYFromReact(e);
notifyPaletteDragMove(x, y);
}, []);
const onTouchEnd = useCallback((e: React.TouchEvent) => {
const s = sessionRef.current;
if (!s) return;
const { x, y } = clientXYFromReact(e);
e.preventDefault();
notifyPaletteDragEnd(x, y);
}, []);
const onTouchCancel = useCallback((e: React.TouchEvent) => {
const s = sessionRef.current;
if (!s) return;
const { x, y } = clientXYFromReact(e);
e.preventDefault();
notifyPaletteDragCancel(x, y);
}, []);
if (!session) return null;
return createPortal(
<div
ref={overlayRef}
ref={overlayRefCallback}
className="se-palette-drag-overlay"
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerCancel}
onTouchMove={onTouchMove}
onTouchEnd={onTouchEnd}
onTouchCancel={onTouchCancel}
>
<div
ref={labelRef}
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import type { DefType } from '../../utils/strategyEditorShared';
import {
handlePalettePointerDown,
handlePaletteTouchStart,
needsPointerPaletteDrag,
writePaletteHtmlDragData,
type PaletteDragPayload,
@@ -94,6 +95,16 @@ function SidewaysFilterChip({
handlePalettePointerDown(e, payload, item.label);
};
const onTouchStart = (e: React.TouchEvent) => {
hideHint();
const payload: PaletteDragPayload = {
type: 'sidewaysFilter',
value: item.id,
label: item.label,
};
handlePaletteTouchStart(e, payload, item.label);
};
const handleClick = () => onSelect();
const handleDoubleClick = (e: React.MouseEvent) => {
e.preventDefault();
@@ -113,6 +124,7 @@ function SidewaysFilterChip({
className={`se-palette-card se-palette-card--range se-palette-card--range-${item.mode}${selected ? ' se-palette-card--selected' : ''}`}
onDragStart={onDragStart}
onPointerDown={onPointerDown}
onTouchStart={onTouchStart}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onMouseEnter={e => showHint(e.currentTarget)}
+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 {