feat: workflow interaction (#4214)
This commit is contained in:
@@ -72,15 +72,13 @@ const Blocks = ({
|
||||
className='!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !bg-transparent !rounded-xl !shadow-lg'
|
||||
htmlContent={(
|
||||
<div>
|
||||
<div className='flex items-center mb-2'>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mr-2'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='text-sm text-gray-900'>{block.title}</div>
|
||||
</div>
|
||||
{nodesExtraData[block.type].about}
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='mb-1 text-sm leading-5 text-gray-900'>{block.title}</div>
|
||||
<div className='text-xs text-gray-700 leading-[18px]'>{nodesExtraData[block.type].about}</div>
|
||||
</div>
|
||||
)}
|
||||
noArrow
|
||||
@@ -91,7 +89,7 @@ const Blocks = ({
|
||||
onClick={() => onSelect(block.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className='mr-2'
|
||||
className='mr-2 shrink-0'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='text-sm text-gray-900'>{block.title}</div>
|
||||
|
@@ -57,16 +57,14 @@ const Blocks = ({
|
||||
className='!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !bg-transparent !rounded-xl !shadow-lg'
|
||||
htmlContent={(
|
||||
<div>
|
||||
<div className='flex items-center mb-2'>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mr-2'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={toolWithProvider.icon}
|
||||
/>
|
||||
<div className='text-sm text-gray-900'>{tool.label[language]}</div>
|
||||
</div>
|
||||
{tool.description[language]}
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={toolWithProvider.icon}
|
||||
/>
|
||||
<div className='mb-1 text-sm leading-5 text-gray-900'>{tool.label[language]}</div>
|
||||
<div className='text-xs text-gray-700 leading-[18px]'>{tool.description[language]}</div>
|
||||
</div>
|
||||
)}
|
||||
noArrow
|
||||
@@ -83,11 +81,11 @@ const Blocks = ({
|
||||
})}
|
||||
>
|
||||
<BlockIcon
|
||||
className='mr-2'
|
||||
className='mr-2 shrink-0'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={toolWithProvider.icon}
|
||||
/>
|
||||
<div className='text-sm text-gray-900'>{tool.label[language]}</div>
|
||||
<div className='text-sm text-gray-900 truncate'>{tool.label[language]}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
))
|
||||
@@ -97,7 +95,7 @@ const Blocks = ({
|
||||
}, [onSelect, language])
|
||||
|
||||
return (
|
||||
<div className='p-1 max-h-[464px] overflow-y-auto'>
|
||||
<div className='p-1 max-w-[320px] max-h-[464px] overflow-y-auto'>
|
||||
{
|
||||
!tools.length && (
|
||||
<div className='flex items-center px-3 h-[22px] text-xs font-medium text-gray-500'>{t('workflow.tabs.noResult')}</div>
|
||||
|
81
web/app/components/workflow/candidate-node.tsx
Normal file
81
web/app/components/workflow/candidate-node.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import produce from 'immer'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import { useEventListener } from 'ahooks'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
import CustomNode from './nodes'
|
||||
|
||||
const CandidateNode = () => {
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const candidateNode = useStore(s => s.candidateNode)
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
const { zoom } = useViewport()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
const { candidateNode, mousePosition } = workflowStore.getState()
|
||||
|
||||
if (candidateNode) {
|
||||
e.preventDefault()
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const nodes = getNodes()
|
||||
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.push({
|
||||
...candidateNode,
|
||||
data: {
|
||||
...candidateNode.data,
|
||||
_isCandidate: false,
|
||||
},
|
||||
position: {
|
||||
x,
|
||||
y,
|
||||
},
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
workflowStore.setState({ candidateNode: undefined })
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener('contextmenu', (e) => {
|
||||
const { candidateNode } = workflowStore.getState()
|
||||
if (candidateNode) {
|
||||
e.preventDefault()
|
||||
workflowStore.setState({ candidateNode: undefined })
|
||||
}
|
||||
})
|
||||
|
||||
if (!candidateNode)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute z-10'
|
||||
style={{
|
||||
left: mousePosition.elementX,
|
||||
top: mousePosition.elementY,
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
<CustomNode {...candidateNode as any} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CandidateNode)
|
@@ -1,90 +1,29 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import cn from 'classnames'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
import { useStore } from '../store'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesSyncDraft,
|
||||
useWorkflowInteractions,
|
||||
useWorkflowRun,
|
||||
useWorkflowStartRun,
|
||||
} from '../hooks'
|
||||
import {
|
||||
BlockEnum,
|
||||
WorkflowRunningStatus,
|
||||
} from '../types'
|
||||
import { WorkflowRunningStatus } from '../types'
|
||||
import ViewHistory from './view-history'
|
||||
import {
|
||||
Play,
|
||||
StopCircle,
|
||||
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { Loading02 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { MessagePlay } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
|
||||
const RunMode = memo(() => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const {
|
||||
handleStopRun,
|
||||
handleRun,
|
||||
} = useWorkflowRun()
|
||||
const {
|
||||
doSyncWorkflowDraft,
|
||||
} = useNodesSyncDraft()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const { handleWorkflowStartRunInWorkflow } = useWorkflowStartRun()
|
||||
const { handleStopRun } = useWorkflowRun()
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
const {
|
||||
workflowRunningData,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
|
||||
return
|
||||
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
|
||||
const startVariables = startNode?.data.variables || []
|
||||
const fileSettings = featuresStore!.getState().features.file
|
||||
const {
|
||||
showDebugAndPreviewPanel,
|
||||
setShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (showDebugAndPreviewPanel) {
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
return
|
||||
}
|
||||
|
||||
if (!startVariables.length && !fileSettings?.image?.enabled) {
|
||||
await doSyncWorkflowDraft()
|
||||
handleRun({ inputs: {}, files: [] })
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
setShowInputsPanel(false)
|
||||
}
|
||||
else {
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
setShowInputsPanel(true)
|
||||
}
|
||||
}, [
|
||||
workflowStore,
|
||||
handleRun,
|
||||
doSyncWorkflowDraft,
|
||||
store,
|
||||
featuresStore,
|
||||
handleCancelDebugAndPreviewPanel,
|
||||
])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -93,7 +32,7 @@ const RunMode = memo(() => {
|
||||
'hover:bg-primary-50 cursor-pointer',
|
||||
isRunning && 'bg-primary-50 !cursor-not-allowed',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onClick={() => handleWorkflowStartRunInWorkflow()}
|
||||
>
|
||||
{
|
||||
isRunning
|
||||
@@ -128,23 +67,7 @@ RunMode.displayName = 'RunMode'
|
||||
|
||||
const PreviewMode = memo(() => {
|
||||
const { t } = useTranslation()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
|
||||
const handleClick = () => {
|
||||
const {
|
||||
showDebugAndPreviewPanel,
|
||||
setShowDebugAndPreviewPanel,
|
||||
setHistoryWorkflowData,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (showDebugAndPreviewPanel)
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
else
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
|
||||
setHistoryWorkflowData(undefined)
|
||||
}
|
||||
const { handleWorkflowStartRunInChatflow } = useWorkflowStartRun()
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -152,7 +75,7 @@ const PreviewMode = memo(() => {
|
||||
'flex items-center px-1.5 h-7 rounded-md text-[13px] font-medium text-primary-600',
|
||||
'hover:bg-primary-50 cursor-pointer',
|
||||
)}
|
||||
onClick={() => handleClick()}
|
||||
onClick={() => handleWorkflowStartRunInChatflow()}
|
||||
>
|
||||
<MessagePlay className='mr-1 w-4 h-4' />
|
||||
{t('workflow.common.debugAndPreview')}
|
||||
|
@@ -9,3 +9,6 @@ export * from './use-workflow-template'
|
||||
export * from './use-checklist'
|
||||
export * from './use-workflow-mode'
|
||||
export * from './use-workflow-interactions'
|
||||
export * from './use-selection-interactions'
|
||||
export * from './use-panel-interactions'
|
||||
export * from './use-workflow-start-run'
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import produce from 'immer'
|
||||
@@ -11,6 +12,7 @@ import type {
|
||||
import {
|
||||
getConnectedEdges,
|
||||
getOutgoers,
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import type { ToolDefaultValue } from '../block-selector/types'
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
import {
|
||||
generateNewNode,
|
||||
getNodesConnectedSourceOrTargetHandleIdsMap,
|
||||
getTopLeftNodePosition,
|
||||
} from '../utils'
|
||||
import { useNodesExtraData } from './use-nodes-data'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
@@ -41,6 +44,7 @@ export const useNodesInteractions = () => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const nodesExtraData = useNodesExtraData()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const {
|
||||
@@ -705,132 +709,6 @@ export const useNodesInteractions = () => {
|
||||
handleSyncWorkflowDraft()
|
||||
}, [store, handleSyncWorkflowDraft, getNodesReadOnly, t])
|
||||
|
||||
const handleNodeCopySelected = useCallback((): undefined | Node[] => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
setClipboardElements,
|
||||
shortcutsDisabled,
|
||||
showFeaturesPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (shortcutsDisabled || showFeaturesPanel)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const nodesToCopy = nodes.filter(node => node.data.selected && node.data.type !== BlockEnum.Start)
|
||||
|
||||
setClipboardElements(nodesToCopy)
|
||||
|
||||
return nodesToCopy
|
||||
}, [getNodesReadOnly, store, workflowStore])
|
||||
|
||||
const handleNodePaste = useCallback((): undefined | Node[] => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
clipboardElements,
|
||||
shortcutsDisabled,
|
||||
showFeaturesPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (shortcutsDisabled || showFeaturesPanel)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodesToPaste: Node[] = []
|
||||
const nodes = getNodes()
|
||||
|
||||
for (const nodeToPaste of clipboardElements) {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
|
||||
|
||||
const newNode = generateNewNode({
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[nodeType],
|
||||
...nodeToPaste.data,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
|
||||
selected: true,
|
||||
},
|
||||
position: {
|
||||
x: nodeToPaste.position.x + 10,
|
||||
y: nodeToPaste.position.y + 10,
|
||||
},
|
||||
})
|
||||
nodesToPaste.push(newNode)
|
||||
}
|
||||
|
||||
setNodes([...nodes.map((n: Node) => ({ ...n, selected: false, data: { ...n.data, selected: false } })), ...nodesToPaste])
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
|
||||
return nodesToPaste
|
||||
}, [getNodesReadOnly, handleSyncWorkflowDraft, store, t, workflowStore])
|
||||
|
||||
const handleNodeDuplicateSelected = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
handleNodeCopySelected()
|
||||
handleNodePaste()
|
||||
}, [getNodesReadOnly, handleNodeCopySelected, handleNodePaste])
|
||||
|
||||
const handleNodeCut = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const nodesToCut = handleNodeCopySelected()
|
||||
if (!nodesToCut)
|
||||
return
|
||||
|
||||
for (const node of nodesToCut)
|
||||
handleNodeDelete(node.id)
|
||||
}, [getNodesReadOnly, handleNodeCopySelected, handleNodeDelete])
|
||||
|
||||
const handleNodeDeleteSelected = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
shortcutsDisabled,
|
||||
showFeaturesPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (shortcutsDisabled || showFeaturesPanel)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
|
||||
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
|
||||
|
||||
if (currentEdgeIndex > -1)
|
||||
return
|
||||
|
||||
const nodes = getNodes()
|
||||
const nodesToDelete = nodes.filter(node => node.data.selected)
|
||||
|
||||
if (!nodesToDelete)
|
||||
return
|
||||
|
||||
for (const node of nodesToDelete)
|
||||
handleNodeDelete(node.id)
|
||||
}, [getNodesReadOnly, handleNodeDelete, store, workflowStore])
|
||||
|
||||
const handleNodeCancelRunningStatus = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
@@ -861,6 +739,173 @@ export const useNodesInteractions = () => {
|
||||
setNodes(newNodes)
|
||||
}, [store])
|
||||
|
||||
const handleNodeContextMenu = useCallback((e: MouseEvent, node: Node) => {
|
||||
e.preventDefault()
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
nodeMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
nodeId: node.id,
|
||||
},
|
||||
})
|
||||
handleNodeSelect(node.id)
|
||||
}, [workflowStore, handleNodeSelect])
|
||||
|
||||
const handleNodesCopy = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
setClipboardElements,
|
||||
shortcutsDisabled,
|
||||
showFeaturesPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (shortcutsDisabled || showFeaturesPanel)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
|
||||
|
||||
if (bundledNodes.length) {
|
||||
setClipboardElements(bundledNodes)
|
||||
return
|
||||
}
|
||||
|
||||
const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
|
||||
|
||||
if (selectedNode)
|
||||
setClipboardElements([selectedNode])
|
||||
}, [getNodesReadOnly, store, workflowStore])
|
||||
|
||||
const handleNodesPaste = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
clipboardElements,
|
||||
shortcutsDisabled,
|
||||
showFeaturesPanel,
|
||||
mousePosition,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (shortcutsDisabled || showFeaturesPanel)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodesToPaste: Node[] = []
|
||||
const nodes = getNodes()
|
||||
|
||||
if (clipboardElements.length) {
|
||||
const { x, y } = getTopLeftNodePosition(clipboardElements)
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const currentPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const offsetX = currentPosition.x - x
|
||||
const offsetY = currentPosition.y - y
|
||||
clipboardElements.forEach((nodeToPaste, index) => {
|
||||
const nodeType = nodeToPaste.data.type
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
|
||||
|
||||
const newNode = generateNewNode({
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[nodeType],
|
||||
...nodeToPaste.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
|
||||
},
|
||||
position: {
|
||||
x: nodeToPaste.position.x + offsetX,
|
||||
y: nodeToPaste.position.y + offsetY,
|
||||
},
|
||||
})
|
||||
newNode.id = newNode.id + index
|
||||
nodesToPaste.push(newNode)
|
||||
})
|
||||
|
||||
setNodes([...nodes, ...nodesToPaste])
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, [t, getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, reactflow])
|
||||
|
||||
const handleNodesDuplicate = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
|
||||
const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
|
||||
|
||||
if (selectedNode) {
|
||||
const nodeType = selectedNode.data.type
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === nodeType)
|
||||
|
||||
const newNode = generateNewNode({
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[nodeType as BlockEnum],
|
||||
...selectedNode.data,
|
||||
selected: false,
|
||||
_isBundled: false,
|
||||
_connectedSourceHandleIds: [],
|
||||
_connectedTargetHandleIds: [],
|
||||
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`),
|
||||
},
|
||||
position: {
|
||||
x: selectedNode.position.x + selectedNode.width! + 10,
|
||||
y: selectedNode.position.y,
|
||||
},
|
||||
})
|
||||
|
||||
setNodes([...nodes, newNode])
|
||||
}
|
||||
}, [store, t, getNodesReadOnly])
|
||||
|
||||
const handleNodesDelete = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
shortcutsDisabled,
|
||||
showFeaturesPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (shortcutsDisabled || showFeaturesPanel)
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
|
||||
|
||||
if (bundledNodes.length) {
|
||||
bundledNodes.forEach(node => handleNodeDelete(node.id))
|
||||
return
|
||||
}
|
||||
|
||||
const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
|
||||
|
||||
if (selectedNode)
|
||||
handleNodeDelete(selectedNode.id)
|
||||
}, [store, workflowStore, getNodesReadOnly, handleNodeDelete])
|
||||
|
||||
return {
|
||||
handleNodeDragStart,
|
||||
handleNodeDrag,
|
||||
@@ -875,12 +920,12 @@ export const useNodesInteractions = () => {
|
||||
handleNodeDelete,
|
||||
handleNodeChange,
|
||||
handleNodeAdd,
|
||||
handleNodeDuplicateSelected,
|
||||
handleNodeCopySelected,
|
||||
handleNodeCut,
|
||||
handleNodeDeleteSelected,
|
||||
handleNodePaste,
|
||||
handleNodeCancelRunningStatus,
|
||||
handleNodesCancelSelected,
|
||||
handleNodeContextMenu,
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
}
|
||||
}
|
||||
|
37
web/app/components/workflow/hooks/use-panel-interactions.ts
Normal file
37
web/app/components/workflow/hooks/use-panel-interactions.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowStore } from '../store'
|
||||
|
||||
export const usePanelInteractions = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const handlePaneContextMenu = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
panelMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
},
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
const handlePaneContextmenuCancel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
panelMenu: undefined,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
const handleNodeContextmenuCancel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
nodeMenu: undefined,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
return {
|
||||
handlePaneContextMenu,
|
||||
handlePaneContextmenuCancel,
|
||||
handleNodeContextmenuCancel,
|
||||
}
|
||||
}
|
109
web/app/components/workflow/hooks/use-selection-interactions.ts
Normal file
109
web/app/components/workflow/hooks/use-selection-interactions.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import produce from 'immer'
|
||||
import type {
|
||||
OnSelectionChangeFunc,
|
||||
} from 'reactflow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import type { Node } from '../types'
|
||||
|
||||
export const useSelectionInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const handleSelectionStart = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
userSelectionRect,
|
||||
} = store.getState()
|
||||
|
||||
if (!userSelectionRect?.width || !userSelectionRect?.height) {
|
||||
const nodes = getNodes()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (node.data._isBundled)
|
||||
node.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
if (edge.data._isBundled)
|
||||
edge.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}
|
||||
}, [store])
|
||||
|
||||
const handleSelectionChange = useCallback<OnSelectionChangeFunc>(({ nodes: nodesInSelection, edges: edgesInSelection }) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
userSelectionRect,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
|
||||
if (!userSelectionRect?.width || !userSelectionRect?.height)
|
||||
return
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
const nodeInSelection = nodesInSelection.find(n => n.id === node.id)
|
||||
|
||||
if (nodeInSelection)
|
||||
node.data._isBundled = true
|
||||
else
|
||||
node.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
const edgeInSelection = edgesInSelection.find(e => e.id === edge.id)
|
||||
|
||||
if (edgeInSelection)
|
||||
edge.data._isBundled = true
|
||||
else
|
||||
edge.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
|
||||
const handleSelectionDrag = useCallback((_: MouseEvent, nodesWithDrag: Node[]) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
|
||||
workflowStore.setState({
|
||||
nodeAnimation: false,
|
||||
})
|
||||
const nodes = getNodes()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
const dragNode = nodesWithDrag.find(n => n.id === node.id)
|
||||
|
||||
if (dragNode)
|
||||
node.position = dragNode.position
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store, workflowStore])
|
||||
|
||||
return {
|
||||
handleSelectionStart,
|
||||
handleSelectionChange,
|
||||
handleSelectionDrag,
|
||||
}
|
||||
}
|
88
web/app/components/workflow/hooks/use-workflow-start-run.tsx
Normal file
88
web/app/components/workflow/hooks/use-workflow-start-run.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import {
|
||||
BlockEnum,
|
||||
WorkflowRunningStatus,
|
||||
} from '../types'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesSyncDraft,
|
||||
useWorkflowInteractions,
|
||||
useWorkflowRun,
|
||||
} from './index'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
|
||||
export const useWorkflowStartRun = () => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const featuresStore = useFeaturesStore()
|
||||
const isChatMode = useIsChatMode()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const { handleRun } = useWorkflowRun()
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
|
||||
const handleWorkflowStartRunInWorkflow = useCallback(async () => {
|
||||
const {
|
||||
workflowRunningData,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
|
||||
return
|
||||
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
|
||||
const startVariables = startNode?.data.variables || []
|
||||
const fileSettings = featuresStore!.getState().features.file
|
||||
const {
|
||||
showDebugAndPreviewPanel,
|
||||
setShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (showDebugAndPreviewPanel) {
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
return
|
||||
}
|
||||
|
||||
if (!startVariables.length && !fileSettings?.image?.enabled) {
|
||||
await doSyncWorkflowDraft()
|
||||
handleRun({ inputs: {}, files: [] })
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
setShowInputsPanel(false)
|
||||
}
|
||||
else {
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
setShowInputsPanel(true)
|
||||
}
|
||||
}, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft])
|
||||
|
||||
const handleWorkflowStartRunInChatflow = useCallback(async () => {
|
||||
const {
|
||||
showDebugAndPreviewPanel,
|
||||
setShowDebugAndPreviewPanel,
|
||||
setHistoryWorkflowData,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (showDebugAndPreviewPanel)
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
else
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
|
||||
setHistoryWorkflowData(undefined)
|
||||
}, [workflowStore, handleCancelDebugAndPreviewPanel])
|
||||
|
||||
const handleStartWorkflowRun = useCallback(() => {
|
||||
if (!isChatMode)
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
else
|
||||
handleWorkflowStartRunInChatflow()
|
||||
}, [isChatMode, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow])
|
||||
|
||||
return {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowStartRunInChatflow,
|
||||
}
|
||||
}
|
@@ -6,19 +6,24 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { setAutoFreeze } from 'immer'
|
||||
import {
|
||||
useEventListener,
|
||||
useKeyPress,
|
||||
} from 'ahooks'
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
ReactFlowProvider,
|
||||
SelectionMode,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useOnViewportChange,
|
||||
} from 'reactflow'
|
||||
import type { Viewport } from 'reactflow'
|
||||
import type {
|
||||
Viewport,
|
||||
} from 'reactflow'
|
||||
import 'reactflow/dist/style.css'
|
||||
import './style.css'
|
||||
import type {
|
||||
@@ -31,9 +36,12 @@ import {
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
usePanelInteractions,
|
||||
useSelectionInteractions,
|
||||
useWorkflow,
|
||||
useWorkflowInit,
|
||||
useWorkflowReadOnly,
|
||||
useWorkflowStartRun,
|
||||
} from './hooks'
|
||||
import Header from './header'
|
||||
import CustomNode from './nodes'
|
||||
@@ -43,8 +51,15 @@ import CustomConnectionLine from './custom-connection-line'
|
||||
import Panel from './panel'
|
||||
import Features from './features'
|
||||
import HelpLine from './help-line'
|
||||
import { useStore } from './store'
|
||||
import CandidateNode from './candidate-node'
|
||||
import PanelContextmenu from './panel-contextmenu'
|
||||
import NodeContextmenu from './node-contextmenu'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
import {
|
||||
getKeyboardKeyCodeBySystem,
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from './utils'
|
||||
@@ -71,9 +86,12 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
edges: originalEdges,
|
||||
viewport,
|
||||
}) => {
|
||||
const workflowContainerRef = useRef<HTMLDivElement>(null)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const [nodes, setNodes] = useNodesState(originalNodes)
|
||||
const [edges, setEdges] = useEdgesState(originalEdges)
|
||||
const showFeaturesPanel = useStore(state => state.showFeaturesPanel)
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const nodeAnimation = useStore(s => s.nodeAnimation)
|
||||
const {
|
||||
handleSyncWorkflowDraft,
|
||||
@@ -118,6 +136,25 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
}
|
||||
}, [handleSyncWorkflowDraftWhenPageClose])
|
||||
|
||||
useEventListener('keydown', (e) => {
|
||||
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))
|
||||
e.preventDefault()
|
||||
})
|
||||
useEventListener('mousemove', (e) => {
|
||||
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
|
||||
|
||||
if (containerClientRect) {
|
||||
workflowStore.setState({
|
||||
mousePosition: {
|
||||
pageX: e.clientX,
|
||||
pageY: e.clientY,
|
||||
elementX: e.clientX - containerClientRect.left,
|
||||
elementY: e.clientY - containerClientRect.top,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const {
|
||||
handleNodeDragStart,
|
||||
handleNodeDrag,
|
||||
@@ -128,11 +165,11 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
handleNodeConnect,
|
||||
handleNodeConnectStart,
|
||||
handleNodeConnectEnd,
|
||||
handleNodeDuplicateSelected,
|
||||
handleNodeCopySelected,
|
||||
handleNodeCut,
|
||||
handleNodeDeleteSelected,
|
||||
handleNodePaste,
|
||||
handleNodeContextMenu,
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
} = useNodesInteractions()
|
||||
const {
|
||||
handleEdgeEnter,
|
||||
@@ -140,9 +177,18 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
handleEdgeDelete,
|
||||
handleEdgesChange,
|
||||
} = useEdgesInteractions()
|
||||
const {
|
||||
handleSelectionStart,
|
||||
handleSelectionChange,
|
||||
handleSelectionDrag,
|
||||
} = useSelectionInteractions()
|
||||
const {
|
||||
handlePaneContextMenu,
|
||||
} = usePanelInteractions()
|
||||
const {
|
||||
isValidConnection,
|
||||
} = useWorkflow()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
|
||||
useOnViewportChange({
|
||||
onEnd: () => {
|
||||
@@ -150,12 +196,12 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
},
|
||||
})
|
||||
|
||||
useKeyPress(['delete', 'backspace'], handleNodeDeleteSelected)
|
||||
useKeyPress(['delete', 'backspace'], handleEdgeDelete)
|
||||
useKeyPress(['ctrl.c', 'meta.c'], handleNodeCopySelected)
|
||||
useKeyPress(['ctrl.x', 'meta.x'], handleNodeCut)
|
||||
useKeyPress(['ctrl.v', 'meta.v'], handleNodePaste)
|
||||
useKeyPress(['ctrl.alt.d', 'meta.shift.d'], handleNodeDuplicateSelected)
|
||||
useKeyPress('delete', handleNodesDelete)
|
||||
useKeyPress('delete', handleEdgeDelete)
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, handleNodesCopy, { exactMatch: true, useCapture: true })
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, handleNodesPaste, { exactMatch: true, useCapture: true })
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, handleNodesDuplicate, { exactMatch: true, useCapture: true })
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, handleStartWorkflowRun, { exactMatch: true, useCapture: true })
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -165,13 +211,17 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
${workflowReadOnly && 'workflow-panel-animation'}
|
||||
${nodeAnimation && 'workflow-node-animation'}
|
||||
`}
|
||||
ref={workflowContainerRef}
|
||||
>
|
||||
<CandidateNode />
|
||||
<Header />
|
||||
<Panel />
|
||||
<Operator />
|
||||
{
|
||||
showFeaturesPanel && <Features />
|
||||
}
|
||||
<PanelContextmenu />
|
||||
<NodeContextmenu />
|
||||
<HelpLine />
|
||||
<ReactFlow
|
||||
nodeTypes={nodeTypes}
|
||||
@@ -184,12 +234,17 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
onNodeMouseEnter={handleNodeEnter}
|
||||
onNodeMouseLeave={handleNodeLeave}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeContextMenu={handleNodeContextMenu}
|
||||
onConnect={handleNodeConnect}
|
||||
onConnectStart={handleNodeConnectStart}
|
||||
onConnectEnd={handleNodeConnectEnd}
|
||||
onEdgeMouseEnter={handleEdgeEnter}
|
||||
onEdgeMouseLeave={handleEdgeLeave}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onSelectionStart={handleSelectionStart}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onSelectionDrag={handleSelectionDrag}
|
||||
onPaneContextMenu={handlePaneContextMenu}
|
||||
connectionLineComponent={CustomConnectionLine}
|
||||
defaultViewport={viewport}
|
||||
multiSelectionKeyCode={null}
|
||||
@@ -198,11 +253,15 @@ const Workflow: FC<WorkflowProps> = memo(({
|
||||
nodesConnectable={!nodesReadOnly}
|
||||
nodesFocusable={!nodesReadOnly}
|
||||
edgesFocusable={!nodesReadOnly}
|
||||
panOnDrag={!workflowReadOnly}
|
||||
panOnDrag={controlMode === 'hand' && !workflowReadOnly}
|
||||
zoomOnPinch={!workflowReadOnly}
|
||||
zoomOnScroll={!workflowReadOnly}
|
||||
zoomOnDoubleClick={!workflowReadOnly}
|
||||
isValidConnection={isValidConnection}
|
||||
selectionKeyCode={null}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
selectionOnDrag={controlMode === 'pointer' && !workflowReadOnly}
|
||||
minZoom={0.25}
|
||||
>
|
||||
<Background
|
||||
gap={[14, 14]}
|
||||
|
44
web/app/components/workflow/node-contextmenu.tsx
Normal file
44
web/app/components/workflow/node-contextmenu.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import { useNodes } from 'reactflow'
|
||||
import PanelOperatorPopup from './nodes/_base/components/panel-operator/panel-operator-popup'
|
||||
import type { Node } from './types'
|
||||
import { useStore } from './store'
|
||||
import { usePanelInteractions } from './hooks'
|
||||
|
||||
const PanelContextmenu = () => {
|
||||
const ref = useRef(null)
|
||||
const nodes = useNodes()
|
||||
const { handleNodeContextmenuCancel } = usePanelInteractions()
|
||||
const nodeMenu = useStore(s => s.nodeMenu)
|
||||
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
|
||||
|
||||
useClickAway(() => {
|
||||
handleNodeContextmenuCancel()
|
||||
}, ref)
|
||||
|
||||
if (!nodeMenu || !currentNode)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute z-[9]'
|
||||
style={{
|
||||
left: nodeMenu.left,
|
||||
top: nodeMenu.top,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<PanelOperatorPopup
|
||||
id={currentNode.id}
|
||||
data={currentNode.data}
|
||||
onClosePopup={() => handleNodeContextmenuCancel()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PanelContextmenu)
|
@@ -1,19 +1,10 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import type { OffsetOptions } from '@floating-ui/react'
|
||||
import ChangeBlock from './change-block'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useNodesExtraData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import PanelOperatorPopup from './panel-operator-popup'
|
||||
import { DotsHorizontal } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
@@ -21,8 +12,6 @@ import {
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
type PanelOperatorProps = {
|
||||
id: string
|
||||
@@ -43,35 +32,7 @@ const PanelOperator = ({
|
||||
onOpenChange,
|
||||
inNode,
|
||||
}: PanelOperatorProps) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const edges = useEdges()
|
||||
const { handleNodeDelete } = useNodesInteractions()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const nodesExtraData = useNodesExtraData()
|
||||
const buildInTools = useStore(s => s.buildInTools)
|
||||
const customTools = useStore(s => s.customTools)
|
||||
const [open, setOpen] = useState(false)
|
||||
const edge = edges.find(edge => edge.target === id)
|
||||
const author = useMemo(() => {
|
||||
if (data.type !== BlockEnum.Tool)
|
||||
return nodesExtraData[data.type].author
|
||||
|
||||
if (data.provider_type === 'builtin')
|
||||
return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
|
||||
|
||||
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
|
||||
}, [data, nodesExtraData, buildInTools, customTools])
|
||||
|
||||
const about = useMemo(() => {
|
||||
if (data.type !== BlockEnum.Tool)
|
||||
return nodesExtraData[data.type].about
|
||||
|
||||
if (data.provider_type === 'builtin')
|
||||
return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
|
||||
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
}, [data, nodesExtraData, language, buildInTools, customTools])
|
||||
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
@@ -100,60 +61,11 @@ const PanelOperator = ({
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[11]'>
|
||||
<div className='w-[240px] border-[0.5px] border-gray-200 rounded-lg shadow-xl bg-white'>
|
||||
<div className='p-1'>
|
||||
{
|
||||
data.type !== BlockEnum.Start && !nodesReadOnly && (
|
||||
<ChangeBlock
|
||||
nodeId={id}
|
||||
nodeType={data.type}
|
||||
sourceHandle={edge?.sourceHandle || 'source'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<a
|
||||
href={
|
||||
language === 'zh_Hans'
|
||||
? 'https://docs.dify.ai/v/zh-hans/guides/workflow'
|
||||
: 'https://docs.dify.ai/features/workflow'
|
||||
}
|
||||
target='_blank'
|
||||
className='flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
>
|
||||
{t('workflow.panel.helpLink')}
|
||||
</a>
|
||||
</div>
|
||||
{
|
||||
data.type !== BlockEnum.Start && !nodesReadOnly && (
|
||||
<>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className={`
|
||||
flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
|
||||
hover:bg-rose-50 hover:text-red-500
|
||||
`}
|
||||
onClick={() => handleNodeDelete(id)}
|
||||
>
|
||||
{t('common.operation.delete')}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div className='px-3 py-2 text-xs text-gray-500'>
|
||||
<div className='flex items-center mb-1 h-[22px] font-medium'>
|
||||
{t('workflow.panel.about').toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className='mb-1 text-gray-700 leading-[18px]'>{about}</div>
|
||||
<div className='leading-[18px]'>
|
||||
{t('workflow.panel.createdBy')} {author}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PanelOperatorPopup
|
||||
id={id}
|
||||
data={data}
|
||||
onClosePopup={() => setOpen(false)}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
|
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
memo,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges } from 'reactflow'
|
||||
import ChangeBlock from './change-block'
|
||||
import {
|
||||
canRunBySingle,
|
||||
} from '@/app/components/workflow/utils'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodesExtraData,
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
type PanelOperatorPopupProps = {
|
||||
id: string
|
||||
data: Node['data']
|
||||
onClosePopup: () => void
|
||||
}
|
||||
const PanelOperatorPopup = ({
|
||||
id,
|
||||
data,
|
||||
onClosePopup,
|
||||
}: PanelOperatorPopupProps) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const edges = useEdges()
|
||||
const {
|
||||
handleNodeDelete,
|
||||
handleNodesDuplicate,
|
||||
handleNodeSelect,
|
||||
handleNodesCopy,
|
||||
} = useNodesInteractions()
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const nodesExtraData = useNodesExtraData()
|
||||
const buildInTools = useStore(s => s.buildInTools)
|
||||
const customTools = useStore(s => s.customTools)
|
||||
const edge = edges.find(edge => edge.target === id)
|
||||
const author = useMemo(() => {
|
||||
if (data.type !== BlockEnum.Tool)
|
||||
return nodesExtraData[data.type].author
|
||||
|
||||
if (data.provider_type === 'builtin')
|
||||
return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
|
||||
|
||||
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
|
||||
}, [data, nodesExtraData, buildInTools, customTools])
|
||||
|
||||
const about = useMemo(() => {
|
||||
if (data.type !== BlockEnum.Tool)
|
||||
return nodesExtraData[data.type].about
|
||||
|
||||
if (data.provider_type === 'builtin')
|
||||
return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
|
||||
return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
}, [data, nodesExtraData, language, buildInTools, customTools])
|
||||
|
||||
const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly
|
||||
|
||||
return (
|
||||
<div className='w-[240px] border-[0.5px] border-gray-200 rounded-lg shadow-xl bg-white'>
|
||||
{
|
||||
(showChangeBlock || canRunBySingle(data.type)) && (
|
||||
<>
|
||||
<div className='p-1'>
|
||||
{
|
||||
canRunBySingle(data.type) && (
|
||||
<div
|
||||
className={`
|
||||
flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
|
||||
hover:bg-gray-50
|
||||
`}
|
||||
onClick={() => {
|
||||
handleNodeSelect(id)
|
||||
handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
|
||||
handleSyncWorkflowDraft(true)
|
||||
onClosePopup()
|
||||
}}
|
||||
>
|
||||
{t('workflow.panel.runThisStep')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showChangeBlock && (
|
||||
<ChangeBlock
|
||||
nodeId={id}
|
||||
nodeType={data.type}
|
||||
sourceHandle={edge?.sourceHandle || 'source'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
data.type !== BlockEnum.Start && data.type !== BlockEnum.End && !nodesReadOnly && (
|
||||
<>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesCopy()
|
||||
}}
|
||||
>
|
||||
{t('workflow.common.copy')}
|
||||
<ShortcutsName keys={['ctrl', 'c']} />
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => {
|
||||
onClosePopup()
|
||||
handleNodesDuplicate()
|
||||
}}
|
||||
>
|
||||
{t('workflow.common.duplicate')}
|
||||
<ShortcutsName keys={['ctrl', 'd']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer
|
||||
hover:bg-rose-50 hover:text-red-500
|
||||
`}
|
||||
onClick={() => handleNodeDelete(id)}
|
||||
>
|
||||
{t('common.operation.delete')}
|
||||
<ShortcutsName keys={['del']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div className='p-1'>
|
||||
<a
|
||||
href={
|
||||
language === 'zh_Hans'
|
||||
? 'https://docs.dify.ai/v/zh-hans/guides/workflow'
|
||||
: 'https://docs.dify.ai/features/workflow'
|
||||
}
|
||||
target='_blank'
|
||||
className='flex items-center px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
>
|
||||
{t('workflow.panel.helpLink')}
|
||||
</a>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div className='px-3 py-2 text-xs text-gray-500'>
|
||||
<div className='flex items-center mb-1 h-[22px] font-medium'>
|
||||
{t('workflow.panel.about').toLocaleUpperCase()}
|
||||
</div>
|
||||
<div className='mb-1 text-gray-700 leading-[18px]'>{about}</div>
|
||||
<div className='leading-[18px]'>
|
||||
{t('workflow.panel.createdBy')} {author}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PanelOperatorPopup)
|
@@ -6,6 +6,7 @@ import {
|
||||
cloneElement,
|
||||
memo,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import type { NodeProps } from '../../types'
|
||||
import {
|
||||
@@ -37,27 +38,30 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
data,
|
||||
children,
|
||||
}) => {
|
||||
const nodeRef = useRef<HTMLDivElement>(null)
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const toolIcon = useToolIcon(data)
|
||||
|
||||
const showSelectedBorder = data.selected || data._isBundled
|
||||
const {
|
||||
showRunningBorder,
|
||||
showSuccessBorder,
|
||||
showFailedBorder,
|
||||
} = useMemo(() => {
|
||||
return {
|
||||
showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !data.selected,
|
||||
showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !data.selected,
|
||||
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !data.selected,
|
||||
showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder,
|
||||
showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder,
|
||||
showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder,
|
||||
}
|
||||
}, [data._runningStatus, data.selected])
|
||||
}, [data._runningStatus, showSelectedBorder])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex border-[2px] rounded-2xl
|
||||
${(data.selected && !data._isInvalidConnection) ? 'border-primary-600' : 'border-transparent'}
|
||||
${(showSelectedBorder && !data._isInvalidConnection) ? 'border-primary-600' : 'border-transparent'}
|
||||
`}
|
||||
ref={nodeRef}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
@@ -68,10 +72,11 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
${showSuccessBorder && '!border-[#12B76A]'}
|
||||
${showFailedBorder && '!border-[#F04438]'}
|
||||
${data._isInvalidConnection && '!border-[#F04438]'}
|
||||
${data._isBundled && '!shadow-lg'}
|
||||
`}
|
||||
>
|
||||
{
|
||||
data.type !== BlockEnum.VariableAssigner && (
|
||||
data.type !== BlockEnum.VariableAssigner && !data._isCandidate && (
|
||||
<NodeTargetHandle
|
||||
id={id}
|
||||
data={data}
|
||||
@@ -81,7 +86,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && (
|
||||
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
|
||||
<NodeSourceHandle
|
||||
id={id}
|
||||
data={data}
|
||||
@@ -91,7 +96,7 @@ const BaseNode: FC<BaseNodeProps> = ({
|
||||
)
|
||||
}
|
||||
{
|
||||
!data._runningStatus && !nodesReadOnly && (
|
||||
!data._runningStatus && !nodesReadOnly && !data._isCandidate && (
|
||||
<NodeControl
|
||||
id={id}
|
||||
data={data}
|
||||
|
110
web/app/components/workflow/operator/add-block.tsx
Normal file
110
web/app/components/workflow/operator/add-block.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { OffsetOptions } from '@floating-ui/react'
|
||||
import {
|
||||
generateNewNode,
|
||||
} from '../utils'
|
||||
import {
|
||||
useNodesExtraData,
|
||||
useNodesReadOnly,
|
||||
usePanelInteractions,
|
||||
} from '../hooks'
|
||||
import { NODES_INITIAL_DATA } from '../constants'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import TipPopup from './tip-popup'
|
||||
import BlockSelector from '@/app/components/workflow/block-selector'
|
||||
import { Plus } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import type {
|
||||
OnSelectBlock,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
BlockEnum,
|
||||
} from '@/app/components/workflow/types'
|
||||
|
||||
type AddBlockProps = {
|
||||
renderTrigger?: (open: boolean) => React.ReactNode
|
||||
offset?: OffsetOptions
|
||||
}
|
||||
const AddBlock = ({
|
||||
renderTrigger,
|
||||
offset,
|
||||
}: AddBlockProps) => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const nodesExtraData = useNodesExtraData()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const [open, setOpen] = useState(false)
|
||||
const availableNextNodes = nodesExtraData[BlockEnum.Start].availableNextNodes
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
setOpen(open)
|
||||
if (!open)
|
||||
handlePaneContextmenuCancel()
|
||||
}, [handlePaneContextmenuCancel])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
|
||||
const {
|
||||
getNodes,
|
||||
} = store.getState()
|
||||
const nodes = getNodes()
|
||||
const nodesWithSameType = nodes.filter(node => node.data.type === type)
|
||||
const newNode = generateNewNode({
|
||||
data: {
|
||||
...NODES_INITIAL_DATA[type],
|
||||
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
|
||||
...(toolDefaultValue || {}),
|
||||
_isCandidate: true,
|
||||
},
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
},
|
||||
})
|
||||
workflowStore.setState({
|
||||
candidateNode: newNode,
|
||||
})
|
||||
}, [store, workflowStore, t])
|
||||
|
||||
const renderTriggerElement = useCallback((open: boolean) => {
|
||||
return (
|
||||
<TipPopup
|
||||
title={t('workflow.common.addBlock')}
|
||||
>
|
||||
<div className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
|
||||
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
|
||||
open && '!bg-black/5',
|
||||
)}>
|
||||
<Plus className='w-4 h-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
)
|
||||
}, [nodesReadOnly, t])
|
||||
|
||||
return (
|
||||
<BlockSelector
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
disabled={nodesReadOnly}
|
||||
onSelect={handleSelect}
|
||||
placement='top-start'
|
||||
offset={offset ?? {
|
||||
mainAxis: 4,
|
||||
crossAxis: -8,
|
||||
}}
|
||||
trigger={renderTrigger || renderTriggerElement}
|
||||
popupClassName='!min-w-[256px]'
|
||||
availableBlocksTypes={availableNextNodes}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(AddBlock)
|
85
web/app/components/workflow/operator/control.tsx
Normal file
85
web/app/components/workflow/operator/control.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from 'classnames'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useWorkflow,
|
||||
} from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import AddBlock from './add-block'
|
||||
import TipPopup from './tip-popup'
|
||||
import {
|
||||
Cursor02C,
|
||||
Hand02,
|
||||
} from '@/app/components/base/icons/src/vender/line/editor'
|
||||
import {
|
||||
Cursor02C as Cursor02CSolid,
|
||||
Hand02 as Hand02Solid,
|
||||
} from '@/app/components/base/icons/src/vender/solid/editor'
|
||||
import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout'
|
||||
|
||||
const Control = () => {
|
||||
const { t } = useTranslation()
|
||||
const controlMode = useStore(s => s.controlMode)
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const { handleLayout } = useWorkflow()
|
||||
const {
|
||||
nodesReadOnly,
|
||||
getNodesReadOnly,
|
||||
} = useNodesReadOnly()
|
||||
|
||||
const goLayout = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
handleLayout()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
|
||||
<AddBlock />
|
||||
<div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
|
||||
<TipPopup title={t('workflow.common.pointerMode')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center mr-[1px] w-8 h-8 rounded-lg cursor-pointer',
|
||||
controlMode === 'pointer' ? 'bg-primary-50 text-primary-600' : 'hover:bg-black/5 hover:text-gray-700',
|
||||
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
|
||||
)}
|
||||
onClick={() => setControlMode('pointer')}
|
||||
>
|
||||
{
|
||||
controlMode === 'pointer' ? <Cursor02CSolid className='w-4 h-4' /> : <Cursor02C className='w-4 h-4' />
|
||||
}
|
||||
</div>
|
||||
</TipPopup>
|
||||
<TipPopup title={t('workflow.common.handMode')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer',
|
||||
controlMode === 'hand' ? 'bg-primary-50 text-primary-600' : 'hover:bg-black/5 hover:text-gray-700',
|
||||
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
|
||||
)}
|
||||
onClick={() => setControlMode('hand')}
|
||||
>
|
||||
{
|
||||
controlMode === 'hand' ? <Hand02Solid className='w-4 h-4' /> : <Hand02 className='w-4 h-4' />
|
||||
}
|
||||
</div>
|
||||
</TipPopup>
|
||||
<div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
|
||||
<TipPopup title={t('workflow.panel.organizeBlocks')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
|
||||
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
|
||||
)}
|
||||
onClick={goLayout}
|
||||
>
|
||||
<OrganizeGrid className='w-4 h-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Control)
|
@@ -1,54 +1,23 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MiniMap } from 'reactflow'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useWorkflow,
|
||||
} from '../hooks'
|
||||
import ZoomInOut from './zoom-in-out'
|
||||
import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout'
|
||||
import TooltipPlus from '@/app/components/base/tooltip-plus'
|
||||
import Control from './control'
|
||||
|
||||
const Operator = () => {
|
||||
const { t } = useTranslation()
|
||||
const { handleLayout } = useWorkflow()
|
||||
const {
|
||||
nodesReadOnly,
|
||||
getNodesReadOnly,
|
||||
} = useNodesReadOnly()
|
||||
|
||||
const goLayout = () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
handleLayout()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
absolute left-6 bottom-6 z-[9]
|
||||
`}>
|
||||
<>
|
||||
<MiniMap
|
||||
style={{
|
||||
width: 128,
|
||||
height: 80,
|
||||
width: 102,
|
||||
height: 72,
|
||||
}}
|
||||
className='!static !m-0 !w-[128px] !h-[80px] !border-[0.5px] !border-black/[0.08] !rounded-lg !shadow-lg'
|
||||
className='!absolute !left-4 !bottom-14 z-[9] !m-0 !w-[102px] !h-[72px] !border-[0.5px] !border-black/[0.08] !rounded-lg !shadow-lg'
|
||||
/>
|
||||
<div className='flex items-center mt-1 p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
|
||||
<div className='flex items-center mt-1 gap-2 absolute left-4 bottom-4 z-[9]'>
|
||||
<ZoomInOut />
|
||||
<TooltipPlus popupContent={t('workflow.panel.organizeBlocks')}>
|
||||
<div
|
||||
className={`
|
||||
ml-[1px] flex items-center justify-center w-8 h-8 cursor-pointer hover:bg-black/5 rounded-lg
|
||||
${nodesReadOnly && '!cursor-not-allowed opacity-50'}
|
||||
`}
|
||||
onClick={goLayout}
|
||||
>
|
||||
<OrganizeGrid className='w-4 h-4' />
|
||||
</div>
|
||||
</TooltipPlus>
|
||||
<Control />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
34
web/app/components/workflow/operator/tip-popup.tsx
Normal file
34
web/app/components/workflow/operator/tip-popup.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { memo } from 'react'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import TooltipPlus from '@/app/components/base/tooltip-plus'
|
||||
|
||||
type TipPopupProps = {
|
||||
title: string
|
||||
children: React.ReactNode
|
||||
shortcuts?: string[]
|
||||
}
|
||||
const TipPopup = ({
|
||||
title,
|
||||
children,
|
||||
shortcuts,
|
||||
}: TipPopupProps) => {
|
||||
return (
|
||||
<TooltipPlus
|
||||
offset={4}
|
||||
hideArrow
|
||||
popupClassName='!p-0 !bg-gray-25'
|
||||
popupContent={
|
||||
<div className='flex items-center gap-1 px-2 h-6 text-xs font-medium text-gray-700 rounded-lg border-[0.5px] border-black/5'>
|
||||
{title}
|
||||
{
|
||||
shortcuts && <ShortcutsName keys={shortcuts} className='!text-[11px]' />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</TooltipPlus>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TipPopup)
|
@@ -5,6 +5,8 @@ import {
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useReactFlow,
|
||||
@@ -14,13 +16,32 @@ import {
|
||||
useNodesSyncDraft,
|
||||
useWorkflowReadOnly,
|
||||
} from '../hooks'
|
||||
import {
|
||||
getKeyboardKeyCodeBySystem,
|
||||
getKeyboardKeyNameBySystem,
|
||||
} from '../utils'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
import TipPopup from './tip-popup'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { SearchLg } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
} from '@/app/components/base/icons/src/vender/line/editor'
|
||||
|
||||
enum ZoomType {
|
||||
zoomIn = 'zoomIn',
|
||||
zoomOut = 'zoomOut',
|
||||
zoomToFit = 'zoomToFit',
|
||||
zoomTo25 = 'zoomTo25',
|
||||
zoomTo50 = 'zoomTo50',
|
||||
zoomTo75 = 'zoomTo75',
|
||||
zoomTo100 = 'zoomTo100',
|
||||
zoomTo200 = 'zoomTo200',
|
||||
}
|
||||
|
||||
const ZoomInOut: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -41,27 +62,29 @@ const ZoomInOut: FC = () => {
|
||||
const ZOOM_IN_OUT_OPTIONS = [
|
||||
[
|
||||
{
|
||||
key: 'in',
|
||||
text: t('workflow.operator.zoomIn'),
|
||||
key: ZoomType.zoomTo200,
|
||||
text: '200%',
|
||||
},
|
||||
{
|
||||
key: 'out',
|
||||
text: t('workflow.operator.zoomOut'),
|
||||
key: ZoomType.zoomTo100,
|
||||
text: '100%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo75,
|
||||
text: '75%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo50,
|
||||
text: '50%',
|
||||
},
|
||||
{
|
||||
key: ZoomType.zoomTo25,
|
||||
text: '25%',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'to50',
|
||||
text: t('workflow.operator.zoomTo50'),
|
||||
},
|
||||
{
|
||||
key: 'to100',
|
||||
text: t('workflow.operator.zoomTo100'),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
key: 'fit',
|
||||
key: ZoomType.zoomToFit,
|
||||
text: t('workflow.operator.zoomToFit'),
|
||||
},
|
||||
],
|
||||
@@ -71,24 +94,99 @@ const ZoomInOut: FC = () => {
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
if (type === 'in')
|
||||
zoomIn()
|
||||
|
||||
if (type === 'out')
|
||||
zoomOut()
|
||||
|
||||
if (type === 'fit')
|
||||
if (type === ZoomType.zoomToFit)
|
||||
fitView()
|
||||
|
||||
if (type === 'to50')
|
||||
if (type === ZoomType.zoomTo25)
|
||||
zoomTo(0.25)
|
||||
|
||||
if (type === ZoomType.zoomTo50)
|
||||
zoomTo(0.5)
|
||||
|
||||
if (type === 'to100')
|
||||
if (type === ZoomType.zoomTo75)
|
||||
zoomTo(0.75)
|
||||
|
||||
if (type === ZoomType.zoomTo100)
|
||||
zoomTo(1)
|
||||
|
||||
if (type === ZoomType.zoomTo200)
|
||||
zoomTo(2)
|
||||
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
|
||||
e.preventDefault()
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.1', (e) => {
|
||||
e.preventDefault()
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.2', (e) => {
|
||||
e.preventDefault()
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
zoomTo(2)
|
||||
handleSyncWorkflowDraft()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.5', (e) => {
|
||||
e.preventDefault()
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
|
||||
e.preventDefault()
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
zoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
|
||||
e.preventDefault()
|
||||
if (workflowReadOnly)
|
||||
return
|
||||
|
||||
zoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
@@ -108,17 +206,47 @@ const ZoomInOut: FC = () => {
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={handleTrigger}>
|
||||
<div className={`
|
||||
flex items-center px-2 h-8 cursor-pointer text-[13px] hover:bg-gray-50 rounded-lg
|
||||
${open && 'bg-gray-50'}
|
||||
p-0.5 h-9 cursor-pointer text-[13px] text-gray-500 font-medium rounded-lg bg-white shadow-lg border-[0.5px] border-gray-100
|
||||
${workflowReadOnly && '!cursor-not-allowed opacity-50'}
|
||||
`}>
|
||||
<SearchLg className='mr-1 w-4 h-4' />
|
||||
<div className='w-[34px]'>{parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
|
||||
<ChevronDown className='ml-1 w-4 h-4' />
|
||||
<div className={cn(
|
||||
'flex items-center justify-between w-[98px] h-8 hover:bg-gray-50 rounded-lg',
|
||||
open && 'bg-gray-50',
|
||||
)}>
|
||||
<TipPopup
|
||||
title={t('workflow.operator.zoomOut')}
|
||||
shortcuts={['ctrl', '-']}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer hover:bg-black/5'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
zoomOut()
|
||||
}}
|
||||
>
|
||||
<ZoomOut className='w-4 h-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<div className='w-[34px]'>{parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
|
||||
<TipPopup
|
||||
title={t('workflow.operator.zoomIn')}
|
||||
shortcuts={['ctrl', '+']}
|
||||
>
|
||||
<div
|
||||
className='flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer hover:bg-black/5'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
zoomIn()
|
||||
}}
|
||||
>
|
||||
<ZoomIn className='w-4 h-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='w-[168px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'>
|
||||
<div className='w-[145px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'>
|
||||
{
|
||||
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
|
||||
<Fragment key={i}>
|
||||
@@ -132,10 +260,30 @@ const ZoomInOut: FC = () => {
|
||||
options.map(option => (
|
||||
<div
|
||||
key={option.key}
|
||||
className='flex items-center px-3 h-8 rounded-lg hover:bg-gray-50 cursor-pointer text-sm text-gray-700'
|
||||
className='flex items-center justify-between px-3 h-8 rounded-lg hover:bg-gray-50 cursor-pointer text-sm text-gray-700'
|
||||
onClick={() => handleZoom(option.key)}
|
||||
>
|
||||
{option.text}
|
||||
{
|
||||
option.key === ZoomType.zoomToFit && (
|
||||
<ShortcutsName keys={[`${getKeyboardKeyNameBySystem('ctrl')}`, '1']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo50 && (
|
||||
<ShortcutsName keys={['shift', '5']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo100 && (
|
||||
<ShortcutsName keys={['shift', '1']} />
|
||||
)
|
||||
}
|
||||
{
|
||||
option.key === ZoomType.zoomTo200 && (
|
||||
<ShortcutsName keys={['shift', '2']} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
123
web/app/components/workflow/panel-contextmenu.tsx
Normal file
123
web/app/components/workflow/panel-contextmenu.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
memo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import cn from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import ShortcutsName from './shortcuts-name'
|
||||
import { useStore } from './store'
|
||||
import {
|
||||
useNodesInteractions,
|
||||
usePanelInteractions,
|
||||
useWorkflowStartRun,
|
||||
} from './hooks'
|
||||
import AddBlock from './operator/add-block'
|
||||
import { exportAppConfig } from '@/service/apps'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
|
||||
const PanelContextmenu = () => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useToastContext()
|
||||
const ref = useRef(null)
|
||||
const panelMenu = useStore(s => s.panelMenu)
|
||||
const clipboardElements = useStore(s => s.clipboardElements)
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const { handleNodesPaste } = useNodesInteractions()
|
||||
const { handlePaneContextmenuCancel } = usePanelInteractions()
|
||||
const { handleStartWorkflowRun } = useWorkflowStartRun()
|
||||
|
||||
useClickAway(() => {
|
||||
handlePaneContextmenuCancel()
|
||||
}, ref)
|
||||
|
||||
const onExport = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const { data } = await exportAppConfig(appDetail.id)
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
a.href = URL.createObjectURL(file)
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
}
|
||||
catch (e) {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const renderTrigger = () => {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
>
|
||||
{t('workflow.common.addBlock')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!panelMenu)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute w-[200px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-xl z-[9]'
|
||||
style={{
|
||||
left: panelMenu.left,
|
||||
top: panelMenu.top,
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<div className='p-1'>
|
||||
<AddBlock
|
||||
renderTrigger={renderTrigger}
|
||||
offset={{
|
||||
mainAxis: -36,
|
||||
crossAxis: -4,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => {
|
||||
handleStartWorkflowRun()
|
||||
handlePaneContextmenuCancel()
|
||||
}}
|
||||
>
|
||||
{t('workflow.common.run')}
|
||||
<ShortcutsName keys={['alt', 'r']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer',
|
||||
!clipboardElements.length ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (clipboardElements.length) {
|
||||
handleNodesPaste()
|
||||
handlePaneContextmenuCancel()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('workflow.common.pasteHere')}
|
||||
<ShortcutsName keys={['ctrl', 'v']} />
|
||||
</div>
|
||||
</div>
|
||||
<div className='h-[1px] bg-gray-100'></div>
|
||||
<div className='p-1'>
|
||||
<div
|
||||
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
|
||||
onClick={() => onExport()}
|
||||
>
|
||||
{t('app.export')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(PanelContextmenu)
|
32
web/app/components/workflow/shortcuts-name.tsx
Normal file
32
web/app/components/workflow/shortcuts-name.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { memo } from 'react'
|
||||
import cn from 'classnames'
|
||||
import { getKeyboardKeyNameBySystem } from './utils'
|
||||
|
||||
type ShortcutsNameProps = {
|
||||
keys: string[]
|
||||
className?: string
|
||||
}
|
||||
const ShortcutsName = ({
|
||||
keys,
|
||||
className,
|
||||
}: ShortcutsNameProps) => {
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex items-center gap-0.5 h-4 text-xs text-gray-400',
|
||||
className,
|
||||
)}>
|
||||
{
|
||||
keys.map(key => (
|
||||
<div
|
||||
key={key}
|
||||
className='capitalize'
|
||||
>
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ShortcutsName)
|
@@ -75,6 +75,27 @@ type Shape = {
|
||||
setShortcutsDisabled: (shortcutsDisabled: boolean) => void
|
||||
showDebugAndPreviewPanel: boolean
|
||||
setShowDebugAndPreviewPanel: (showDebugAndPreviewPanel: boolean) => void
|
||||
selection: null | { x1: number; y1: number; x2: number; y2: number }
|
||||
setSelection: (selection: Shape['selection']) => void
|
||||
bundleNodeSize: { width: number; height: number } | null
|
||||
setBundleNodeSize: (bundleNodeSize: Shape['bundleNodeSize']) => void
|
||||
controlMode: 'pointer' | 'hand'
|
||||
setControlMode: (controlMode: Shape['controlMode']) => void
|
||||
candidateNode?: Node
|
||||
setCandidateNode: (candidateNode?: Node) => void
|
||||
panelMenu?: {
|
||||
top: number
|
||||
left: number
|
||||
}
|
||||
setPanelMenu: (panelMenu: Shape['panelMenu']) => void
|
||||
nodeMenu?: {
|
||||
top: number
|
||||
left: number
|
||||
nodeId: string
|
||||
}
|
||||
setNodeMenu: (nodeMenu: Shape['nodeMenu']) => void
|
||||
mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number }
|
||||
setMousePosition: (mousePosition: Shape['mousePosition']) => void
|
||||
}
|
||||
|
||||
export const createWorkflowStore = () => {
|
||||
@@ -126,6 +147,23 @@ export const createWorkflowStore = () => {
|
||||
setShortcutsDisabled: shortcutsDisabled => set(() => ({ shortcutsDisabled })),
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })),
|
||||
selection: null,
|
||||
setSelection: selection => set(() => ({ selection })),
|
||||
bundleNodeSize: null,
|
||||
setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })),
|
||||
controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand',
|
||||
setControlMode: (controlMode) => {
|
||||
set(() => ({ controlMode }))
|
||||
localStorage.setItem('workflow-operation-mode', controlMode)
|
||||
},
|
||||
candidateNode: undefined,
|
||||
setCandidateNode: candidateNode => set(() => ({ candidateNode })),
|
||||
panelMenu: undefined,
|
||||
setPanelMenu: panelMenu => set(() => ({ panelMenu })),
|
||||
nodeMenu: undefined,
|
||||
setNodeMenu: nodeMenu => set(() => ({ nodeMenu })),
|
||||
mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 },
|
||||
setMousePosition: mousePosition => set(() => ({ mousePosition })),
|
||||
}))
|
||||
}
|
||||
|
||||
|
@@ -4,4 +4,15 @@
|
||||
|
||||
.workflow-node-animation .react-flow__node {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
#workflow-container .react-flow__nodesselection-rect {
|
||||
border: 1px solid #528BFF;
|
||||
background: rgba(21, 94, 239, 0.05);
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
#workflow-container .react-flow__selection {
|
||||
border: 1px solid #528BFF;
|
||||
background: rgba(21, 94, 239, 0.05);
|
||||
}
|
@@ -37,6 +37,8 @@ export type CommonNodeType<T = {}> = {
|
||||
_isSingleRun?: boolean
|
||||
_runningStatus?: NodeRunningStatus
|
||||
_singleRunningStatus?: NodeRunningStatus
|
||||
_isCandidate?: boolean
|
||||
_isBundled?: boolean
|
||||
selected?: boolean
|
||||
title: string
|
||||
desc: string
|
||||
@@ -48,6 +50,7 @@ export type CommonEdgeType = {
|
||||
_connectedNodeIsHovering?: boolean
|
||||
_connectedNodeIsSelected?: boolean
|
||||
_runned?: boolean
|
||||
_isBundled?: boolean
|
||||
sourceType: BlockEnum
|
||||
targetType: BlockEnum
|
||||
}
|
||||
|
@@ -361,3 +361,48 @@ export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => {
|
||||
|
||||
return [newNodes, newEdges] as [Node[], Edge[]]
|
||||
}
|
||||
|
||||
export const isMac = () => {
|
||||
return navigator.userAgent.toUpperCase().includes('MAC')
|
||||
}
|
||||
|
||||
const specialKeysNameMap: Record<string, string | undefined> = {
|
||||
ctrl: '⌘',
|
||||
alt: '⌥',
|
||||
}
|
||||
|
||||
export const getKeyboardKeyNameBySystem = (key: string) => {
|
||||
if (isMac())
|
||||
return specialKeysNameMap[key] || key
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
const specialKeysCodeMap: Record<string, string | undefined> = {
|
||||
ctrl: 'meta',
|
||||
}
|
||||
|
||||
export const getKeyboardKeyCodeBySystem = (key: string) => {
|
||||
if (isMac())
|
||||
return specialKeysCodeMap[key] || key
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export const getTopLeftNodePosition = (nodes: Node[]) => {
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.position.x < minX)
|
||||
minX = node.position.x
|
||||
|
||||
if (node.position.y < minY)
|
||||
minY = node.position.y
|
||||
})
|
||||
|
||||
return {
|
||||
x: minX,
|
||||
y: minY,
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user