浏览代码

feat: workflow interaction (#4214)

zxhlyh 11 月之前
父节点
当前提交
9b24f12bf5
共有 54 个文件被更改,包括 1906 次插入382 次删除
  1. 二进制
      web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip
  2. 二进制
      web/app/components/base/icons/assets/vender/line/arrows/flip-forward.zip
  3. 5 0
      web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg
  4. 5 0
      web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg
  5. 5 0
      web/app/components/base/icons/assets/vender/line/editor/hand-02.svg
  6. 5 0
      web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg
  7. 5 0
      web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg
  8. 5 0
      web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg
  9. 5 0
      web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg
  10. 39 0
      web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json
  11. 16 0
      web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx
  12. 1 0
      web/app/components/base/icons/src/vender/line/arrows/index.ts
  13. 38 0
      web/app/components/base/icons/src/vender/line/editor/Cursor02C.json
  14. 16 0
      web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx
  15. 39 0
      web/app/components/base/icons/src/vender/line/editor/Hand02.json
  16. 16 0
      web/app/components/base/icons/src/vender/line/editor/Hand02.tsx
  17. 39 0
      web/app/components/base/icons/src/vender/line/editor/ZoomIn.json
  18. 16 0
      web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx
  19. 39 0
      web/app/components/base/icons/src/vender/line/editor/ZoomOut.json
  20. 16 0
      web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx
  21. 4 0
      web/app/components/base/icons/src/vender/line/editor/index.ts
  22. 36 0
      web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json
  23. 16 0
      web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx
  24. 38 0
      web/app/components/base/icons/src/vender/solid/editor/Hand02.json
  25. 16 0
      web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx
  26. 2 0
      web/app/components/base/icons/src/vender/solid/editor/index.ts
  27. 12 3
      web/app/components/base/tooltip-plus/index.tsx
  28. 8 10
      web/app/components/workflow/block-selector/blocks.tsx
  29. 11 13
      web/app/components/workflow/block-selector/tools.tsx
  30. 81 0
      web/app/components/workflow/candidate-node.tsx
  31. 9 86
      web/app/components/workflow/header/run-and-history.tsx
  32. 3 0
      web/app/components/workflow/hooks/index.ts
  33. 131 86
      web/app/components/workflow/hooks/use-nodes-interactions.ts
  34. 37 0
      web/app/components/workflow/hooks/use-panel-interactions.ts
  35. 109 0
      web/app/components/workflow/hooks/use-selection-interactions.ts
  36. 88 0
      web/app/components/workflow/hooks/use-workflow-start-run.tsx
  37. 73 14
      web/app/components/workflow/index.tsx
  38. 44 0
      web/app/components/workflow/node-contextmenu.tsx
  39. 6 94
      web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx
  40. 181 0
      web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx
  41. 13 8
      web/app/components/workflow/nodes/_base/node.tsx
  42. 110 0
      web/app/components/workflow/operator/add-block.tsx
  43. 85 0
      web/app/components/workflow/operator/control.tsx
  44. 8 39
      web/app/components/workflow/operator/index.tsx
  45. 34 0
      web/app/components/workflow/operator/tip-popup.tsx
  46. 177 29
      web/app/components/workflow/operator/zoom-in-out.tsx
  47. 123 0
      web/app/components/workflow/panel-contextmenu.tsx
  48. 32 0
      web/app/components/workflow/shortcuts-name.tsx
  49. 38 0
      web/app/components/workflow/store.ts
  50. 11 0
      web/app/components/workflow/style.css
  51. 3 0
      web/app/components/workflow/types.ts
  52. 45 0
      web/app/components/workflow/utils.ts
  53. 6 0
      web/i18n/en-US/workflow.ts
  54. 6 0
      web/i18n/zh-Hans/workflow.ts

二进制
web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip


二进制
web/app/components/base/icons/assets/vender/line/arrows/flip-forward.zip


+ 5 - 0
web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon_2" d="M2.66699 4.66667H9.33366C11.5428 4.66667 13.3337 6.45753 13.3337 8.66667C13.3337 10.8758 11.5428 12.6667 9.33366 12.6667H2.66699M2.66699 4.66667L5.33366 2M2.66699 4.66667L5.33366 7.33333" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Vector" d="M2.4598 3.3093L6.05377 13.551C6.25503 14.1246 7.05599 14.1516 7.29552 13.593L9.08053 9.43022C9.14793 9.27295 9.27326 9.14762 9.43053 9.08022L13.5933 7.29522C14.1519 7.05569 14.1249 6.25472 13.5513 6.05346L3.30961 2.45949C2.78207 2.27437 2.27468 2.78176 2.4598 3.3093Z" stroke="#667085" stroke-width="1.5" stroke-linejoin="round"/>
+</g>
+</svg>

文件差异内容过多而无法显示
+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/hand-02.svg


+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon_2" d="M14 14L11.1 11.1M7.33333 5.33333V9.33333M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Icon_2" d="M14 14L11.1 11.1M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z" stroke="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+</g>
+</svg>

+ 5 - 0
web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg

@@ -0,0 +1,5 @@
+<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g id="Icon">
+<path id="Vector" d="M3.53647 1.81277C2.46674 1.43738 1.43787 2.46625 1.81326 3.53598L5.40722 13.7777C5.81532 14.9407 7.43953 14.9956 7.92526 13.8628L9.70733 9.70683L13.8633 7.92476C14.9961 7.4391 14.9412 5.81484 13.7782 5.40674L3.53647 1.81277Z" fill="#155EEF"/>
+</g>
+</svg>

文件差异内容过多而无法显示
+ 5 - 0
web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg


+ 39 - 0
web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon_2",
+							"d": "M2.66699 4.66667H9.33366C11.5428 4.66667 13.3337 6.45753 13.3337 8.66667C13.3337 10.8758 11.5428 12.6667 9.33366 12.6667H2.66699M2.66699 4.66667L5.33366 2M2.66699 4.66667L5.33366 7.33333",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "ReverseLeft"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ReverseLeft.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'ReverseLeft'
+
+export default Icon

+ 1 - 0
web/app/components/base/icons/src/vender/line/arrows/index.ts

@@ -9,3 +9,4 @@ export { default as Collapse04 } from './Collapse04'
 export { default as FlipBackward } from './FlipBackward'
 export { default as RefreshCcw01 } from './RefreshCcw01'
 export { default as RefreshCw05 } from './RefreshCw05'
+export { default as ReverseLeft } from './ReverseLeft'

+ 38 - 0
web/app/components/base/icons/src/vender/line/editor/Cursor02C.json

@@ -0,0 +1,38 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"d": "M2.4598 3.3093L6.05377 13.551C6.25503 14.1246 7.05599 14.1516 7.29552 13.593L9.08053 9.43022C9.14793 9.27295 9.27326 9.14762 9.43053 9.08022L13.5933 7.29522C14.1519 7.05569 14.1249 6.25472 13.5513 6.05346L3.30961 2.45949C2.78207 2.27437 2.27468 2.78176 2.4598 3.3093Z",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Cursor02C"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Cursor02C.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Cursor02C'
+
+export default Icon

文件差异内容过多而无法显示
+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/Hand02.json


+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/Hand02.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Hand02.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Hand02'
+
+export default Icon

+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomIn.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon_2",
+							"d": "M14 14L11.1 11.1M7.33333 5.33333V9.33333M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "ZoomIn"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ZoomIn.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'ZoomIn'
+
+export default Icon

+ 39 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomOut.json

@@ -0,0 +1,39 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Icon_2",
+							"d": "M14 14L11.1 11.1M5.33333 7.33333H9.33333M12.6667 7.33333C12.6667 10.2789 10.2789 12.6667 7.33333 12.6667C4.38781 12.6667 2 10.2789 2 7.33333C2 4.38781 4.38781 2 7.33333 2C10.2789 2 12.6667 4.38781 12.6667 7.33333Z",
+							"stroke": "currentColor",
+							"stroke-width": "1.5",
+							"stroke-linecap": "round",
+							"stroke-linejoin": "round"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "ZoomOut"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './ZoomOut.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'ZoomOut'
+
+export default Icon

+ 4 - 0
web/app/components/base/icons/src/vender/line/editor/index.ts

@@ -1,7 +1,11 @@
 export { default as AlignLeft } from './AlignLeft'
 export { default as BezierCurve03 } from './BezierCurve03'
 export { default as Colors } from './Colors'
+export { default as Cursor02C } from './Cursor02C'
+export { default as Hand02 } from './Hand02'
 export { default as ImageIndentLeft } from './ImageIndentLeft'
 export { default as LeftIndent02 } from './LeftIndent02'
 export { default as LetterSpacing01 } from './LetterSpacing01'
 export { default as TypeSquare } from './TypeSquare'
+export { default as ZoomIn } from './ZoomIn'
+export { default as ZoomOut } from './ZoomOut'

+ 36 - 0
web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json

@@ -0,0 +1,36 @@
+{
+	"icon": {
+		"type": "element",
+		"isRootNode": true,
+		"name": "svg",
+		"attributes": {
+			"width": "16",
+			"height": "16",
+			"viewBox": "0 0 16 16",
+			"fill": "none",
+			"xmlns": "http://www.w3.org/2000/svg"
+		},
+		"children": [
+			{
+				"type": "element",
+				"name": "g",
+				"attributes": {
+					"id": "Icon"
+				},
+				"children": [
+					{
+						"type": "element",
+						"name": "path",
+						"attributes": {
+							"id": "Vector",
+							"d": "M3.53647 1.81277C2.46674 1.43738 1.43787 2.46625 1.81326 3.53598L5.40722 13.7777C5.81532 14.9407 7.43953 14.9956 7.92526 13.8628L9.70733 9.70683L13.8633 7.92476C14.9961 7.4391 14.9412 5.81484 13.7782 5.40674L3.53647 1.81277Z",
+							"fill": "currentColor"
+						},
+						"children": []
+					}
+				]
+			}
+		]
+	},
+	"name": "Cursor02C"
+}

+ 16 - 0
web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Cursor02C.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Cursor02C'
+
+export default Icon

文件差异内容过多而无法显示
+ 38 - 0
web/app/components/base/icons/src/vender/solid/editor/Hand02.json


+ 16 - 0
web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx

@@ -0,0 +1,16 @@
+// GENERATE BY script
+// DON NOT EDIT IT MANUALLY
+
+import * as React from 'react'
+import data from './Hand02.json'
+import IconBase from '@/app/components/base/icons/IconBase'
+import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
+
+const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
+  props,
+  ref,
+) => <IconBase {...props} ref={ref} data={data as IconData} />)
+
+Icon.displayName = 'Hand02'
+
+export default Icon

