터치로 전략컨트롤 동작
This commit is contained in:
@@ -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)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user