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