+ 2 - 0
web/app/components/base/icons/src/vender/solid/editor/index.ts

@@ -1,5 +1,7 @@
 export { default as Brush01 } from './Brush01'
 export { default as Citations } from './Citations'
 export { default as Colors } from './Colors'
+export { default as Cursor02C } from './Cursor02C'
+export { default as Hand02 } from './Hand02'
 export { default as Paragraph } from './Paragraph'
 export { default as TypeSquare } from './TypeSquare'

+ 12 - 3
web/app/components/base/tooltip-plus/index.tsx

@@ -1,13 +1,17 @@
 'use client'
 import type { FC } from 'react'
 import React, { useState } from 'react'
+import cn from 'classnames'
+import type { OffsetOptions, Placement } from '@floating-ui/react'
 import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '@/app/components/base/portal-to-follow-elem'
 export type TooltipProps = {
-  position?: 'top' | 'right' | 'bottom' | 'left'
+  position?: Placement
   triggerMethod?: 'hover' | 'click'
   popupContent: React.ReactNode
   children: React.ReactNode
   hideArrow?: boolean
+  popupClassName?: string
+  offset?: OffsetOptions
 }
 
 const arrow = (
@@ -20,6 +24,8 @@ const Tooltip: FC<TooltipProps> = ({
   popupContent,
   children,
   hideArrow,
+  popupClassName,
+  offset,
 }) => {
   const [open, setOpen] = useState(false)
 
@@ -28,7 +34,7 @@ const Tooltip: FC<TooltipProps> = ({
       open={open}
       onOpenChange={setOpen}
       placement={position}
-      offset={10}
+      offset={offset ?? 10}
     >
       <PortalToFollowElemTrigger
         onClick={() => triggerMethod === 'click' && setOpen(v => !v)}
@@ -40,7 +46,10 @@ const Tooltip: FC<TooltipProps> = ({
       <PortalToFollowElemContent
         className="z-[9999]"
       >
-        <div className='relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg'>
+        <div className={cn(
+          'relative px-3 py-2 text-xs font-normal text-gray-700 bg-white rounded-md shadow-lg',
+          popupClassName,
+        )}>
           {popupContent}
           {!hideArrow && arrow}
         </div>

+ 8 - 10
web/app/components/workflow/block-selector/blocks.tsx

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

+ 11 - 13
web/app/components/workflow/block-selector/tools.tsx

@@ -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 - 0
web/app/components/workflow/candidate-node.tsx

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

+ 9 - 86
web/app/components/workflow/header/run-and-history.tsx

@@ -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')}

+ 3 - 0
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'

+ 131 - 86
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,7 +709,51 @@ export const useNodesInteractions = () => {
     handleSyncWorkflowDraft()
   }, [store, handleSyncWorkflowDraft, getNodesReadOnly, t])
 
-  const handleNodeCopySelected = useCallback((): undefined | Node[] => {
+  const handleNodeCancelRunningStatus = useCallback(() => {
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    const nodes = getNodes()
+    const newNodes = produce(nodes, (draft) => {
+      draft.forEach((node) => {
+        node.data._runningStatus = undefined
+      })
+    })
+    setNodes(newNodes)
+  }, [store])
+
+  const handleNodesCancelSelected = useCallback(() => {
+    const {
+      getNodes,
+      setNodes,
+    } = store.getState()
+
+    const nodes = getNodes()
+    const newNodes = produce(nodes, (draft) => {
+      draft.forEach((node) => {
+        node.data.selected = false
+      })
+    })
+    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
 
@@ -723,14 +771,20 @@ export const useNodesInteractions = () => {
     } = store.getState()
 
     const nodes = getNodes()
-    const nodesToCopy = nodes.filter(node => node.data.selected && node.data.type !== BlockEnum.Start)
+    const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    setClipboardElements(nodesToCopy)
+    if (bundledNodes.length) {
+      setClipboardElements(bundledNodes)
+      return
+    }
+
+    const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    return nodesToCopy
+    if (selectedNode)
+      setClipboardElements([selectedNode])
   }, [getNodesReadOnly, store, workflowStore])
 
-  const handleNodePaste = useCallback((): undefined | Node[] => {
+  const handleNodesPaste = useCallback(() => {
     if (getNodesReadOnly())
       return
 
@@ -738,6 +792,7 @@ export const useNodesInteractions = () => {
       clipboardElements,
       shortcutsDisabled,
       showFeaturesPanel,
+      mousePosition,
     } = workflowStore.getState()
 
     if (shortcutsDisabled || showFeaturesPanel)
@@ -751,55 +806,77 @@ export const useNodesInteractions = () => {
     const nodesToPaste: Node[] = []
     const nodes = getNodes()
 
-    for (const nodeToPaste of clipboardElements) {
-      const nodeType = nodeToPaste.data.type
+    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],
-          ...nodeToPaste.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}`),
-          selected: true,
         },
         position: {
-          x: nodeToPaste.position.x + 10,
-          y: nodeToPaste.position.y + 10,
+          x: selectedNode.position.x + selectedNode.width! + 10,
+          y: selectedNode.position.y,
         },
       })
-      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])
+      setNodes([...nodes, newNode])
+    }
+  }, [store, t, getNodesReadOnly])
 
-  const handleNodeDeleteSelected = useCallback(() => {
+  const handleNodesDelete = useCallback(() => {
     if (getNodesReadOnly())
       return
 
@@ -813,53 +890,21 @@ export const useNodesInteractions = () => {
 
     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)
+    const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    if (!nodesToDelete)
+    if (bundledNodes.length) {
+      bundledNodes.forEach(node => handleNodeDelete(node.id))
       return
+    }
 
-    for (const node of nodesToDelete)
-      handleNodeDelete(node.id)
-  }, [getNodesReadOnly, handleNodeDelete, store, workflowStore])
-
-  const handleNodeCancelRunningStatus = useCallback(() => {
-    const {
-      getNodes,
-      setNodes,
-    } = store.getState()
-
-    const nodes = getNodes()
-    const newNodes = produce(nodes, (draft) => {
-      draft.forEach((node) => {
-        node.data._runningStatus = undefined
-      })
-    })
-    setNodes(newNodes)
-  }, [store])
-
-  const handleNodesCancelSelected = useCallback(() => {
-    const {
-      getNodes,
-      setNodes,
-    } = store.getState()
+    const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End)
 
-    const nodes = getNodes()
-    const newNodes = produce(nodes, (draft) => {
-      draft.forEach((node) => {
-        node.data.selected = false
-      })
-    })
-    setNodes(newNodes)
-  }, [store])
+    if (selectedNode)
+      handleNodeDelete(selectedNode.id)
+  }, [store, workflowStore, getNodesReadOnly, handleNodeDelete])
 
   return {
     handleNodeDragStart,
@@ -875,12 +920,12 @@ export const useNodesInteractions = () => {
     handleNodeDelete,
     handleNodeChange,
     handleNodeAdd,
-    handleNodeDuplicateSelected,
-    handleNodeCopySelected,
-    handleNodeCut,
-    handleNodeDeleteSelected,
-    handleNodePaste,
     handleNodeCancelRunningStatus,
     handleNodesCancelSelected,
+    handleNodeContextMenu,
+    handleNodesCopy,
+    handleNodesPaste,
+    handleNodesDuplicate,
+    handleNodesDelete,
   }
 }

+ 37 - 0
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,
+  }
+}

+ 109 - 0
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<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 - 0
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,
+  }
+}

+ 73 - 14
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<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,
@@ -141,8 +178,17 @@ const Workflow: FC<WorkflowProps> = memo(({
     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 - 0
web/app/components/workflow/node-contextmenu.tsx

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

+ 6 - 94
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 = ({
         </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>
   )

+ 181 - 0
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 (
+    <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)

+ 13 - 8
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<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 - 0
web/app/components/workflow/operator/add-block.tsx

@@ -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 - 0
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 (
+    <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)

+ 8 - 39
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 (
-    <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 - 0
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 (
+    <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)

+ 177 - 29
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,
@@ -15,12 +17,31 @@ import {
   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: 'to50',
-        text: t('workflow.operator.zoomTo50'),
+        key: ZoomType.zoomTo75,
+        text: '75%',
       },
       {
-        key: 'to100',
-        text: t('workflow.operator.zoomTo100'),
+        key: ZoomType.zoomTo50,
+        text: '50%',
+      },
+      {
+        key: ZoomType.zoomTo25,
+        text: '25%',
       },
     ],
     [
       {
-        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 - 0
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 (
+      <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 - 0
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 (
+    <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)

+ 38 - 0
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 })),
   }))
 }
 

+ 11 - 0
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);
 }

+ 3 - 0
web/app/components/workflow/types.ts

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

+ 45 - 0
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<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,
+  }
+}

+ 6 - 0
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',

+ 6 - 0
web/i18n/zh-Hans/workflow.ts

@@ -52,6 +52,12 @@ const translation = {
     jinjaEditorPlaceholder: '输入 “/” 或 “{” 插入变量',
     viewOnly: '只读',
     showRunHistory: '显示运行历史',
+    copy: '拷贝',
+    duplicate: '复制',
+    addBlock: '添加节点',
+    pasteHere: '粘贴到这里',
+    pointerMode: '指针模式',
+    handMode: '手模式',
   },
   errorMsg: {
     fieldRequired: '{{field}} 不能为空',