전략편집기 수정

This commit is contained in:
Macbook
2026-05-24 12:25:17 +09:00
parent ea39f7df27
commit 1465cb2255
10 changed files with 714 additions and 83 deletions
@@ -51,9 +51,16 @@ import {
} from '../../utils/strategyFlowLayout';
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
import { StrategyFlowEdge } from './StrategyFlowEdge';
import { edgeDisconnectRef } from './strategyEditorCallbacks';
import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks';
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
import { getMinimapColors } from './minimapTheme';
import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage';
export type FlowLayoutSeed = {
seedKey: string;
positions: Record<string, { x: number; y: number }>;
edgeHandles: Record<string, EdgeHandleBinding>;
};
const nodeTypes = {
start: StartNode,
@@ -75,6 +82,8 @@ interface Props {
onChange: (root: LogicNode | null) => void;
selectedNodeId: string | null;
onSelectNode: (id: string | null) => void;
layoutSeed: FlowLayoutSeed;
onLayoutChange?: (snapshot: FlowLayoutChangePayload) => void;
}
function collectTreeIds(node: LogicNode | null): string[] {
@@ -88,12 +97,14 @@ function saveEdgeHandles(
handles: Map<string, EdgeHandleBinding>,
edgeId: string,
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
manual = true,
lockHandles = false,
) {
handles.set(edgeId, {
sourceHandle: conn.sourceHandle,
targetHandle: conn.targetHandle,
manual,
manual: lockHandles,
manualSource: lockHandles,
manualTarget: lockHandles,
});
}
@@ -170,12 +181,15 @@ function StrategyEditorCanvasInner({
onChange,
selectedNodeId,
onSelectNode,
layoutSeed,
onLayoutChange,
}: Props) {
const { fitView, screenToFlowPosition } = useReactFlow();
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
const edgeHandlesRef = useRef(new Map<string, EdgeHandleBinding>());
const structureKeyRef = useRef('');
const orphanKeyRef = useRef('');
const structureKeyRef = useRef<string | null>(null);
const orphanKeyRef = useRef<string | null>(null);
const layoutEmitTimerRef = useRef<number | null>(null);
const [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
@@ -185,8 +199,8 @@ function StrategyEditorCanvasInner({
edgesRef.current = edges;
const treeStructureKey = useMemo(
() => `${signalTab}:${collectTreeIds(root).join('|')}`,
[root, signalTab],
() => collectTreeIds(root).join('|'),
[root],
);
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
@@ -210,6 +224,53 @@ function StrategyEditorCanvasInner({
return minimapColors.stroke;
}, [minimapColors]);
const signalTabRef = useRef(signalTab);
signalTabRef.current = signalTab;
const emitLayoutChange = useCallback((tab: 'buy' | 'sell') => {
if (!onLayoutChange) return;
onLayoutChange({
tab,
positions: Object.fromEntries(positionsRef.current),
edgeHandles: Object.fromEntries(edgeHandlesRef.current),
});
}, [onLayoutChange]);
const scheduleLayoutEmit = useCallback(() => {
if (!onLayoutChange) return;
const tabAtSchedule = signalTabRef.current;
if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current);
layoutEmitTimerRef.current = window.setTimeout(() => {
layoutEmitTimerRef.current = null;
emitLayoutChange(tabAtSchedule);
}, 120);
}, [onLayoutChange, emitLayoutChange]);
useEffect(() => () => {
if (layoutEmitTimerRef.current != null) window.clearTimeout(layoutEmitTimerRef.current);
}, []);
useEffect(() => {
positionsRef.current = new Map(Object.entries(layoutSeed.positions));
edgeHandlesRef.current = new Map(Object.entries(layoutSeed.edgeHandles));
}, [layoutSeed.edgeHandles, layoutSeed.positions, layoutSeed.seedKey]);
useEffect(() => {
layoutFlushRef.current = () => {
if (layoutEmitTimerRef.current != null) {
window.clearTimeout(layoutEmitTimerRef.current);
layoutEmitTimerRef.current = null;
}
for (const n of nodesRef.current) {
positionsRef.current.set(n.id, { x: n.position.x, y: n.position.y });
}
emitLayoutChange(signalTabRef.current);
};
return () => {
layoutFlushRef.current = null;
};
}, [emitLayoutChange]);
const handleDelete = useCallback((id: string) => {
if (id === START_NODE_ID) return;
if (isOrphanNode(orphans, id)) {
@@ -229,7 +290,8 @@ function StrategyEditorCanvasInner({
onChange(next);
positionsRef.current.delete(id);
if (selectedNodeId === id) onSelectNode(null);
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId]);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId, scheduleLayoutEmit]);
const deleteSelectedNodes = useCallback(() => {
const ids = nodesRef.current
@@ -260,7 +322,8 @@ function StrategyEditorCanvasInner({
onChange(tree);
}
onSelectNode(null);
}, [root, orphans, onChange, onOrphansChange, onSelectNode]);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, onSelectNode, scheduleLayoutEmit]);
const clearDropPreview = useCallback(() => {
setNodes(nds => nds.map(n => ({
@@ -341,7 +404,8 @@ function StrategyEditorCanvasInner({
y: flowPos.y - FLOW_NODE_H / 2,
});
onOrphansChange([...orphans, newNode]);
}, [orphans, onOrphansChange, signalTab, def]);
scheduleLayoutEmit();
}, [orphans, onOrphansChange, signalTab, def, scheduleLayoutEmit]);
const applyDropWithAttach = useCallback((
anchorId: string,
@@ -363,11 +427,13 @@ function StrategyEditorCanvasInner({
if (!root) {
onChange(newNode);
saveAttachHandles(START_NODE_ID);
scheduleLayoutEmit();
return;
}
if (anchorId === START_NODE_ID) {
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
saveAttachHandles(START_NODE_ID);
scheduleLayoutEmit();
return;
}
@@ -381,7 +447,8 @@ function StrategyEditorCanvasInner({
onChange(addChild(root, anchorId, newNode));
saveAttachHandles(anchorId);
}, [root, signalTab, def, onChange]);
scheduleLayoutEmit();
}, [root, signalTab, def, onChange, scheduleLayoutEmit]);
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
updateDropPreview(anchorId, flowPos);
@@ -425,20 +492,8 @@ function StrategyEditorCanvasInner({
orphans,
), [root, def, signalTab, flowCallbacks, orphans]);
useEffect(() => {
positionsRef.current = new Map();
edgeHandlesRef.current = new Map();
structureKeyRef.current = '';
orphanKeyRef.current = '';
onOrphansChange([]);
}, [signalTab, onOrphansChange]);
// 트리 구조 변경 → 전체 재배치 / 조건 편집 → 라벨만 갱신(위치 유지)
useEffect(() => {
for (const n of nodesRef.current) {
positionsRef.current.set(n.id, n.position);
}
const { nodes: layoutNodes, edges: layoutEdges } = rebuildFlow();
const mergedEdges = applyEdgeHandles(layoutEdges, positionsRef.current, edgeHandlesRef.current);
pruneEdgeHandles(edgeHandlesRef.current, mergedEdges);
@@ -449,13 +504,25 @@ function StrategyEditorCanvasInner({
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
const prev = nodesRef.current.find(p => p.id === n.id);
return { ...n, selected: prev?.selected ?? false };
const savedPos = layoutSeed.positions[n.id];
return {
...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : n.position,
selected: prev?.selected ?? false,
};
}));
if (structureChanged || orphansChanged) {
const hasSavedLayout = layoutNodes.some(
n => n.id !== START_NODE_ID && layoutSeed.positions[n.id] != null,
);
const needsNodeSync = layoutNodes.length !== nodesRef.current.length
|| !nodesRef.current.some(n => n.id === START_NODE_ID);
if (structureChanged || orphansChanged || needsNodeSync) {
syncNodesFromLayout();
setEdges(mergedEdges);
if (structureChanged) {
if (structureChanged && !hasSavedLayout) {
requestAnimationFrame(() => {
fitView({ padding: 0.4, duration: 200 });
});
@@ -464,11 +531,16 @@ function StrategyEditorCanvasInner({
setNodes(prev => prev.map(n => {
const fresh = layoutNodes.find(ln => ln.id === n.id);
if (!fresh) return n;
return { ...n, data: fresh.data };
const savedPos = layoutSeed.positions[n.id];
return {
...n,
position: savedPos ? { x: savedPos.x, y: savedPos.y } : fresh.position,
data: fresh.data,
};
}));
setEdges(mergedEdges);
}
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView]);
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView, layoutSeed]);
const handleDisconnectEdge = useCallback((edgeId: string) => {
const edge = edgesRef.current.find(e => e.id === edgeId);
@@ -492,10 +564,22 @@ function StrategyEditorCanvasInner({
useEffect(() => {
edgeDisconnectRef.current = handleDisconnectEdge;
edgeBindingUpdateRef.current = (edgeId, patch) => {
const prev = edgeHandlesRef.current.get(edgeId) ?? {};
const next: EdgeHandleBinding = { ...prev, ...patch };
if (patch.sourceHandle != null) next.manualSource = true;
if (patch.targetHandle != null) next.manualTarget = true;
if (patch.centerX != null || patch.centerY != null) next.manualCenter = true;
if (next.manualSource && next.manualTarget) next.manual = true;
edgeHandlesRef.current.set(edgeId, next);
setEdges(prevEdges => applyEdgeHandles(prevEdges, positionsRef.current, edgeHandlesRef.current));
scheduleLayoutEmit();
};
return () => {
edgeDisconnectRef.current = null;
edgeBindingUpdateRef.current = null;
};
}, [handleDisconnectEdge]);
}, [handleDisconnectEdge, setEdges, scheduleLayoutEmit]);
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
@@ -530,17 +614,20 @@ function StrategyEditorCanvasInner({
if (childOrphan) {
onOrphansChange(orphans.filter(o => o.id !== childId));
connectOrphanIntoTree(wired, childOrphan, root, onChange);
scheduleLayoutEmit();
return;
}
if (parentOrphan) {
onOrphansChange(orphans.filter(o => o.id !== parentId));
connectOrphanAsParent(wired, parentOrphan, root, onChange);
scheduleLayoutEmit();
return;
}
if (!root) return;
applyTreeConnection(parentId, childId, root, onChange);
}, [root, orphans, onChange, onOrphansChange]);
scheduleLayoutEmit();
}, [root, orphans, onChange, onOrphansChange, scheduleLayoutEmit]);
const onNodesChange: OnNodesChange = useCallback((changes) => {
const positionChanges = changes.filter(
@@ -565,7 +652,7 @@ function StrategyEditorCanvasInner({
onNodesChangeBase(changes);
let dragEnded = false;
let positionsChanged = false;
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
@@ -584,7 +671,7 @@ function StrategyEditorCanvasInner({
const p = positionsRef.current.get(n.id);
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
}));
if (startChange?.dragging === false) dragEnded = true;
positionsChanged = true;
}
for (const ch of positionChanges) {
@@ -595,6 +682,7 @@ function StrategyEditorCanvasInner({
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
if (ch.dragging === true) {
positionsChanged = true;
const nodesSnapshot = nodesRef.current.map(n => (
n.id === ch.id
? { id: n.id, position: ch.position! }
@@ -625,17 +713,18 @@ function StrategyEditorCanvasInner({
);
applyResolvedConnection(pending.parentId, pending.childId, conn);
}
dragEnded = true;
positionsChanged = true;
}
continue;
}
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
if (ch.dragging === false) dragEnded = true;
positionsChanged = true;
}
if (dragEnded) {
if (positionsChanged) {
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
scheduleLayoutEmit();
}
}, [
onNodesChangeBase,
@@ -647,6 +736,7 @@ function StrategyEditorCanvasInner({
clearDropPreview,
updateOrphanConnectPreview,
applyResolvedConnection,
scheduleLayoutEmit,
]);
const onConnect = useCallback((conn: Connection) => {
@@ -667,16 +757,20 @@ function StrategyEditorCanvasInner({
return;
}
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection);
setEdges(prev => prev.map(e => (
e.id === oldEdge.id
? {
...e,
sourceHandle: newConnection.sourceHandle,
targetHandle: newConnection.targetHandle,
}
: e
)));
saveEdgeHandles(edgeHandlesRef.current, `${resolved.parentId}-${resolved.childId}`, newConnection, true);
setEdges(prev => applyEdgeHandles(
prev.map(e => (
e.id === oldEdge.id
? {
...e,
sourceHandle: newConnection.sourceHandle,
targetHandle: newConnection.targetHandle,
}
: e
)),
positionsRef.current,
edgeHandlesRef.current,
));
}, [root, orphans, applyResolvedConnection, setEdges]);
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
@@ -1,8 +1,30 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react';
import { edgeDisconnectRef } from './strategyEditorCallbacks';
import React, { useCallback, useRef, useState } from 'react';
import {
BaseEdge,
EdgeLabelRenderer,
getSmoothStepPath,
useReactFlow,
type EdgeProps,
} from '@xyflow/react';
import { bindWindowDrag } from '../../utils/pointerDrag';
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
import {
buildPositionsFromNodes,
getEdgeDragZone,
pickSourceHandleAtPoint,
pickTargetHandleAtPoint,
type EdgeDragZone,
} from '../../utils/strategyEdgeInteraction';
import { edgeBindingUpdateRef, edgeDisconnectRef } from './strategyEditorCallbacks';
type StrategyEdgeData = {
binding?: EdgeHandleBinding;
};
export function StrategyFlowEdge({
id,
source,
target,
sourceX,
sourceY,
targetX,
@@ -10,7 +32,12 @@ export function StrategyFlowEdge({
sourcePosition,
targetPosition,
selected,
data,
}: EdgeProps) {
const { screenToFlowPosition, getNodes } = useReactFlow();
const binding = (data as StrategyEdgeData | undefined)?.binding;
const hasCenter = binding?.manualCenter && binding.centerX != null && binding.centerY != null;
const [edgePath, labelX, labelY] = getSmoothStepPath({
sourceX,
sourceY,
@@ -18,15 +45,147 @@ export function StrategyFlowEdge({
targetY,
sourcePosition,
targetPosition,
...(hasCenter ? { centerX: binding!.centerX!, centerY: binding!.centerY! } : {}),
});
const dragRef = useRef<{
zone: EdgeDragZone;
startCenterX: number;
startCenterY: number;
} | null>(null);
const unbindRef = useRef<(() => void) | null>(null);
const [dragZone, setDragZone] = useState<EdgeDragZone | null>(null);
const commitBinding = useCallback((patch: EdgeHandleBinding) => {
edgeBindingUpdateRef.current?.(id, patch);
}, [id]);
const positionsFromFlow = useCallback(() => {
return buildPositionsFromNodes(getNodes());
}, [getNodes]);
const onHitPointerDown = useCallback((e: React.PointerEvent<SVGPathElement>) => {
if (e.button !== 0) return;
if ((e.target as Element).closest('.se-flow-edge-del')) return;
e.preventDefault();
e.stopPropagation();
const flow = screenToFlowPosition({ x: e.clientX, y: e.clientY });
const zone = getEdgeDragZone(sourceX, sourceY, targetX, targetY, flow.x, flow.y);
setDragZone(zone);
try {
(e.currentTarget as SVGPathElement).setPointerCapture(e.pointerId);
} catch { /* ignore */ }
const positions = positionsFromFlow();
const startCenterX = binding?.centerX ?? labelX;
const startCenterY = binding?.centerY ?? labelY;
dragRef.current = { zone, startCenterX, startCenterY };
if (zone === 'source') {
commitBinding({
sourceHandle: pickSourceHandleAtPoint(source, flow, positions),
manualSource: true,
});
} else if (zone === 'target') {
commitBinding({
targetHandle: pickTargetHandleAtPoint(target, flow, positions),
manualTarget: true,
});
} else {
commitBinding({
centerX: flow.x,
centerY: flow.y,
manualCenter: true,
});
}
unbindRef.current?.();
unbindRef.current = bindWindowDrag(
({ x, y }) => {
const d = dragRef.current;
if (!d) return;
const pos = screenToFlowPosition({ x, y });
const positions = positionsFromFlow();
if (d.zone === 'source') {
commitBinding({
sourceHandle: pickSourceHandleAtPoint(source, pos, positions),
manualSource: true,
});
} else if (d.zone === 'target') {
commitBinding({
targetHandle: pickTargetHandleAtPoint(target, pos, positions),
manualTarget: true,
});
} else {
commitBinding({
centerX: pos.x,
centerY: pos.y,
manualCenter: true,
});
}
},
() => {
dragRef.current = null;
setDragZone(null);
unbindRef.current = null;
},
);
}, [
binding?.centerX,
binding?.centerY,
commitBinding,
labelX,
labelY,
positionsFromFlow,
screenToFlowPosition,
source,
sourceX,
sourceY,
target,
targetX,
targetY,
]);
const onHitPointerUp = useCallback((e: React.PointerEvent<SVGPathElement>) => {
dragRef.current = null;
setDragZone(null);
unbindRef.current?.();
unbindRef.current = null;
try {
(e.currentTarget as SVGPathElement).releasePointerCapture(e.pointerId);
} catch { /* ignore */ }
}, []);
const hitCursor = dragZone === 'source'
? 'grab'
: dragZone === 'target'
? 'grab'
: dragZone === 'middle'
? 'move'
: 'pointer';
return (
<>
<BaseEdge
id={id}
path={edgePath}
className={`se-flow-edge${selected ? ' se-flow-edge--selected' : ''}`}
style={{ strokeWidth: selected ? 3 : 2 }}
style={{ strokeWidth: selected ? 3 : 2, pointerEvents: 'none' }}
/>
<path
d={edgePath}
fill="none"
stroke="transparent"
strokeWidth={22}
className={`se-flow-edge-hit${dragZone ? ` se-flow-edge-hit--${dragZone}` : ''}`}
style={{ cursor: hitCursor }}
onPointerDown={onHitPointerDown}
onPointerUp={onHitPointerUp}
onPointerCancel={onHitPointerUp}
/>
{selected && edgeDisconnectRef.current && (
<EdgeLabelRenderer>
@@ -39,8 +198,8 @@ export function StrategyFlowEdge({
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
pointerEvents: 'all',
}}
onClick={(e) => {
e.stopPropagation();
onClick={(ev) => {
ev.stopPropagation();
edgeDisconnectRef.current?.(id);
}}
>
@@ -1,4 +1,16 @@
import type { EdgeHandleBinding } from '../../utils/strategyFlowLayout';
/** 연결선 끊기 — 커스텀 엣지 컴포넌트에서 캔버스 핸들러 참조 */
export const edgeDisconnectRef = {
current: null as ((edgeId: string) => void) | null,
};
/** 연결선 핸들·경로 바인딩 갱신 */
export const edgeBindingUpdateRef = {
current: null as ((edgeId: string, patch: Partial<EdgeHandleBinding>) => void) | null,
};
/** 탭 전환 직전 — 현재 탭 레이아웃 즉시 저장 */
export const layoutFlushRef = {
current: null as (() => void) | null,
};