전략편집기 메뉴 추가
This commit is contained in:
@@ -0,0 +1,820 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
useReactFlow,
|
||||
ConnectionMode,
|
||||
SelectionMode,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type OnNodesChange,
|
||||
type OnSelectionChangeFunc,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import type { Theme } from '../../types/index';
|
||||
import type { LogicNode } from '../../utils/strategyTypes';
|
||||
import {
|
||||
addChild,
|
||||
deleteNode,
|
||||
mergeAtRoot,
|
||||
makeNode,
|
||||
type DefType,
|
||||
} from '../../utils/strategyEditorShared';
|
||||
import {
|
||||
START_NODE_ID,
|
||||
applyEdgeHandles,
|
||||
buildConnectionFromPositions,
|
||||
canAcceptPaletteAsParent,
|
||||
connectionSidesFromDrop,
|
||||
disconnectTreeEdge,
|
||||
extractNode,
|
||||
findNodeAtFlowPosition,
|
||||
findNodeInTree,
|
||||
FLOW_NODE_H,
|
||||
FLOW_NODE_W,
|
||||
getNodeDimensions,
|
||||
isDescendant,
|
||||
isOrphanNode,
|
||||
isPaletteConnectTarget,
|
||||
isValidStrategyConnection,
|
||||
resolveStrategyConnection,
|
||||
resolveOrphanDragConnection,
|
||||
logicNodeToFlow,
|
||||
spawnPositionNearAnchor,
|
||||
type EdgeHandleBinding,
|
||||
} from '../../utils/strategyFlowLayout';
|
||||
import { ConditionFlowNode, LogicGateNode, StartNode } from './FlowNodes';
|
||||
import { StrategyFlowEdge } from './StrategyFlowEdge';
|
||||
import { edgeDisconnectRef } from './strategyEditorCallbacks';
|
||||
import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton';
|
||||
import { getMinimapColors } from './minimapTheme';
|
||||
|
||||
const nodeTypes = {
|
||||
start: StartNode,
|
||||
logic: LogicGateNode,
|
||||
condition: ConditionFlowNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
strategy: StrategyFlowEdge,
|
||||
};
|
||||
|
||||
interface Props {
|
||||
theme: Theme;
|
||||
root: LogicNode | null;
|
||||
orphans: LogicNode[];
|
||||
onOrphansChange: (nodes: LogicNode[]) => void;
|
||||
def: DefType;
|
||||
signalTab: 'buy' | 'sell';
|
||||
onChange: (root: LogicNode | null) => void;
|
||||
selectedNodeId: string | null;
|
||||
onSelectNode: (id: string | null) => void;
|
||||
}
|
||||
|
||||
function collectTreeIds(node: LogicNode | null): string[] {
|
||||
if (!node) return [];
|
||||
const ids = [node.id];
|
||||
for (const c of node.children ?? []) ids.push(...collectTreeIds(c));
|
||||
return ids;
|
||||
}
|
||||
|
||||
function saveEdgeHandles(
|
||||
handles: Map<string, EdgeHandleBinding>,
|
||||
edgeId: string,
|
||||
conn: Pick<Connection, 'sourceHandle' | 'targetHandle'>,
|
||||
manual = true,
|
||||
) {
|
||||
handles.set(edgeId, {
|
||||
sourceHandle: conn.sourceHandle,
|
||||
targetHandle: conn.targetHandle,
|
||||
manual,
|
||||
});
|
||||
}
|
||||
|
||||
function applyTreeConnection(
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
root: LogicNode,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
) {
|
||||
if (sourceId === START_NODE_ID) {
|
||||
const { tree, node } = extractNode(root, targetId);
|
||||
if (!node) return;
|
||||
onChange(node);
|
||||
if (tree && tree.children?.length) {
|
||||
onChange(mergeAtRoot(node, tree, false));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceNode = findNodeInTree(root, sourceId);
|
||||
if (!sourceNode || !['AND', 'OR', 'NOT'].includes(sourceNode.type)) return;
|
||||
if (isDescendant(root, targetId, sourceId)) return;
|
||||
|
||||
const { tree: afterRemove, node: moved } = extractNode(root, targetId);
|
||||
if (!moved) return;
|
||||
const base = afterRemove ?? root;
|
||||
if (sourceId === targetId) return;
|
||||
onChange(addChild(base, sourceId, moved));
|
||||
}
|
||||
|
||||
function pruneEdgeHandles(handles: Map<string, EdgeHandleBinding>, edges: Edge[]) {
|
||||
const ids = new Set(edges.map(e => e.id));
|
||||
for (const id of handles.keys()) {
|
||||
if (!ids.has(id)) handles.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function connectOrphanIntoTree(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
): void {
|
||||
const sourceId = conn.source!;
|
||||
if (sourceId === START_NODE_ID) {
|
||||
onChange(root ? mergeAtRoot(root, orphan, false) : orphan);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
onChange(addChild(root, sourceId, orphan));
|
||||
}
|
||||
|
||||
function connectOrphanAsParent(
|
||||
conn: Connection,
|
||||
orphan: LogicNode,
|
||||
root: LogicNode | null,
|
||||
onChange: (root: LogicNode | null) => void,
|
||||
): void {
|
||||
if (!root) return;
|
||||
const { tree, node: moved } = extractNode(root, conn.target!);
|
||||
if (!moved) return;
|
||||
const isOp = ['AND', 'OR', 'NOT'].includes(orphan.type);
|
||||
const combined = { ...orphan, children: [...(orphan.children ?? []), moved] };
|
||||
onChange(tree ? mergeAtRoot(tree, combined, isOp) : combined);
|
||||
}
|
||||
|
||||
function StrategyEditorCanvasInner({
|
||||
theme,
|
||||
root,
|
||||
orphans,
|
||||
onOrphansChange,
|
||||
def,
|
||||
signalTab,
|
||||
onChange,
|
||||
selectedNodeId,
|
||||
onSelectNode,
|
||||
}: 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 [nodes, setNodes, onNodesChangeBase] = useNodesState<Node>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const nodesRef = useRef(nodes);
|
||||
const edgesRef = useRef(edges);
|
||||
nodesRef.current = nodes;
|
||||
edgesRef.current = edges;
|
||||
|
||||
const treeStructureKey = useMemo(
|
||||
() => `${signalTab}:${collectTreeIds(root).join('|')}`,
|
||||
[root, signalTab],
|
||||
);
|
||||
const orphanKey = useMemo(() => orphans.map(o => o.id).join('|'), [orphans]);
|
||||
|
||||
/** START에서 연결된 트리 전체 (미연결 고아 제외) */
|
||||
const treeLinkedIds = useMemo(() => {
|
||||
const ids = new Set<string>([START_NODE_ID]);
|
||||
for (const id of collectTreeIds(root)) ids.add(id);
|
||||
return ids;
|
||||
}, [root]);
|
||||
|
||||
const minimapColors = useMemo(() => getMinimapColors(theme, signalTab), [theme, signalTab]);
|
||||
|
||||
const minimapNodeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
if (node.type === 'condition') return minimapColors.condition;
|
||||
return minimapColors.logic;
|
||||
}, [minimapColors]);
|
||||
|
||||
const minimapNodeStrokeColor = useCallback((node: Node) => {
|
||||
if (node.id === START_NODE_ID) return minimapColors.start;
|
||||
return minimapColors.stroke;
|
||||
}, [minimapColors]);
|
||||
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
if (id === START_NODE_ID) return;
|
||||
if (isOrphanNode(orphans, id)) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== id));
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
return;
|
||||
}
|
||||
if (!root) return;
|
||||
if (root.id === id) {
|
||||
onChange(null);
|
||||
onSelectNode(null);
|
||||
positionsRef.current.delete(id);
|
||||
return;
|
||||
}
|
||||
const next = deleteNode(root, id);
|
||||
onChange(next);
|
||||
positionsRef.current.delete(id);
|
||||
if (selectedNodeId === id) onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, selectedNodeId]);
|
||||
|
||||
const deleteSelectedNodes = useCallback(() => {
|
||||
const ids = nodesRef.current
|
||||
.filter(n => n.selected && n.id !== START_NODE_ID)
|
||||
.map(n => n.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
const orphanIds = new Set(ids.filter(id => isOrphanNode(orphans, id)));
|
||||
if (orphanIds.size) {
|
||||
onOrphansChange(orphans.filter(o => !orphanIds.has(o.id)));
|
||||
orphanIds.forEach(id => positionsRef.current.delete(id));
|
||||
}
|
||||
|
||||
const treeIds = ids.filter(id => !orphanIds.has(id));
|
||||
if (treeIds.length && root) {
|
||||
let tree: LogicNode | null = root;
|
||||
for (const id of treeIds) {
|
||||
if (!tree) break;
|
||||
if (tree.id === id) {
|
||||
tree = null;
|
||||
positionsRef.current.delete(id);
|
||||
continue;
|
||||
}
|
||||
const next = deleteNode(tree, id);
|
||||
if (next !== tree) positionsRef.current.delete(id);
|
||||
tree = next;
|
||||
}
|
||||
onChange(tree);
|
||||
}
|
||||
onSelectNode(null);
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode]);
|
||||
|
||||
const clearDropPreview = useCallback(() => {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: false,
|
||||
activeSourceSide: undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const updateDropPreview = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
const canParent = canAcceptPaletteAsParent(anchorId, root);
|
||||
if (!canParent) {
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: { ...n.data, dragOver: false, activeSourceSide: undefined },
|
||||
})));
|
||||
return;
|
||||
}
|
||||
|
||||
const dim = getNodeDimensions(anchorId);
|
||||
const { sourceSide } = connectionSidesFromDrop(anchor.position, dim, flowPos);
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: n.id === anchorId,
|
||||
activeSourceSide: n.id === anchorId ? sourceSide : undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes, root]);
|
||||
|
||||
const orphanDragTargetRef = useRef<{ parentId: string; childId: string } | null>(null);
|
||||
|
||||
const updateOrphanConnectPreview = useCallback((
|
||||
resolved: { parentId: string; childId: string },
|
||||
draggedOrphanId: string,
|
||||
orphanPos: { x: number; y: number },
|
||||
) => {
|
||||
const { parentId, childId } = resolved;
|
||||
const nodesSnapshot = nodesRef.current;
|
||||
const parentNode = nodesSnapshot.find(n => n.id === parentId);
|
||||
const childNode = nodesSnapshot.find(n => n.id === childId);
|
||||
if (!parentNode || !childNode) return;
|
||||
|
||||
const parentDim = getNodeDimensions(parentId);
|
||||
const childDim = getNodeDimensions(childId);
|
||||
const parentPos = parentId === draggedOrphanId ? orphanPos : parentNode.position;
|
||||
const childPos = childId === draggedOrphanId ? orphanPos : childNode.position;
|
||||
const childCenter = {
|
||||
x: childPos.x + childDim.w / 2,
|
||||
y: childPos.y + childDim.h / 2,
|
||||
};
|
||||
const { sourceSide } = connectionSidesFromDrop(parentPos, parentDim, childCenter);
|
||||
|
||||
setNodes(nds => nds.map(n => ({
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
dragOver: n.id === parentId,
|
||||
activeSourceSide: n.id === parentId ? sourceSide : undefined,
|
||||
},
|
||||
})));
|
||||
}, [setNodes]);
|
||||
|
||||
const addOrphanAt = useCallback((
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
positionsRef.current.set(newNode.id, {
|
||||
x: flowPos.x - FLOW_NODE_W / 2,
|
||||
y: flowPos.y - FLOW_NODE_H / 2,
|
||||
});
|
||||
onOrphansChange([...orphans, newNode]);
|
||||
}, [orphans, onOrphansChange, signalTab, def]);
|
||||
|
||||
const applyDropWithAttach = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
const newNode = makeNode(data.type, data.value, signalTab, def);
|
||||
const anchor = nodesRef.current.find(n => n.id === anchorId);
|
||||
if (!anchor) return;
|
||||
|
||||
const anchorDim = getNodeDimensions(anchorId);
|
||||
const { sourceHandle, targetHandle, sourceSide } = connectionSidesFromDrop(anchor.position, anchorDim, flowPos);
|
||||
positionsRef.current.set(newNode.id, spawnPositionNearAnchor(anchor.position, anchorDim, sourceSide));
|
||||
|
||||
const saveAttachHandles = (parentId: string) => {
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${newNode.id}`, { sourceHandle, targetHandle });
|
||||
};
|
||||
|
||||
if (!root) {
|
||||
onChange(newNode);
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
return;
|
||||
}
|
||||
if (anchorId === START_NODE_ID) {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
saveAttachHandles(START_NODE_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorNode = findNodeInTree(root, anchorId);
|
||||
if (anchorNode?.type === 'CONDITION') {
|
||||
onChange(mergeAtRoot(root, newNode, data.type === 'operator'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canAcceptPaletteAsParent(anchorId, root)) return;
|
||||
|
||||
onChange(addChild(root, anchorId, newNode));
|
||||
saveAttachHandles(anchorId);
|
||||
}, [root, signalTab, def, onChange]);
|
||||
|
||||
const handleDragOverTarget = useCallback((anchorId: string, flowPos: { x: number; y: number }) => {
|
||||
updateDropPreview(anchorId, flowPos);
|
||||
}, [updateDropPreview]);
|
||||
|
||||
const handleDragLeaveTarget = useCallback((anchorId: string) => {
|
||||
setNodes(nds => nds.map(n => (
|
||||
n.id === anchorId
|
||||
? { ...n, data: { ...n.data, dragOver: false, activeSourceSide: undefined } }
|
||||
: n
|
||||
)));
|
||||
}, [setNodes]);
|
||||
|
||||
const handleDropTarget = useCallback((
|
||||
anchorId: string,
|
||||
data: { type: string; value: string; label: string },
|
||||
flowPos: { x: number; y: number },
|
||||
) => {
|
||||
clearDropPreview();
|
||||
if (isPaletteConnectTarget(anchorId, root, orphans)) {
|
||||
applyDropWithAttach(anchorId, data, flowPos);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
}, [clearDropPreview, applyDropWithAttach, addOrphanAt, root, orphans]);
|
||||
|
||||
const flowCallbacks = useMemo(() => ({
|
||||
onDelete: handleDelete,
|
||||
onDropTarget: handleDropTarget,
|
||||
onDragOverTarget: handleDragOverTarget,
|
||||
onDragLeaveTarget: handleDragLeaveTarget,
|
||||
}), [handleDelete, handleDropTarget, handleDragOverTarget, handleDragLeaveTarget]);
|
||||
|
||||
const rebuildFlow = useCallback(() => logicNodeToFlow(
|
||||
root,
|
||||
def,
|
||||
signalTab,
|
||||
positionsRef.current,
|
||||
flowCallbacks,
|
||||
null,
|
||||
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);
|
||||
const structureChanged = structureKeyRef.current !== treeStructureKey;
|
||||
const orphansChanged = orphanKeyRef.current !== orphanKey;
|
||||
structureKeyRef.current = treeStructureKey;
|
||||
orphanKeyRef.current = orphanKey;
|
||||
|
||||
const syncNodesFromLayout = () => setNodes(layoutNodes.map(n => {
|
||||
const prev = nodesRef.current.find(p => p.id === n.id);
|
||||
return { ...n, selected: prev?.selected ?? false };
|
||||
}));
|
||||
|
||||
if (structureChanged || orphansChanged) {
|
||||
syncNodesFromLayout();
|
||||
setEdges(mergedEdges);
|
||||
if (structureChanged) {
|
||||
requestAnimationFrame(() => {
|
||||
fitView({ padding: 0.4, duration: 200 });
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setNodes(prev => prev.map(n => {
|
||||
const fresh = layoutNodes.find(ln => ln.id === n.id);
|
||||
if (!fresh) return n;
|
||||
return { ...n, data: fresh.data };
|
||||
}));
|
||||
setEdges(mergedEdges);
|
||||
}
|
||||
}, [treeStructureKey, orphanKey, root, orphans, def, rebuildFlow, setNodes, setEdges, fitView]);
|
||||
|
||||
const handleDisconnectEdge = useCallback((edgeId: string) => {
|
||||
const edge = edgesRef.current.find(e => e.id === edgeId);
|
||||
if (!edge?.source || !edge.target) return;
|
||||
|
||||
edgeHandlesRef.current.delete(edgeId);
|
||||
const { root: nextRoot, orphans: nextOrphans } = disconnectTreeEdge(
|
||||
edge.source,
|
||||
edge.target,
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
|
||||
if (nextRoot === root && nextOrphans.length === orphans.length) return;
|
||||
|
||||
onChange(nextRoot);
|
||||
onOrphansChange(nextOrphans);
|
||||
onSelectNode(null);
|
||||
setEdges(prev => prev.map(e => ({ ...e, selected: false })));
|
||||
}, [root, orphans, onChange, onOrphansChange, onSelectNode, setEdges]);
|
||||
|
||||
useEffect(() => {
|
||||
edgeDisconnectRef.current = handleDisconnectEdge;
|
||||
return () => {
|
||||
edgeDisconnectRef.current = null;
|
||||
};
|
||||
}, [handleDisconnectEdge]);
|
||||
|
||||
const onSelectionChange: OnSelectionChangeFunc = useCallback(({ nodes: selNodes, edges: selEdges }) => {
|
||||
const picked = selNodes.filter(n => n.id !== START_NODE_ID);
|
||||
if (picked.length === 1) onSelectNode(picked[0].id);
|
||||
else onSelectNode(null);
|
||||
if (selEdges.length === 1) {
|
||||
// 연결선만 선택된 경우 노드 선택 해제
|
||||
if (picked.length === 0) onSelectNode(null);
|
||||
}
|
||||
}, [onSelectNode]);
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(conn: Connection | Edge) => isValidStrategyConnection(
|
||||
{ source: conn.source, target: conn.target, sourceHandle: conn.sourceHandle ?? null, targetHandle: conn.targetHandle ?? null },
|
||||
root,
|
||||
orphans,
|
||||
),
|
||||
[root, orphans],
|
||||
);
|
||||
|
||||
const applyResolvedConnection = useCallback((
|
||||
parentId: string,
|
||||
childId: string,
|
||||
conn: Connection,
|
||||
) => {
|
||||
const wired: Connection = { ...conn, source: parentId, target: childId };
|
||||
saveEdgeHandles(edgeHandlesRef.current, `${parentId}-${childId}`, wired);
|
||||
|
||||
const childOrphan = orphans.find(o => o.id === childId);
|
||||
const parentOrphan = orphans.find(o => o.id === parentId);
|
||||
|
||||
if (childOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== childId));
|
||||
connectOrphanIntoTree(wired, childOrphan, root, onChange);
|
||||
return;
|
||||
}
|
||||
if (parentOrphan) {
|
||||
onOrphansChange(orphans.filter(o => o.id !== parentId));
|
||||
connectOrphanAsParent(wired, parentOrphan, root, onChange);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!root) return;
|
||||
applyTreeConnection(parentId, childId, root, onChange);
|
||||
}, [root, orphans, onChange, onOrphansChange]);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback((changes) => {
|
||||
const positionChanges = changes.filter(
|
||||
(ch): ch is Extract<typeof ch, { type: 'position' }> => ch.type === 'position' && !!ch.position,
|
||||
);
|
||||
const startChange = positionChanges.find(ch => ch.id === START_NODE_ID);
|
||||
const otherTreePositionChanges = positionChanges.filter(
|
||||
ch => ch.id !== START_NODE_ID && treeLinkedIds.has(ch.id),
|
||||
);
|
||||
|
||||
let startGroupDelta: { dx: number; dy: number } | null = null;
|
||||
const startPos = startChange?.position;
|
||||
if (startChange && startPos && otherTreePositionChanges.length === 0) {
|
||||
const prev = positionsRef.current.get(START_NODE_ID);
|
||||
if (prev) {
|
||||
startGroupDelta = {
|
||||
dx: startPos.x - prev.x,
|
||||
dy: startPos.y - prev.y,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
onNodesChangeBase(changes);
|
||||
|
||||
let dragEnded = false;
|
||||
|
||||
if (startGroupDelta && startPos && (startGroupDelta.dx !== 0 || startGroupDelta.dy !== 0)) {
|
||||
positionsRef.current.set(START_NODE_ID, { x: startPos.x, y: startPos.y });
|
||||
for (const id of treeLinkedIds) {
|
||||
if (id === START_NODE_ID) continue;
|
||||
const p = positionsRef.current.get(id);
|
||||
if (p) {
|
||||
positionsRef.current.set(id, {
|
||||
x: p.x + startGroupDelta.dx,
|
||||
y: p.y + startGroupDelta.dy,
|
||||
});
|
||||
}
|
||||
}
|
||||
setNodes(nds => nds.map(n => {
|
||||
if (!treeLinkedIds.has(n.id)) return n;
|
||||
const p = positionsRef.current.get(n.id);
|
||||
return p ? { ...n, position: { x: p.x, y: p.y } } : n;
|
||||
}));
|
||||
if (startChange?.dragging === false) dragEnded = true;
|
||||
}
|
||||
|
||||
for (const ch of positionChanges) {
|
||||
if (!ch.position) continue;
|
||||
if (startGroupDelta && treeLinkedIds.has(ch.id)) continue;
|
||||
|
||||
if (isOrphanNode(orphans, ch.id)) {
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
|
||||
if (ch.dragging === true) {
|
||||
const nodesSnapshot = nodesRef.current.map(n => (
|
||||
n.id === ch.id
|
||||
? { id: n.id, position: ch.position! }
|
||||
: { id: n.id, position: n.position }
|
||||
));
|
||||
const resolved = resolveOrphanDragConnection(
|
||||
ch.id,
|
||||
ch.position,
|
||||
nodesSnapshot,
|
||||
root,
|
||||
orphans,
|
||||
);
|
||||
orphanDragTargetRef.current = resolved;
|
||||
if (resolved) {
|
||||
updateOrphanConnectPreview(resolved, ch.id, ch.position);
|
||||
} else {
|
||||
clearDropPreview();
|
||||
}
|
||||
} else if (ch.dragging === false) {
|
||||
const pending = orphanDragTargetRef.current;
|
||||
orphanDragTargetRef.current = null;
|
||||
clearDropPreview();
|
||||
if (pending) {
|
||||
const conn = buildConnectionFromPositions(
|
||||
pending.parentId,
|
||||
pending.childId,
|
||||
positionsRef.current,
|
||||
);
|
||||
applyResolvedConnection(pending.parentId, pending.childId, conn);
|
||||
}
|
||||
dragEnded = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
positionsRef.current.set(ch.id, { x: ch.position.x, y: ch.position.y });
|
||||
if (ch.dragging === false) dragEnded = true;
|
||||
}
|
||||
|
||||
if (dragEnded) {
|
||||
setEdges(prev => applyEdgeHandles(prev, positionsRef.current, edgeHandlesRef.current));
|
||||
}
|
||||
}, [
|
||||
onNodesChangeBase,
|
||||
setNodes,
|
||||
setEdges,
|
||||
treeLinkedIds,
|
||||
orphans,
|
||||
root,
|
||||
clearDropPreview,
|
||||
updateOrphanConnectPreview,
|
||||
applyResolvedConnection,
|
||||
]);
|
||||
|
||||
const onConnect = useCallback((conn: Connection) => {
|
||||
const resolved = resolveStrategyConnection(conn, root, orphans);
|
||||
if (!resolved) return;
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, conn);
|
||||
}, [root, orphans, applyResolvedConnection]);
|
||||
|
||||
const onReconnect = useCallback((oldEdge: Edge, newConnection: Connection) => {
|
||||
const resolved = resolveStrategyConnection(newConnection, root, orphans);
|
||||
if (!resolved) return;
|
||||
|
||||
edgeHandlesRef.current.delete(oldEdge.id);
|
||||
|
||||
const endpointsChanged = oldEdge.source !== resolved.parentId || oldEdge.target !== resolved.childId;
|
||||
if (endpointsChanged) {
|
||||
applyResolvedConnection(resolved.parentId, resolved.childId, newConnection);
|
||||
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
|
||||
)));
|
||||
}, [root, orphans, applyResolvedConnection, setEdges]);
|
||||
|
||||
const isPaletteDrag = (e: React.DragEvent) => e.dataTransfer.types.includes('application/json');
|
||||
|
||||
const resolvePaletteDrop = useCallback((flowPos: { x: number; y: number }) => {
|
||||
const hit = findNodeAtFlowPosition(flowPos, nodesRef.current);
|
||||
if (hit && isPaletteConnectTarget(hit.id, root, orphans)) {
|
||||
return { mode: 'connect' as const, anchorId: hit.id };
|
||||
}
|
||||
return { mode: 'orphan' as const };
|
||||
}, [root, orphans]);
|
||||
|
||||
const onPaneDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
clearDropPreview();
|
||||
try {
|
||||
const data = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
const flowPos = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
const drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
applyDropWithAttach(drop.anchorId, data, flowPos);
|
||||
} else {
|
||||
addOrphanAt(data, flowPos);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [clearDropPreview, screenToFlowPosition, applyDropWithAttach, addOrphanAt, resolvePaletteDrop]);
|
||||
|
||||
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 drop = resolvePaletteDrop(flowPos);
|
||||
if (drop.mode === 'connect') {
|
||||
updateDropPreview(drop.anchorId, flowPos);
|
||||
} else {
|
||||
clearDropPreview();
|
||||
}
|
||||
}, [screenToFlowPosition, resolvePaletteDrop, updateDropPreview, clearDropPreview]);
|
||||
|
||||
const onPaneDragLeave = useCallback((e: React.DragEvent) => {
|
||||
const rel = e.relatedTarget as Element | null;
|
||||
if (rel && e.currentTarget.contains(rel)) return;
|
||||
clearDropPreview();
|
||||
}, [clearDropPreview]);
|
||||
|
||||
const onKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'Delete' && e.key !== 'Backspace') return;
|
||||
|
||||
const selectedEdge = edgesRef.current.find(ed => ed.selected);
|
||||
if (selectedEdge) {
|
||||
e.preventDefault();
|
||||
handleDisconnectEdge(selectedEdge.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCount = nodesRef.current.filter(n => n.selected && n.id !== START_NODE_ID).length;
|
||||
if (selectedCount > 1) {
|
||||
e.preventDefault();
|
||||
deleteSelectedNodes();
|
||||
} else if (selectedNodeId && selectedNodeId !== START_NODE_ID) {
|
||||
e.preventDefault();
|
||||
handleDelete(selectedNodeId);
|
||||
}
|
||||
}, [selectedNodeId, handleDelete, deleteSelectedNodes, handleDisconnectEdge]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`se-canvas-wrap se-canvas-wrap--${signalTab}`}
|
||||
onKeyDown={onKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onReconnect={onReconnect}
|
||||
onDrop={onPaneDrop}
|
||||
onDragOver={onPaneDragOver}
|
||||
onDragLeave={onPaneDragLeave}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
edgesReconnectable
|
||||
reconnectRadius={24}
|
||||
minZoom={0.25}
|
||||
maxZoom={1.8}
|
||||
nodesDraggable
|
||||
nodesConnectable
|
||||
elementsSelectable
|
||||
selectionOnDrag
|
||||
selectionMode={SelectionMode.Partial}
|
||||
panOnDrag={[1, 2]}
|
||||
selectNodesOnDrag={false}
|
||||
multiSelectionKeyCode={['Meta', 'Control', 'Shift']}
|
||||
deleteKeyCode={null}
|
||||
defaultEdgeOptions={{
|
||||
type: 'strategy',
|
||||
className: 'se-flow-edge',
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={24} size={1} className="se-flow-bg" />
|
||||
<Controls className="se-flow-controls" showInteractive={false} />
|
||||
<MiniMap
|
||||
className="se-flow-minimap"
|
||||
zoomable
|
||||
pannable
|
||||
nodeStrokeWidth={2}
|
||||
bgColor={minimapColors.bg}
|
||||
maskColor={minimapColors.mask}
|
||||
maskStrokeColor={minimapColors.maskStroke}
|
||||
nodeColor={minimapNodeColor}
|
||||
nodeStrokeColor={minimapNodeStrokeColor}
|
||||
/>
|
||||
<Panel position="top-left" className="se-canvas-toolbar">
|
||||
<span className="se-canvas-toolbar-title">전략 빌더</span>
|
||||
<span className="se-canvas-toolbar-hint">
|
||||
다중 선택 × 일괄삭제 · 연결선 × 끊기 · Del 삭제
|
||||
{orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
|
||||
</span>
|
||||
</Panel>
|
||||
{!root && (
|
||||
<Panel position="top-center" className="se-canvas-empty">
|
||||
우측에서 지표·연산자를 드래그하거나 클릭하여 조건을 구성하세요
|
||||
</Panel>
|
||||
)}
|
||||
<MultiSelectionDeleteButton onDelete={deleteSelectedNodes} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyEditorCanvas(props: Props) {
|
||||
return <StrategyEditorCanvasInner {...props} />;
|
||||
}
|
||||
Reference in New Issue
Block a user