diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index c18a21e..7f3061f 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -39,7 +39,7 @@ import { } from '../../utils/paletteDragSession'; import { isOverStrategyBuilder, - projectScreenToFlowPosition, + resolvePaletteDropClientPoint, } from '../../utils/flowScreenProjection'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { @@ -300,7 +300,7 @@ function StrategyEditorCanvasInner({ onExtraStartIdsChange, onExtraRootsChange, }: Props) { - const { fitView, screenToFlowPosition } = useReactFlow(); + const { fitView } = useReactFlow(); const canvasWrapRef = useRef(null); const reactFlowRef = useRef(null); const positionsRef = useRef(new Map()); @@ -1211,24 +1211,20 @@ function StrategyEditorCanvasInner({ )); }, [root, orphans, extraRoots, applyResolvedConnection, setEdges]); - const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => { - const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); - if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { - return { mode: 'connect' as const, anchorId: hit.id }; - } - return { mode: 'orphan' as const }; - }, [root, orphans]); + const resolveDropFromClient = useCallback((clientX: number, clientY: number) => ( + resolvePaletteDropClientPoint( + clientX, + clientY, + reactFlowRef.current, + canvasWrapRef.current, + nodesRef.current, + findNodeAtFlowPosition, + nodeId => isPaletteConnectTarget(nodeId, root, orphans), + ) + ), [root, orphans]); const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e); - const clientToFlow = useCallback((clientX: number, clientY: number) => { - const rf = reactFlowRef.current; - if (rf) { - return projectScreenToFlowPosition(clientX, clientY, rf, canvasWrapRef.current); - } - return screenToFlowPosition({ x: clientX, y: clientY }); - }, [screenToFlowPosition]); - const applyPaletteDropAt = useCallback(( data: { type: string; value: string; label: string; composite?: boolean }, clientX: number, @@ -1236,15 +1232,14 @@ function StrategyEditorCanvasInner({ ) => { if (!isOverStrategyBuilder(clientX, clientY)) return; - const flowPos = clientToFlow(clientX, clientY); + const { flowPos, anchorId } = resolveDropFromClient(clientX, clientY); if (data.type === 'start') { addStartAt(flowPos); return; } - const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); - if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { - handleDropTarget(hit.id, data, flowPos); + if (anchorId) { + handleDropTarget(anchorId, data, flowPos); return; } @@ -1253,7 +1248,7 @@ function StrategyEditorCanvasInner({ requestAnimationFrame(() => { reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 }); }); - }, [clientToFlow, addStartAt, handleDropTarget, addOrphanAt, root, orphans]); + }, [resolveDropFromClient, addStartAt, handleDropTarget, addOrphanAt]); const onPaneDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -1267,12 +1262,10 @@ function StrategyEditorCanvasInner({ applyDropRef.current = applyPaletteDropAt; const clearPreviewRef = useRef(clearDropPreview); clearPreviewRef.current = clearDropPreview; - const resolveDropRef = useRef(resolvePaletteDrop); - resolveDropRef.current = resolvePaletteDrop; const updatePreviewRef = useRef(updateDropPreview); updatePreviewRef.current = updateDropPreview; - const clientToFlowRef = useRef(clientToFlow); - clientToFlowRef.current = clientToFlow; + const resolveDropFromClientRef = useRef(resolveDropFromClient); + resolveDropFromClientRef.current = resolveDropFromClient; useEffect(() => { if (!needsPointerPaletteDrag()) return; @@ -1282,10 +1275,9 @@ function StrategyEditorCanvasInner({ clearPreviewRef.current(); return; } - const flowPos = clientToFlowRef.current(ev.x, ev.y); - const drop = resolveDropRef.current(flowPos); - if (drop.mode === 'connect') { - updatePreviewRef.current(drop.anchorId, flowPos); + const { flowPos, anchorId } = resolveDropFromClientRef.current(ev.x, ev.y); + if (anchorId) { + updatePreviewRef.current(anchorId, flowPos); } else { clearPreviewRef.current(); } @@ -1306,14 +1298,13 @@ function StrategyEditorCanvasInner({ e.preventDefault(); if (!isPaletteDrag(e)) return; e.dataTransfer.dropEffect = 'copy'; - const flowPos = clientToFlow(e.clientX, e.clientY); - const drop = resolvePaletteDrop(flowPos); - if (drop.mode === 'connect') { - updateDropPreview(drop.anchorId, flowPos); + const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY); + if (anchorId) { + updateDropPreview(anchorId, flowPos); } else { clearDropPreview(); } - }, [clientToFlow, resolvePaletteDrop, updateDropPreview, clearDropPreview]); + }, [resolveDropFromClient, updateDropPreview, clearDropPreview]); const onPaneDragLeave = useCallback((e: React.DragEvent) => { const rel = e.relatedTarget as Element | null; diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts index 3ada175..da1e636 100644 --- a/frontend/src/utils/flowScreenProjection.ts +++ b/frontend/src/utils/flowScreenProjection.ts @@ -2,6 +2,24 @@ import type { ReactFlowInstance } from '@xyflow/react'; type FlowProjector = Pick; +function manualProject( + clientX: number, + clientY: number, + rf: FlowProjector, + containerEl: HTMLElement | null, +): { x: number; y: number } { + const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? containerEl; + if (!host) return { x: 0, y: 0 }; + + const rect = host.getBoundingClientRect(); + const { x: vx, y: vy, zoom } = rf.getViewport(); + const z = zoom || 1; + return { + x: (clientX - rect.left - vx) / z, + y: (clientY - rect.top - vy) / z, + }; +} + /** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */ export function projectScreenToFlowPosition( clientX: number, @@ -13,31 +31,82 @@ export function projectScreenToFlowPosition( try { const pos = rf.screenToFlowPosition({ x: clientX, y: clientY }); if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) { - return pos; + // WebView 버그: 화면 픽셀을 그대로 반환하는 경우 + const looksLikeScreenPx = + Math.abs(pos.x - clientX) < 2 && Math.abs(pos.y - clientY) < 2; + if (!looksLikeScreenPx) return pos; } } catch { /* manual fallback */ } + return manualProject(clientX, clientY, rf, containerEl); } - const host = (containerEl?.querySelector('.react-flow') as HTMLElement | null) ?? containerEl; - if (!host || !rf) { - return { x: clientX, y: clientY }; - } - - const rect = host.getBoundingClientRect(); - const { x: vx, y: vy, zoom } = rf.getViewport(); - const z = zoom || 1; - return { - x: (clientX - rect.left - vx) / z, - y: (clientY - rect.top - vy) / z, - }; + return manualProject( + clientX, + clientY, + { screenToFlowPosition: p => p, getViewport: () => ({ x: 0, y: 0, zoom: 1 }) }, + containerEl, + ); } /** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 release 무시) */ export function isOverStrategyBuilder(clientX: number, clientY: number): boolean { const el = document.elementFromPoint(clientX, clientY); if (!el) return false; - if (el.closest('.se-palette-panel, .se-side-wrap--right')) return false; + if (el.closest('.se-palette-panel, .se-side-wrap--right, .se-palette-drag-ghost')) return false; return el.closest('.se-canvas-wrap') != null; } + +/** DOM hit-test — flow 좌표 변환 오류 시에도 드롭 대상 노드 식별 */ +export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null { + const el = document.elementFromPoint(clientX, clientY); + if (!el) return null; + + const nodeEl = el.closest('.react-flow__node') as HTMLElement | null; + if (nodeEl) { + const id = nodeEl.getAttribute('data-id'); + if (id) return id; + } + + const handleEl = el.closest('[data-nodeid]') as HTMLElement | null; + if (handleEl) { + const id = handleEl.getAttribute('data-nodeid'); + if (id) return id; + } + + return null; +} + +export interface PaletteDropResolution { + flowPos: { x: number; y: number }; + anchorId: string | null; +} + +/** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */ +export function resolvePaletteDropClientPoint( + clientX: number, + clientY: number, + rf: FlowProjector | null, + containerEl: HTMLElement | null, + nodes: { id: string; position: { x: number; y: number } }[], + findAtFlow: ( + flowPos: { x: number; y: number }, + list: { id: string; position: { x: number; y: number } }[], + ) => { id: string } | null, + isConnectTarget: (nodeId: string) => boolean, +): PaletteDropResolution { + const flowPos = projectScreenToFlowPosition(clientX, clientY, rf, containerEl); + + const domId = findFlowNodeIdAtClientPoint(clientX, clientY); + if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) { + return { flowPos, anchorId: domId }; + } + + const flowHit = findAtFlow(flowPos, nodes); + if (flowHit && isConnectTarget(flowHit.id)) { + return { flowPos, anchorId: flowHit.id }; + } + + return { flowPos, anchorId: null }; +} diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index e50e34e..390ad64 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -1,7 +1,7 @@ /** * Tauri WebView 등 HTML5 drag-and-drop 미지원 환경용 팔레트 드래그 세션 */ -import { bindWindowDrag, type ClientXY } from './pointerDrag'; +import { bindWindowDrag, clientXYFromNative, type ClientXY } from './pointerDrag'; import { isDesktop } from './platform'; export type PaletteDragPayload = Record & { @@ -64,6 +64,7 @@ function emit(event: PaletteDragEvent) { function removeGhost() { ghostEl?.remove(); ghostEl = null; + document.querySelectorAll('.se-palette-drag-ghost').forEach(el => el.remove()); } function ensureGhost(label: string, x: number, y: number) { @@ -83,6 +84,7 @@ function moveGhost(x: number, y: number) { } function finishDrag(phase: 'end' | 'cancel', xy: ClientXY) { + if (!activePayload) return; const payload = activePayload; cleanupDrag(); if (payload && phase === 'end') { @@ -118,6 +120,21 @@ function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) { (endXY) => finishDrag('end', endXY), { preventTouchScroll: true }, ); + + // Tauri WebView: bubble 단계 pointerup 누락 대비 + const captureEnd = (ev: Event) => { + if (!activePayload) return; + const xy = clientXYFromNative(ev); + if (xy) finishDrag('end', xy); + }; + window.addEventListener('pointerup', captureEnd, true); + window.addEventListener('pointercancel', captureEnd, true); + const prevUnbind = unbindMove; + unbindMove = () => { + window.removeEventListener('pointerup', captureEnd, true); + window.removeEventListener('pointercancel', captureEnd, true); + prevUnbind(); + }; } /**