diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 3775b53..9865ca4 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -142,6 +142,7 @@ import { } from '../utils/strategyImportExport'; import PaletteChip from './strategyEditor/PaletteChip'; import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay'; +import { needsPointerPaletteDrag } from '../utils/paletteDragSession'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import { clampPanelSize, @@ -1617,7 +1618,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop return (
- + {needsPointerPaletteDrag() ? : null} {saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
)} diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 054cca7..ede77aa 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { + handlePaletteMouseDown, handlePalettePointerDown, handlePaletteTouchStart, needsPointerPaletteDrag, @@ -56,6 +57,10 @@ export default function PaletteChip({ handlePalettePointerDown(e, buildPayload(), composite ? `${label} + ${label}` : label); }; + const onMouseDown = (e: React.MouseEvent) => { + handlePaletteMouseDown(e, buildPayload(), composite ? `${label} + ${label}` : label); + }; + const onTouchStart = (e: React.TouchEvent) => { handlePaletteTouchStart(e, buildPayload(), composite ? `${label} + ${label}` : label); }; @@ -88,6 +93,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} + onMouseDown={onMouseDown} onTouchStart={onTouchStart} onClick={handleClick} onDoubleClick={handleDoubleClick} diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 5aea13d..f18690e 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,13 +1,13 @@ /** - * Desktop(Tauri) 및 터치 기기 — HTML5 DnD 대신 pointer/touch + 전체화면 overlay 드래그 + * Desktop(Tauri) 및 터치 — HTML5 DnD 대신 pointer/touch + overlay 드래그 + * (useDraggablePanel 과 동일: pointerdown 1회 bindWindowDrag 로 전체 제스처 처리) */ import { elementsUnderDragPoint } from './flowScreenProjection'; import { bindWindowDrag, - clientXYFromNative, isPrimaryPointerButton, } from './pointerDrag'; -import { isDesktop } from './platform'; +import { isDesktop, isMacDesktop } from './platform'; export type PaletteDragPayload = Record & { type: string; @@ -45,7 +45,6 @@ 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 gestureCaptureEl: HTMLElement | null = null; @@ -53,11 +52,8 @@ let listeners = new Set(); let dropHandler: PaletteDropHandler | null = null; let moveRafId: number | null = null; let pendingMove: { x: number; y: number } | null = null; -let unbindActiveMove: (() => void) | null = null; -let unbindActiveEnd: (() => void) | null = null; -let pendingPointerListeners: (() => void) | null = null; +let unbindGesture: (() => void) | null = null; let lastDragClientXY: { x: number; y: number } | null = null; -let endListenersArmed = false; function hasCoarsePointer(): boolean { if (typeof window === 'undefined') return false; @@ -68,8 +64,10 @@ function hasCoarsePointer(): boolean { } } -/** HTML5 DnD 대신 pointer/touch overlay 드래그 사용 */ +/** HTML5 DnD 대신 pointer/touch overlay 드래그 사용 (Windows Desktop 등) */ export function needsPointerPaletteDrag(): boolean { + // macOS Tauri WKWebView — Safari 웹과 동일하게 HTML5 DnD 사용 + if (isMacDesktop()) return false; return isDesktop() || hasCoarsePointer(); } @@ -123,36 +121,19 @@ function releasePointerCapture(pointerId: number | null) { } catch { /* ignore */ } - try { - if (document.body.hasPointerCapture(pointerId)) { - document.body.releasePointerCapture(pointerId); - } - } catch { - /* ignore */ - } } -function clearPendingPointerListeners() { - pendingPointerListeners?.(); - pendingPointerListeners = null; -} - -function unbindActiveDragListeners() { - unbindActiveMove?.(); - unbindActiveMove = null; - unbindActiveEnd?.(); - unbindActiveEnd = null; - endListenersArmed = false; +function unbindGestureListeners() { + unbindGesture?.(); + unbindGesture = null; } function cleanupDrag() { const pointerId = activePointerId; - unbindActiveDragListeners(); - clearPendingPointerListeners(); + unbindGestureListeners(); releasePointerCapture(pointerId); activePayload = null; activePointerId = null; - activeTouchId = null; pendingMove = null; lastDragClientXY = null; if (moveRafId != null) { @@ -179,6 +160,20 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { cleanupDrag(); } +function mountDragOverlay( + payload: PaletteDragPayload, + xy: { x: number; y: number }, + pointerId: number, +) { + activePayload = payload; + activePointerId = pointerId; + lastDragClientXY = { x: xy.x, y: xy.y }; + document.body.classList.add('se-palette-drag-active'); + overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y }); + emit({ phase: 'start', x: xy.x, y: xy.y, payload }); + notifyPaletteDragMove(xy.x, xy.y); +} + export function notifyPaletteDragMove(clientX: number, clientY: number) { if (!activePayload) return; lastDragClientXY = { x: clientX, y: clientY }; @@ -194,12 +189,12 @@ export function notifyPaletteDragMove(clientX: number, clientY: number) { } export function notifyPaletteDragEnd(clientX: number, clientY: number) { - if (!endListenersArmed) return; + if (!activePayload) return; finishDrag('end', { x: clientX, y: clientY }); } export function notifyPaletteDragCancel(clientX: number, clientY: number) { - if (!endListenersArmed) return; + if (!activePayload) return; finishDrag('cancel', { x: clientX, y: clientY }); } @@ -209,114 +204,15 @@ 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 bindActiveDragMoveListeners(pointerId: number) { - unbindActiveMove?.(); - - unbindActiveMove = bindWindowDrag( - (xy, ev) => { - if (!activePayload || activePointerId !== pointerId) return; - if (!eventMatchesActiveInput(ev)) return; - notifyPaletteDragMove(xy.x, xy.y); - }, - () => { /* end 는 별도 등록 */ }, - { preventTouchScroll: true, useCapture: true }, - ); -} - -function bindActiveDragEndListeners(pointerId: number) { - unbindActiveEnd?.(); - - const onEnd = (ev: Event) => { - if (!endListenersArmed) return; - if (!activePayload || activePointerId !== pointerId) return; - if (!eventMatchesActiveInput(ev)) return; - const xy = clientXYFromNative(ev) ?? lastDragClientXY; - if (!xy) return; - finishDrag('end', xy); - }; - - window.addEventListener('pointerup', onEnd, true); - window.addEventListener('mouseup', onEnd, true); - window.addEventListener('touchend', onEnd, true); - - unbindActiveEnd = () => { - window.removeEventListener('pointerup', onEnd, true); - window.removeEventListener('mouseup', onEnd, true); - window.removeEventListener('touchend', onEnd, true); - }; -} - -function captureOnOverlay(pointerId: number) { - try { - overlayElement?.setPointerCapture(pointerId); - } catch { - /* ignore */ - } - try { - if (!overlayElement?.hasPointerCapture(pointerId)) { - document.body.setPointerCapture(pointerId); - } - } catch { - /* ignore */ - } -} - -function armDragEndListeners(pointerId: number) { - endListenersArmed = true; - bindActiveDragEndListeners(pointerId); -} - -function beginDrag( - payload: PaletteDragPayload, - xy: { x: number; y: number }, - pointerId: number, - touchId: number | null = null, -) { - if (activePayload) { - cleanupDrag(); - } - - activePayload = payload; - activePointerId = pointerId; - activeTouchId = touchId; - lastDragClientXY = { x: xy.x, y: xy.y }; - endListenersArmed = false; - document.body.classList.add('se-palette-drag-active'); - - bindActiveDragMoveListeners(pointerId); - overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y }); - - emit({ phase: 'start', x: xy.x, y: xy.y, payload }); - notifyPaletteDragMove(xy.x, xy.y); - - requestAnimationFrame(() => { - if (activePointerId !== pointerId) return; - captureOnOverlay(pointerId); - requestAnimationFrame(() => { - if (activePointerId !== pointerId) return; - armDragEndListeners(pointerId); - }); - }); -} - export function bindPaletteDragOverlayElement(el: HTMLElement | null) { overlayElement = el; overlayController?.bindElement(el); if (el && activePointerId != null) { - captureOnOverlay(activePointerId); + try { + el.setPointerCapture(activePointerId); + } catch { + /* window gesture listener 가 move/end 처리 */ + } } } @@ -324,36 +220,35 @@ function startPaletteDragGesture( startX: number, startY: number, pointerId: number, - touchId: number | null, payload: PaletteDragPayload, ): void { if (activePayload) { cleanupDrag(); } + unbindGestureListeners(); - clearPendingPointerListeners(); + let dragging = false; - 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); + unbindGesture = bindWindowDrag( + (xy) => { + if (!dragging) { + const dx = xy.x - startX; + const dy = xy.y - startY; + if (Math.hypot(dx, dy) < DRAG_THRESHOLD_PX) return; + dragging = true; + mountDragOverlay(payload, xy, pointerId); + return; + } + notifyPaletteDragMove(xy.x, xy.y); }, - () => { - if (dragStarted) return; - clearPendingPointerListeners(); + (xy) => { + unbindGestureListeners(); + if (dragging && activePayload) { + finishDrag('end', xy); + } }, { preventTouchScroll: true }, ); - - pendingPointerListeners = unbind; } export function handlePalettePointerDown( @@ -366,25 +261,36 @@ export function handlePalettePointerDown( if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; e.preventDefault(); + e.stopPropagation(); const sourceEl = e.currentTarget as HTMLElement; try { sourceEl.setPointerCapture(e.pointerId); gestureCaptureEl = sourceEl; } catch { - /* overlay/body capture later */ + /* bindWindowDrag 가 move/end 처리 */ } - startPaletteDragGesture( - e.clientX, - e.clientY, - e.pointerId, - null, - payload, - ); + startPaletteDragGesture(e.clientX, e.clientY, e.pointerId, payload); } -/** PointerEvent 미지원 환경 — touchstart 전용 진입 */ +/** macOS WebView 등 — pointer 대신 mouse 이벤트만 오는 경우 */ +export function handlePaletteMouseDown( + e: React.MouseEvent, + payload: PaletteDragPayload, + _label: string, +): void { + if (!needsPointerPaletteDrag()) return; + if (e.button !== 0) return; + if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; + if (typeof window !== 'undefined' && 'PointerEvent' in window) return; + + e.preventDefault(); + e.stopPropagation(); + startPaletteDragGesture(e.clientX, e.clientY, 1, payload); +} + +/** PointerEvent 미지원 — touchstart 전용 */ export function handlePaletteTouchStart( e: React.TouchEvent, payload: PaletteDragPayload, @@ -396,15 +302,8 @@ export function handlePaletteTouchStart( if ((e.target as HTMLElement).closest('input, textarea, select, button')) return; e.preventDefault(); - const touch = e.touches[0]; - startPaletteDragGesture( - touch.clientX, - touch.clientY, - touch.identifier, - touch.identifier, - payload, - ); + startPaletteDragGesture(touch.clientX, touch.clientY, touch.identifier, payload); } export function writePaletteHtmlDragData(e: React.DragEvent, payload: PaletteDragPayload): void {