diff --git a/web/app/components/base/input/index.tsx b/web/app/components/base/input/index.tsx index 30fd90aff8..ae171b0a76 100644 --- a/web/app/components/base/input/index.tsx +++ b/web/app/components/base/input/index.tsx @@ -32,7 +32,7 @@ export type InputProps = { unit?: string } & Omit, 'size'> & VariantProps -const Input = ({ +const Input = React.forwardRef(({ size, disabled, destructive, @@ -47,12 +47,13 @@ const Input = ({ onChange = noop, unit, ...props -}: InputProps) => { +}, ref) => { const { t } = useTranslation() return (
{showLeftIcon && } ) -} +}) + +Input.displayName = 'Input' export default Input diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 4a472a755f..9a662e34cb 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -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) => { } >
- +
+ + +
{ + 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) => { + 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 => 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 ( +
+ 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 diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index c56b497ac2..71b8a0f001 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -286,6 +286,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', @@ -940,6 +948,11 @@ const translation = { debug: { settingsTab: 'Settings', lastRunTab: 'Last Run', + copyLastRun: 'Copy Last Run', + noLastRunFound: 'No previous run found', + noMatchingInputsFound: 'No matching inputs found from last run', + lastRunInputsCopied: '{{count}} input(s) copied from last run', + copyLastRunError: 'Failed to copy last run inputs', noData: { description: 'The results of the last run will be displayed here', runThisNode: 'Run this node', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 6bd202d58f..90d7fd3b9d 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -287,6 +287,14 @@ const translation = { zoomTo50: '缩放到 50%', zoomTo100: '放大到 100%', zoomToFit: '自适应视图', + searchNodes: '搜索节点', + searchNodesShortcut: '搜索节点 ({{shortcut}})', + noNodesFound: '未找到节点', + searchResults: '{{count}}个结果', + navigate: '导航', + select: '选择', + close: '关闭', + searchHint: '尝试按节点名称或类型搜索', }, variableReference: { noAvailableVars: '没有可用变量', @@ -941,6 +949,11 @@ const translation = { debug: { settingsTab: '设置', lastRunTab: '上次运行', + copyLastRun: '复制上次运行值', + noLastRunFound: '未找到上次运行记录', + noMatchingInputsFound: '上次运行中未找到匹配的输入', + lastRunInputsCopied: '已复制{{count}}个输入值', + copyLastRunError: '复制上次运行输入失败', noData: { description: '上次运行的结果将显示在这里', runThisNode: '运行此节点',