diff --git a/src/App.tsx b/src/App.tsx index bbdabdb..f66695c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -42,6 +42,7 @@ function AppContent() { const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null); + const [addNodeCallback, setAddNodeCallback] = useState<((nodeTypeId: string, position?: { x: number; y: number }) => void) | null>(null); const { fitView } = useReactFlow(); @@ -145,6 +146,7 @@ function AppContent() { setSelectedNode(null); setSelectedEdge(null); }} + onAddNode={addNodeCallback || undefined} /> )} @@ -167,6 +169,7 @@ function AppContent() { setSelectedNode(null); } }} + onAddNodeRequest={(callback: any) => setAddNodeCallback(() => callback)} /> diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 6acb588..b5dafc1 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -42,6 +42,7 @@ interface GraphEditorProps { selectedEdge: Relation | null; onNodeSelect: (node: Actor | null) => void; onEdgeSelect: (edge: Relation | null) => void; + onAddNodeRequest?: (nodeTypeId: string, position?: { x: number; y: number }) => void; } /** @@ -57,7 +58,7 @@ interface GraphEditorProps { * * Usage: Core component that wraps React Flow with custom nodes and edges */ -const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { +const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest }: GraphEditorProps) => { // Sync with workspace active document const { activeDocumentId } = useActiveDocument(); const { saveViewport, getViewport, createDocument } = useWorkspaceStore(); @@ -110,6 +111,9 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { // Track if a drag is in progress to capture state before drag const dragInProgressRef = useRef(false); + // Track pending selection (ID of item to select after next sync) + const pendingSelectionRef = useRef<{ type: 'node' | 'edge', id: string } | null>(null); + // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; @@ -119,13 +123,61 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { } | null>(null); // Sync store changes to React Flow state + // IMPORTANT: Preserve selection state, unless we have a pending selection (new item added) useEffect(() => { - setNodesState(storeNodes as Node[]); - }, [storeNodes, setNodesState]); + const hasPendingSelection = pendingSelectionRef.current !== null; + const pendingType = pendingSelectionRef.current?.type; + const pendingId = pendingSelectionRef.current?.id; - useEffect(() => { - setEdgesState(storeEdges as Edge[]); - }, [storeEdges, setEdgesState]); + setNodesState((currentNodes) => { + // If we have a pending selection, deselect all nodes (or select the new node) + if (hasPendingSelection) { + const pendingNodeId = pendingType === 'node' ? pendingId : null; + + return (storeNodes as Node[]).map((node) => ({ + ...node, + selected: node.id === pendingNodeId, + })); + } + + // Otherwise, preserve existing selection state + const selectionMap = new Map( + currentNodes.map((node) => [node.id, node.selected]) + ); + + return (storeNodes as Node[]).map((node) => ({ + ...node, + selected: selectionMap.get(node.id) || false, + })); + }); + + setEdgesState((currentEdges) => { + // If we have a pending selection, deselect all edges (or select the new edge) + if (hasPendingSelection) { + const pendingEdgeId = pendingType === 'edge' ? pendingId : null; + + const newEdges = (storeEdges as Edge[]).map((edge) => ({ + ...edge, + selected: edge.id === pendingEdgeId, + })); + + // Clear pending selection after applying it to both nodes and edges + pendingSelectionRef.current = null; + + return newEdges; + } + + // Otherwise, preserve existing selection state + const selectionMap = new Map( + currentEdges.map((edge) => [edge.id, edge.selected]) + ); + + return (storeEdges as Edge[]).map((edge) => ({ + ...edge, + selected: selectionMap.get(edge.id) || false, + })); + }); + }, [storeNodes, storeEdges, setNodesState, setEdgesState]); // Save viewport when switching documents and restore viewport for new document useEffect(() => { @@ -315,20 +367,18 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { type: edgeType, // Don't set label - will use type's label as default }, - selected: true, // Auto-select the new edge in ReactFlow }; // Use React Flow's addEdge helper to properly format the edge const updatedEdges = addEdge(edgeWithData, storeEdges as Edge[]); - // Deselect all nodes - const updatedNodes = nodes.map((node) => ({ ...node, selected: false })); - setNodesState(updatedNodes as Node[]); - // Find the newly added edge (it will be the last one) const newEdge = updatedEdges[updatedEdges.length - 1] as Relation; - // Use the history-tracked addEdge function + // Set pending selection - will be applied after Zustand sync + pendingSelectionRef.current = { type: 'edge', id: newEdge.id }; + + // Use the history-tracked addEdge function (triggers sync which will apply selection) addEdgeWithHistory(newEdge); }, [ @@ -336,8 +386,6 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { edgeTypeConfigs, addEdgeWithHistory, selectedRelationType, - nodes, - setNodesState, ], ); @@ -432,6 +480,34 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { } }, [contextMenu]); + // Shared node creation logic (used by context menu and left panel) + const handleAddNode = useCallback( + (nodeTypeId: string, position?: { x: number; y: number }) => { + // Use provided position or random position for toolbar/panel + const nodePosition = position || { + x: Math.random() * 400 + 100, + y: Math.random() * 300 + 100, + }; + + const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId); + const newNode = createNode(nodeTypeId, nodePosition, nodeTypeConfig); + + // Set pending selection - will be applied after Zustand sync + pendingSelectionRef.current = { type: 'node', id: newNode.id }; + + // Use history-tracked addNode (triggers sync which will apply selection) + addNodeWithHistory(newNode); + }, + [nodeTypeConfigs, addNodeWithHistory], + ); + + // Call the onAddNodeRequest callback if provided + useEffect(() => { + if (onAddNodeRequest) { + onAddNodeRequest(handleAddNode as any); + } + }, [onAddNodeRequest, handleAddNode]); + // Add new actor at context menu position const handleAddActorFromContextMenu = useCallback( (nodeTypeId: string) => { @@ -442,30 +518,10 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect }: GraphEditorProps) => { y: contextMenu.y, }); - const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === nodeTypeId); - const newNode = createNode(nodeTypeId, position, nodeTypeConfig); - newNode.selected = true; // Auto-select the new node in ReactFlow - - // Deselect all existing nodes and edges BEFORE adding the new one - const updatedNodes = nodes.map((node) => ({ ...node, selected: false })); - const updatedEdges = edges.map((edge) => ({ ...edge, selected: false })); - setNodesState(updatedNodes as Node[]); - setEdgesState(updatedEdges as Edge[]); - - // Use history-tracked addNode instead of setNodes - addNodeWithHistory(newNode); + handleAddNode(nodeTypeId, position); setContextMenu(null); }, - [ - contextMenu, - screenToFlowPosition, - nodeTypeConfigs, - addNodeWithHistory, - nodes, - edges, - setNodesState, - setEdgesState, - ], + [contextMenu, screenToFlowPosition, handleAddNode], ); // Show empty state when no document is active diff --git a/src/components/Panels/LeftPanel.tsx b/src/components/Panels/LeftPanel.tsx index d76ab6b..2e1100b 100644 --- a/src/components/Panels/LeftPanel.tsx +++ b/src/components/Panels/LeftPanel.tsx @@ -26,9 +26,10 @@ import { createNode } from '../../utils/nodeUtils'; interface LeftPanelProps { onDeselectAll: () => void; + onAddNode?: (nodeTypeId: string, position?: { x: number; y: number }) => void; } -const LeftPanel = ({ onDeselectAll }: LeftPanelProps) => { +const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => { const { leftPanelCollapsed, leftPanelWidth, @@ -44,22 +45,26 @@ const LeftPanel = ({ onDeselectAll }: LeftPanelProps) => { const handleAddNode = useCallback( (nodeTypeId: string) => { - // Deselect all other nodes/edges first - onDeselectAll(); + // Use the shared callback from GraphEditor if available + if (onAddNode) { + onAddNode(nodeTypeId); + } else { + // Fallback to old behavior (for backwards compatibility) + onDeselectAll(); - // Create node at center of viewport (approximate) - const position = { - x: Math.random() * 400 + 100, - y: Math.random() * 300 + 100, - }; + const position = { + x: Math.random() * 400 + 100, + y: Math.random() * 300 + 100, + }; - const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeTypeId); - const newNode = createNode(nodeTypeId, position, nodeTypeConfig); - newNode.selected = true; // Auto-select in ReactFlow + const nodeTypeConfig = nodeTypes.find((nt) => nt.id === nodeTypeId); + const newNode = createNode(nodeTypeId, position, nodeTypeConfig); + newNode.selected = true; - addNode(newNode); + addNode(newNode); + } }, - [addNode, nodeTypes, onDeselectAll] + [onAddNode, onDeselectAll, addNode, nodeTypes] ); const selectedEdgeTypeConfig = edgeTypes.find(et => et.id === selectedRelationType);