mirror of
https://github.com/OFFIS-ESC/constellation-analyzer
synced 2026-01-27 07:43:41 +00:00
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:
parent
aa2bd7e5d7
commit
1646cfb0ce
6 changed files with 453 additions and 13 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { ReactFlowProvider, useReactFlow } from "reactflow";
|
import { ReactFlowProvider, useReactFlow } from "reactflow";
|
||||||
import GraphEditor from "./components/Editor/GraphEditor";
|
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 RightPanel from "./components/Panels/RightPanel";
|
||||||
import BottomPanel from "./components/Timeline/BottomPanel";
|
import BottomPanel from "./components/Timeline/BottomPanel";
|
||||||
import DocumentTabs from "./components/Workspace/DocumentTabs";
|
import DocumentTabs from "./components/Workspace/DocumentTabs";
|
||||||
|
|
@ -46,6 +46,9 @@ function AppContent() {
|
||||||
const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
|
const { handleNewDocument, NewDocumentDialog } = useCreateDocument();
|
||||||
const [showDocumentManager, setShowDocumentManager] = useState(false);
|
const [showDocumentManager, setShowDocumentManager] = useState(false);
|
||||||
const [showKeyboardHelp, setShowKeyboardHelp] = 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 [selectedNode, setSelectedNode] = useState<Actor | null>(null);
|
||||||
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
|
const [selectedEdge, setSelectedEdge] = useState<Relation | null>(null);
|
||||||
const [addNodeCallback, setAddNodeCallback] = useState<
|
const [addNodeCallback, setAddNodeCallback] = useState<
|
||||||
|
|
@ -88,6 +91,7 @@ function AppContent() {
|
||||||
onOpenHelp: () => setShowKeyboardHelp(true),
|
onOpenHelp: () => setShowKeyboardHelp(true),
|
||||||
onFitView: handleFitView,
|
onFitView: handleFitView,
|
||||||
onSelectAll: handleSelectAll,
|
onSelectAll: handleSelectAll,
|
||||||
|
onFocusSearch: () => leftPanelRef.current?.focusSearch(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Escape key to close property panels
|
// Escape key to close property panels
|
||||||
|
|
@ -147,6 +151,7 @@ function AppContent() {
|
||||||
{/* Left Panel */}
|
{/* Left Panel */}
|
||||||
{leftPanelVisible && activeDocumentId && (
|
{leftPanelVisible && activeDocumentId && (
|
||||||
<LeftPanel
|
<LeftPanel
|
||||||
|
ref={leftPanelRef}
|
||||||
onDeselectAll={() => {
|
onDeselectAll={() => {
|
||||||
setSelectedNode(null);
|
setSelectedNode(null);
|
||||||
setSelectedEdge(null);
|
setSelectedEdge(null);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
EdgeProps,
|
EdgeProps,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
} from 'reactflow';
|
} from 'reactflow';
|
||||||
import { useGraphStore } from '../../stores/graphStore';
|
import { useGraphStore } from '../../stores/graphStore';
|
||||||
|
import { useSearchStore } from '../../stores/searchStore';
|
||||||
import type { RelationData } from '../../types';
|
import type { RelationData } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -32,6 +33,7 @@ const CustomEdge = ({
|
||||||
selected,
|
selected,
|
||||||
}: EdgeProps<RelationData>) => {
|
}: EdgeProps<RelationData>) => {
|
||||||
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
const edgeTypes = useGraphStore((state) => state.edgeTypes);
|
||||||
|
const { visibleRelationTypes } = useSearchStore();
|
||||||
|
|
||||||
// Calculate the bezier path
|
// Calculate the bezier path
|
||||||
const [edgePath, labelX, labelY] = getBezierPath({
|
const [edgePath, labelX, labelY] = getBezierPath({
|
||||||
|
|
@ -61,6 +63,18 @@ const CustomEdge = ({
|
||||||
// Get directionality (default to 'directed' for backwards compatibility)
|
// Get directionality (default to 'directed' for backwards compatibility)
|
||||||
const directionality = data?.directionality || edgeTypeConfig?.defaultDirectionality || 'directed';
|
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)
|
// Create unique marker IDs based on color (for reusability)
|
||||||
const safeColor = edgeColor.replace('#', '');
|
const safeColor = edgeColor.replace('#', '');
|
||||||
const markerEndId = `arrow-end-${safeColor}`;
|
const markerEndId = `arrow-end-${safeColor}`;
|
||||||
|
|
@ -114,6 +128,7 @@ const CustomEdge = ({
|
||||||
stroke: edgeColor,
|
stroke: edgeColor,
|
||||||
strokeWidth: selected ? 3 : 2,
|
strokeWidth: selected ? 3 : 2,
|
||||||
strokeDasharray,
|
strokeDasharray,
|
||||||
|
opacity: edgeOpacity,
|
||||||
}}
|
}}
|
||||||
markerEnd={markerEnd}
|
markerEnd={markerEnd}
|
||||||
markerStart={markerStart}
|
markerStart={markerStart}
|
||||||
|
|
@ -127,6 +142,7 @@ const CustomEdge = ({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||||
pointerEvents: 'all',
|
pointerEvents: 'all',
|
||||||
|
opacity: edgeOpacity,
|
||||||
}}
|
}}
|
||||||
className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm"
|
className="bg-white px-2 py-1 rounded border border-gray-300 text-xs font-medium shadow-sm"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { memo } from 'react';
|
import { memo, useMemo } from 'react';
|
||||||
import { Handle, Position, NodeProps, useStore } from 'reactflow';
|
import { Handle, Position, NodeProps, useStore } from 'reactflow';
|
||||||
import { useGraphStore } from '../../stores/graphStore';
|
import { useGraphStore } from '../../stores/graphStore';
|
||||||
|
import { useSearchStore } from '../../stores/searchStore';
|
||||||
import { getContrastColor, adjustColorBrightness } from '../../utils/colorUtils';
|
import { getContrastColor, adjustColorBrightness } from '../../utils/colorUtils';
|
||||||
import { getIconComponent } from '../../utils/iconUtils';
|
import { getIconComponent } from '../../utils/iconUtils';
|
||||||
import type { ActorData } from '../../types';
|
import type { ActorData } from '../../types';
|
||||||
|
|
@ -18,6 +19,7 @@ import type { ActorData } from '../../types';
|
||||||
*/
|
*/
|
||||||
const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
||||||
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
const nodeTypes = useGraphStore((state) => state.nodeTypes);
|
||||||
|
const { searchText, visibleActorTypes } = useSearchStore();
|
||||||
|
|
||||||
// Check if any connection is being made (to show handles)
|
// Check if any connection is being made (to show handles)
|
||||||
const connectionNodeId = useStore((state) => state.connectionNodeId);
|
const connectionNodeId = useStore((state) => state.connectionNodeId);
|
||||||
|
|
@ -36,6 +38,39 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
||||||
// Show handles when selected or when connecting
|
// Show handles when selected or when connecting
|
||||||
const showHandles = selected || isConnecting;
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
|
|
@ -49,8 +84,11 @@ const CustomNode = ({ data, selected }: NodeProps<ActorData>) => {
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderColor: borderColor,
|
borderColor: borderColor,
|
||||||
color: textColor,
|
color: textColor,
|
||||||
|
opacity: nodeOpacity,
|
||||||
boxShadow: selected
|
boxShadow: selected
|
||||||
? `0 0 0 3px ${nodeColor}40` // Add outer glow when selected (40 = ~25% opacity)
|
? `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,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,23 @@
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState, useMemo, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||||
import { IconButton, Tooltip } from '@mui/material';
|
import { IconButton, Tooltip, Checkbox } from '@mui/material';
|
||||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||||
import SettingsIcon from '@mui/icons-material/Settings';
|
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 { usePanelStore } from '../../stores/panelStore';
|
||||||
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
import { useGraphWithHistory } from '../../hooks/useGraphWithHistory';
|
||||||
import { useEditorStore } from '../../stores/editorStore';
|
import { useEditorStore } from '../../stores/editorStore';
|
||||||
|
import { useSearchStore } from '../../stores/searchStore';
|
||||||
import { createNode } from '../../utils/nodeUtils';
|
import { createNode } from '../../utils/nodeUtils';
|
||||||
import { getIconComponent } from '../../utils/iconUtils';
|
import { getIconComponent } from '../../utils/iconUtils';
|
||||||
import { getContrastColor } from '../../utils/colorUtils';
|
import { getContrastColor } from '../../utils/colorUtils';
|
||||||
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
import NodeTypeConfigModal from '../Config/NodeTypeConfig';
|
||||||
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
import EdgeTypeConfigModal from '../Config/EdgeTypeConfig';
|
||||||
|
import type { Actor } from '../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LeftPanel - Collapsible tools panel on the left side
|
* LeftPanel - Collapsible tools panel on the left side
|
||||||
|
|
@ -31,7 +36,11 @@ interface LeftPanelProps {
|
||||||
onAddNode?: (nodeTypeId: string, position?: { x: number; y: number }) => void;
|
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 {
|
const {
|
||||||
leftPanelCollapsed,
|
leftPanelCollapsed,
|
||||||
leftPanelWidth,
|
leftPanelWidth,
|
||||||
|
|
@ -41,11 +50,99 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
|
||||||
expandLeftPanel,
|
expandLeftPanel,
|
||||||
} = usePanelStore();
|
} = usePanelStore();
|
||||||
|
|
||||||
const { nodeTypes, edgeTypes, addNode } = useGraphWithHistory();
|
const { nodeTypes, edgeTypes, addNode, nodes } = useGraphWithHistory();
|
||||||
const { selectedRelationType, setSelectedRelationType } = useEditorStore();
|
const { selectedRelationType, setSelectedRelationType } = useEditorStore();
|
||||||
const [showNodeConfig, setShowNodeConfig] = useState(false);
|
const [showNodeConfig, setShowNodeConfig] = useState(false);
|
||||||
const [showEdgeConfig, setShowEdgeConfig] = 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(
|
const handleAddNode = useCallback(
|
||||||
(nodeTypeId: string) => {
|
(nodeTypeId: string) => {
|
||||||
// Use the shared callback from GraphEditor if available
|
// 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" />}
|
{leftPanelSections.search ? <ExpandLessIcon fontSize="small" /> : <ExpandMoreIcon fontSize="small" />}
|
||||||
</button>
|
</button>
|
||||||
{leftPanelSections.search && (
|
{leftPanelSections.search && (
|
||||||
<div className="px-3 py-3">
|
<div className="px-3 py-3 space-y-4">
|
||||||
<p className="text-xs text-gray-500 italic">
|
{/* Search Input */}
|
||||||
Search features coming soon
|
<div>
|
||||||
</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -285,6 +512,8 @@ const LeftPanel = ({ onDeselectAll, onAddNode }: LeftPanelProps) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
|
LeftPanel.displayName = 'LeftPanel';
|
||||||
|
|
||||||
export default LeftPanel;
|
export default LeftPanel;
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ interface UseGlobalShortcutsOptions {
|
||||||
onOpenHelp?: () => void;
|
onOpenHelp?: () => void;
|
||||||
onFitView?: () => void;
|
onFitView?: () => void;
|
||||||
onSelectAll?: () => void;
|
onSelectAll?: () => void;
|
||||||
|
onFocusSearch?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
|
export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
|
||||||
|
|
@ -183,6 +184,15 @@ export function useGlobalShortcuts(options: UseGlobalShortcutsOptions = {}) {
|
||||||
category: "Navigation",
|
category: "Navigation",
|
||||||
enabled: !!options.onOpenHelp,
|
enabled: !!options.onOpenHelp,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "focus-search",
|
||||||
|
description: "Focus Search",
|
||||||
|
key: "f",
|
||||||
|
ctrl: true,
|
||||||
|
handler: () => options.onFocusSearch?.(),
|
||||||
|
category: "Navigation",
|
||||||
|
enabled: !!options.onFocusSearch,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Register all shortcuts
|
// Register all shortcuts
|
||||||
|
|
|
||||||
142
src/stores/searchStore.ts
Normal file
142
src/stores/searchStore.ts
Normal 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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
Loading…
Reference in a new issue