You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
gcgj-dify-1.7.0/web/app/components/workflow/operator/node-search.tsx

315 lines
9.9 KiB
TypeScript

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'
const SearchResultItem = ({ node, searchQuery, isSelected, onClick, highlightMatch }: any) => {
const toolIcon = useToolIcon(node.nodeData)
return (
<div
className={cn(
'flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-state-base-hover',
isSelected && 'bg-state-base-hover ring-1 ring-blue-500',
)}
onClick={onClick}
>
<BlockIcon
type={node.blockType}
toolIcon={toolIcon}
className='shrink-0'
size='sm'
/>
<div className='min-w-0 flex-1'>
<div className='truncate font-medium text-text-secondary'>
{highlightMatch(node.title, searchQuery)}
</div>
<div className='truncate text-xs text-text-quaternary'>
{highlightMatch(node.type, searchQuery)}
</div>
{node.desc && (
<div className='mt-0.5 truncate text-xs text-text-quaternary'>
{highlightMatch(node.desc, searchQuery)}
</div>
)}
</div>
</div>
)
}
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<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(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) ? (
<mark key={index} className='rounded bg-yellow-200 px-0.5 text-yellow-900'>
{part}
</mark>
) : part,
)
}, [])
const searchableNodes = useMemo(() => {
const filteredNodes = nodes.filter((node) => {
return node.id && node.data && node.type !== 'sticky'
})
const result = filteredNodes
.map((node) => {
const nodeData = node.data as any
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<typeof node> => node !== null)
.sort((a, b) => b.score - a.score)
.slice(0, 8)
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)
}, 200)
}, [])
const maxResultsWidth = Math.min((workflowCanvasWidth || 800) - 40, 320)
return (
<div className='relative'>
<PortalToFollowElem
placement='bottom-start'
open={isOpen && searchQuery.trim().length > 0}
onOpenChange={setIsOpen}
>
<PortalToFollowElemTrigger>
<div className='relative'>
<Input
ref={inputRef}
wrapperClassName='w-[240px]'
value={searchQuery}
onChange={(e) => {
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'
/>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent
style={{
width: `${maxResultsWidth}px`,
zIndex: 1000,
}}
>
<div className='max-h-80 overflow-hidden rounded-lg border border-divider-regular bg-components-panel-bg shadow-lg'>
{filteredResults.length > 0 ? (
<div ref={resultsRef} className='max-h-80 overflow-y-auto'>
<div className='p-1'>
{filteredResults.map((node, index) => (
<SearchResultItem
key={node.id}
node={node}
searchQuery={searchQuery}
isSelected={selectedIndex === index}
onClick={() => {
handleNodeSelect(node.id)
setSearchQuery('')
setIsOpen(false)
setSelectedIndex(-1)
}}
highlightMatch={highlightMatch}
/>
))}
</div>
<div className='border-t border-divider-subtle bg-gray-50 px-3 py-2 text-xs text-text-tertiary'>
<div className='flex items-center justify-between'>
<span>{t('workflow.operator.searchResults', { count: filteredResults.length })}</span>
<span className='text-xs'>
{t('workflow.operator.navigate')} {t('workflow.operator.select')} {t('workflow.operator.close')}
</span>
</div>
</div>
</div>
) : searchQuery.trim() && (
<div className='p-8 text-center text-text-tertiary'>
<div className='text-sm'>{t('workflow.operator.noNodesFound')}</div>
<div className='mt-1 text-xs'>{t('workflow.operator.searchHint')}</div>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
)
}
export default NodeSearch