From b0e2efd7cbb1888d7bd6b427b15ae179f459412f Mon Sep 17 00:00:00 2001 From: Macbook Date: Mon, 15 Jun 2026 01:16:14 +0900 Subject: [PATCH] =?UTF-8?q?=EC=95=B1=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/strategyEditor/FlowNodes.tsx | 13 ++++-- .../strategyEditor/StrategyEditorCanvas.tsx | 33 +++++++++---- frontend/src/utils/flowScreenProjection.ts | 46 ++++++++++++++++--- frontend/src/utils/paletteDragSession.ts | 18 ++++++++ 4 files changed, 90 insertions(+), 20 deletions(-) diff --git a/frontend/src/components/strategyEditor/FlowNodes.tsx b/frontend/src/components/strategyEditor/FlowNodes.tsx index dd731f0..f32674d 100644 --- a/frontend/src/components/strategyEditor/FlowNodes.tsx +++ b/frontend/src/components/strategyEditor/FlowNodes.tsx @@ -37,9 +37,14 @@ import StochPairNodeSettings from './StochPairNodeSettings'; import PriceExtremePairNodeSettings from './PriceExtremePairNodeSettings'; import { isPaletteHtmlDrag, + isPaletteHtmlDragActive, readPaletteHtmlDragData, } from '../../utils/paletteDragSession'; +function isPaletteDropDrag(e: React.DragEvent): boolean { + return isPaletteHtmlDragActive() || isPaletteHtmlDrag(e); +} + const LOGIC_COLORS: Record = { AND: '#00aaff', OR: '#00d4ff', @@ -130,13 +135,13 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) { const { screenToFlowPosition } = useReactFlow(); const onDragEnter = useCallback((e: React.DragEvent) => { - if (!isPaletteHtmlDrag(e)) return; + if (!isPaletteDropDrag(e)) return; e.preventDefault(); e.stopPropagation(); }, []); const onDragOver = useCallback((e: React.DragEvent) => { - if (!isPaletteHtmlDrag(e)) return; + if (!isPaletteDropDrag(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; @@ -146,8 +151,10 @@ function usePaletteDropHandlers(id: string, d: StrategyFlowNodeData) { const onDragLeave = useCallback((e: React.DragEvent) => { e.stopPropagation(); + // WebKit HTML5 drag — relatedTarget 이 null 인 spurious dragleave 무시 const rel = e.relatedTarget as Node | null; - if (rel && e.currentTarget.contains(rel)) return; + if (!rel) return; + if (e.currentTarget.contains(rel)) return; d.onDragLeaveTarget?.(id); }, [d, id]); diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index e6d156e..a94a0ac 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -82,6 +82,7 @@ import { spawnPositionNearAnchor, subtreeToFlatOrphans, type EdgeHandleBinding, + type StrategyFlowNodeData, } from '../../utils/strategyFlowLayout'; import { createStartNodeId, @@ -906,10 +907,16 @@ function StrategyEditorCanvasInner({ const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => { const prev = nodesRef.current.find(p => p.id === n.id); const savedPos = layoutSeed.positions[n.id]; + const dragOver = (prev?.data as StrategyFlowNodeData | undefined)?.dragOver; + const activeSourceSide = (prev?.data as StrategyFlowNodeData | undefined)?.activeSourceSide; return { ...n, position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position, selected: prev?.selected ?? false, + data: { + ...n.data, + ...(dragOver ? { dragOver, activeSourceSide } : {}), + }, }; })); @@ -933,10 +940,15 @@ function StrategyEditorCanvasInner({ const fresh = layoutNodes.find(ln => ln.id === n.id); if (!fresh) return n; const savedPos = layoutSeed.positions[n.id]; + const dragOver = (n.data as StrategyFlowNodeData).dragOver; + const activeSourceSide = (n.data as StrategyFlowNodeData).activeSourceSide; return { ...n, position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position, - data: fresh.data, + data: { + ...fresh.data, + ...(dragOver ? { dragOver, activeSourceSide } : {}), + }, }; })); setEdges(mergedEdges); @@ -1226,7 +1238,10 @@ function StrategyEditorCanvasInner({ ) ), [root, orphans]); - const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e); + const isPaletteDrag = useCallback( + (e: React.DragEvent) => isPaletteHtmlDragActive() || isPaletteHtmlDrag(e), + [], + ); const applyPaletteDropAt = useCallback(( data: { type: string; value: string; label: string; composite?: boolean }, @@ -1291,11 +1306,8 @@ function StrategyEditorCanvasInner({ e.preventDefault(); const { flowPos, anchorId } = resolveDropFromClientRef.current(e.clientX, e.clientY); if (anchorId) { - const sideKey = `${anchorId}:${Math.round(flowPos.x / 8)}:${Math.round(flowPos.y / 8)}`; - if (sideKey === lastPreviewKeyRef.current) return; - lastPreviewKeyRef.current = sideKey; - lastPreviewAnchorRef.current = anchorId; updatePreviewRef.current(anchorId, flowPos); + lastPreviewAnchorRef.current = anchorId; return; } if (lastPreviewAnchorRef.current != null) { @@ -1311,9 +1323,11 @@ function StrategyEditorCanvasInner({ markPaletteHtmlDragEnd(); }; document.addEventListener('dragover', onDocDragOver, { passive: false }); + document.addEventListener('drag', onDocDragOver); document.addEventListener('dragend', onDocDragEnd); return () => { document.removeEventListener('dragover', onDocDragOver); + document.removeEventListener('drag', onDocDragOver); document.removeEventListener('dragend', onDocDragEnd); }; }, []); @@ -1368,14 +1382,13 @@ function StrategyEditorCanvasInner({ const { flowPos, anchorId } = resolveDropFromClient(e.clientX, e.clientY); if (anchorId) { updateDropPreview(anchorId, flowPos); - } else { - clearDropPreview(); } - }, [resolveDropFromClient, updateDropPreview, clearDropPreview]); + }, [resolveDropFromClient, updateDropPreview, isPaletteDrag]); const onPaneDragLeave = useCallback((e: React.DragEvent) => { const rel = e.relatedTarget as Element | null; - if (rel && e.currentTarget.contains(rel)) return; + if (!rel) return; + if (e.currentTarget.contains(rel)) return; clearDropPreview(); }, [clearDropPreview]); diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts index f4ce949..489cffb 100644 --- a/frontend/src/utils/flowScreenProjection.ts +++ b/frontend/src/utils/flowScreenProjection.ts @@ -1,4 +1,5 @@ import type { ReactFlowInstance } from '@xyflow/react'; +import { getLastHtmlPaletteDragClientXY, isPaletteHtmlDragActive } from './paletteDragSession'; type FlowProjector = Pick; @@ -89,8 +90,27 @@ export function isOverStrategyBuilder(clientX: number, clientY: number): boolean return false; } -/** DOM hit-test — overlay 아래 react-flow 노드 탐색 */ +/** DOM hit-test — react-flow 노드 id (Safari HTML5 drag: elementsFromPoint 불가 → rect 우선) */ export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): string | null { + const pad = 10; + const nodeEls = document.querySelectorAll('.react-flow__node'); + for (const nodeEl of nodeEls) { + const rect = nodeEl.getBoundingClientRect(); + if ( + clientX >= rect.left - pad && clientX <= rect.right + pad + && clientY >= rect.top - pad && clientY <= rect.bottom + pad + ) { + const id = nodeEl.getAttribute('data-id') + ?? nodeEl.dataset.id + ?? (nodeEl.id.startsWith('react-flow__node-') + ? nodeEl.id.slice('react-flow__node-'.length) + : null) + ?? nodeEl.querySelector('[data-nodeid]')?.getAttribute('data-nodeid') + ?? null; + if (id) return id; + } + } + for (const el of elementsUnderDragPoint(clientX, clientY)) { const nodeEl = el.closest('.react-flow__node') as HTMLElement | null; if (nodeEl) { @@ -98,7 +118,9 @@ export function findFlowNodeIdAtClientPoint(clientX: number, clientY: number): s ?? nodeEl.dataset.id ?? (nodeEl.id.startsWith('react-flow__node-') ? nodeEl.id.slice('react-flow__node-'.length) - : null); + : null) + ?? nodeEl.querySelector('[data-nodeid]')?.getAttribute('data-nodeid') + ?? null; if (id) return id; } const handleEl = el.closest('[data-nodeid]') as HTMLElement | null; @@ -115,6 +137,14 @@ export interface PaletteDropResolution { anchorId: string | null; } +/** WKWebView HTML5 drag — dragover client 좌표가 0,0 으로 오는 경우 보정 */ +export function resolvePaletteDragClientPoint(clientX: number, clientY: number): { x: number; y: number } { + if (clientX !== 0 || clientY !== 0) return { x: clientX, y: clientY }; + if (!isPaletteHtmlDragActive()) return { x: clientX, y: clientY }; + const last = getLastHtmlPaletteDragClientXY(); + return last ?? { x: clientX, y: clientY }; +} + /** client 좌표 → flow 위치 + 연결 대상 노드 (DOM 우선, flow hit 보조) */ export function resolvePaletteDropClientPoint( clientX: number, @@ -128,9 +158,10 @@ export function resolvePaletteDropClientPoint( ) => { id: string } | null, isConnectTarget: (nodeId: string) => boolean, ): PaletteDropResolution { - const flowPos = projectScreenToFlowPosition(clientX, clientY, rf, containerEl); + const { x: px, y: py } = resolvePaletteDragClientPoint(clientX, clientY); + const flowPos = projectScreenToFlowPosition(px, py, rf, containerEl); - const domId = findFlowNodeIdAtClientPoint(clientX, clientY); + const domId = findFlowNodeIdAtClientPoint(px, py); if (domId && nodes.some(n => n.id === domId) && isConnectTarget(domId)) { return { flowPos, anchorId: domId }; } @@ -140,12 +171,13 @@ export function resolvePaletteDropClientPoint( 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}"]`); + const el = document.querySelector(`.react-flow__node[data-id="${escaped}"]`) + ?? document.getElementById(`react-flow__node-${node.id}`); if (!el) continue; const rect = el.getBoundingClientRect(); if ( - clientX >= rect.left && clientX <= rect.right - && clientY >= rect.top && clientY <= rect.bottom + px >= rect.left && px <= rect.right + && py >= rect.top && py <= rect.bottom ) { return { flowPos, anchorId: node.id }; } diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 7ffb51d..112a351 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -55,6 +55,7 @@ let pendingMove: { x: number; y: number } | null = null; let unbindGesture: (() => void) | null = null; let lastDragClientXY: { x: number; y: number } | null = null; let htmlPaletteDragActive = false; +let lastHtmlDragClientXY: { x: number; y: number } | null = null; function installHtmlPaletteDragGlobals() { if (typeof window === 'undefined') return; @@ -64,12 +65,23 @@ function installHtmlPaletteDragGlobals() { window.addEventListener('dragover', (e) => { if (!htmlPaletteDragActive) return; + if (e.clientX !== 0 || e.clientY !== 0) { + lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; + } e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'; }, { passive: false }); + window.addEventListener('drag', (e) => { + if (!htmlPaletteDragActive) return; + if (e.clientX !== 0 || e.clientY !== 0) { + lastHtmlDragClientXY = { x: e.clientX, y: e.clientY }; + } + }); + window.addEventListener('dragend', () => { htmlPaletteDragActive = false; + lastHtmlDragClientXY = null; }); } @@ -328,10 +340,16 @@ export function handlePaletteTouchStart( export function markPaletteHtmlDragStart(): void { htmlPaletteDragActive = true; + lastHtmlDragClientXY = null; } export function markPaletteHtmlDragEnd(): void { htmlPaletteDragActive = false; + lastHtmlDragClientXY = null; +} + +export function getLastHtmlPaletteDragClientXY(): { x: number; y: number } | null { + return lastHtmlDragClientXY; } export function isPaletteHtmlDragActive(): boolean {