diff --git a/flowsint-app/src/components/graphs/graph-viewer.tsx b/flowsint-app/src/components/graphs/graph-viewer.tsx index d2f1621..9b22977 100644 --- a/flowsint-app/src/components/graphs/graph-viewer.tsx +++ b/flowsint-app/src/components/graphs/graph-viewer.tsx @@ -167,6 +167,8 @@ const GraphViewer: React.FC = ({ const graphRef = useRef(null) const containerRef = useRef(null) const isGraphReadyRef = useRef(false) + const hasInitialZoomedRef = useRef(false) + const previousNodeCountRef = useRef(0) // Store selectors const nodeColors = useNodesDisplaySettings((s) => s.colors) @@ -175,6 +177,7 @@ const GraphViewer: React.FC = ({ const setCurrentLayoutType = useGraphControls((s) => s.setCurrentLayoutType) const shouldRegenerateLayoutOnNextRefetch = useGraphControls((s) => s.shouldRegenerateLayoutOnNextRefetch) const setShouldRegenerateLayoutOnNextRefetch = useGraphControls((s) => s.setShouldRegenerateLayoutOnNextRefetch) + const autoZoomOnCurrentNode = useGraphSettingsStore((s) => s.getSettingValue('general', 'autoZoomOnCurrentNode')) // Combine graph store selectors with useShallow for better performance const { currentNode, currentEdge, selectedNodes, selectedEdges, toggleEdgeSelection, setCurrentEdge, clearSelectedEdges, setOpenMainDialog } = useGraphStore( @@ -421,7 +424,7 @@ const GraphViewer: React.FC = ({ setIsRegeneratingLayout(false) } }) - }, [graphData, applyLayout, setCurrentLayoutType]) + }, [applyLayout, setCurrentLayoutType]) // Optimized graph initialization callback const initializeGraph = useCallback( @@ -442,6 +445,16 @@ const GraphViewer: React.FC = ({ } if (isGraphReadyRef.current) return isGraphReadyRef.current = true + + // Center graph immediately once ready + if (graphData.nodes.length > 0) { + setTimeout(() => { + if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { + graphRef.current.zoomToFit(400) + } + }, 50) + } + // Only set global actions if no instanceId is provided (for main graph) if (!instanceId) { @@ -463,6 +476,14 @@ const GraphViewer: React.FC = ({ graphRef.current.zoomToFit(400) } }, + centerOnNode: (x: number, y: number) => { + if (graphRef.current && typeof graphRef.current.centerAt === 'function') { + graphRef.current.centerAt(x, y, 400) + if (typeof graphRef.current.zoom === 'function') { + graphRef.current.zoom(6, 400) + } + } + }, regenerateLayout: regenerateLayout, getViewportCenter: () => { if (!graphRef.current || !containerRef.current) return null @@ -484,7 +505,7 @@ const GraphViewer: React.FC = ({ // Call external ref callback onGraphRef?.(graphInstance) }, - [setActions, onGraphRef, instanceId, regenerateLayout] + [setActions, onGraphRef, instanceId, regenerateLayout, graphData.nodes.length] ) // Initialize graph once ready @@ -498,6 +519,7 @@ const GraphViewer: React.FC = ({ zoomIn: () => { }, zoomOut: () => { }, zoomToFit: () => { }, + centerOnNode: () => { }, regenerateLayout: () => { }, getViewportCenter: () => null }) @@ -506,26 +528,34 @@ const GraphViewer: React.FC = ({ } }, [initializeGraph, instanceId, setActions]) - // Auto-center graph on initial load + // Auto-center graph on initial load ONLY useEffect(() => { - // Only auto-center if we have nodes and the graph is ready - if (graphRef.current && graphData.nodes.length > 0 && containerSize.width > 0) { + const currentNodeCount = graphData.nodes.length + + // Reset zoom flag if node count changed significantly (new graph loaded or major changes) + if (previousNodeCountRef.current !== currentNodeCount) { + hasInitialZoomedRef.current = false + previousNodeCountRef.current = currentNodeCount + } + + // Only auto-center if we have nodes, the graph is ready, and we haven't zoomed yet + if (graphRef.current && currentNodeCount > 0 && containerSize.width > 0 && !hasInitialZoomedRef.current) { // If flag is set, regenerate layout instead of just zooming if (shouldRegenerateLayoutOnNextRefetch && currentLayoutType) { setShouldRegenerateLayoutOnNextRefetch(false) + hasInitialZoomedRef.current = true const timer = setTimeout(() => { regenerateLayout(currentLayoutType) - }, 500) + }, 100) return () => clearTimeout(timer) } // Otherwise just zoom to fit on initial load - const timer = setTimeout(() => { - if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { - graphRef.current.zoomToFit(400) - } - }, 500) - return () => clearTimeout(timer) + hasInitialZoomedRef.current = true + // Zoom immediately when graph is ready + if (graphRef.current && typeof graphRef.current.zoomToFit === 'function') { + graphRef.current.zoomToFit(400) + } } }, [graphData.nodes.length, containerSize.width, shouldRegenerateLayoutOnNextRefetch, currentLayoutType, regenerateLayout, setShouldRegenerateLayoutOnNextRefetch]) @@ -533,8 +563,21 @@ const GraphViewer: React.FC = ({ const handleNodeClick = useCallback( (node: any, event: MouseEvent) => { onNodeClick?.(node, event) + + // Auto-zoom to clicked node if enabled and not multi-selecting + const isMultiSelect = event.ctrlKey || event.shiftKey + if (autoZoomOnCurrentNode && !isMultiSelect && node?.x && node?.y && graphRef.current) { + setTimeout(() => { + if (graphRef.current && typeof graphRef.current.centerAt === 'function') { + graphRef.current.centerAt(node.x, node.y, 400) + if (typeof graphRef.current.zoom === 'function') { + graphRef.current.zoom(6, 400) + } + } + }, 100) + } }, - [onNodeClick] + [onNodeClick, autoZoomOnCurrentNode] ) const handleNodeRightClick = useCallback( diff --git a/flowsint-app/src/components/graphs/nodes-panel.tsx b/flowsint-app/src/components/graphs/nodes-panel.tsx index 26b9876..0d33849 100644 --- a/flowsint-app/src/components/graphs/nodes-panel.tsx +++ b/flowsint-app/src/components/graphs/nodes-panel.tsx @@ -2,6 +2,8 @@ import type React from 'react' import { memo, useMemo, useState, useCallback, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import { useGraphStore } from '@/stores/graph-store' +import { useGraphControls } from '@/stores/graph-controls-store' +import { useGraphSettingsStore } from '@/stores/graph-settings-store' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { TypeBadge } from '@/components/type-badge' @@ -27,15 +29,28 @@ const NodeRenderer = memo( setCurrentNode, onCheckboxChange, isNodeChecked, - isCurrent + isCurrent, + centerOnNode, + autoZoomOnCurrentNode }: { node: any setCurrentNode: (node: GraphNode) => void onCheckboxChange: (node: GraphNode, checked: boolean) => void isNodeChecked: (nodeId: string) => boolean isCurrent: (nodeId: string) => boolean + centerOnNode: (x: number, y: number) => void + autoZoomOnCurrentNode: boolean }) => { - const handleClick = useCallback(() => setCurrentNode(node), [node, setCurrentNode]) + const handleClick = useCallback(() => { + setCurrentNode(node) + // Auto-zoom if enabled and node has coordinates + if (autoZoomOnCurrentNode && node?.x !== undefined && node?.y !== undefined) { + setTimeout(() => { + centerOnNode(node.x, node.y) + }, 100) + } + }, [node, setCurrentNode, centerOnNode, autoZoomOnCurrentNode]) + const handleCheckboxChange = useCallback( (checked: boolean) => { onCheckboxChange(node, checked) @@ -78,7 +93,9 @@ const VirtualizedItem = memo( setCurrentNode, onCheckboxChange, isNodeChecked, - isCurrent + isCurrent, + centerOnNode, + autoZoomOnCurrentNode }: { index: number node: GraphNode @@ -86,6 +103,8 @@ const VirtualizedItem = memo( onCheckboxChange: (node: GraphNode, checked: boolean) => void isNodeChecked: (nodeId: string) => boolean isCurrent: (nodeId: string) => boolean + centerOnNode: (x: number, y: number) => void + autoZoomOnCurrentNode: boolean }) => { return ( ) } @@ -104,6 +125,8 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?: const setCurrentNode = useGraphStore((state) => state.setCurrentNode) const setSelectedNodes = useGraphStore((state) => state.setSelectedNodes) const selectedNodes = useGraphStore((state) => state.selectedNodes || []) + const centerOnNode = useGraphControls((state) => state.centerOnNode) + const autoZoomOnCurrentNode = useGraphSettingsStore((s) => s.getSettingValue('general', 'autoZoomOnCurrentNode')) const [searchQuery, setSearchQuery] = useState('') const [filters, setFilters] = useState(null) @@ -341,6 +364,8 @@ const NodesPanel = memo(({ nodes, isLoading }: { nodes: GraphNode[]; isLoading?: setCurrentNode={setCurrentNode} onCheckboxChange={handleCheckboxChange} isNodeChecked={isNodeChecked} + centerOnNode={centerOnNode} + autoZoomOnCurrentNode={autoZoomOnCurrentNode} /> ) diff --git a/flowsint-app/src/stores/graph-controls-store.ts b/flowsint-app/src/stores/graph-controls-store.ts index 39b8320..0e0ecd3 100644 --- a/flowsint-app/src/stores/graph-controls-store.ts +++ b/flowsint-app/src/stores/graph-controls-store.ts @@ -14,6 +14,7 @@ type GraphControlsStore = { zoomToFit: () => void zoomIn: () => void zoomOut: () => void + centerOnNode: (x: number, y: number) => void onLayout: (layout: any) => void setActions: (actions: Partial) => void refetchGraph: (onSuccess?: () => void) => void @@ -37,6 +38,7 @@ export const useGraphControls = create()( zoomToFit: () => { }, zoomIn: () => { }, zoomOut: () => { }, + centerOnNode: () => { }, onLayout: () => { }, setActions: (actions) => set(actions), refetchGraph: (onSuccess) => { }, diff --git a/flowsint-app/src/stores/graph-settings-store.ts b/flowsint-app/src/stores/graph-settings-store.ts index 873bf7f..d41bb1e 100644 --- a/flowsint-app/src/stores/graph-settings-store.ts +++ b/flowsint-app/src/stores/graph-settings-store.ts @@ -10,6 +10,11 @@ const DEFAULT_SETTINGS = { value: false, description: 'Display Flo, your AI assistant.' }, + autoZoomOnCurrentNode: { + type: 'boolean', + value: true, + description: 'Automatically zoom to the current node when it changes.' + }, }, graph: { nodeSize: {