import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNodes } from 'reactflow' import { useKeyPress } from 'ahooks' import Input from '@/app/components/base/input' import { useNodesInteractions, useToolIcon } from '../hooks' import BlockIcon from '../block-icon' import { useStore } from '../store' import { getKeyboardKeyCodeBySystem, isEventTargetInputArea } from '../utils' import cn from '@/utils/classnames' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem' import type { BlockEnum, CommonNodeType } from '../types' // Constants for layout dimensions and behavior const SEARCH_LAYOUT_CONSTANTS = { DEFAULT_CANVAS_WIDTH: 800, CANVAS_MARGIN: 40, MAX_RESULTS_WIDTH: 320, MAX_SEARCH_RESULTS: 8, BLUR_DELAY_MS: 200, } as const // Interface for search result node data type SearchResultNode = { blockType: BlockEnum nodeData: CommonNodeType title: string type: string desc?: string } // Props interface for SearchResultItem component type SearchResultItemProps = { node: SearchResultNode searchQuery: string isSelected: boolean onClick: () => void highlightMatch: (text: string, query: string) => React.ReactNode } const SearchResultItem = ({ node, searchQuery, isSelected, onClick, highlightMatch }: SearchResultItemProps) => { const toolIcon = useToolIcon(node.nodeData) return (
{highlightMatch(node.title, searchQuery)}
{highlightMatch(node.type, searchQuery)}
{node.desc && (
{highlightMatch(node.desc, searchQuery)}
)}
) } const NodeSearch = () => { const { t } = useTranslation() const nodes = useNodes() const [searchQuery, setSearchQuery] = useState('') const [isOpen, setIsOpen] = useState(false) const [selectedIndex, setSelectedIndex] = useState(-1) const { handleNodeSelect } = useNodesInteractions() const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth) const resultsRef = useRef(null) const inputRef = useRef(null) const isMac = useMemo(() => { return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().includes('MAC') }, []) useEffect(() => { const handleClickOutside = (event: MouseEvent) => { const target = event.target as HTMLElement if ( resultsRef.current && !resultsRef.current.contains(target) && inputRef.current && !inputRef.current.contains(target) ) { setIsOpen(false) setSelectedIndex(-1) } } if (isOpen) document.addEventListener('mousedown', handleClickOutside) return () => { document.removeEventListener('mousedown', handleClickOutside) } }, [isOpen]) useEffect(() => { if (searchQuery.trim()) setIsOpen(true) }, [searchQuery]) const highlightMatch = useCallback((text: string, query: string) => { if (!query.trim()) return text const safeQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const regex = new RegExp(`(${safeQuery})`, 'gi') const parts = text.split(regex) return parts.map((part, index) => regex.test(part) ? ( {part} ) : part, ) }, []) const searchableNodes = useMemo(() => { const filteredNodes = nodes.filter((node) => { if (!node.id || !node.data || node.type === 'sticky') return false const nodeData = node.data as CommonNodeType const nodeType = nodeData?.type const internalStartNodes = ['iteration-start', 'loop-start'] return !internalStartNodes.includes(nodeType) }) const result = filteredNodes .map((node) => { const nodeData = node.data as CommonNodeType const processedNode = { id: node.id, title: nodeData?.title || nodeData?.type || 'Untitled', type: nodeData?.type || '', desc: nodeData?.desc || '', blockType: nodeData?.type, nodeData, } return processedNode }) return result }, [nodes]) const filteredResults = useMemo(() => { if (!searchQuery.trim()) return [] const query = searchQuery.toLowerCase() const results = searchableNodes .map((node) => { const titleMatch = node.title.toLowerCase() const typeMatch = node.type.toLowerCase() const descMatch = node.desc.toLowerCase() let score = 0 if (titleMatch.startsWith(query)) score += 100 else if (titleMatch.includes(query)) score += 50 else if (typeMatch === query) score += 80 else if (typeMatch.includes(query)) score += 30 else if (descMatch.includes(query)) score += 20 return score > 0 ? { ...node, score } : null }) .filter((node): node is NonNullable => node !== null) .sort((a, b) => b.score - a.score) .slice(0, SEARCH_LAYOUT_CONSTANTS.MAX_SEARCH_RESULTS) return results }, [searchableNodes, searchQuery]) const handleFocusSearch = useCallback((e: KeyboardEvent) => { if (isEventTargetInputArea(e.target as HTMLElement)) return e.preventDefault() inputRef.current?.focus() }, []) useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleFocusSearch, { exactMatch: true, useCapture: true, }) useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!isOpen || filteredResults.length === 0) return switch (e.key) { case 'ArrowDown': { e.preventDefault() setSelectedIndex(prev => prev < filteredResults.length - 1 ? prev + 1 : 0, ) break } case 'ArrowUp': { e.preventDefault() setSelectedIndex(prev => prev > 0 ? prev - 1 : filteredResults.length - 1, ) break } case 'Enter': { e.preventDefault() if (selectedIndex >= 0 && filteredResults[selectedIndex]) { handleNodeSelect(filteredResults[selectedIndex].id) setSearchQuery('') setIsOpen(false) setSelectedIndex(-1) } break } case 'Escape': { e.preventDefault() setSearchQuery('') setIsOpen(false) setSelectedIndex(-1) break } default: break } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [isOpen, filteredResults, selectedIndex, handleNodeSelect]) useEffect(() => { setSelectedIndex(-1) }, [searchQuery]) useEffect(() => { if (selectedIndex >= 0 && resultsRef.current) { const listContainer = resultsRef.current.children[0] as HTMLElement | undefined const selectedElement = listContainer?.children[selectedIndex] as HTMLElement | undefined if (selectedElement) selectedElement.scrollIntoView({ block: 'nearest' }) } }, [selectedIndex]) const handleInputBlur = useCallback(() => { setTimeout(() => { setIsOpen(false) setSelectedIndex(-1) }, SEARCH_LAYOUT_CONSTANTS.BLUR_DELAY_MS) }, []) const maxResultsWidth = Math.min( (workflowCanvasWidth || SEARCH_LAYOUT_CONSTANTS.DEFAULT_CANVAS_WIDTH) - SEARCH_LAYOUT_CONSTANTS.CANVAS_MARGIN, SEARCH_LAYOUT_CONSTANTS.MAX_RESULTS_WIDTH, ) return (
0} onOpenChange={setIsOpen} >
{ setSearchQuery(e.target.value) setIsOpen(true) }} onFocus={() => setIsOpen(true)} onBlur={handleInputBlur} onClear={() => setSearchQuery('')} placeholder={t('workflow.operator.searchNodesShortcut', { shortcut: isMac ? 'Cmd+K' : 'Ctrl+K', })} showLeftIcon showClearIcon className='text-sm' />
{filteredResults.length > 0 ? (
{filteredResults.map((node, index) => ( { handleNodeSelect(node.id) setSearchQuery('') setIsOpen(false) setSelectedIndex(-1) }} highlightMatch={highlightMatch} /> ))}
{t('workflow.operator.searchResults', { count: filteredResults.length })} ↑↓ {t('workflow.operator.navigate')} • ↵ {t('workflow.operator.select')} • ⎋ {t('workflow.operator.close')}
) : searchQuery.trim() && (
{t('workflow.operator.noNodesFound')}
{t('workflow.operator.searchHint')}
)}
) } export default NodeSearch