feat: add search and filter functionality with Ctrl+F shortcut

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 <noreply@anthropic.com>
This commit is contained in:
Jan-Henrik Bruhn 2025-10-12 12:11:29 +02:00
parent aa2bd7e5d7
commit 1646cfb0ce
6 changed files with 453 additions and 13 deletions

View file

@ -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<LeftPanelRef>(null);
const [selectedNode, setSelectedNode] = useState<Actor | null>(null);
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(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 && (
<LeftPanel
ref={leftPanelRef}
onDeselectAll={() => {
setSelectedNode(null);
setSelectedEdge(null);

View file

@ -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<RelationData>) => {
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"
>

View file

@ -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<ActorData>) => {
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<ActorData>) => {
// 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 (
<div
className={`
@ -49,8 +84,11 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
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,
}}
>

View file

@ -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<LeftPanelRef, LeftPanelProps>(({ 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<HTMLInputElement>(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 ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
</button>
{leftPanelSections.search && (
<div className="px-3 py-3">
<p className="text-xs text-gray-500 italic">
Search features coming soon
</p>
<div className="px-3 py-3 space-y-4">
{/* Search Input */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-xs font-medium text-gray-600">
Search
</label>
{/* Reset Filters Link */}
{hasActiveFilters() && (
<button
onClick={clearFilters}
className="flex items-center space-x-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
>
<FilterAltOffIcon sx={{ fontSize: 14 }} />
<span>Reset filters</span>
</button>
)}
</div>
<div className="relative">
<div className="absolute left-2.5 top-1/2 transform -translate-y-1/2 pointer-events-none">
<SearchIcon className="text-gray-400" sx={{ fontSize: 18 }} />
</div>
<input
ref={searchInputRef}
type="text"
value={searchText}
onChange={(e) => 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 && (
<div className="absolute right-1 top-1/2 transform -translate-y-1/2">
<IconButton
size="small"
onClick={() => setSearchText('')}
sx={{ padding: '4px' }}
>
<ClearIcon sx={{ fontSize: 16 }} />
</IconButton>
</div>
)}
</div>
</div>
{/* Filter by Actor Type */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">
Filter by Actor Type
</label>
<div className="space-y-1.5">
{nodeTypes.map((nodeType) => {
const isVisible = visibleActorTypes[nodeType.id] !== false;
const IconComponent = getIconComponent(nodeType.icon);
return (
<label
key={nodeType.id}
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
>
<Checkbox
checked={isVisible}
onChange={() => setActorTypeVisible(nodeType.id, !isVisible)}
size="small"
sx={{ padding: '2px' }}
/>
<div className="flex items-center space-x-2 flex-1">
{IconComponent ? (
<div
className="w-4 h-4 flex items-center justify-center"
style={{ color: nodeType.color, fontSize: '1rem' }}
>
<IconComponent />
</div>
) : (
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: nodeType.color }}
/>
)}
<span className="text-sm text-gray-700">{nodeType.label}</span>
</div>
</label>
);
})}
</div>
</div>
{/* Filter by Relation */}
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">
Filter by Relation
</label>
<div className="space-y-1.5">
{edgeTypes.map((edgeType) => {
const isVisible = visibleRelationTypes[edgeType.id] !== false;
return (
<label
key={edgeType.id}
className="flex items-center space-x-2 cursor-pointer hover:bg-gray-50 px-2 py-1 rounded transition-colors"
>
<Checkbox
checked={isVisible}
onChange={() => setRelationTypeVisible(edgeType.id, !isVisible)}
size="small"
sx={{ padding: '2px' }}
/>
<div className="flex items-center space-x-2 flex-1">
<div
className="w-6 h-0.5"
style={{
backgroundColor: edgeType.color,
borderStyle: edgeType.style === 'dashed' ? 'dashed' : edgeType.style === 'dotted' ? 'dotted' : 'solid',
borderWidth: edgeType.style !== 'solid' ? '1px' : '0',
borderColor: edgeType.style !== 'solid' ? edgeType.color : 'transparent',
height: edgeType.style !== 'solid' ? '0' : '2px',
}}
/>
<span className="text-sm text-gray-700">{edgeType.label}</span>
</div>
</label>
);
})}
</div>
</div>
{/* Results Summary */}
<div className="pt-2 border-t border-gray-100">
<div className="text-xs text-gray-600">
<span className="font-medium">Results:</span>{' '}
{matchingNodes.length} actor{matchingNodes.length !== 1 ? 's' : ''}
{searchText || hasActiveFilters() ? ` of ${nodes.length}` : ''}
</div>
</div>
</div>
)}
</div>
@ -285,6 +512,8 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
/>
</div>
);
};
});
LeftPanel.displayName = 'LeftPanel';
export default LeftPanel;

View file

@ -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

142
src/stores/searchStore.ts Normal file
View file

@ -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<string, boolean>;
setActorTypeVisible: (typeId: string, visible: boolean) => void;
toggleActorType: (typeId: string) => void;
setAllActorTypesVisible: (visible: boolean) => void;
// Filter visibility by relation types (edgeTypeId -> visible)
visibleRelationTypes: Record<string, boolean>;
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<SearchStore>((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<string, boolean> = {};
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<string, boolean> = {};
Object.keys(state.visibleRelationTypes).forEach((typeId) => {
updated[typeId] = visible;
});
return { visibleRelationTypes: updated };
}),
clearFilters: () =>
set((state) => {
// Reset all actor types to visible
const resetActorTypes: Record<string, boolean> = {};
Object.keys(state.visibleActorTypes).forEach((typeId) => {
resetActorTypes[typeId] = true;
});
// Reset all relation types to visible
const resetRelationTypes: Record<string, boolean> = {};
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;
},
}));