Merge branch 'main' into feat/rag-2

This commit is contained in:
twwu
2025-08-11 11:15:58 +08:00
214 changed files with 8987 additions and 838 deletions

View File

@ -23,7 +23,7 @@ const useStickyScroll = ({
return
const { height: wrapHeight, top: wrapTop } = wrapDom.getBoundingClientRect()
const { top: nextToStickyTop } = stickyDOM.getBoundingClientRect()
let scrollPositionNew = ScrollPosition.belowTheWrap
let scrollPositionNew: ScrollPosition
if (nextToStickyTop - wrapTop >= wrapHeight)
scrollPositionNew = ScrollPosition.belowTheWrap

View File

@ -36,9 +36,9 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
<div
data-tooltip-id='workflow.undo'
className={
classNames('flex items-center px-1.5 w-8 h-8 rounded-md system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer select-none',
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
(nodesReadOnly || buttonsDisabled.undo)
&& 'hover:bg-transparent text-text-disabled hover:text-text-disabled cursor-not-allowed')}
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')}
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
>
<RiArrowGoBackLine className='h-4 w-4' />
@ -48,9 +48,9 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
<div
data-tooltip-id='workflow.redo'
className={
classNames('flex items-center px-1.5 w-8 h-8 rounded-md system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer select-none',
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
(nodesReadOnly || buttonsDisabled.redo)
&& 'hover:bg-transparent text-text-disabled hover:text-text-disabled cursor-not-allowed',
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
>

View File

@ -127,7 +127,7 @@ const ViewWorkflowHistory = () => {
>
<div
className={
classNames('flex items-center justify-center w-8 h-8 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer',
classNames('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-accent-active text-text-accent',
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}

View File

@ -21,4 +21,5 @@ export * from './use-tool-icon'
export * from './use-DSL'
export * from './use-inspect-vars-crud'
export * from './use-set-workflow-vars-with-value'
export * from './use-workflow-search'
export * from './use-format-time-from-now'

View 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
}

View File

@ -60,6 +60,7 @@ import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
import { CUSTOM_DATA_SOURCE_EMPTY_NODE } from './nodes/data-source-empty/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'
@ -70,6 +71,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,
@ -283,6 +285,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()

View File

@ -182,7 +182,7 @@ const FormItem: FC<Props> = ({
value={singleFileValue}
onChange={handleSingleFileChange}
fileConfig={{
allowed_file_types: inStepRun
allowed_file_types: inStepRun && (!payload.allowed_file_types || payload.allowed_file_types.length === 0)
? [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
@ -190,7 +190,7 @@ const FormItem: FC<Props> = ({
SupportUploadFileTypes.video,
]
: payload.allowed_file_types,
allowed_file_extensions: inStepRun
allowed_file_extensions: inStepRun && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0)
? [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],
@ -209,7 +209,7 @@ const FormItem: FC<Props> = ({
value={value}
onChange={files => onChange(files)}
fileConfig={{
allowed_file_types: (inStepRun || isIteratorItemFile)
allowed_file_types: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_types || payload.allowed_file_types.length === 0)
? [
SupportUploadFileTypes.image,
SupportUploadFileTypes.document,
@ -217,7 +217,7 @@ const FormItem: FC<Props> = ({
SupportUploadFileTypes.video,
]
: payload.allowed_file_types,
allowed_file_extensions: (inStepRun || isIteratorItemFile)
allowed_file_extensions: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0)
? [
...FILE_EXTS[SupportUploadFileTypes.image],
...FILE_EXTS[SupportUploadFileTypes.document],

View File

@ -5,7 +5,7 @@ export type GroupLabelProps = ComponentProps<'div'>
export const GroupLabel: FC<GroupLabelProps> = (props) => {
const { children, className, ...rest } = props
return <div {...rest} className={classNames('mb-1 system-2xs-medium-uppercase text-text-tertiary', className)}>
return <div {...rest} className={classNames('system-2xs-medium-uppercase mb-1 text-text-tertiary', className)}>
{children}
</div>
}

View File

@ -11,7 +11,6 @@ import {
import {
useNodeDataUpdate,
useNodesInteractions,
useNodesSyncDraft,
} from '../../../hooks'
import { type Node, NodeRunningStatus } from '../../../types'
import { canRunBySingle } from '../../../utils'
@ -30,7 +29,6 @@ const NodeControl: FC<NodeControlProps> = ({
const [open, setOpen] = useState(false)
const { handleNodeDataUpdate } = useNodeDataUpdate()
const { handleNodeSelect } = useNodesInteractions()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
const handleOpenChange = useCallback((newOpen: boolean) => {
setOpen(newOpen)

View File

@ -66,7 +66,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
>
<div
className={classNames(
'flex items-center px-2 py-1 gap-0.5 rounded-lg bg-components-input-bg-normal',
'flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1',
disabled ? 'cursor-not-allowed !bg-components-input-bg-disabled' : 'cursor-pointer hover:bg-state-base-hover-alt',
open && 'bg-state-base-hover-alt',
className,
@ -99,7 +99,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
<div
key={item.value}
className={classNames(
'flex items-center px-2 py-1 gap-1 self-stretch rounded-lg',
'flex items-center gap-1 self-stretch rounded-lg px-2 py-1',
'cursor-pointer hover:bg-state-base-hover',
)}
onClick={() => {

View File

@ -36,6 +36,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
const { inputs, setInputs } = useNodeCrud<ListFilterNodeType>(id, payload)
const { getCurrentVariableType } = useWorkflowVariables()
const getType = useCallback((variable?: ValueSelector) => {
const varType = getCurrentVariableType({
parentNode: isInIteration ? iterationNode : loopNode,
@ -44,7 +45,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
isChatMode,
isConstant: false,
})
let itemVarType = VarType.string
let itemVarType = varType
switch (varType) {
case VarType.arrayNumber:
itemVarType = VarType.number
@ -58,8 +59,6 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
case VarType.arrayObject:
itemVarType = VarType.object
break
default:
itemVarType = varType
}
return { varType, itemVarType }
}, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode])

View File

@ -13,7 +13,7 @@ const ErrorMessage: FC<ErrorMessageProps> = ({
}) => {
return (
<div className={classNames(
'flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg',
'mt-1 flex gap-x-1 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg p-2',
className,
)}>
<RiErrorWarningFill className='h-4 w-4 shrink-0 text-text-destructive' />

View File

@ -16,17 +16,22 @@ import {
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { getNodesBounds, useReactFlow } from 'reactflow'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
import { useStore } from '@/app/components/workflow/store'
const ExportImage: FC = () => {
const { t } = useTranslation()
const { getNodesReadOnly } = useNodesReadOnly()
const reactFlow = useReactFlow()
const appDetail = useAppStore(s => s.appDetail)
const [open, setOpen] = useState(false)
const [previewUrl, setPreviewUrl] = useState('')
const [previewTitle, setPreviewTitle] = useState('')
const knowledgeName = useStore(s => s.knowledgeName)
const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg') => {
const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => {
if (!appDetail && !knowledgeName)
return
@ -46,31 +51,123 @@ const ExportImage: FC = () => {
}
let dataUrl
switch (type) {
case 'png':
dataUrl = await toPng(flowElement, { filter })
break
case 'jpeg':
dataUrl = await toJpeg(flowElement, { filter })
break
case 'svg':
dataUrl = await toSvg(flowElement, { filter })
break
default:
dataUrl = await toPng(flowElement, { filter })
let filename = `${appDetail.name}`
if (currentWorkflow) {
// Get all nodes and their bounds
const nodes = reactFlow.getNodes()
const nodesBounds = getNodesBounds(nodes)
// Save current viewport
const currentViewport = reactFlow.getViewport()
// Calculate the required zoom to fit all nodes
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
const zoom = Math.min(
viewportWidth / (nodesBounds.width + 100),
viewportHeight / (nodesBounds.height + 100),
1,
)
// Calculate center position
const centerX = nodesBounds.x + nodesBounds.width / 2
const centerY = nodesBounds.y + nodesBounds.height / 2
// Set viewport to show all nodes
reactFlow.setViewport({
x: viewportWidth / 2 - centerX * zoom,
y: viewportHeight / 2 - centerY * zoom,
zoom,
})
// Wait for the transition to complete
await new Promise(resolve => setTimeout(resolve, 300))
// Calculate actual content size with padding
const padding = 50 // More padding for better visualization
const contentWidth = nodesBounds.width + padding * 2
const contentHeight = nodesBounds.height + padding * 2
// Export with higher quality for whole workflow
const exportOptions = {
filter,
backgroundColor: '#1a1a1a', // Dark background to match previous style
pixelRatio: 2, // Higher resolution for better zoom
width: contentWidth,
height: contentHeight,
style: {
width: `${contentWidth}px`,
height: `${contentHeight}px`,
transform: `translate(${padding - nodesBounds.x}px, ${padding - nodesBounds.y}px) scale(${zoom})`,
},
}
switch (type) {
case 'png':
dataUrl = await toPng(flowElement, exportOptions)
break
case 'jpeg':
dataUrl = await toJpeg(flowElement, exportOptions)
break
case 'svg':
dataUrl = await toSvg(flowElement, { filter })
break
default:
dataUrl = await toPng(flowElement, exportOptions)
}
filename += '-whole-workflow'
// Restore original viewport after a delay
setTimeout(() => {
reactFlow.setViewport(currentViewport)
}, 500)
}
else {
// Current viewport export (existing functionality)
switch (type) {
case 'png':
dataUrl = await toPng(flowElement, { filter })
break
case 'jpeg':
dataUrl = await toJpeg(flowElement, { filter })
break
case 'svg':
dataUrl = await toSvg(flowElement, { filter })
break
default:
dataUrl = await toPng(flowElement, { filter })
}
}
const link = document.createElement('a')
link.href = dataUrl
link.download = `${appDetail ? appDetail.name : knowledgeName}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
if (currentWorkflow) {
// For whole workflow, show preview first
setPreviewUrl(dataUrl)
setPreviewTitle(`${filename}.${type}`)
// Also auto-download
const link = document.createElement('a')
link.href = dataUrl
link.download = `${filename}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
else {
// For current view, just download
const link = document.createElement('a')
link.href = dataUrl
link.download = `${appDetail ? filename : knowledgeName}.${type}`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
}
catch (error) {
console.error('Export image failed:', error)
}
}, [getNodesReadOnly, appDetail, knowledgeName])
}, [getNodesReadOnly, appDetail, reactFlow, knowledgeName])
const handleTrigger = useCallback(() => {
if (getNodesReadOnly())
@ -80,53 +177,90 @@ const ExportImage: FC = () => {
}, [getNodesReadOnly])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -8,
}}
>
<PortalToFollowElemTrigger>
<TipPopup title={t('workflow.common.exportImage')}>
<div
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
`${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
)}
onClick={handleTrigger}
>
<RiExportLine className='h-4 w-4' />
</div>
</TipPopup>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'>
<div className='p-1'>
<>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="top-start"
offset={{
mainAxis: 4,
crossAxis: -8,
}}
>
<PortalToFollowElemTrigger>
<TipPopup title={t('workflow.common.exportImage')}>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('png')}
className={cn(
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
`${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
)}
onClick={handleTrigger}
>
{t('workflow.common.exportPNG')}
<RiExportLine className='h-4 w-4' />
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('jpeg')}
>
{t('workflow.common.exportJPEG')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('svg')}
>
{t('workflow.common.exportSVG')}
</TipPopup>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'>
<div className='p-1'>
<div className='px-2 py-1 text-xs font-medium text-text-tertiary'>
{t('workflow.common.currentView')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('png')}
>
{t('workflow.common.exportPNG')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('jpeg')}
>
{t('workflow.common.exportJPEG')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('svg')}
>
{t('workflow.common.exportSVG')}
</div>
<div className='border-border-divider mx-2 my-1 border-t' />
<div className='px-2 py-1 text-xs font-medium text-text-tertiary'>
{t('workflow.common.currentWorkflow')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('png', true)}
>
{t('workflow.common.exportPNG')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('jpeg', true)}
>
{t('workflow.common.exportJPEG')}
</div>
<div
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
onClick={() => handleExportImage('svg', true)}
>
{t('workflow.common.exportSVG')}
</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</PortalToFollowElemContent>
</PortalToFollowElem>
{previewUrl && (
<ImagePreview
url={previewUrl}
title={previewTitle}
onCancel={() => setPreviewUrl('')}
/>
)}
</>
)
}

View File

@ -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

View File

@ -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'
@ -11,12 +11,12 @@ const Record = () => {
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const getWorkflowRunAndTraceUrl = useHooksStore(s => s.getWorkflowRunAndTraceUrl)
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])

View File

@ -260,7 +260,30 @@ const SelectionContextmenu = () => {
// Get all selected nodes
const selectedNodeIds = selectedNodes.map(node => node.id)
const nodesToAlign = nodes.filter(node => selectedNodeIds.includes(node.id))
// Find container nodes and their children
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
// when the container is selected. This prevents child nodes from being moved outside their containers.
const childNodeIds = new Set<string>()
nodes.forEach((node) => {
// Check if this is a container node (Iteration or Loop)
if (node.data._children && node.data._children.length > 0) {
// If container node is selected, add its children to the exclusion set
if (selectedNodeIds.includes(node.id)) {
// Add all its children to the childNodeIds set
node.data._children.forEach((child: { nodeId: string; nodeType: string }) => {
childNodeIds.add(child.nodeId)
})
}
}
})
// Filter out child nodes from the alignment operation
// Only align nodes that are selected AND are not children of container nodes
// This ensures container nodes can be aligned while their children stay in the same relative position
const nodesToAlign = nodes.filter(node =>
selectedNodeIds.includes(node.id) && !childNodeIds.has(node.id))
if (nodesToAlign.length <= 1) {
handleSelectionContextmenuCancel()

View 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,
)
}
}