Feat node search (#23685)
Co-authored-by: GuanMu <ballmanjq@gmail.com> Co-authored-by: zhujiruo <zhujiruo@foxmail.com> Co-authored-by: Matri Qi <matrixdom@126.com> Co-authored-by: croatialu <wuli.croatia@foxmail.com> Co-authored-by: HyaCinth <88471803+HyaCiovo@users.noreply.github.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
@@ -18,3 +18,4 @@ export * from './use-workflow-mode'
|
||||
export * from './use-workflow-refresh-draft'
|
||||
export * from './use-inspect-vars-crud'
|
||||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-workflow-search'
|
||||
|
||||
123
web/app/components/workflow/hooks/use-workflow-search.tsx
Normal file
123
web/app/components/workflow/hooks/use-workflow-search.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { useNodesInteractions } from './use-nodes-interactions'
|
||||
import type { CommonNodeType } from '../types'
|
||||
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { setupNodeSelectionListener } from '../utils/node-navigation'
|
||||
|
||||
/**
|
||||
* Hook to register workflow nodes search functionality
|
||||
*/
|
||||
export const useWorkflowSearch = () => {
|
||||
const nodes = useNodes()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
// Filter and process nodes for search
|
||||
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
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
title: nodeData?.title || nodeData?.type || 'Untitled',
|
||||
type: nodeData?.type || '',
|
||||
desc: nodeData?.desc || '',
|
||||
blockType: nodeData?.type,
|
||||
nodeData,
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}, [nodes])
|
||||
|
||||
// Create search function for workflow nodes
|
||||
const searchWorkflowNodes = useCallback((query: string) => {
|
||||
if (!searchableNodes.length || !query.trim()) return []
|
||||
|
||||
const searchTerm = query.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(searchTerm)) score += 100
|
||||
else if (titleMatch.includes(searchTerm)) score += 50
|
||||
else if (typeMatch === searchTerm) score += 80
|
||||
else if (typeMatch.includes(searchTerm)) score += 30
|
||||
else if (descMatch.includes(searchTerm)) score += 20
|
||||
|
||||
return score > 0
|
||||
? {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.desc || node.type,
|
||||
type: 'workflow-node' as const,
|
||||
path: `#${node.id}`,
|
||||
icon: (
|
||||
<BlockIcon
|
||||
type={node.blockType}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
metadata: {
|
||||
nodeId: node.id,
|
||||
nodeData: node.nodeData,
|
||||
},
|
||||
// Add required data property for SearchResult type
|
||||
data: node.nodeData,
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||
.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase()
|
||||
const bTitle = b.title.toLowerCase()
|
||||
|
||||
if (aTitle.startsWith(searchTerm) && !bTitle.startsWith(searchTerm)) return -1
|
||||
if (!aTitle.startsWith(searchTerm) && bTitle.startsWith(searchTerm)) return 1
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return results
|
||||
}, [searchableNodes])
|
||||
|
||||
// Directly set the search function on the action object
|
||||
useEffect(() => {
|
||||
if (searchableNodes.length > 0) {
|
||||
// Set the search function directly on the action
|
||||
workflowNodesAction.searchFn = searchWorkflowNodes
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up when component unmounts
|
||||
workflowNodesAction.searchFn = undefined
|
||||
}
|
||||
}, [searchableNodes, searchWorkflowNodes])
|
||||
|
||||
// Set up node selection event listener using the utility function
|
||||
useEffect(() => {
|
||||
return setupNodeSelectionListener(handleNodeSelect)
|
||||
}, [handleNodeSelect])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import { CUSTOM_LOOP_START_NODE } from './nodes/loop-start/constants'
|
||||
import CustomSimpleNode from './simple-node'
|
||||
import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
|
||||
import Operator from './operator'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
import Control from './operator/control'
|
||||
import CustomEdge from './custom-edge'
|
||||
import CustomConnectionLine from './custom-connection-line'
|
||||
@@ -68,6 +69,7 @@ import NodeContextmenu from './node-contextmenu'
|
||||
import SelectionContextmenu from './selection-contextmenu'
|
||||
import SyncingDataModal from './syncing-data-modal'
|
||||
import LimitTips from './limit-tips'
|
||||
import { setupScrollToNodeListener } from './utils/node-navigation'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
@@ -280,6 +282,14 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
})
|
||||
|
||||
useShortcuts()
|
||||
// Initialize workflow node search functionality
|
||||
useWorkflowSearch()
|
||||
|
||||
// Set up scroll to node event listener using the utility function
|
||||
useEffect(() => {
|
||||
return setupScrollToNodeListener(nodes, reactflow)
|
||||
}, [nodes, reactflow])
|
||||
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
|
||||
useEffect(() => {
|
||||
fetchInspectVars()
|
||||
|
||||
@@ -52,7 +52,9 @@ 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} />
|
||||
</div>
|
||||
<VariableTrigger />
|
||||
<div className='relative'>
|
||||
<MiniMap
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
124
web/app/components/workflow/utils/node-navigation.ts
Normal file
124
web/app/components/workflow/utils/node-navigation.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Node navigation utilities for workflow
|
||||
* This module provides functions for node selection, focusing and scrolling in workflow
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for node selection event detail
|
||||
*/
|
||||
export type NodeSelectionDetail = {
|
||||
nodeId: string;
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node in the workflow
|
||||
* @param nodeId - The ID of the node to select
|
||||
* @param focus - Whether to focus/scroll to the node
|
||||
*/
|
||||
export function selectWorkflowNode(nodeId: string, focus = false): void {
|
||||
// Create and dispatch a custom event for node selection
|
||||
const event = new CustomEvent('workflow:select-node', {
|
||||
detail: {
|
||||
nodeId,
|
||||
focus,
|
||||
},
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a specific node in the workflow
|
||||
* @param nodeId - The ID of the node to scroll to
|
||||
*/
|
||||
export function scrollToWorkflowNode(nodeId: string): void {
|
||||
// Create and dispatch a custom event for scrolling to node
|
||||
const event = new CustomEvent('workflow:scroll-to-node', {
|
||||
detail: { nodeId },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup node selection event listener
|
||||
* @param handleNodeSelect - Function to handle node selection
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupNodeSelectionListener(
|
||||
handleNodeSelect: (nodeId: string) => void,
|
||||
): () => void {
|
||||
// Event handler for node selection
|
||||
const handleNodeSelection = (event: CustomEvent<NodeSelectionDetail>) => {
|
||||
const { nodeId, focus } = event.detail
|
||||
if (nodeId) {
|
||||
// Select the node
|
||||
handleNodeSelect(nodeId)
|
||||
|
||||
// If focus is requested, scroll to the node
|
||||
if (focus) {
|
||||
// Use a small timeout to ensure node selection happens first
|
||||
setTimeout(() => {
|
||||
scrollToWorkflowNode(nodeId)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener(
|
||||
'workflow:select-node',
|
||||
handleNodeSelection as EventListener,
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'workflow:select-node',
|
||||
handleNodeSelection as EventListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll to node event listener with ReactFlow
|
||||
* @param nodes - The workflow nodes
|
||||
* @param reactflow - The ReactFlow instance
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupScrollToNodeListener(
|
||||
nodes: any[],
|
||||
reactflow: any,
|
||||
): () => void {
|
||||
// Event handler for scrolling to node
|
||||
const handleScrollToNode = (event: CustomEvent<NodeSelectionDetail>) => {
|
||||
const { nodeId } = event.detail
|
||||
if (nodeId) {
|
||||
// Find the target node
|
||||
const node = nodes.find(n => n.id === nodeId)
|
||||
if (node) {
|
||||
// Use ReactFlow's fitView API to scroll to the node
|
||||
reactflow.fitView({
|
||||
nodes: [node],
|
||||
padding: 0.2,
|
||||
duration: 800,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener(
|
||||
'workflow:scroll-to-node',
|
||||
handleScrollToNode as EventListener,
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'workflow:scroll-to-node',
|
||||
handleScrollToNode as EventListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user