{
'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 (
{
'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()}
>
{t('workflow.common.debugAndPreview')}
diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts
index eb59bf5736..44f5464e41 100644
--- a/web/app/components/workflow/hooks/index.ts
+++ b/web/app/components/workflow/hooks/index.ts
@@ -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'
diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts
index d1ae373adf..68bb4d4aa2 100644
--- a/web/app/components/workflow/hooks/use-nodes-interactions.ts
+++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts
@@ -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,
}
}
diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts
new file mode 100644
index 0000000000..1f02ac7c74
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-panel-interactions.ts
@@ -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,
+ }
+}
diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts
new file mode 100644
index 0000000000..38fd4f497b
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-selection-interactions.ts
@@ -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
(({ 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,
+ }
+}
diff --git a/web/app/components/workflow/hooks/use-workflow-start-run.tsx b/web/app/components/workflow/hooks/use-workflow-start-run.tsx
new file mode 100644
index 0000000000..f80191cc2d
--- /dev/null
+++ b/web/app/components/workflow/hooks/use-workflow-start-run.tsx
@@ -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,
+ }
+}
diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx
index 3f173fc02d..b6f652570e 100644
--- a/web/app/components/workflow/index.tsx
+++ b/web/app/components/workflow/index.tsx
@@ -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 = memo(({
edges: originalEdges,
viewport,
}) => {
+ const workflowContainerRef = useRef(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 = 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 = memo(({
handleNodeConnect,
handleNodeConnectStart,
handleNodeConnectEnd,
- handleNodeDuplicateSelected,
- handleNodeCopySelected,
- handleNodeCut,
- handleNodeDeleteSelected,
- handleNodePaste,
+ handleNodeContextMenu,
+ handleNodesCopy,
+ handleNodesPaste,
+ handleNodesDuplicate,
+ handleNodesDelete,
} = useNodesInteractions()
const {
handleEdgeEnter,
@@ -140,9 +177,18 @@ const Workflow: FC = 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 = 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 (
= memo(({
${workflowReadOnly && 'workflow-panel-animation'}
${nodeAnimation && 'workflow-node-animation'}
`}
+ ref={workflowContainerRef}
>
+
{
showFeaturesPanel &&
}
+
+
= 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 = 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}
>
{
+ 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 (
+
+
handleNodeContextmenuCancel()}
+ />
+
+ )
+}
+
+export default memo(PanelContextmenu)
diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx
index e313d74034..d6913db526 100644
--- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx
+++ b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx
@@ -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 = ({
-
-
- {
- data.type !== BlockEnum.Start && !nodesReadOnly && (
- <>
-
-
-
handleNodeDelete(id)}
- >
- {t('common.operation.delete')}
-
-
- >
- )
- }
-
-
-
-
- {t('workflow.panel.about').toLocaleUpperCase()}
-
-
{about}
-
- {t('workflow.panel.createdBy')} {author}
-
-
-
-
+ setOpen(false)}
+ />
)
diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
new file mode 100644
index 0000000000..78e7657569
--- /dev/null
+++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
@@ -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 (
+
+ {
+ (showChangeBlock || canRunBySingle(data.type)) && (
+ <>
+
+ {
+ canRunBySingle(data.type) && (
+
{
+ handleNodeSelect(id)
+ handleNodeDataUpdate({ id, data: { _isSingleRun: true } })
+ handleSyncWorkflowDraft(true)
+ onClosePopup()
+ }}
+ >
+ {t('workflow.panel.runThisStep')}
+
+ )
+ }
+ {
+ showChangeBlock && (
+
+ )
+ }
+
+
+ >
+ )
+ }
+ {
+ data.type !== BlockEnum.Start && data.type !== BlockEnum.End && !nodesReadOnly && (
+ <>
+
+
{
+ onClosePopup()
+ handleNodesCopy()
+ }}
+ >
+ {t('workflow.common.copy')}
+
+
+
{
+ onClosePopup()
+ handleNodesDuplicate()
+ }}
+ >
+ {t('workflow.common.duplicate')}
+
+
+
+
+
+
handleNodeDelete(id)}
+ >
+ {t('common.operation.delete')}
+
+
+
+
+ >
+ )
+ }
+
+
+
+
+
+ {t('workflow.panel.about').toLocaleUpperCase()}
+
+
{about}
+
+ {t('workflow.panel.createdBy')} {author}
+
+
+
+
+ )
+}
+
+export default memo(PanelOperatorPopup)
diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx
index 01cbe3f35e..c5fcd5b99d 100644
--- a/web/app/components/workflow/nodes/_base/node.tsx
+++ b/web/app/components/workflow/nodes/_base/node.tsx
@@ -6,6 +6,7 @@ import {
cloneElement,
memo,
useMemo,
+ useRef,
} from 'react'
import type { NodeProps } from '../../types'
import {
@@ -37,27 +38,30 @@ const BaseNode: FC = ({
data,
children,
}) => {
+ const nodeRef = useRef(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 (
= ({
${showSuccessBorder && '!border-[#12B76A]'}
${showFailedBorder && '!border-[#F04438]'}
${data._isInvalidConnection && '!border-[#F04438]'}
+ ${data._isBundled && '!shadow-lg'}
`}
>
{
- data.type !== BlockEnum.VariableAssigner && (
+ data.type !== BlockEnum.VariableAssigner && !data._isCandidate && (
= ({
)
}
{
- data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && (
+ data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && (
= ({
)
}
{
- !data._runningStatus && !nodesReadOnly && (
+ !data._runningStatus && !nodesReadOnly && !data._isCandidate && (
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((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 (
+
+
+
+ )
+ }, [nodesReadOnly, t])
+
+ return (
+
+ )
+}
+
+export default memo(AddBlock)
diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx
new file mode 100644
index 0000000000..94d47129c5
--- /dev/null
+++ b/web/app/components/workflow/operator/control.tsx
@@ -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 (
+
+
+
+
+ setControlMode('pointer')}
+ >
+ {
+ controlMode === 'pointer' ? :
+ }
+
+
+
+ setControlMode('hand')}
+ >
+ {
+ controlMode === 'hand' ? :
+ }
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default memo(Control)
diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx
index 72fc5d2975..41465122b4 100644
--- a/web/app/components/workflow/operator/index.tsx
+++ b/web/app/components/workflow/operator/index.tsx
@@ -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 (
-
+ <>
-
+ >
)
}
diff --git a/web/app/components/workflow/operator/tip-popup.tsx b/web/app/components/workflow/operator/tip-popup.tsx
new file mode 100644
index 0000000000..ecd108dffc
--- /dev/null
+++ b/web/app/components/workflow/operator/tip-popup.tsx
@@ -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 (
+
+ {title}
+ {
+ shortcuts &&
+ }
+
+ }
+ >
+ {children}
+
+ )
+}
+
+export default memo(TipPopup)
diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx
index 1fe4d5e41d..6839c1aa04 100644
--- a/web/app/components/workflow/operator/zoom-in-out.tsx
+++ b/web/app/components/workflow/operator/zoom-in-out.tsx
@@ -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 = () => {
>
-
-
{parseFloat(`${zoom * 100}`).toFixed(0)}%
-
+
+
+ {
+ e.stopPropagation()
+ zoomOut()
+ }}
+ >
+
+
+
+
{parseFloat(`${zoom * 100}`).toFixed(0)}%
+
+ {
+ e.stopPropagation()
+ zoomIn()
+ }}
+ >
+
+
+
+
-
+
{
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
@@ -132,10 +260,30 @@ const ZoomInOut: FC = () => {
options.map(option => (
handleZoom(option.key)}
>
{option.text}
+ {
+ option.key === ZoomType.zoomToFit && (
+
+ )
+ }
+ {
+ option.key === ZoomType.zoomTo50 && (
+
+ )
+ }
+ {
+ option.key === ZoomType.zoomTo100 && (
+
+ )
+ }
+ {
+ option.key === ZoomType.zoomTo200 && (
+
+ )
+ }
))
}
diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx
new file mode 100644
index 0000000000..eeae51c8d1
--- /dev/null
+++ b/web/app/components/workflow/panel-contextmenu.tsx
@@ -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 (
+
+ {t('workflow.common.addBlock')}
+
+ )
+ }
+
+ if (!panelMenu)
+ return null
+
+ return (
+
+
+
+
{
+ handleStartWorkflowRun()
+ handlePaneContextmenuCancel()
+ }}
+ >
+ {t('workflow.common.run')}
+
+
+
+
+
+
{
+ if (clipboardElements.length) {
+ handleNodesPaste()
+ handlePaneContextmenuCancel()
+ }
+ }}
+ >
+ {t('workflow.common.pasteHere')}
+
+
+
+
+
+
onExport()}
+ >
+ {t('app.export')}
+
+
+
+ )
+}
+
+export default memo(PanelContextmenu)
diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx
new file mode 100644
index 0000000000..dfd44940e0
--- /dev/null
+++ b/web/app/components/workflow/shortcuts-name.tsx
@@ -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 (
+
+ {
+ keys.map(key => (
+
+ {getKeyboardKeyNameBySystem(key)}
+
+ ))
+ }
+
+ )
+}
+
+export default memo(ShortcutsName)
diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts
index 9aaece0c91..4ee28da4bc 100644
--- a/web/app/components/workflow/store.ts
+++ b/web/app/components/workflow/store.ts
@@ -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 })),
}))
}
diff --git a/web/app/components/workflow/style.css b/web/app/components/workflow/style.css
index fc6130cc02..9ec8586ccc 100644
--- a/web/app/components/workflow/style.css
+++ b/web/app/components/workflow/style.css
@@ -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);
}
\ No newline at end of file
diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts
index 50d6922c9c..f0b5e08c6c 100644
--- a/web/app/components/workflow/types.ts
+++ b/web/app/components/workflow/types.ts
@@ -37,6 +37,8 @@ export type CommonNodeType = {
_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
}
diff --git a/web/app/components/workflow/utils.ts b/web/app/components/workflow/utils.ts
index 6a5f0d9e47..d4f6f77d71 100644
--- a/web/app/components/workflow/utils.ts
+++ b/web/app/components/workflow/utils.ts
@@ -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 = {
+ ctrl: '⌘',
+ alt: '⌥',
+}
+
+export const getKeyboardKeyNameBySystem = (key: string) => {
+ if (isMac())
+ return specialKeysNameMap[key] || key
+
+ return key
+}
+
+const specialKeysCodeMap: Record = {
+ 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,
+ }
+}
diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts
index 42a073d093..5093b6631d 100644
--- a/web/i18n/en-US/workflow.ts
+++ b/web/i18n/en-US/workflow.ts
@@ -52,6 +52,12 @@ const translation = {
jinjaEditorPlaceholder: 'Type \'/\' or \'{\' to insert variable',
viewOnly: 'View Only',
showRunHistory: 'Show Run History',
+ copy: 'Copy',
+ duplicate: 'Duplicate',
+ addBlock: 'Add Block',
+ pasteHere: 'Paste Here',
+ pointerMode: 'Pointer Mode',
+ handMode: 'Hand Mode',
},
errorMsg: {
fieldRequired: '{{field}} is required',
diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts
index 4c99499808..baae846376 100644
--- a/web/i18n/zh-Hans/workflow.ts
+++ b/web/i18n/zh-Hans/workflow.ts
@@ -52,6 +52,12 @@ const translation = {
jinjaEditorPlaceholder: '输入 “/” 或 “{” 插入变量',
viewOnly: '只读',
showRunHistory: '显示运行历史',
+ copy: '拷贝',
+ duplicate: '复制',
+ addBlock: '添加节点',
+ pasteHere: '粘贴到这里',
+ pointerMode: '指针模式',
+ handMode: '手模式',
},
errorMsg: {
fieldRequired: '{{field}} 不能为空',