mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 00:48:04 +08:00
Merge branch 'main' into feat/rag-2
This commit is contained in:
@ -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
|
||||
|
||||
@ -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()}
|
||||
>
|
||||
|
||||
@ -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',
|
||||
)}
|
||||
|
||||
@ -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'
|
||||
|
||||
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
|
||||
}
|
||||
@ -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()
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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' />
|
||||
|
||||
@ -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('')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
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