diff --git a/src/components/Editor/GraphEditor.tsx b/src/components/Editor/GraphEditor.tsx index 09a2593..27b1822 100644 --- a/src/components/Editor/GraphEditor.tsx +++ b/src/components/Editor/GraphEditor.tsx @@ -24,6 +24,7 @@ import "reactflow/dist/style.css"; import { useGraphWithHistory } from "../../hooks/useGraphWithHistory"; import { useDocumentHistory } from "../../hooks/useDocumentHistory"; import { useEditorStore } from "../../stores/editorStore"; +import { useSearchStore } from "../../stores/searchStore"; import { useActiveDocument } from "../../stores/workspace/useActiveDocument"; import { useWorkspaceStore } from "../../stores/workspaceStore"; import { useCreateDocument } from "../../hooks/useCreateDocument"; @@ -99,8 +100,17 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq screenToFlowPosition, setViewport, getViewport: getCurrentViewport, + fitView, } = useReactFlow(); + // Search and filter state for auto-zoom + const { + searchText, + visibleActorTypes, + visibleRelationTypes, + autoZoomEnabled, + } = useSearchStore(); + // Track previous document ID to save viewport before switching const prevDocumentIdRef = useRef(null); @@ -237,6 +247,83 @@ const GraphEditor = ({ onNodeSelect, onEdgeSelect, onAddNodeRequest, onExportReq return () => window.removeEventListener('closeAllMenus', handleCloseAllMenus); }, []); + // Auto-zoom to filtered results when search/filter changes + useEffect(() => { + // Skip if auto-zoom is disabled + if (!autoZoomEnabled) return; + + // Skip if there are no nodes + if (nodes.length === 0) return; + + // Check if any filters are active + const hasSearchText = searchText.trim() !== ''; + const hasTypeFilters = + Object.values(visibleActorTypes).some(v => v === false) || + Object.values(visibleRelationTypes).some(v => v === false); + + // Skip if no filters are active + if (!hasSearchText && !hasTypeFilters) return; + + // Debounce to avoid excessive viewport changes while typing + const timeoutId = setTimeout(() => { + const searchLower = searchText.toLowerCase().trim(); + + // Calculate matching nodes (same logic as LeftPanel and CustomNode) + const matchingNodeIds = nodes + .filter((node) => { + const actor = node as Actor; + const actorType = actor.data?.type || ''; + + // Filter by actor type visibility + const isTypeVisible = visibleActorTypes[actorType] !== false; + if (!isTypeVisible) { + return false; + } + + // Filter by search text + if (searchLower) { + const label = actor.data?.label?.toLowerCase() || ''; + const description = actor.data?.description?.toLowerCase() || ''; + const nodeTypeConfig = nodeTypeConfigs.find((nt) => nt.id === actorType); + const typeName = nodeTypeConfig?.label?.toLowerCase() || ''; + + const matches = + label.includes(searchLower) || + description.includes(searchLower) || + typeName.includes(searchLower); + + if (!matches) { + return false; + } + } + + return true; + }) + .map((node) => node.id); + + // Only zoom if there are matching nodes and not all nodes match + if (matchingNodeIds.length > 0 && matchingNodeIds.length < nodes.length) { + fitView({ + nodes: matchingNodeIds.map((id) => ({ id })), + padding: 0.2, // 20% padding around selection + duration: 300, // 300ms smooth animation + maxZoom: 2.5, // Allow more zoom in + minZoom: 0.5, // Don't zoom out too much + }); + } + }, 300); // Debounce 300ms + + return () => clearTimeout(timeoutId); + }, [ + searchText, + visibleActorTypes, + visibleRelationTypes, + autoZoomEnabled, + nodes, + nodeTypeConfigs, + fitView, + ]); + // Save viewport periodically (debounced) const handleViewportChange = useCallback( (_event: MouseEvent | TouchEvent | null, viewport: Viewport) => { diff --git a/src/components/Panels/LeftPanel.tsx b/src/components/Panels/LeftPanel.tsx index 8d70163..10a6c57 100644 --- a/src/components/Panels/LeftPanel.tsx +++ b/src/components/Panels/LeftPanel.tsx @@ -8,6 +8,7 @@ import SettingsIcon from '@mui/icons-material/Settings'; import SearchIcon from '@mui/icons-material/Search'; import ClearIcon from '@mui/icons-material/Clear'; import FilterAltOffIcon from '@mui/icons-material/FilterAltOff'; +import CenterFocusStrongIcon from '@mui/icons-material/CenterFocusStrong'; import { usePanelStore } from '../../stores/panelStore'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useEditorStore } from '../../stores/editorStore'; @@ -86,6 +87,8 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA setActorTypeVisible, visibleRelationTypes, setRelationTypeVisible, + autoZoomEnabled, + setAutoZoomEnabled, clearFilters, hasActiveFilters, } = useSearchStore(); @@ -401,16 +404,33 @@ const LeftPanel = forwardRef(({ onDeselectAll, onA - {/* Reset Filters Link */} - {hasActiveFilters() && ( - - )} +
+ {/* Reset Filters Link */} + {hasActiveFilters() && ( + + )} + {/* Auto-zoom toggle icon */} + + setAutoZoomEnabled(!autoZoomEnabled)} + sx={{ padding: '4px' }} + > + + + +
diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts index 7211843..4d53ae2 100644 --- a/src/stores/searchStore.ts +++ b/src/stores/searchStore.ts @@ -7,6 +7,7 @@ import { create } from 'zustand'; * - Search text for filtering both actors (by label, description, or type) and relations (by label or type) * - Filter by actor types (show/hide specific node types) * - Filter by relation types (show/hide specific edge types) + * - Auto-zoom to filtered results (optional, enabled by default) * - Results tracking */ @@ -27,6 +28,10 @@ interface SearchStore { toggleRelationType: (typeId: string) => void; setAllRelationTypesVisible: (visible: boolean) => void; + // Auto-zoom to filtered results + autoZoomEnabled: boolean; + setAutoZoomEnabled: (enabled: boolean) => void; + // Clear all filters clearFilters: () => void; @@ -38,6 +43,7 @@ export const useSearchStore = create((set, get) => ({ searchText: '', visibleActorTypes: {}, visibleRelationTypes: {}, + autoZoomEnabled: true, setSearchText: (text: string) => set({ searchText: text }), @@ -92,6 +98,9 @@ export const useSearchStore = create((set, get) => ({ return { visibleRelationTypes: updated }; }), + setAutoZoomEnabled: (enabled: boolean) => + set({ autoZoomEnabled: enabled }), + clearFilters: () => set((state) => { // Reset all actor types to visible