From 1646cfb0cef89814fe15d8dfaba05f78305aef2b Mon Sep 17 00:00:00 2001 From: Jan-Henrik Bruhn Date: Sun, 12 Oct 2025 12:11:29 +0200 Subject: [PATCH] feat: add search and filter functionality with Ctrl+F shortcut MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements search and filter feature from UX_ANALYSIS.md to help users find actors and relations in complex graphs. Features: - Search store with Zustand for managing search/filter state - Real-time search by actor label, description, or type name - Filter by actor types (show/hide specific node types) - Filter by relation types (show/hide specific edge types) - Visual feedback: non-matching items dimmed to 20% opacity - Matching items highlighted with colored glow when filters active - Results counter showing X actors of Y total - Ctrl+F keyboard shortcut to focus search input - Expands left panel if collapsed - Opens search section if closed - Focuses search input field UI improvements: - Search input with magnifying glass icon and clear button - Reset filters link (only visible when filters active) - Checkboxes for each actor/relation type with visual indicators - Smooth transitions and hover states - Fixed icon overlap issue in search input Components modified: - CustomNode: Apply opacity/highlighting based on search matches - CustomEdge: Apply opacity based on relation type filters - LeftPanel: Full search UI with filters in existing section - App: Wire up Ctrl+F shortcut with ref-based focus handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/App.tsx | 9 +- src/components/Edges/CustomEdge.tsx | 18 +- src/components/Nodes/CustomNode.tsx | 40 ++++- src/components/Panels/LeftPanel.tsx | 247 +++++++++++++++++++++++++++- src/hooks/useGlobalShortcuts.ts | 10 ++ src/stores/searchStore.ts | 142 ++++++++++++++++ 6 files changed, 453 insertions(+), 13 deletions(-) create mode 100644 src/stores/searchStore.ts diff --git a/src/App.tsx b/src/App.tsx index ed5f69c..79e55df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { ReactFlowProvider, useReactFlow } from "reactflow"; import GraphEditor from "./components/Editor/GraphEditor"; -import LeftPanel from "./components/Panels/LeftPanel"; +import LeftPanel, { type LeftPanelRef } from "./components/Panels/LeftPanel"; import RightPanel from "./components/Panels/RightPanel"; import BottomPanel from "./components/Timeline/BottomPanel"; import DocumentTabs from "./components/Workspace/DocumentTabs"; @@ -46,6 +46,9 @@ function AppContent() { const { handleNewDocument, NewDocumentDialog } = useCreateDocument(); const [showDocumentManager, setShowDocumentManager] = useState(false); const [showKeyboardHelp, setShowKeyboardHelp] = useState(false); + + // Ref for LeftPanel to call focusSearch + const leftPanelRef = useRef(null); const [selectedNode, setSelectedNode] = useState(null); const [selectedEdge, setSelectedEdge] = useState(null); const [addNodeCallback, setAddNodeCallback] = useState< @@ -88,6 +91,7 @@ function AppContent() { onOpenHelp: () => setShowKeyboardHelp(true), onFitView: handleFitView, onSelectAll: handleSelectAll, + onFocusSearch: () => leftPanelRef.current?.focusSearch(), }); // Escape key to close property panels @@ -147,6 +151,7 @@ function AppContent() { {/* Left Panel */} {leftPanelVisible && activeDocumentId && ( { setSelectedNode(null); setSelectedEdge(null); diff --git a/src/components/Edges/CustomEdge.tsx b/src/components/Edges/CustomEdge.tsx index 1464bf3..c1033a5 100644 --- a/src/components/Edges/CustomEdge.tsx +++ b/src/components/Edges/CustomEdge.tsx @@ -1,4 +1,4 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { EdgeProps, getBezierPath, @@ -6,6 +6,7 @@ import { BaseEdge, } from 'reactflow'; import { useGraphStore } from '../../stores/graphStore'; +import { useSearchStore } from '../../stores/searchStore'; import type { RelationData } from '../../types'; /** @@ -32,6 +33,7 @@ const CustomEdge = ({ selected, }: EdgeProps) => { const edgeTypes = useGraphStore((state) => state.edgeTypes); + const { visibleRelationTypes } = useSearchStore(); // Calculate the bezier path const [edgePath, labelX, labelY] = getBezierPath({ @@ -61,6 +63,18 @@ const CustomEdge = ({ // Get directionality (default to 'directed' for backwards compatibility) const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed'; + // Check if this edge matches the filter criteria + const isVisible = useMemo(() => { + const edgeType = data?.type || ''; + return visibleRelationTypes[edgeType] !== false; + }, [data?.type, visibleRelationTypes]); + + // Determine if filters are active + const hasActiveFilters = Object.values(visibleRelationTypes).some(v => v === false); + + // Calculate opacity based on visibility + const edgeOpacity = hasActiveFilters && !isVisible ? 0.2 : 1.0; + // Create unique marker IDs based on color (for reusability) const safeColor = edgeColor.replace('#', ''); const markerEndId = `arrow-end-${safeColor}`; @@ -114,6 +128,7 @@ const CustomEdge = ({ stroke: edgeColor, strokeWidth: selected ? 3 : 2, strokeDasharray, + opacity: edgeOpacity, }} markerEnd={markerEnd} markerStart={markerStart} @@ -127,6 +142,7 @@ const CustomEdge = ({ position: 'absolute', transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`, pointerEvents: 'all', + opacity: edgeOpacity, }} className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm" > diff --git a/src/components/Nodes/CustomNode.tsx b/src/components/Nodes/CustomNode.tsx index d4f41b2..60b7d1d 100644 --- a/src/components/Nodes/CustomNode.tsx +++ b/src/components/Nodes/CustomNode.tsx @@ -1,6 +1,7 @@ -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { Handle, Position, NodeProps, useStore } from 'reactflow'; import { useGraphStore } from '../../stores/graphStore'; +import { useSearchStore } from '../../stores/searchStore'; import { getContrastColor, adjustColorBrightness } from '../../utils/colorUtils'; import { getIconComponent } from '../../utils/iconUtils'; import type { ActorData } from '../../types'; @@ -18,6 +19,7 @@ import type { ActorData } from '../../types'; */ const CustomNode = ({ data, selected }: NodeProps) => { const nodeTypes = useGraphStore((state) => state.nodeTypes); + const { searchText, visibleActorTypes } = useSearchStore(); // Check if any connection is being made (to show handles) const connectionNodeId = useStore((state) => state.connectionNodeId); @@ -36,6 +38,39 @@ const CustomNode = ({ data, selected }: NodeProps) => { // Show handles when selected or when connecting const showHandles = selected || isConnecting; + // Check if this node matches the search and filter criteria + const isMatch = useMemo(() => { + // Check type visibility + const isTypeVisible = visibleActorTypes[data.type] !== false; + if (!isTypeVisible) { + return false; + } + + // Check search text match + if (searchText.trim()) { + const searchLower = searchText.toLowerCase(); + const label = data.label?.toLowerCase() || ''; + const description = data.description?.toLowerCase() || ''; + const typeName = nodeLabel.toLowerCase(); + + return ( + label.includes(searchLower) || + description.includes(searchLower) || + typeName.includes(searchLower) + ); + } + + return true; + }, [searchText, visibleActorTypes, data.type, data.label, data.description, nodeLabel]); + + // Determine if filters are active + const hasActiveFilters = searchText.trim() !== '' || + Object.values(visibleActorTypes).some(v => v === false); + + // Calculate opacity based on match status + const nodeOpacity = hasActiveFilters && !isMatch ? 0.2 : 1.0; + const isHighlighted = hasActiveFilters && isMatch; + return (
) => { borderStyle: 'solid', borderColor: borderColor, color: textColor, + opacity: nodeOpacity, boxShadow: selected ? `0 0 0 3px ${nodeColor}40` // Add outer glow when selected (40 = ~25% opacity) + : isHighlighted + ? `0 0 0 3px ${nodeColor}80, 0 0 12px ${nodeColor}60` // Highlight glow for search matches : undefined, }} > diff --git a/src/components/Panels/LeftPanel.tsx b/src/components/Panels/LeftPanel.tsx index 828d7fe..5b7ce96 100644 --- a/src/components/Panels/LeftPanel.tsx +++ b/src/components/Panels/LeftPanel.tsx @@ -1,18 +1,23 @@ -import { useCallback, useState } from 'react'; -import { IconButton, Tooltip } from '@mui/material'; +import { useCallback, useState, useMemo, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'; +import { IconButton, Tooltip, Checkbox } from '@mui/material'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; 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 { usePanelStore } from '../../stores/panelStore'; import { useGraphWithHistory } from '../../hooks/useGraphWithHistory'; import { useEditorStore } from '../../stores/editorStore'; +import { useSearchStore } from '../../stores/searchStore'; import { createNode } from '../../utils/nodeUtils'; import { getIconComponent } from '../../utils/iconUtils'; import { getContrastColor } from '../../utils/colorUtils'; import NodeTypeConfigModal from '../Config/NodeTypeConfig'; import EdgeTypeConfigModal from '../Config/EdgeTypeConfig'; +import type { Actor } from '../../types'; /** * LeftPanel - Collapsible tools panel on the left side @@ -31,7 +36,11 @@ interface LeftPanelProps { onAddNode?: (nodeTypeId: string, position?: { x: number; y: number }) => void; } -const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => { +export interface LeftPanelRef { + focusSearch: () => void; +} + +const LeftPanel = forwardRef(({ onDeselectAll, onAddNode }, ref) => { const { leftPanelCollapsed, leftPanelWidth, @@ -41,11 +50,99 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => { expandLeftPanel, } = usePanelStore(); - const { nodeTypes, edgeTypes, addNode } = useGraphWithHistory(); + const { nodeTypes, edgeTypes, addNode, nodes } = useGraphWithHistory(); const { selectedRelationType, setSelectedRelationType } = useEditorStore(); const [showNodeConfig, setShowNodeConfig] = useState(false); const [showEdgeConfig, setShowEdgeConfig] = useState(false); + // Ref for the search input + const searchInputRef = useRef(null); + + // Expose focusSearch method to parent via ref + useImperativeHandle(ref, () => ({ + focusSearch: () => { + // Expand left panel if collapsed + if (leftPanelCollapsed) { + expandLeftPanel(); + } + + // Expand search section if collapsed + if (!leftPanelSections.search) { + toggleLeftPanelSection('search'); + } + + // Focus the search input after a small delay to ensure DOM updates + setTimeout(() => { + searchInputRef.current?.focus(); + }, 100); + }, + }), [leftPanelCollapsed, leftPanelSections.search, expandLeftPanel, toggleLeftPanelSection]); + + // Search and filter state + const { + searchText, + setSearchText, + visibleActorTypes, + setActorTypeVisible, + visibleRelationTypes, + setRelationTypeVisible, + clearFilters, + hasActiveFilters, + } = useSearchStore(); + + // Initialize filter state when node/edge types change + useEffect(() => { + nodeTypes.forEach((nodeType) => { + if (!(nodeType.id in visibleActorTypes)) { + setActorTypeVisible(nodeType.id, true); + } + }); + }, [nodeTypes, visibleActorTypes, setActorTypeVisible]); + + useEffect(() => { + edgeTypes.forEach((edgeType) => { + if (!(edgeType.id in visibleRelationTypes)) { + setRelationTypeVisible(edgeType.id, true); + } + }); + }, [edgeTypes, visibleRelationTypes, setRelationTypeVisible]); + + // Calculate matching nodes based on search and filters + const matchingNodes = useMemo(() => { + const searchLower = searchText.toLowerCase().trim(); + + return 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 = nodeTypes.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; + }); + }, [nodes, searchText, visibleActorTypes, nodeTypes]); + + const handleAddNode = useCallback( (nodeTypeId: string) => { // Use the shared callback from GraphEditor if available @@ -265,10 +362,140 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => { {leftPanelSections.search ? : } {leftPanelSections.search && ( -
-

- Search features coming soon -

+
+ {/* Search Input */} +
+
+ + {/* Reset Filters Link */} + {hasActiveFilters() && ( + + )} +
+
+
+ +
+ setSearchText(e.target.value)} + placeholder="Search actors..." + className="w-full pl-9 pr-9 py-2 text-sm border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + {searchText && ( +
+ setSearchText('')} + sx={{ padding: '4px' }} + > + + +
+ )} +
+
+ + {/* Filter by Actor Type */} +
+ +
+ {nodeTypes.map((nodeType) => { + const isVisible = visibleActorTypes[nodeType.id] !== false; + const IconComponent = getIconComponent(nodeType.icon); + + return ( +
+ + {/* Filter by Relation */} +
+ +
+ {edgeTypes.map((edgeType) => { + const isVisible = visibleRelationTypes[edgeType.id] !== false; + + return ( +
+ + {/* Results Summary */} +
+
+ Results:{' '} + {matchingNodes.length} actor{matchingNodes.length !== 1 ? 's' : ''} + {searchText || hasActiveFilters() ? ` of ${nodes.length}` : ''} +
+
)}
@@ -285,6 +512,8 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => { />
); -}; +}); + +LeftPanel.displayName = 'LeftPanel'; export default LeftPanel; diff --git a/src/hooks/useGlobalShortcuts.ts b/src/hooks/useGlobalShortcuts.ts index b185f81..59c7702 100644 --- a/src/hooks/useGlobalShortcuts.ts +++ b/src/hooks/useGlobalShortcuts.ts @@ -18,6 +18,7 @@ interface UseGlobalShortcutsOptions { onOpenHelp?: () => void; onFitView?: () => void; onSelectAll?: () => void; + onFocusSearch?: () => void; } export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) { @@ -183,6 +184,15 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) { category: "Navigation", enabled: !!options.onOpenHelp, }, + { + id: "focus-search", + description: "Focus Search", + key: "f", + ctrl: true, + handler: () => options.onFocusSearch?.(), + category: "Navigation", + enabled: !!options.onFocusSearch, + }, ]; // Register all shortcuts diff --git a/src/stores/searchStore.ts b/src/stores/searchStore.ts new file mode 100644 index 0000000..dd2dbdc --- /dev/null +++ b/src/stores/searchStore.ts @@ -0,0 +1,142 @@ +import { create } from 'zustand'; + +/** + * SearchStore - Manages search and filter state + * + * Features: + * - Search text for filtering nodes by label, description, or type + * - Filter by actor types (show/hide specific node types) + * - Filter by relation types (show/hide specific edge types) + * - Results tracking + */ + +interface SearchStore { + // Search text + searchText: string; + setSearchText: (text: string) => void; + + // Filter visibility by actor types (nodeTypeId -> visible) + visibleActorTypes: Record; + setActorTypeVisible: (typeId: string, visible: boolean) => void; + toggleActorType: (typeId: string) => void; + setAllActorTypesVisible: (visible: boolean) => void; + + // Filter visibility by relation types (edgeTypeId -> visible) + visibleRelationTypes: Record; + setRelationTypeVisible: (typeId: string, visible: boolean) => void; + toggleRelationType: (typeId: string) => void; + setAllRelationTypesVisible: (visible: boolean) => void; + + // Clear all filters + clearFilters: () => void; + + // Check if any filters are active + hasActiveFilters: () => boolean; +} + +export const useSearchStore = create((set, get) => ({ + searchText: '', + visibleActorTypes: {}, + visibleRelationTypes: {}, + + setSearchText: (text: string) => + set({ searchText: text }), + + setActorTypeVisible: (typeId: string, visible: boolean) => + set((state) => ({ + visibleActorTypes: { + ...state.visibleActorTypes, + [typeId]: visible, + }, + })), + + toggleActorType: (typeId: string) => + set((state) => ({ + visibleActorTypes: { + ...state.visibleActorTypes, + [typeId]: !state.visibleActorTypes[typeId], + }, + })), + + setAllActorTypesVisible: (visible: boolean) => + set((state) => { + const updated: Record = {}; + Object.keys(state.visibleActorTypes).forEach((typeId) => { + updated[typeId] = visible; + }); + return { visibleActorTypes: updated }; + }), + + setRelationTypeVisible: (typeId: string, visible: boolean) => + set((state) => ({ + visibleRelationTypes: { + ...state.visibleRelationTypes, + [typeId]: visible, + }, + })), + + toggleRelationType: (typeId: string) => + set((state) => ({ + visibleRelationTypes: { + ...state.visibleRelationTypes, + [typeId]: !state.visibleRelationTypes[typeId], + }, + })), + + setAllRelationTypesVisible: (visible: boolean) => + set((state) => { + const updated: Record = {}; + Object.keys(state.visibleRelationTypes).forEach((typeId) => { + updated[typeId] = visible; + }); + return { visibleRelationTypes: updated }; + }), + + clearFilters: () => + set((state) => { + // Reset all actor types to visible + const resetActorTypes: Record = {}; + Object.keys(state.visibleActorTypes).forEach((typeId) => { + resetActorTypes[typeId] = true; + }); + + // Reset all relation types to visible + const resetRelationTypes: Record = {}; + Object.keys(state.visibleRelationTypes).forEach((typeId) => { + resetRelationTypes[typeId] = true; + }); + + return { + searchText: '', + visibleActorTypes: resetActorTypes, + visibleRelationTypes: resetRelationTypes, + }; + }), + + hasActiveFilters: () => { + const state = get(); + + // Check if search text is present + if (state.searchText.trim() !== '') { + return true; + } + + // Check if any actor type is hidden + const hasHiddenActorType = Object.values(state.visibleActorTypes).some( + (visible) => !visible + ); + if (hasHiddenActorType) { + return true; + } + + // Check if any relation type is hidden + const hasHiddenRelationType = Object.values(state.visibleRelationTypes).some( + (visible) => !visible + ); + if (hasHiddenRelationType) { + return true; + } + + return false; + }, +}));