From 0045f0ef2d4cbe97e2b265109d63a2913c84f54b Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 23:39:51 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/components/StrategyEditorPage.tsx | 2 + .../strategyEditor/PaletteDragOverlay.tsx | 89 ++++++++++++++ .../strategyEditor/StrategyEditorCanvas.tsx | 4 +- frontend/src/styles/strategyEditor.css | 34 ++++++ frontend/src/utils/flowScreenProjection.ts | 50 +++++--- frontend/src/utils/paletteDragSession.ts | 112 ++++++++++-------- 6 files changed, 221 insertions(+), 70 deletions(-) create mode 100644 frontend/src/components/strategyEditor/PaletteDragOverlay.tsx diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index 2dd0fd4..3775b53 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -141,6 +141,7 @@ import { strategyDtoToListExportItem, } from '../utils/strategyImportExport'; import PaletteChip from './strategyEditor/PaletteChip'; +import PaletteDragOverlay from './strategyEditor/PaletteDragOverlay'; import SePanelCollapseHandle from './strategyEditor/SePanelCollapseHandle'; import { clampPanelSize, @@ -1616,6 +1617,7 @@ export default function StrategyEditorPage({ theme, onNavigateToBacktest }: Prop return (
+ {saveToast && (
전략이 DB에 성공적으로 저장되었습니다.
)} diff --git a/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx new file mode 100644 index 0000000..adee0aa --- /dev/null +++ b/frontend/src/components/strategyEditor/PaletteDragOverlay.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { + bindPaletteDragOverlayElement, + notifyPaletteDragCancel, + notifyPaletteDragEnd, + notifyPaletteDragMove, + registerPaletteDragOverlay, + type PaletteDragOverlaySession, +} from '../../utils/paletteDragSession'; + +/** + * Tauri WebView — 드래그 중 전체 화면 overlay 가 pointer 이벤트·drop 을 수신 + */ +export default function PaletteDragOverlay() { + const [session, setSession] = useState(null); + const [cursor, setCursor] = useState({ x: 0, y: 0 }); + const overlayRef = useRef(null); + + useEffect(() => { + registerPaletteDragOverlay({ + mount: next => { + setCursor({ x: next.x, y: next.y }); + setSession(next); + }, + unmount: () => setSession(null), + bindElement: el => { + overlayRef.current = el as HTMLDivElement | null; + }, + }); + return () => registerPaletteDragOverlay(null); + }, []); + + useLayoutEffect(() => { + bindPaletteDragOverlayElement(overlayRef.current); + return () => bindPaletteDragOverlayElement(null); + }, [session]); + + useLayoutEffect(() => { + if (!session || !overlayRef.current) return; + try { + overlayRef.current.setPointerCapture(session.pointerId); + } catch { + /* body capture fallback */ + } + }, [session]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!session || e.pointerId !== session.pointerId) return; + e.preventDefault(); + e.stopPropagation(); + setCursor({ x: e.clientX, y: e.clientY }); + notifyPaletteDragMove(e.clientX, e.clientY); + }, [session]); + + const onPointerUp = useCallback((e: React.PointerEvent) => { + if (!session || e.pointerId !== session.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; + e.preventDefault(); + e.stopPropagation(); + notifyPaletteDragCancel(e.clientX, e.clientY); + }, [session]); + + if (!session) return null; + + return createPortal( +
+
+ {session.payload.label} +
+
, + document.body, + ); +} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 90ef0e8..69981e9 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -1233,9 +1233,9 @@ function StrategyEditorCanvasInner({ clientX: number, clientY: number, ) => { - if (!isOverStrategyBuilder(clientX, clientY)) return; - const { flowPos, anchorId } = resolveDropFromClient(clientX, clientY); + const overCanvas = isOverStrategyBuilder(clientX, clientY); + if (!overCanvas && !anchorId) return; if (data.type === 'start') { addStartAt(flowPos); return; diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index fb0178e..b7976a2 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -2027,6 +2027,12 @@ background: var(--se-palette-card-bg); transition: transform 0.12s, box-shadow 0.12s, border-color 0.12s, background 0.12s; } +.desktop-client .se-palette-card { + cursor: grab; +} +.desktop-client .se-palette-card:active { + cursor: grabbing; +} .se-palette-card:hover { transform: translateY(-2px); } @@ -2039,6 +2045,34 @@ body.se-palette-drag-active { user-select: none; } +.se-palette-drag-overlay { + position: fixed; + inset: 0; + z-index: 20040; + touch-action: none; + cursor: grabbing; + background: transparent; +} + +.se-palette-drag-overlay-label { + position: fixed; + z-index: 20041; + pointer-events: none; + transform: translate(-50%, -100%); + padding: 6px 12px; + border-radius: 8px; + font-size: 0.72rem; + font-weight: 700; + color: var(--se-text, #e2e8f0); + background: color-mix(in srgb, var(--se-accent, #4dabf7) 22%, var(--se-panel-card-bg, #1e293b)); + border: 1px solid color-mix(in srgb, var(--se-accent, #4dabf7) 55%, transparent); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + white-space: nowrap; + max-width: min(240px, 90vw); + overflow: hidden; + text-overflow: ellipsis; +} + .se-palette-drag-ghost { position: fixed; z-index: 20050; diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts index eba100f..c4f1650 100644 --- a/frontend/src/utils/flowScreenProjection.ts +++ b/frontend/src/utils/flowScreenProjection.ts @@ -2,6 +2,8 @@ import type { ReactFlowInstance } from '@xyflow/react'; type FlowProjector = Pick; +const DRAG_LAYER_SELECTOR = '.se-palette-drag-overlay, .se-palette-drag-ghost'; + function manualProject( clientX: number, clientY: number, @@ -20,6 +22,27 @@ function manualProject( }; } +/** 드래그 overlay 아래 DOM 탐색 (overlay pointer-events 잠시 비활성) */ +export function elementsUnderDragPoint(clientX: number, clientY: number): Element[] { + const overlays = document.querySelectorAll(DRAG_LAYER_SELECTOR); + const prev: string[] = []; + overlays.forEach(el => { + prev.push(el.style.pointerEvents); + el.style.pointerEvents = 'none'; + }); + try { + if (typeof document.elementsFromPoint === 'function') { + return document.elementsFromPoint(clientX, clientY); + } + const hit = document.elementFromPoint(clientX, clientY); + return hit ? [hit] : []; + } finally { + overlays.forEach((el, i) => { + el.style.pointerEvents = prev[i] ?? ''; + }); + } +} + /** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */ export function projectScreenToFlowPosition( clientX: number, @@ -31,7 +54,6 @@ export function projectScreenToFlowPosition( try { const pos = rf.screenToFlowPosition({ x: clientX, y: clientY }); if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) { - // WebView 버그: 화면 픽셀을 그대로 반환하는 경우 const looksLikeScreenPx = Math.abs(pos.x - clientX) < 2 && Math.abs(pos.y - clientY) < 2; if (!looksLikeScreenPx) return pos; @@ -50,26 +72,18 @@ export function projectScreenToFlowPosition( ); } -/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 release 무시) */ +/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 */ export function isOverStrategyBuilder(clientX: number, clientY: number): boolean { - const elements = typeof document.elementsFromPoint === 'function' - ? document.elementsFromPoint(clientX, clientY) - : [document.elementFromPoint(clientX, clientY)].filter(Boolean) as Element[]; - - for (const el of elements) { - if (el.closest('.se-palette-panel, .se-side-wrap--right, .se-palette-drag-ghost')) continue; - if (el.closest('.se-canvas-wrap')) return true; + for (const el of elementsUnderDragPoint(clientX, clientY)) { + if (el.closest('.se-palette-panel, .se-side-wrap--right')) continue; + if (el.closest('.se-canvas-wrap, .react-flow__pane, .react-flow__renderer')) return true; } return false; } -/** DOM hit-test — elementsFromPoint 로 ghost·overlay 아래 노드까지 탐색 */ +/** DOM hit-test — overlay 아래 react-flow 노드 탐색 */ export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null { - const elements = typeof document.elementsFromPoint === 'function' - ? document.elementsFromPoint(clientX, clientY) - : [document.elementFromPoint(clientX, clientY)].filter(Boolean) as Element[]; - - for (const el of elements) { + for (const el of elementsUnderDragPoint(clientX, clientY)) { const nodeEl = el.closest('.react-flow__node') as HTMLElement | null; if (nodeEl) { const id = nodeEl.getAttribute('data-id'); @@ -109,10 +123,12 @@ export function resolvePaletteDropClientPoint( return { flowPos, anchorId: domId }; } - // DOM hit-test 실패 시 START 노드 bounding rect 로 보조 for (const node of nodes) { if (!isConnectTarget(node.id)) continue; - const el = document.querySelector(`.react-flow__node[data-id="${CSS.escape(node.id)}"]`); + const escaped = typeof CSS !== 'undefined' && CSS.escape + ? CSS.escape(node.id) + : node.id.replace(/"/g, '\\"'); + const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`); if (!el) continue; const rect = el.getBoundingClientRect(); if ( diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 25172e1..396d917 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,7 +1,6 @@ /** - * Tauri WebView — HTML5 DnD 대신 pointer capture 기반 팔레트 드래그 + * Desktop(Tauri) — HTML5 DnD 대신 pointer + 전체화면 overlay 드래그 */ -import { clientXYFromNative } from './pointerDrag'; import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { @@ -19,16 +18,29 @@ export interface PaletteDragEvent { payload: PaletteDragPayload; } +export interface PaletteDragOverlaySession { + payload: PaletteDragPayload; + pointerId: number; + x: number; + y: number; +} + type PaletteDragListener = (event: PaletteDragEvent) => void; type PaletteDropHandler = (event: PaletteDragEvent) => void; +type OverlayController = { + mount: (session: PaletteDragOverlaySession) => void; + unmount: () => void; + bindElement: (el: HTMLElement | null) => void; +}; + let suppressClickUntil = 0; const DRAG_THRESHOLD_PX = 6; let activePayload: PaletteDragPayload | null = null; let activePointerId: number | null = null; -let unbindMove: (() => void) | null = null; -let pendingPointerId: number | null = null; +let overlayController: OverlayController | null = null; +let overlayElement: HTMLElement | null = null; let listeners = new Set(); let dropHandler: PaletteDropHandler | null = null; @@ -41,11 +53,14 @@ export function subscribePaletteDrag(listener: PaletteDragListener): () => void return () => listeners.delete(listener); } -/** Canvas 가 mount 직후 drop 콜백 등록 (subscribe 누락 방지) */ export function setPaletteDragDropHandler(handler: PaletteDropHandler | null): void { dropHandler = handler; } +export function registerPaletteDragOverlay(controller: OverlayController | null): void { + overlayController = controller; +} + export function getActivePaletteDrag(): PaletteDragPayload | null { return activePayload; } @@ -71,12 +86,10 @@ function dispatchEnd(xy: { x: number; y: number }, payload: PaletteDragPayload) } function cleanupDrag() { - unbindMove?.(); - unbindMove = null; activePayload = null; activePointerId = null; - pendingPointerId = null; document.body.classList.remove('se-palette-drag-active'); + overlayController?.unmount(); document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); } @@ -92,13 +105,40 @@ function finishDrag(phase: 'end' | 'cancel', xy: { x: number; y: number }) { } } -/** 캔버스 pointerup 백업 — WebView 에서 window 이벤트 누락 시 */ +export function notifyPaletteDragMove(clientX: number, clientY: number) { + if (!activePayload) return; + emit({ phase: 'move', x: clientX, y: clientY, payload: activePayload }); +} + +export function notifyPaletteDragEnd(clientX: number, clientY: number) { + finishDrag('end', { x: clientX, y: clientY }); +} + +export function notifyPaletteDragCancel(clientX: number, clientY: number) { + finishDrag('cancel', { x: clientX, y: clientY }); +} + export function completePaletteDragAt(clientX: number, clientY: number): boolean { if (!activePayload) return false; finishDrag('end', { x: clientX, y: clientY }); return true; } +function captureOnOverlay(pointerId: number) { + try { + overlayElement?.setPointerCapture(pointerId); + } catch { + /* ignore */ + } + try { + if (!overlayElement?.hasPointerCapture(pointerId)) { + document.body.setPointerCapture(pointerId); + } + } catch { + /* ignore */ + } +} + function beginDrag( payload: PaletteDragPayload, xy: { x: number; y: number }, @@ -108,48 +148,18 @@ function beginDrag( activePointerId = pointerId; document.body.classList.add('se-palette-drag-active'); - try { - document.body.setPointerCapture(pointerId); - } catch { - /* ignore */ - } + overlayController?.mount({ payload, pointerId, x: xy.x, y: xy.y }); + requestAnimationFrame(() => captureOnOverlay(pointerId)); emit({ phase: 'start', x: xy.x, y: xy.y, payload }); +} - const onMove = (ev: Event) => { - if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId || !activePayload) return; - if (ev.cancelable) ev.preventDefault(); - emit({ phase: 'move', x: ev.clientX, y: ev.clientY, payload: activePayload }); - }; - - const onUp = (ev: Event) => { - if (!(ev instanceof PointerEvent) || ev.pointerId !== pointerId) return; - const xy = clientXYFromNative(ev) ?? { x: ev.clientX, y: ev.clientY }; - finishDrag('end', xy); - }; - - window.addEventListener('pointermove', onMove, { passive: false }); - window.addEventListener('pointerup', onUp, true); - window.addEventListener('pointercancel', onUp, true); - window.addEventListener('mouseup', onUp, true); - document.addEventListener('pointerup', onUp, true); - document.addEventListener('lostpointercapture', onUp, true); - - unbindMove = () => { - window.removeEventListener('pointermove', onMove); - window.removeEventListener('pointerup', onUp, true); - window.removeEventListener('pointercancel', onUp, true); - window.removeEventListener('mouseup', onUp, true); - document.removeEventListener('pointerup', onUp, true); - document.removeEventListener('lostpointercapture', onUp, true); - try { - if (document.body.hasPointerCapture(pointerId)) { - document.body.releasePointerCapture(pointerId); - } - } catch { - /* ignore */ - } - }; +export function bindPaletteDragOverlayElement(el: HTMLElement | null) { + overlayElement = el; + overlayController?.bindElement(el); + if (el && activePointerId != null) { + captureOnOverlay(activePointerId); + } } export function handlePalettePointerDown( @@ -161,10 +171,11 @@ export function handlePalettePointerDown( if (e.button !== 0) 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; - pendingPointerId = pointerId; const onPointerMove = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; @@ -185,12 +196,11 @@ export function handlePalettePointerDown( window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerCancel); - pendingPointerId = null; }; const onPointerCancel = onPointerUp; - window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointermove', onPointerMove, { passive: false }); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerCancel); }