데스크탑 앱 전략편집기 수정

This commit is contained in:
Macbook
2026-06-14 22:39:00 +09:00
parent 76ffa5ac52
commit 21f5d16b6f
4 changed files with 134 additions and 47 deletions
@@ -15,6 +15,7 @@ import {
type Node, type Node,
type OnNodesChange, type OnNodesChange,
type OnSelectionChangeFunc, type OnSelectionChangeFunc,
type ReactFlowInstance,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type { Theme } from '../../types/index'; import type { Theme } from '../../types/index';
@@ -36,6 +37,10 @@ import {
subscribePaletteDrag, subscribePaletteDrag,
type PaletteDragEvent, type PaletteDragEvent,
} from '../../utils/paletteDragSession'; } from '../../utils/paletteDragSession';
import {
isOverStrategyBuilder,
projectScreenToFlowPosition,
} from '../../utils/flowScreenProjection';
import { setStochPairSecondary } from '../../utils/stochOverboughtPair'; import { setStochPairSecondary } from '../../utils/stochOverboughtPair';
import { import {
setPriceExtremePairFilterLevel, setPriceExtremePairFilterLevel,
@@ -296,6 +301,8 @@ function StrategyEditorCanvasInner({
onExtraRootsChange, onExtraRootsChange,
}: Props) { }: Props) {
const { fitView, screenToFlowPosition } = useReactFlow(); const { fitView, screenToFlowPosition } = useReactFlow();
const canvasWrapRef = useRef<HTMLDivElement>(null);
const reactFlowRef = useRef<ReactFlowInstance | null>(null);
const positionsRef = useRef(new Map<string, { x: number; y: number }>()); const positionsRef = useRef(new Map<string, { x: number; y: number }>());
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>()); const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
const structureKeyRef = useRef<string | null>(null); const structureKeyRef = useRef<string | null>(null);
@@ -610,16 +617,16 @@ function StrategyEditorCanvasInner({
anchorId: string, anchorId: string,
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean },
flowPos: { x: number; y: number }, flowPos: { x: number; y: number },
) => { ): boolean => {
if (data.type === 'start') { if (data.type === 'start') {
addStartAt(flowPos); addStartAt(flowPos);
return; return true;
} }
const newNode = resolvePaletteDropNode(data); const newNode = resolvePaletteDropNode(data);
if (!newNode) return; if (!newNode) return false;
const anchor = nodesRef.current.find(n => n.id === anchorId); const anchor = nodesRef.current.find(n => n.id === anchorId);
if (!anchor) return; if (!anchor) return false;
const anchorDim = getNodeDimensions(anchorId); const anchorDim = getNodeDimensions(anchorId);
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos); const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
@@ -641,32 +648,38 @@ function StrategyEditorCanvasInner({
} }
saveAttachHandles(anchorId); saveAttachHandles(anchorId);
scheduleLayoutEmit(); scheduleLayoutEmit();
return; return true;
} }
const anchorNode = findNodeInTree(root, anchorId) const anchorNode = findNodeInTree(root, anchorId)
?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean); ?? Object.values(extraRoots).map(br => findNodeInTree(br, anchorId)).find(Boolean);
if (anchorNode?.type === 'CONDITION') { if (anchorNode?.type === 'CONDITION') {
onChange(mergeAtRoot(root, newNode, data.type === 'operator')); 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)) { if (root && findNodeInTree(root, anchorId)) {
onChange(addChild(root, anchorId, newNode)); onChange(addChild(root, anchorId, newNode));
} else { saveAttachHandles(anchorId);
const parentBranch = Object.entries(extraRoots).find(([, br]) => br && findNodeInTree(br, anchorId)); scheduleLayoutEmit();
if (parentBranch) { return true;
const [sid, br] = parentBranch;
onExtraRootsChange?.({
...extraRoots,
[sid]: addChild(br!, anchorId, newNode),
});
}
} }
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]); }, [root, extraRoots, onChange, onExtraRootsChange, addStartAt, scheduleLayoutEmit, resolvePaletteDropNode]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => { const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
@@ -688,7 +701,8 @@ function StrategyEditorCanvasInner({
) => { ) => {
clearDropPreview(); clearDropPreview();
if (isPaletteConnectTarget(anchorId, root, orphans)) { if (isPaletteConnectTarget(anchorId, root, orphans)) {
applyDropWithAttach(anchorId, data, flowPos); const attached = applyDropWithAttach(anchorId, data, flowPos);
if (!attached) addOrphanAt(data, flowPos);
} else { } else {
addOrphanAt(data, flowPos); addOrphanAt(data, flowPos);
} }
@@ -1207,23 +1221,39 @@ function StrategyEditorCanvasInner({
const isPaletteDrag = (e: React.DragEvent) => isPaletteHtmlDrag(e); 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(( const applyPaletteDropAt = useCallback((
data: { type: string; value: string; label: string; composite?: boolean }, data: { type: string; value: string; label: string; composite?: boolean },
clientX: number, clientX: number,
clientY: number, clientY: number,
) => { ) => {
const flowPos = screenToFlowPosition({ x: clientX, y: clientY }); if (!isOverStrategyBuilder(clientX, clientY)) return;
const flowPos = clientToFlow(clientX, clientY);
if (data.type === 'start') { if (data.type === 'start') {
addStartAt(flowPos); addStartAt(flowPos);
return; return;
} }
const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') { const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
applyDropWithAttach(drop.anchorId, data, flowPos); if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
} else { handleDropTarget(hit.id, data, flowPos);
addOrphanAt(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) => { const onPaneDrop = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
@@ -1233,48 +1263,57 @@ function StrategyEditorCanvasInner({
applyPaletteDropAt(data, e.clientX, e.clientY); applyPaletteDropAt(data, e.clientX, e.clientY);
}, [clearDropPreview, applyPaletteDropAt]); }, [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(() => { useEffect(() => {
if (!needsPointerPaletteDrag()) return; if (!needsPointerPaletteDrag()) return;
return subscribePaletteDrag((ev: PaletteDragEvent) => { return subscribePaletteDrag((ev: PaletteDragEvent) => {
if (ev.phase === 'move') { if (ev.phase === 'move') {
const flowPos = screenToFlowPosition({ x: ev.x, y: ev.y }); if (!isOverStrategyBuilder(ev.x, ev.y)) {
const drop = resolvePaletteDrop(flowPos); clearPreviewRef.current();
return;
}
const flowPos = clientToFlowRef.current(ev.x, ev.y);
const drop = resolveDropRef.current(flowPos);
if (drop.mode === 'connect') { if (drop.mode === 'connect') {
updateDropPreview(drop.anchorId, flowPos); updatePreviewRef.current(drop.anchorId, flowPos);
} else { } else {
clearDropPreview(); clearPreviewRef.current();
} }
return; return;
} }
if (ev.phase === 'end') { if (ev.phase === 'end') {
clearDropPreview(); clearPreviewRef.current();
applyPaletteDropAt(ev.payload, ev.x, ev.y); applyDropRef.current(ev.payload, ev.x, ev.y);
return; return;
} }
if (ev.phase === 'cancel') { if (ev.phase === 'cancel') {
clearDropPreview(); clearPreviewRef.current();
} }
}); });
}, [ }, []);
screenToFlowPosition,
resolvePaletteDrop,
updateDropPreview,
clearDropPreview,
applyPaletteDropAt,
]);
const onPaneDragOver = useCallback((e: React.DragEvent) => { const onPaneDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
if (!isPaletteDrag(e)) return; if (!isPaletteDrag(e)) return;
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY }); const flowPos = clientToFlow(e.clientX, e.clientY);
const drop = resolvePaletteDrop(flowPos); const drop = resolvePaletteDrop(flowPos);
if (drop.mode === 'connect') { if (drop.mode === 'connect') {
updateDropPreview(drop.anchorId, flowPos); updateDropPreview(drop.anchorId, flowPos);
} else { } else {
clearDropPreview(); clearDropPreview();
} }
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]); }, [clientToFlow, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
const onPaneDragLeave = useCallback((e: React.DragEvent) => { const onPaneDragLeave = useCallback((e: React.DragEvent) => {
const rel = e.relatedTarget as Element | null; const rel = e.relatedTarget as Element | null;
@@ -1304,11 +1343,13 @@ function StrategyEditorCanvasInner({
return ( return (
<div <div
ref={canvasWrapRef}
className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`} className={`se-canvas-wrap se-canvas-wrap--${signalTab} se-canvas-wrap--${interactionMode}`}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
tabIndex={0} tabIndex={0}
> >
<ReactFlow <ReactFlow
onInit={inst => { reactFlowRef.current = inst; }}
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
@@ -0,0 +1,43 @@
import type { ReactFlowInstance } from '@xyflow/react';
type FlowProjector = Pick<ReactFlowInstance, 'screenToFlowPosition' | 'getViewport'>;
/** 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;
}
+1 -3
View File
@@ -108,16 +108,14 @@ function beginDrag(payload: PaletteDragPayload, label: string, xy: ClientXY) {
ensureGhost(label, xy.x, xy.y); ensureGhost(label, xy.x, xy.y);
emit({ phase: 'start', x: xy.x, y: xy.y, payload }); emit({ phase: 'start', x: xy.x, y: xy.y, payload });
let lastXY = xy;
unbindMove = bindWindowDrag( unbindMove = bindWindowDrag(
({ x, y }) => { ({ x, y }) => {
lastXY = { x, y };
moveGhost(x, y); moveGhost(x, y);
if (activePayload) { if (activePayload) {
emit({ phase: 'move', x, y, payload: activePayload }); emit({ phase: 'move', x, y, payload: activePayload });
} }
}, },
() => finishDrag('end', lastXY), (endXY) => finishDrag('end', endXY),
{ preventTouchScroll: true }, { preventTouchScroll: true },
); );
} }
+7 -2
View File
@@ -48,21 +48,26 @@ export interface BindWindowDragOptions {
*/ */
export function bindWindowDrag( export function bindWindowDrag(
onMove: (xy: ClientXY, ev: Event) => void, onMove: (xy: ClientXY, ev: Event) => void,
onEnd: () => void, onEnd: (xy: ClientXY, ev: Event) => void,
options: BindWindowDragOptions = {}, options: BindWindowDragOptions = {},
): () => void { ): () => void {
const { preventTouchScroll = true } = options; const { preventTouchScroll = true } = options;
let lastXY: ClientXY = { x: 0, y: 0 };
const move = (ev: Event) => { const move = (ev: Event) => {
const xy = clientXYFromNative(ev); const xy = clientXYFromNative(ev);
if (!xy) return; if (!xy) return;
lastXY = xy;
if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) { if (preventTouchScroll && ev.cancelable && (ev instanceof TouchEvent || ev.type === 'touchmove')) {
ev.preventDefault(); ev.preventDefault();
} }
onMove(xy, ev); onMove(xy, ev);
}; };
const end = () => onEnd(); const end = (ev: Event) => {
const xy = clientXYFromNative(ev) ?? lastXY;
onEnd(xy, ev);
};
window.addEventListener('pointermove', move); window.addEventListener('pointermove', move);
window.addEventListener('pointerup', end); window.addEventListener('pointerup', end);