feat: add auto-zoom to filtered search results

Implements automatic viewport adjustment to focus on filtered nodes when
search or filter criteria change, providing immediate visual feedback.

Features:
- Auto-zoom triggers when search text or type filters are applied
- 300ms debouncing prevents excessive zooming while typing
- Only zooms when some (but not all) nodes match filters
- Smooth 300ms animation with 20% padding and 2.5x max zoom
- Toggle control via compact icon button in search header
- Enabled by default, persists user preference in search store

UI Changes:
- Added CenterFocusStrong icon toggle in LeftPanel search header
- Icon is blue when enabled, gray when disabled
- Tooltip shows current state
- Positioned after "Reset filters" button

Implementation:
- Search store tracks autoZoomEnabled state
- GraphEditor monitors filter changes and calculates matching nodes
- Uses React Flow's fitView() with smart node filtering
- Skips zoom when disabled, no nodes, or no active filters

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-16 14:26:34 +02:00
parent f9c208d7ac
commit 58e04650c0
3 changed files with 126 additions and 10 deletions

View file

@ -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<string | null>(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) => {

View file

@ -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<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
setActorTypeVisible,
visibleRelationTypes,
setRelationTypeVisible,
autoZoomEnabled,
setAutoZoomEnabled,
clearFilters,
hasActiveFilters,
} = useSearchStore();
@ -401,6 +404,7 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
<label className="block text-xs font-medium text-gray-600">
Search
</label>
<div className="flex items-center space-x-1">
{/* Reset Filters Link */}
{hasActiveFilters() && (
<button
@ -411,6 +415,22 @@ const LeftPanel = forwardRef<LeftPanelRef, LeftPanelProps>(({ onDeselectAll, onA
<span>Reset filters</span>
</button>
)}
{/* Auto-zoom toggle icon */}
<Tooltip title={autoZoomEnabled ? "Auto-zoom enabled" : "Auto-zoom disabled"} placement="top">
<IconButton
size="small"
onClick={() => setAutoZoomEnabled(!autoZoomEnabled)}
sx={{ padding: '4px' }}
>
<CenterFocusStrongIcon
sx={{
fontSize: 16,
color: autoZoomEnabled ? '#3b82f6' : '#9ca3af'
}}
/>
</IconButton>
</Tooltip>
</div>
</div>
<div className="relative">
<div className="absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none">

View file

@ -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<SearchStore>((set, get) => ({
searchText: '',
visibleActorTypes: {},
visibleRelationTypes: {},
autoZoomEnabled: true,
setSearchText: (text: string) =>
set({ searchText: text }),
@ -92,6 +98,9 @@ export const useSearchStore = create<SearchStore>((set, get) => ({
return { visibleRelationTypes: updated };
}),
setAutoZoomEnabled: (enabled: boolean) =>
set({ autoZoomEnabled: enabled }),
clearFilters: () =>
set((state) => {
// Reset all actor types to visible