From 21f5d16b6fb373283455969b0c92b972c3a0db6a Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 14 Jun 2026 22:39:00 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8D=B0=EC=8A=A4=ED=81=AC=ED=83=91=20?= =?UTF-8?q?=EC=95=B1=20=EC=A0=84=EB=9E=B5=ED=8E=B8=EC=A7=91=EA=B8=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../strategyEditor/StrategyEditorCanvas.tsx | 125 ++++++++++++------ frontend/src/utils/flowScreenProjection.ts | 43 ++++++ frontend/src/utils/paletteDragSession.ts | 4 +- frontend/src/utils/pointerDrag.ts | 9 +- 4 files changed, 134 insertions(+), 47 deletions(-) create mode 100644 frontend/src/utils/flowScreenProjection.ts diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index 77558c7..c18a21e 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -15,6 +15,7 @@ import { type Node, type OnNodesChange, type OnSelectionChangeFunc, + type ReactFlowInstance, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import type { Theme } from '../../types/index'; @@ -36,6 +37,10 @@ import { subscribePaletteDrag, type PaletteDragEvent, } from '../../utils/paletteDragSession'; +import { + isOverStrategyBuilder, + projectScreenToFlowPosition, +} from '../../utils/flowScreenProjection'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { setPriceExtremePairFilterLevel, @@ -296,6 +301,8 @@ function StrategyEditorCanvasInner({ onExtraRootsChange, }: Props) { const { fitView, screenToFlowPosition } = useReactFlow(); + const canvasWrapRef = useRef(null); + const reactFlowRef = useRef(null); const positionsRef = useRef(new Map()); const edgeHandlesRef = useRef(new Map()); const structureKeyRef = useRef(null); @@ -610,16 +617,16 @@ function StrategyEditorCanvasInner({ anchorId: string, data: { type: string; value: string; label: string; composite?: boolean }, flowPos: { x: number; y: number }, - ) => { + ): boolean => { if (data.type === 'start') { addStartAt(flowPos); - return; + return true; } const newNode = resolvePaletteDropNode(data); - if (!newNode) return; + if (!newNode) return false; const anchor = nodesRef.current.find(n => n.id === anchorId); - if (!anchor) return; + if (!anchor) return false; const anchorDim = getNodeDimensions(anchorId); const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos); @@ -641,32 +648,38 @@ function StrategyEditorCanvasInner({ } saveAttachHandles(anchorId); scheduleLayoutEmit(); - return; + return true; } const anchorNode = findNodeInTree(root, anchorId) ?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean); if (anchorNode?.type === 'CONDITION') { onChange(mergeAtRoot(root, newNode, data.type === 'operator')); - return; + return true; } - if (!canAcceptPaletteAsParent(anchorId, root)) return; + if (!canAcceptPaletteAsParent(anchorId, root)) return false; if (root && findNodeInTree(root, anchorId)) { onChange(addChild(root, anchorId, newNode)); - } else { - const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId)); - if (parentBranch) { - const [sid, br] = parentBranch; - onExtraRootsChange?.({ - ...extraRoots, - [sid]: addChild(br!, anchorId, newNode), - }); - } + saveAttachHandles(anchorId); + scheduleLayoutEmit(); + return true; } - saveAttachHandles(anchorId); - scheduleLayoutEmit(); + + const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId)); + if (parentBranch) { + const [sid, br] = parentBranch; + onExtraRootsChange?.({ + ...extraRoots, + [sid]: addChild(br!, anchorId, newNode), + }); + saveAttachHandles(anchorId); + scheduleLayoutEmit(); + return true; + } + + return false; }, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]); const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => { @@ -688,7 +701,8 @@ function StrategyEditorCanvasInner({ ) => { clearDropPreview(); if (isPaletteConnectTarget(anchorId, root, orphans)) { - applyDropWithAttach(anchorId, data, flowPos); + const attached = applyDropWithAttach(anchorId, data, flowPos); + if (!attached) addOrphanAt(data, flowPos); } else { addOrphanAt(data, flowPos); } @@ -1207,23 +1221,39 @@ function StrategyEditorCanvasInner({ 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, clientY: number, ) => { - const flowPos = screenToFlowPosition({ x: clientX, y: clientY }); + if (!isOverStrategyBuilder(clientX, clientY)) return; + + const flowPos = clientToFlow(clientX, clientY); if (data.type === 'start') { addStartAt(flowPos); return; } - const drop = resolvePaletteDrop(flowPos); - if (drop.mode === 'connect') { - applyDropWithAttach(drop.anchorId, data, flowPos); - } else { - addOrphanAt(data, flowPos); + + const hit = findNodeAtFlowPosition(flowPos, nodesRef.current); + if (hit && isPaletteConnectTarget(hit.id, root, orphans)) { + handleDropTarget(hit.id, data, flowPos); + return; } - }, [screenToFlowPosition, applyDropWithAttach, addOrphanAt, addStartAt, resolvePaletteDrop]); + + addOrphanAt(data, flowPos); + + requestAnimationFrame(() => { + reactFlowRef.current?.fitView({ padding: 0.35, duration: 180 }); + }); + }, [clientToFlow, addStartAt, handleDropTarget, addOrphanAt, root, orphans]); const onPaneDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); @@ -1233,48 +1263,57 @@ function StrategyEditorCanvasInner({ applyPaletteDropAt(data, e.clientX, e.clientY); }, [clearDropPreview, applyPaletteDropAt]); + const applyDropRef = useRef(applyPaletteDropAt); + 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; + useEffect(() => { if (!needsPointerPaletteDrag()) return; return subscribePaletteDrag((ev: PaletteDragEvent) => { if (ev.phase === 'move') { - const flowPos = screenToFlowPosition({ x: ev.x, y: ev.y }); - const drop = resolvePaletteDrop(flowPos); + if (!isOverStrategyBuilder(ev.x, ev.y)) { + clearPreviewRef.current(); + return; + } + const flowPos = clientToFlowRef.current(ev.x, ev.y); + const drop = resolveDropRef.current(flowPos); if (drop.mode === 'connect') { - updateDropPreview(drop.anchorId, flowPos); + updatePreviewRef.current(drop.anchorId, flowPos); } else { - clearDropPreview(); + clearPreviewRef.current(); } return; } if (ev.phase === 'end') { - clearDropPreview(); - applyPaletteDropAt(ev.payload, ev.x, ev.y); + clearPreviewRef.current(); + applyDropRef.current(ev.payload, ev.x, ev.y); return; } if (ev.phase === 'cancel') { - clearDropPreview(); + clearPreviewRef.current(); } }); - }, [ - screenToFlowPosition, - resolvePaletteDrop, - updateDropPreview, - clearDropPreview, - applyPaletteDropAt, - ]); + }, []); const onPaneDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); if (!isPaletteDrag(e)) return; e.dataTransfer.dropEffect = 'copy'; - const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); + const flowPos = clientToFlow(e.clientX, e.clientY); const drop = resolvePaletteDrop(flowPos); if (drop.mode === 'connect') { updateDropPreview(drop.anchorId, flowPos); } else { clearDropPreview(); } - }, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]); + }, [clientToFlow, resolvePaletteDrop, updateDropPreview, clearDropPreview]); const onPaneDragLeave = useCallback((e: React.DragEvent) => { const rel = e.relatedTarget as Element | null; @@ -1304,11 +1343,13 @@ function StrategyEditorCanvasInner({ return (
{ reactFlowRef.current = inst; }} nodes={nodes} edges={edges} nodeTypes={nodeTypes} diff --git a/frontend/src/utils/flowScreenProjection.ts b/frontend/src/utils/flowScreenProjection.ts new file mode 100644 index 0000000..3ada175 --- /dev/null +++ b/frontend/src/utils/flowScreenProjection.ts @@ -0,0 +1,43 @@ +import type { ReactFlowInstance } from '@xyflow/react'; + +type FlowProjector = Pick; + +/** screen(client) → React Flow 좌표 (Tauri WebView screenToFlowPosition 보정) */ +export function projectScreenToFlowPosition( + clientX: number, + clientY: number, + rf: FlowProjector | null, + containerEl: HTMLElement | null, +): { x: number; y: number } { + if (rf) { + try { + const pos = rf.screenToFlowPosition({ x: clientX, y: clientY }); + if (Number.isFinite(pos.x) && Number.isFinite(pos.y)) { + return pos; + } + } catch { + /* manual fallback */ + } + } + + 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, + }; +} + +/** 팔레트 드롭 — 전략 빌더 캔버스 위인지 (우측 팔레트 위 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; + return el.closest('.se-canvas-wrap') != null; +} diff --git a/frontend/src/utils/paletteDragSession.ts b/frontend/src/utils/paletteDragSession.ts index 3b4b47a..e50e34e 100644 --- a/frontend/src/utils/paletteDragSession.ts +++ b/frontend/src/utils/paletteDragSession.ts @@ -108,16 +108,14 @@ function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) { ensureGhost(label, xy.x, xy.y); emit({ phase: 'start', x: xy.x, y: xy.y, payload }); - let lastXY = xy; unbindMove = bindWindowDrag( ({ x, y }) => { - lastXY = { x, y }; moveGhost(x, y); if (activePayload) { emit({ phase: 'move', x, y, payload: activePayload }); } }, - () => finishDrag('end', lastXY), + (endXY) => finishDrag('end', endXY), { preventTouchScroll: true }, ); } diff --git a/frontend/src/utils/pointerDrag.ts b/frontend/src/utils/pointerDrag.ts index c89aeb2..020aba6 100644 --- a/frontend/src/utils/pointerDrag.ts +++ b/frontend/src/utils/pointerDrag.ts @@ -48,21 +48,26 @@ export interface BindWindowDragOptions { */ export function bindWindowDrag( onMove: (xy: ClientXY, ev: Event) => void, - onEnd: () => void, + onEnd: (xy: ClientXY, ev: Event) => void, options: BindWindowDragOptions = {}, ): () => void { const { preventTouchScroll = true } = options; + let lastXY: ClientXY = { x: 0, y: 0 }; const move = (ev: Event) => { const xy = clientXYFromNative(ev); if (!xy) return; + lastXY = xy; if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) { ev.preventDefault(); } onMove(xy, ev); }; - const end = () => onEnd(); + const end = (ev: Event) => { + const xy = clientXYFromNative(ev) ?? lastXY; + onEnd(xy, ev); + }; window.addEventListener('pointermove', move); window.addEventListener('pointerup', end);