pull/22574/merge
GuanMu 6 months ago committed by GitHub
commit 8a07c05b3d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -32,7 +32,7 @@ export type InputProps = {
unit?: string
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
const Input = ({
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
size,
disabled,
destructive,
@ -47,12 +47,13 @@ const Input = ({
onChange = noop,
unit,
...props
}: InputProps) => {
}, ref) => {
const { t } = useTranslation()
return (
<div className={cn('relative w-full', wrapperClassName)}>
{showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
<input
ref={ref}
style={styleCss}
className={cn(
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
@ -92,6 +93,8 @@ const Input = ({
}
</div>
)
}
})
Input.displayName = 'Input'
export default Input

@ -2,6 +2,7 @@ import { memo, useEffect, useMemo, useRef } from 'react'
import { MiniMap } from 'reactflow'
import UndoRedo from '../header/undo-redo'
import ZoomInOut from './zoom-in-out'
import NodeSearch from './node-search'
import VariableTrigger from '../variable-inspect/trigger'
import VariableInspectPanel from '../variable-inspect'
import { useStore } from '../store'
@ -52,7 +53,10 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
}
>
<div className='flex justify-between px-1 pb-2'>
<UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
<div className='flex items-center gap-2'>
<UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
<NodeSearch />
</div>
<VariableTrigger />
<div className='relative'>
<MiniMap

@ -0,0 +1,351 @@
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 (
<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) => {
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<typeof node> => 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 (
<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

@ -1,5 +1,5 @@
import { memo, useCallback } from 'react'
import type { WorkflowDataUpdater } from '../types'
import type { WorkflowRunDetailResponse } from '@/models/log'
import Run from '../run'
import { useStore } from '../store'
import { useWorkflowUpdate } from '../hooks'
@ -9,12 +9,12 @@ const Record = () => {
const historyWorkflowData = useStore(s => s.historyWorkflowData)
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const handleResultCallback = useCallback((res: any) => {
const graph: WorkflowDataUpdater = res.graph
const handleResultCallback = useCallback((res: WorkflowRunDetailResponse) => {
const graph = res.graph
handleUpdateWorkflowCanvas({
nodes: graph.nodes,
edges: graph.edges,
viewport: graph.viewport,
viewport: graph.viewport || { x: 0, y: 0, zoom: 1 },
})
}, [handleUpdateWorkflowCanvas])

@ -287,6 +287,14 @@ const translation = {
zoomTo50: 'Zoom to 50%',
zoomTo100: 'Zoom to 100%',
zoomToFit: 'Zoom to Fit',
searchNodes: 'Search nodes',
searchNodesShortcut: 'Search nodes ({{shortcut}})',
noNodesFound: 'No nodes found',
searchResults: '{{count}} result(s)',
navigate: 'navigate',
select: 'select',
close: 'close',
searchHint: 'Try searching by node name or type',
},
variableReference: {
noAvailableVars: 'No available variables',

@ -288,6 +288,14 @@ const translation = {
zoomTo50: '缩放到 50%',
zoomTo100: '放大到 100%',
zoomToFit: '自适应视图',
searchNodes: '搜索节点',
searchNodesShortcut: '搜索节点 ({{shortcut}})',
noNodesFound: '未找到节点',
searchResults: '{{count}}个结果',
navigate: '导航',
select: '选择',
close: '关闭',
searchHint: '尝试按节点名称或类型搜索',
},
variableReference: {
noAvailableVars: '没有可用变量',

Loading…
Cancel
Save