import { FC, useCallback, useEffect } from 'react' import { EdgeById, NodeById, GraphSearchInputProps, GraphSearchContextProviderProps } from '@react-sigma/graph-search' import { AsyncSearch } from '@/components/ui/AsyncSearch' import { searchResultLimit } from '@/lib/constants' import { useGraphStore } from '@/stores/graph' import MiniSearch from 'minisearch' import { useTranslation } from 'react-i18next' // Message item identifier for search results export const messageId = '__message_item' // Search result option item interface export interface OptionItem { id: string type: 'nodes' | 'edges' | 'message' message?: string } const NodeOption = ({ id }: { id: string }) => { const graph = useGraphStore.use.sigmaGraph() if (!graph?.hasNode(id)) { return null } return } function OptionComponent(item: OptionItem) { return (
{item.type === 'nodes' && } {item.type === 'edges' && } {item.type === 'message' &&
{item.message}
}
) } /** * Component thats display the search input. */ export const GraphSearchInput = ({ onChange, onFocus, value }: { onChange: GraphSearchInputProps['onChange'] onFocus?: GraphSearchInputProps['onFocus'] value?: GraphSearchInputProps['value'] }) => { const { t } = useTranslation() const graph = useGraphStore.use.sigmaGraph() const searchEngine = useGraphStore.use.searchEngine() // Reset search engine when graph changes useEffect(() => { if (graph) { useGraphStore.getState().resetSearchEngine() } }, [graph]); // Create search engine when needed useEffect(() => { // Skip if no graph, empty graph, or search engine already exists if (!graph || graph.nodes().length === 0 || searchEngine) { return } // Create new search engine const newSearchEngine = new MiniSearch({ idField: 'id', fields: ['label'], searchOptions: { prefix: true, fuzzy: 0.2, boost: { label: 2 } } }) // Add nodes to search engine const documents = graph.nodes().map((id: string) => ({ id: id, label: graph.getNodeAttribute(id, 'label') })) newSearchEngine.addAll(documents) // Update search engine in store useGraphStore.getState().setSearchEngine(newSearchEngine) }, [graph, searchEngine]) /** * Loading the options while the user is typing. */ const loadOptions = useCallback( async (query?: string): Promise => { if (onFocus) onFocus(null) // Safety checks to prevent crashes if (!graph || !searchEngine) { return [] } // Verify graph has nodes before proceeding if (graph.nodes().length === 0) { return [] } // If no query, return some nodes for user to select if (!query) { const nodeIds = graph.nodes() .filter(id => graph.hasNode(id)) .slice(0, searchResultLimit) return nodeIds.map(id => ({ id, type: 'nodes' })) } // If has query, search nodes and verify they still exist let result: OptionItem[] = searchEngine.search(query) .filter((r: { id: string }) => graph.hasNode(r.id)) .map((r: { id: string }) => ({ id: r.id, type: 'nodes' })) // Add middle-content matching if results are few // This enables matching content in the middle of text, not just from the beginning if (result.length < 5) { // Get already matched IDs to avoid duplicates const matchedIds = new Set(result.map(item => item.id)) // Perform middle-content matching on all nodes const middleMatchResults = graph.nodes() .filter(id => { // Skip already matched nodes if (matchedIds.has(id)) return false // Get node label const label = graph.getNodeAttribute(id, 'label') // Match if label contains query string but doesn't start with it return label && typeof label === 'string' && !label.toLowerCase().startsWith(query.toLowerCase()) && label.toLowerCase().includes(query.toLowerCase()) }) .map(id => ({ id, type: 'nodes' as const })) // Merge results result = [...result, ...middleMatchResults] } // prettier-ignore return result.length <= searchResultLimit ? result : [ ...result.slice(0, searchResultLimit), { type: 'message', id: messageId, message: t('graphPanel.search.message', { count: result.length - searchResultLimit }) } ] }, [graph, searchEngine, onFocus, t] ) return ( item.id} value={value && value.type !== 'message' ? value.id : null} onChange={(id) => { if (id !== messageId) onChange(id ? { id, type: 'nodes' } : null) }} onFocus={(id) => { if (id !== messageId && onFocus) onFocus(id ? { id, type: 'nodes' } : null) }} label={'item'} placeholder={t('graphPanel.search.placeholder')} /> ) } /** * Component that display the search. */ const GraphSearch: FC = ({ ...props }) => { return } export default GraphSearch