전략편집기 수정

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');