From 9b24f12bf5669a9fa829f12f745a0f3dd6cb623d Mon Sep 17 00:00:00 2001 From: zxhlyh Date: Thu, 9 May 2024 17:18:51 +0800 Subject: [PATCH] feat: workflow interaction (#4214) --- .../vender/line/arrows/flip-backward.zip | Bin 0 -> 372 bytes .../vender/line/arrows/flip-forward.zip | Bin 0 -> 370 bytes .../vender/line/arrows/reverse-left.svg | 5 + .../assets/vender/line/editor/cursor-02c.svg | 5 + .../assets/vender/line/editor/hand-02.svg | 5 + .../assets/vender/line/editor/zoom-in.svg | 5 + .../assets/vender/line/editor/zoom-out.svg | 5 + .../assets/vender/solid/editor/cursor-02c.svg | 5 + .../assets/vender/solid/editor/hand-02.svg | 5 + .../src/vender/line/arrows/ReverseLeft.json | 39 +++ .../src/vender/line/arrows/ReverseLeft.tsx | 16 + .../icons/src/vender/line/arrows/index.ts | 1 + .../src/vender/line/editor/Cursor02C.json | 38 +++ .../src/vender/line/editor/Cursor02C.tsx | 16 + .../icons/src/vender/line/editor/Hand02.json | 39 +++ .../icons/src/vender/line/editor/Hand02.tsx | 16 + .../icons/src/vender/line/editor/ZoomIn.json | 39 +++ .../icons/src/vender/line/editor/ZoomIn.tsx | 16 + .../icons/src/vender/line/editor/ZoomOut.json | 39 +++ .../icons/src/vender/line/editor/ZoomOut.tsx | 16 + .../icons/src/vender/line/editor/index.ts | 4 + .../src/vender/solid/editor/Cursor02C.json | 36 ++ .../src/vender/solid/editor/Cursor02C.tsx | 16 + .../icons/src/vender/solid/editor/Hand02.json | 38 +++ .../icons/src/vender/solid/editor/Hand02.tsx | 16 + .../icons/src/vender/solid/editor/index.ts | 2 + .../components/base/tooltip-plus/index.tsx | 15 +- .../workflow/block-selector/blocks.tsx | 18 +- .../workflow/block-selector/tools.tsx | 24 +- .../components/workflow/candidate-node.tsx | 81 +++++ .../workflow/header/run-and-history.tsx | 95 +----- web/app/components/workflow/hooks/index.ts | 3 + .../workflow/hooks/use-nodes-interactions.ts | 307 ++++++++++-------- .../workflow/hooks/use-panel-interactions.ts | 37 +++ .../hooks/use-selection-interactions.ts | 109 +++++++ .../workflow/hooks/use-workflow-start-run.tsx | 88 +++++ web/app/components/workflow/index.tsx | 87 ++++- .../components/workflow/node-contextmenu.tsx | 44 +++ .../_base/components/panel-operator/index.tsx | 100 +----- .../panel-operator/panel-operator-popup.tsx | 181 +++++++++++ .../components/workflow/nodes/_base/node.tsx | 21 +- .../workflow/operator/add-block.tsx | 110 +++++++ .../components/workflow/operator/control.tsx | 85 +++++ .../components/workflow/operator/index.tsx | 47 +-- .../workflow/operator/tip-popup.tsx | 34 ++ .../workflow/operator/zoom-in-out.tsx | 214 ++++++++++-- .../components/workflow/panel-contextmenu.tsx | 123 +++++++ .../components/workflow/shortcuts-name.tsx | 32 ++ web/app/components/workflow/store.ts | 38 +++ web/app/components/workflow/style.css | 11 + web/app/components/workflow/types.ts | 3 + web/app/components/workflow/utils.ts | 45 +++ web/i18n/en-US/workflow.ts | 6 + web/i18n/zh-Hans/workflow.ts | 6 + 54 files changed, 1955 insertions(+), 431 deletions(-) create mode 100644 web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip create mode 100644 web/app/components/base/icons/assets/vender/line/arrows/flip-forward.zip create mode 100644 web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg create mode 100644 web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg create mode 100644 web/app/components/base/icons/assets/vender/line/editor/hand-02.svg create mode 100644 web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg create mode 100644 web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg create mode 100644 web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg create mode 100644 web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg create mode 100644 web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json create mode 100644 web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx create mode 100644 web/app/components/base/icons/src/vender/line/editor/Cursor02C.json create mode 100644 web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx create mode 100644 web/app/components/base/icons/src/vender/line/editor/Hand02.json create mode 100644 web/app/components/base/icons/src/vender/line/editor/Hand02.tsx create mode 100644 web/app/components/base/icons/src/vender/line/editor/ZoomIn.json create mode 100644 web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx create mode 100644 web/app/components/base/icons/src/vender/line/editor/ZoomOut.json create mode 100644 web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx create mode 100644 web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json create mode 100644 web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx create mode 100644 web/app/components/base/icons/src/vender/solid/editor/Hand02.json create mode 100644 web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx create mode 100644 web/app/components/workflow/candidate-node.tsx create mode 100644 web/app/components/workflow/hooks/use-panel-interactions.ts create mode 100644 web/app/components/workflow/hooks/use-selection-interactions.ts create mode 100644 web/app/components/workflow/hooks/use-workflow-start-run.tsx create mode 100644 web/app/components/workflow/node-contextmenu.tsx create mode 100644 web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx create mode 100644 web/app/components/workflow/operator/add-block.tsx create mode 100644 web/app/components/workflow/operator/control.tsx create mode 100644 web/app/components/workflow/operator/tip-popup.tsx create mode 100644 web/app/components/workflow/panel-contextmenu.tsx create mode 100644 web/app/components/workflow/shortcuts-name.tsx diff --git a/web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip b/web/app/components/base/icons/assets/vender/line/arrows/flip-backward.zip new file mode 100644 index 0000000000000000000000000000000000000000..5cb3d8483a4c5c406753aa6048b8eb7748d1c6d0 GIT binary patch literal 372 zcmWIWW@h1HU|`^2ut=F3F*!0L`w@^=%*enX0i?t8i?Y*l^2-&R6Z6Uvi}gK|^YiqI z%hGcvoXtC|z|-))mg}@Ne}RXG)l|7_-s-vM4MKBgZAcYRUGwGR^A$-~mlPh5pI@)C zXQsZaN_p2_S0j$v*SqZQ{djcii_AJPQ4Iy=ZtLBN8(uW$3$BZAp0E4Ou+Kke*%X&c zN|GM1qXfyZi#=l_Y~I=q}scgwY__MK^+y>2rei`YeX^XG&Hr+%C< zUFBU?!9H6#x9;E{-Qf z^V2)qpX{=bcp{a!VV(S=(D`$H{U0VxU}RpmaKmJ2_h!3_?t-5(f0GJ23agfwEOnWr zlK5_ALeIsVp4(TKv0s<0v15y0^-`c+xn zho`<}DRA48wSccU(26N9>;Au@+J|#a$fU-2eT`qP8udC%NvDuc{@R1N*Lbr-S-*dE zeZt;*?)KwEBa3$Z`+pC#U^p%mcF$_7%# O2!w7xS_~Mf3=9BdosMe& literal 0 HcmV?d00001 diff --git a/web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg b/web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg new file mode 100644 index 0000000000..34313b1529 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/arrows/reverse-left.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg b/web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg new file mode 100644 index 0000000000..0bad691d9d --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/editor/cursor-02c.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/line/editor/hand-02.svg b/web/app/components/base/icons/assets/vender/line/editor/hand-02.svg new file mode 100644 index 0000000000..4d72cd307e --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/editor/hand-02.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg b/web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg new file mode 100644 index 0000000000..8f61261589 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/editor/zoom-in.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg b/web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg new file mode 100644 index 0000000000..83b645402e --- /dev/null +++ b/web/app/components/base/icons/assets/vender/line/editor/zoom-out.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg b/web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg new file mode 100644 index 0000000000..7a55feeef9 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/editor/cursor-02c.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg b/web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg new file mode 100644 index 0000000000..1ae44e2c82 --- /dev/null +++ b/web/app/components/base/icons/assets/vender/solid/editor/hand-02.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json b/web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.json new file mode 100644 index 0000000000..48c6d1fbd6 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx b/web/app/components/base/icons/src/vender/line/arrows/ReverseLeft.tsx new file mode 100644 index 0000000000..3f73da3d7c --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'ReverseLeft' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/arrows/index.ts b/web/app/components/base/icons/src/vender/line/arrows/index.ts index b091430a10..5b482a75d0 100644 --- a/web/app/components/base/icons/src/vender/line/arrows/index.ts +++ b/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' diff --git a/web/app/components/base/icons/src/vender/line/editor/Cursor02C.json b/web/app/components/base/icons/src/vender/line/editor/Cursor02C.json new file mode 100644 index 0000000000..3210368785 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx b/web/app/components/base/icons/src/vender/line/editor/Cursor02C.tsx new file mode 100644 index 0000000000..70d89802df --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Cursor02C' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/editor/Hand02.json b/web/app/components/base/icons/src/vender/line/editor/Hand02.json new file mode 100644 index 0000000000..a4aedd557f --- /dev/null +++ b/web/app/components/base/icons/src/vender/line/editor/Hand02.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": "Vector", + "d": "M11.3344 5C11.3344 4.44771 11.7821 4 12.3344 4C12.8867 4 13.3344 4.44771 13.3344 5V9.21947C13.3344 11.8597 11.1941 14 8.55387 14C6.779 14 5.15019 13.0167 4.32353 11.446L2.53767 8.05287C2.41421 7.81827 2.44145 7.53287 2.60703 7.32587L2.83481 7.04113C3.29483 6.46614 4.13389 6.37291 4.7089 6.83293L5.33441 7.33333V3.66667C5.33441 3.11438 5.78213 2.66667 6.33441 2.66667C6.88667 2.66667 7.3344 3.11438 7.3344 3.66667M11.3344 5V3.66667C11.3344 3.11438 10.8867 2.66667 10.3344 2.66667C9.78213 2.66667 9.3344 3.11438 9.3344 3.66667M11.3344 5V8M7.3344 3.66667V3C7.3344 2.44771 7.78213 2 8.3344 2C8.88667 2 9.3344 2.44771 9.3344 3V3.66667M7.3344 3.66667V7.33333M9.3344 3.66667V7.66667", + "stroke": "currentColor", + "stroke-width": "1.5", + "stroke-linecap": "round", + "stroke-linejoin": "round" + }, + "children": [] + } + ] + } + ] + }, + "name": "Hand02" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/editor/Hand02.tsx b/web/app/components/base/icons/src/vender/line/editor/Hand02.tsx new file mode 100644 index 0000000000..10a2f37302 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Hand02' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/editor/ZoomIn.json b/web/app/components/base/icons/src/vender/line/editor/ZoomIn.json new file mode 100644 index 0000000000..5a04fbb931 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx b/web/app/components/base/icons/src/vender/line/editor/ZoomIn.tsx new file mode 100644 index 0000000000..e76cd512b3 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'ZoomIn' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/editor/ZoomOut.json b/web/app/components/base/icons/src/vender/line/editor/ZoomOut.json new file mode 100644 index 0000000000..ab19418e05 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx b/web/app/components/base/icons/src/vender/line/editor/ZoomOut.tsx new file mode 100644 index 0000000000..51c01a9135 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'ZoomOut' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/line/editor/index.ts b/web/app/components/base/icons/src/vender/line/editor/index.ts index f571be03c6..aafadaf5d2 100644 --- a/web/app/components/base/icons/src/vender/line/editor/index.ts +++ b/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' diff --git a/web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json b/web/app/components/base/icons/src/vender/solid/editor/Cursor02C.json new file mode 100644 index 0000000000..3825744c86 --- /dev/null +++ b/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" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx b/web/app/components/base/icons/src/vender/solid/editor/Cursor02C.tsx new file mode 100644 index 0000000000..70d89802df --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Cursor02C' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/editor/Hand02.json b/web/app/components/base/icons/src/vender/solid/editor/Hand02.json new file mode 100644 index 0000000000..6256d22cf4 --- /dev/null +++ b/web/app/components/base/icons/src/vender/solid/editor/Hand02.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", + "fill-rule": "evenodd", + "clip-rule": "evenodd", + "d": "M8.04519 1.33331C7.62792 1.33331 7.28966 1.66963 7.28966 2.08449V6.59153C7.28966 6.79898 7.12053 6.96711 6.91193 6.96711C6.70333 6.96711 6.53417 6.79898 6.53417 6.59153V2.83566C6.53417 2.4208 6.19593 2.08449 5.77868 2.08449C5.36143 2.08449 5.02318 2.4208 5.02318 2.83566V7.43091C5.02318 7.58418 4.92957 7.72205 4.78663 7.77931C4.6437 7.83658 4.4801 7.80178 4.37325 7.69138L3.47554 6.76385C2.95809 6.22921 2.07117 6.32919 1.68723 6.96545L1.66699 6.99898L3.52969 11.5222C4.31291 13.4242 6.17482 14.6666 8.24186 14.6666C11.054 14.6666 13.3337 12.4 13.3337 9.60398V4.33801C13.3337 3.92315 12.9954 3.58683 12.5782 3.58683C12.1609 3.58683 11.8227 3.92315 11.8227 4.33801V7.34271C11.8227 7.55011 11.6535 7.71831 11.4449 7.71831C11.2363 7.71831 11.0672 7.55011 11.0672 7.34271V2.83566C11.0672 2.4208 10.7289 2.08449 10.3117 2.08449C9.89439 2.08449 9.55619 2.4208 9.55619 2.83566V6.96711C9.55619 7.17458 9.38706 7.34271 9.17839 7.34271C8.96979 7.34271 8.80066 7.17458 8.80066 6.96711V2.08449C8.80066 1.66963 8.46239 1.33331 8.04519 1.33331Z", + "fill": "currentColor" + }, + "children": [] + } + ] + } + ] + }, + "name": "Hand02" +} \ No newline at end of file diff --git a/web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx b/web/app/components/base/icons/src/vender/solid/editor/Hand02.tsx new file mode 100644 index 0000000000..10a2f37302 --- /dev/null +++ b/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, Omit>(( + props, + ref, +) => ) + +Icon.displayName = 'Hand02' + +export default Icon diff --git a/web/app/components/base/icons/src/vender/solid/editor/index.ts b/web/app/components/base/icons/src/vender/solid/editor/index.ts index 6b1a0a1afa..273cb09ac3 100644 --- a/web/app/components/base/icons/src/vender/solid/editor/index.ts +++ b/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' diff --git a/web/app/components/base/tooltip-plus/index.tsx b/web/app/components/base/tooltip-plus/index.tsx index 6157b9d13a..00d3f1af59 100644 --- a/web/app/components/base/tooltip-plus/index.tsx +++ b/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 = ({ popupContent, children, hideArrow, + popupClassName, + offset, }) => { const [open, setOpen] = useState(false) @@ -28,7 +34,7 @@ const Tooltip: FC = ({ open={open} onOpenChange={setOpen} placement={position} - offset={10} + offset={offset ?? 10} > triggerMethod === 'click' && setOpen(v => !v)} @@ -40,7 +46,10 @@ const Tooltip: FC = ({ -
+
{popupContent} {!hideArrow && arrow}
diff --git a/web/app/components/workflow/block-selector/blocks.tsx b/web/app/components/workflow/block-selector/blocks.tsx index 3c3a1a3f49..e969612d37 100644 --- a/web/app/components/workflow/block-selector/blocks.tsx +++ b/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={(
-
- -
{block.title}
-
- {nodesExtraData[block.type].about} + +
{block.title}
+
{nodesExtraData[block.type].about}
)} noArrow @@ -91,7 +89,7 @@ const Blocks = ({ onClick={() => onSelect(block.type)} >
{block.title}
diff --git a/web/app/components/workflow/block-selector/tools.tsx b/web/app/components/workflow/block-selector/tools.tsx index 23ecbd4253..285631d41e 100644 --- a/web/app/components/workflow/block-selector/tools.tsx +++ b/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={(
-
- -
{tool.label[language]}
-
- {tool.description[language]} + +
{tool.label[language]}
+
{tool.description[language]}
)} noArrow @@ -83,11 +81,11 @@ const Blocks = ({ })} > -
{tool.label[language]}
+
{tool.label[language]}
)) @@ -97,7 +95,7 @@ const Blocks = ({ }, [onSelect, language]) return ( -
+
{ !tools.length && (
{t('workflow.tabs.noResult')}
diff --git a/web/app/components/workflow/candidate-node.tsx b/web/app/components/workflow/candidate-node.tsx new file mode 100644 index 0000000000..1987e6227b --- /dev/null +++ b/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 ( +
+ +
+ ) +} + +export default memo(CandidateNode) diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index f93459f15b..2ac3a28b58 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/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 ( <>
{ 'hover:bg-primary-50 cursor-pointer', isRunning && 'bg-primary-50 !cursor-not-allowed', )} - onClick={handleClick} + onClick={() => handleWorkflowStartRunInWorkflow()} > { isRunning @@ -128,23 +67,7 @@ RunMode.displayName = 'RunMode' const PreviewMode = memo(() => { const { t } = useTranslation() - const workflowStore = useWorkflowStore() - const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() - - const handleClick = () => { - const { - showDebugAndPreviewPanel, - setShowDebugAndPreviewPanel, - setHistoryWorkflowData, - } = workflowStore.getState() - - if (showDebugAndPreviewPanel) - handleCancelDebugAndPreviewPanel() - else - setShowDebugAndPreviewPanel(true) - - setHistoryWorkflowData(undefined) - } + const { handleWorkflowStartRunInChatflow } = useWorkflowStartRun() return (
{ 'flex items-center px-1.5 h-7 rounded-md text-[13px] font-medium text-primary-600', 'hover:bg-primary-50 cursor-pointer', )} - onClick={() => handleClick()} + onClick={() => handleWorkflowStartRunInChatflow()} > {t('workflow.common.debugAndPreview')} diff --git a/web/app/components/workflow/hooks/index.ts b/web/app/components/workflow/hooks/index.ts index eb59bf5736..44f5464e41 100644 --- a/web/app/components/workflow/hooks/index.ts +++ b/web/app/components/workflow/hooks/index.ts @@ -9,3 +9,6 @@ export * from './use-workflow-template' export * from './use-checklist' export * from './use-workflow-mode' export * from './use-workflow-interactions' +export * from './use-selection-interactions' +export * from './use-panel-interactions' +export * from './use-workflow-start-run' diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index d1ae373adf..68bb4d4aa2 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1,3 +1,4 @@ +import type { MouseEvent } from 'react' import { useCallback, useRef } from 'react' import { useTranslation } from 'react-i18next' import produce from 'immer' @@ -11,6 +12,7 @@ import type { import { getConnectedEdges, getOutgoers, + useReactFlow, useStoreApi, } from 'reactflow' import type { ToolDefaultValue } from '../block-selector/types' @@ -29,6 +31,7 @@ import { import { generateNewNode, getNodesConnectedSourceOrTargetHandleIdsMap, + getTopLeftNodePosition, } from '../utils' import { useNodesExtraData } from './use-nodes-data' import { useNodesSyncDraft } from './use-nodes-sync-draft' @@ -41,6 +44,7 @@ export const useNodesInteractions = () => { const { t } = useTranslation() const store = useStoreApi() const workflowStore = useWorkflowStore() + const reactflow = useReactFlow() const nodesExtraData = useNodesExtraData() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { @@ -705,132 +709,6 @@ export const useNodesInteractions = () => { handleSyncWorkflowDraft() }, [store, handleSyncWorkflowDraft, getNodesReadOnly, t]) - const handleNodeCopySelected = useCallback((): undefined | Node[] => { - if (getNodesReadOnly()) - return - - const { - setClipboardElements, - shortcutsDisabled, - showFeaturesPanel, - } = workflowStore.getState() - - if (shortcutsDisabled || showFeaturesPanel) - return - - const { - getNodes, - } = store.getState() - - const nodes = getNodes() - const nodesToCopy = nodes.filter(node => node.data.selected && node.data.type !== BlockEnum.Start) - - setClipboardElements(nodesToCopy) - - return nodesToCopy - }, [getNodesReadOnly, store, workflowStore]) - - const handleNodePaste = useCallback((): undefined | Node[] => { - if (getNodesReadOnly()) - return - - const { - clipboardElements, - shortcutsDisabled, - showFeaturesPanel, - } = workflowStore.getState() - - if (shortcutsDisabled || showFeaturesPanel) - return - - const { - getNodes, - setNodes, - } = store.getState() - - const nodesToPaste: Node[] = [] - const nodes = getNodes() - - for (const nodeToPaste of clipboardElements) { - const nodeType = nodeToPaste.data.type - const nodesWithSameType = nodes.filter(node => node.data.type === nodeType) - - const newNode = generateNewNode({ - data: { - ...NODES_INITIAL_DATA[nodeType], - ...nodeToPaste.data, - _connectedSourceHandleIds: [], - _connectedTargetHandleIds: [], - title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`), - selected: true, - }, - position: { - x: nodeToPaste.position.x + 10, - y: nodeToPaste.position.y + 10, - }, - }) - nodesToPaste.push(newNode) - } - - setNodes([...nodes.map((n: Node) => ({ ...n, selected: false, data: { ...n.data, selected: false } })), ...nodesToPaste]) - - handleSyncWorkflowDraft() - - return nodesToPaste - }, [getNodesReadOnly, handleSyncWorkflowDraft, store, t, workflowStore]) - - const handleNodeDuplicateSelected = useCallback(() => { - if (getNodesReadOnly()) - return - - handleNodeCopySelected() - handleNodePaste() - }, [getNodesReadOnly, handleNodeCopySelected, handleNodePaste]) - - const handleNodeCut = useCallback(() => { - if (getNodesReadOnly()) - return - - const nodesToCut = handleNodeCopySelected() - if (!nodesToCut) - return - - for (const node of nodesToCut) - handleNodeDelete(node.id) - }, [getNodesReadOnly, handleNodeCopySelected, handleNodeDelete]) - - const handleNodeDeleteSelected = useCallback(() => { - if (getNodesReadOnly()) - return - - const { - shortcutsDisabled, - showFeaturesPanel, - } = workflowStore.getState() - - if (shortcutsDisabled || showFeaturesPanel) - return - - const { - getNodes, - edges, - } = store.getState() - - const currentEdgeIndex = edges.findIndex(edge => edge.selected) - - if (currentEdgeIndex > -1) - return - - const nodes = getNodes() - const nodesToDelete = nodes.filter(node => node.data.selected) - - if (!nodesToDelete) - return - - for (const node of nodesToDelete) - handleNodeDelete(node.id) - }, [getNodesReadOnly, handleNodeDelete, store, workflowStore]) - const handleNodeCancelRunningStatus = useCallback(() => { const { getNodes, @@ -861,6 +739,173 @@ export const useNodesInteractions = () => { setNodes(newNodes) }, [store]) + const handleNodeContextMenu = useCallback((e: MouseEvent, node: Node) => { + e.preventDefault() + const container = document.querySelector('#workflow-container') + const { x, y } = container!.getBoundingClientRect() + workflowStore.setState({ + nodeMenu: { + top: e.clientY - y, + left: e.clientX - x, + nodeId: node.id, + }, + }) + handleNodeSelect(node.id) + }, [workflowStore, handleNodeSelect]) + + const handleNodesCopy = useCallback(() => { + if (getNodesReadOnly()) + return + + const { + setClipboardElements, + shortcutsDisabled, + showFeaturesPanel, + } = workflowStore.getState() + + if (shortcutsDisabled || showFeaturesPanel) + return + + const { + getNodes, + } = store.getState() + + const nodes = getNodes() + const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End) + + if (bundledNodes.length) { + setClipboardElements(bundledNodes) + return + } + + const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End) + + if (selectedNode) + setClipboardElements([selectedNode]) + }, [getNodesReadOnly, store, workflowStore]) + + const handleNodesPaste = useCallback(() => { + if (getNodesReadOnly()) + return + + const { + clipboardElements, + shortcutsDisabled, + showFeaturesPanel, + mousePosition, + } = workflowStore.getState() + + if (shortcutsDisabled || showFeaturesPanel) + return + + const { + getNodes, + setNodes, + } = store.getState() + + const nodesToPaste: Node[] = [] + const nodes = getNodes() + + if (clipboardElements.length) { + const { x, y } = getTopLeftNodePosition(clipboardElements) + const { screenToFlowPosition } = reactflow + const currentPosition = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY }) + const offsetX = currentPosition.x - x + const offsetY = currentPosition.y - y + clipboardElements.forEach((nodeToPaste, index) => { + const nodeType = nodeToPaste.data.type + const nodesWithSameType = nodes.filter(node => node.data.type === nodeType) + + const newNode = generateNewNode({ + data: { + ...NODES_INITIAL_DATA[nodeType], + ...nodeToPaste.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`), + }, + position: { + x: nodeToPaste.position.x + offsetX, + y: nodeToPaste.position.y + offsetY, + }, + }) + newNode.id = newNode.id + index + nodesToPaste.push(newNode) + }) + + setNodes([...nodes, ...nodesToPaste]) + handleSyncWorkflowDraft() + } + }, [t, getNodesReadOnly, store, workflowStore, handleSyncWorkflowDraft, reactflow]) + + const handleNodesDuplicate = useCallback(() => { + if (getNodesReadOnly()) + return + + const { + getNodes, + setNodes, + } = store.getState() + const nodes = getNodes() + + const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End) + + if (selectedNode) { + const nodeType = selectedNode.data.type + const nodesWithSameType = nodes.filter(node => node.data.type === nodeType) + + const newNode = generateNewNode({ + data: { + ...NODES_INITIAL_DATA[nodeType as BlockEnum], + ...selectedNode.data, + selected: false, + _isBundled: false, + _connectedSourceHandleIds: [], + _connectedTargetHandleIds: [], + title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${nodeType}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${nodeType}`), + }, + position: { + x: selectedNode.position.x + selectedNode.width! + 10, + y: selectedNode.position.y, + }, + }) + + setNodes([...nodes, newNode]) + } + }, [store, t, getNodesReadOnly]) + + const handleNodesDelete = useCallback(() => { + if (getNodesReadOnly()) + return + + const { + shortcutsDisabled, + showFeaturesPanel, + } = workflowStore.getState() + + if (shortcutsDisabled || showFeaturesPanel) + return + + const { + getNodes, + } = store.getState() + + const nodes = getNodes() + const bundledNodes = nodes.filter(node => node.data._isBundled && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End) + + if (bundledNodes.length) { + bundledNodes.forEach(node => handleNodeDelete(node.id)) + return + } + + const selectedNode = nodes.find(node => node.data.selected && node.data.type !== BlockEnum.Start && node.data.type !== BlockEnum.End) + + if (selectedNode) + handleNodeDelete(selectedNode.id) + }, [store, workflowStore, getNodesReadOnly, handleNodeDelete]) + return { handleNodeDragStart, handleNodeDrag, @@ -875,12 +920,12 @@ export const useNodesInteractions = () => { handleNodeDelete, handleNodeChange, handleNodeAdd, - handleNodeDuplicateSelected, - handleNodeCopySelected, - handleNodeCut, - handleNodeDeleteSelected, - handleNodePaste, handleNodeCancelRunningStatus, handleNodesCancelSelected, + handleNodeContextMenu, + handleNodesCopy, + handleNodesPaste, + handleNodesDuplicate, + handleNodesDelete, } } diff --git a/web/app/components/workflow/hooks/use-panel-interactions.ts b/web/app/components/workflow/hooks/use-panel-interactions.ts new file mode 100644 index 0000000000..1f02ac7c74 --- /dev/null +++ b/web/app/components/workflow/hooks/use-panel-interactions.ts @@ -0,0 +1,37 @@ +import type { MouseEvent } from 'react' +import { useCallback } from 'react' +import { useWorkflowStore } from '../store' + +export const usePanelInteractions = () => { + const workflowStore = useWorkflowStore() + + const handlePaneContextMenu = useCallback((e: MouseEvent) => { + e.preventDefault() + const container = document.querySelector('#workflow-container') + const { x, y } = container!.getBoundingClientRect() + workflowStore.setState({ + panelMenu: { + top: e.clientY - y, + left: e.clientX - x, + }, + }) + }, [workflowStore]) + + const handlePaneContextmenuCancel = useCallback(() => { + workflowStore.setState({ + panelMenu: undefined, + }) + }, [workflowStore]) + + const handleNodeContextmenuCancel = useCallback(() => { + workflowStore.setState({ + nodeMenu: undefined, + }) + }, [workflowStore]) + + return { + handlePaneContextMenu, + handlePaneContextmenuCancel, + handleNodeContextmenuCancel, + } +} diff --git a/web/app/components/workflow/hooks/use-selection-interactions.ts b/web/app/components/workflow/hooks/use-selection-interactions.ts new file mode 100644 index 0000000000..38fd4f497b --- /dev/null +++ b/web/app/components/workflow/hooks/use-selection-interactions.ts @@ -0,0 +1,109 @@ +import type { MouseEvent } from 'react' +import { + useCallback, +} from 'react' +import produce from 'immer' +import type { + OnSelectionChangeFunc, +} from 'reactflow' +import { useStoreApi } from 'reactflow' +import { useWorkflowStore } from '../store' +import type { Node } from '../types' + +export const useSelectionInteractions = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + + const handleSelectionStart = useCallback(() => { + const { + getNodes, + setNodes, + edges, + setEdges, + userSelectionRect, + } = store.getState() + + if (!userSelectionRect?.width || !userSelectionRect?.height) { + const nodes = getNodes() + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + if (node.data._isBundled) + node.data._isBundled = false + }) + }) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + draft.forEach((edge) => { + if (edge.data._isBundled) + edge.data._isBundled = false + }) + }) + setEdges(newEdges) + } + }, [store]) + + const handleSelectionChange = useCallback(({ nodes: nodesInSelection, edges: edgesInSelection }) => { + const { + getNodes, + setNodes, + edges, + setEdges, + userSelectionRect, + } = store.getState() + + const nodes = getNodes() + + if (!userSelectionRect?.width || !userSelectionRect?.height) + return + + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + const nodeInSelection = nodesInSelection.find(n => n.id === node.id) + + if (nodeInSelection) + node.data._isBundled = true + else + node.data._isBundled = false + }) + }) + setNodes(newNodes) + const newEdges = produce(edges, (draft) => { + draft.forEach((edge) => { + const edgeInSelection = edgesInSelection.find(e => e.id === edge.id) + + if (edgeInSelection) + edge.data._isBundled = true + else + edge.data._isBundled = false + }) + }) + setEdges(newEdges) + }, [store]) + + const handleSelectionDrag = useCallback((_: MouseEvent, nodesWithDrag: Node[]) => { + const { + getNodes, + setNodes, + } = store.getState() + + workflowStore.setState({ + nodeAnimation: false, + }) + const nodes = getNodes() + const newNodes = produce(nodes, (draft) => { + draft.forEach((node) => { + const dragNode = nodesWithDrag.find(n => n.id === node.id) + + if (dragNode) + node.position = dragNode.position + }) + }) + setNodes(newNodes) + }, [store, workflowStore]) + + return { + handleSelectionStart, + handleSelectionChange, + handleSelectionDrag, + } +} diff --git a/web/app/components/workflow/hooks/use-workflow-start-run.tsx b/web/app/components/workflow/hooks/use-workflow-start-run.tsx new file mode 100644 index 0000000000..f80191cc2d --- /dev/null +++ b/web/app/components/workflow/hooks/use-workflow-start-run.tsx @@ -0,0 +1,88 @@ +import { useCallback } from 'react' +import { useStoreApi } from 'reactflow' +import { useWorkflowStore } from '../store' +import { + BlockEnum, + WorkflowRunningStatus, +} from '../types' +import { + useIsChatMode, + useNodesSyncDraft, + useWorkflowInteractions, + useWorkflowRun, +} from './index' +import { useFeaturesStore } from '@/app/components/base/features/hooks' + +export const useWorkflowStartRun = () => { + const store = useStoreApi() + const workflowStore = useWorkflowStore() + const featuresStore = useFeaturesStore() + const isChatMode = useIsChatMode() + const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions() + const { handleRun } = useWorkflowRun() + const { doSyncWorkflowDraft } = useNodesSyncDraft() + + const handleWorkflowStartRunInWorkflow = useCallback(async () => { + const { + workflowRunningData, + } = workflowStore.getState() + + if (workflowRunningData?.result.status === WorkflowRunningStatus.Running) + return + + const { getNodes } = store.getState() + const nodes = getNodes() + const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const startVariables = startNode?.data.variables || [] + const fileSettings = featuresStore!.getState().features.file + const { + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setShowInputsPanel, + } = workflowStore.getState() + + if (showDebugAndPreviewPanel) { + handleCancelDebugAndPreviewPanel() + return + } + + if (!startVariables.length && !fileSettings?.image?.enabled) { + await doSyncWorkflowDraft() + handleRun({ inputs: {}, files: [] }) + setShowDebugAndPreviewPanel(true) + setShowInputsPanel(false) + } + else { + setShowDebugAndPreviewPanel(true) + setShowInputsPanel(true) + } + }, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft]) + + const handleWorkflowStartRunInChatflow = useCallback(async () => { + const { + showDebugAndPreviewPanel, + setShowDebugAndPreviewPanel, + setHistoryWorkflowData, + } = workflowStore.getState() + + if (showDebugAndPreviewPanel) + handleCancelDebugAndPreviewPanel() + else + setShowDebugAndPreviewPanel(true) + + setHistoryWorkflowData(undefined) + }, [workflowStore, handleCancelDebugAndPreviewPanel]) + + const handleStartWorkflowRun = useCallback(() => { + if (!isChatMode) + handleWorkflowStartRunInWorkflow() + else + handleWorkflowStartRunInChatflow() + }, [isChatMode, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow]) + + return { + handleStartWorkflowRun, + handleWorkflowStartRunInWorkflow, + handleWorkflowStartRunInChatflow, + } +} diff --git a/web/app/components/workflow/index.tsx b/web/app/components/workflow/index.tsx index 3f173fc02d..b6f652570e 100644 --- a/web/app/components/workflow/index.tsx +++ b/web/app/components/workflow/index.tsx @@ -6,19 +6,24 @@ import { useCallback, useEffect, useMemo, + useRef, } from 'react' import { setAutoFreeze } from 'immer' import { + useEventListener, useKeyPress, } from 'ahooks' import ReactFlow, { Background, ReactFlowProvider, + SelectionMode, useEdgesState, useNodesState, useOnViewportChange, } from 'reactflow' -import type { Viewport } from 'reactflow' +import type { + Viewport, +} from 'reactflow' import 'reactflow/dist/style.css' import './style.css' import type { @@ -31,9 +36,12 @@ import { useNodesInteractions, useNodesReadOnly, useNodesSyncDraft, + usePanelInteractions, + useSelectionInteractions, useWorkflow, useWorkflowInit, useWorkflowReadOnly, + useWorkflowStartRun, } from './hooks' import Header from './header' import CustomNode from './nodes' @@ -43,8 +51,15 @@ import CustomConnectionLine from './custom-connection-line' import Panel from './panel' import Features from './features' import HelpLine from './help-line' -import { useStore } from './store' +import CandidateNode from './candidate-node' +import PanelContextmenu from './panel-contextmenu' +import NodeContextmenu from './node-contextmenu' import { + useStore, + useWorkflowStore, +} from './store' +import { + getKeyboardKeyCodeBySystem, initialEdges, initialNodes, } from './utils' @@ -71,9 +86,12 @@ const Workflow: FC = memo(({ edges: originalEdges, viewport, }) => { + const workflowContainerRef = useRef(null) + const workflowStore = useWorkflowStore() const [nodes, setNodes] = useNodesState(originalNodes) const [edges, setEdges] = useEdgesState(originalEdges) const showFeaturesPanel = useStore(state => state.showFeaturesPanel) + const controlMode = useStore(s => s.controlMode) const nodeAnimation = useStore(s => s.nodeAnimation) const { handleSyncWorkflowDraft, @@ -118,6 +136,25 @@ const Workflow: FC = memo(({ } }, [handleSyncWorkflowDraftWhenPageClose]) + useEventListener('keydown', (e) => { + if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey)) + e.preventDefault() + }) + useEventListener('mousemove', (e) => { + const containerClientRect = workflowContainerRef.current?.getBoundingClientRect() + + if (containerClientRect) { + workflowStore.setState({ + mousePosition: { + pageX: e.clientX, + pageY: e.clientY, + elementX: e.clientX - containerClientRect.left, + elementY: e.clientY - containerClientRect.top, + }, + }) + } + }) + const { handleNodeDragStart, handleNodeDrag, @@ -128,11 +165,11 @@ const Workflow: FC = memo(({ handleNodeConnect, handleNodeConnectStart, handleNodeConnectEnd, - handleNodeDuplicateSelected, - handleNodeCopySelected, - handleNodeCut, - handleNodeDeleteSelected, - handleNodePaste, + handleNodeContextMenu, + handleNodesCopy, + handleNodesPaste, + handleNodesDuplicate, + handleNodesDelete, } = useNodesInteractions() const { handleEdgeEnter, @@ -140,9 +177,18 @@ const Workflow: FC = memo(({ handleEdgeDelete, handleEdgesChange, } = useEdgesInteractions() + const { + handleSelectionStart, + handleSelectionChange, + handleSelectionDrag, + } = useSelectionInteractions() + const { + handlePaneContextMenu, + } = usePanelInteractions() const { isValidConnection, } = useWorkflow() + const { handleStartWorkflowRun } = useWorkflowStartRun() useOnViewportChange({ onEnd: () => { @@ -150,12 +196,12 @@ const Workflow: FC = memo(({ }, }) - useKeyPress(['delete', 'backspace'], handleNodeDeleteSelected) - useKeyPress(['delete', 'backspace'], handleEdgeDelete) - useKeyPress(['ctrl.c', 'meta.c'], handleNodeCopySelected) - useKeyPress(['ctrl.x', 'meta.x'], handleNodeCut) - useKeyPress(['ctrl.v', 'meta.v'], handleNodePaste) - useKeyPress(['ctrl.alt.d', 'meta.shift.d'], handleNodeDuplicateSelected) + useKeyPress('delete', handleNodesDelete) + useKeyPress('delete', handleEdgeDelete) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, handleNodesCopy, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, handleNodesPaste, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, handleNodesDuplicate, { exactMatch: true, useCapture: true }) + useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, handleStartWorkflowRun, { exactMatch: true, useCapture: true }) return (
= memo(({ ${workflowReadOnly && 'workflow-panel-animation'} ${nodeAnimation && 'workflow-node-animation'} `} + ref={workflowContainerRef} > +
{ showFeaturesPanel && } + + = memo(({ onNodeMouseEnter={handleNodeEnter} onNodeMouseLeave={handleNodeLeave} onNodeClick={handleNodeClick} + onNodeContextMenu={handleNodeContextMenu} onConnect={handleNodeConnect} onConnectStart={handleNodeConnectStart} onConnectEnd={handleNodeConnectEnd} onEdgeMouseEnter={handleEdgeEnter} onEdgeMouseLeave={handleEdgeLeave} onEdgesChange={handleEdgesChange} + onSelectionStart={handleSelectionStart} + onSelectionChange={handleSelectionChange} + onSelectionDrag={handleSelectionDrag} + onPaneContextMenu={handlePaneContextMenu} connectionLineComponent={CustomConnectionLine} defaultViewport={viewport} multiSelectionKeyCode={null} @@ -198,11 +253,15 @@ const Workflow: FC = memo(({ nodesConnectable={!nodesReadOnly} nodesFocusable={!nodesReadOnly} edgesFocusable={!nodesReadOnly} - panOnDrag={!workflowReadOnly} + panOnDrag={controlMode === 'hand' && !workflowReadOnly} zoomOnPinch={!workflowReadOnly} zoomOnScroll={!workflowReadOnly} zoomOnDoubleClick={!workflowReadOnly} isValidConnection={isValidConnection} + selectionKeyCode={null} + selectionMode={SelectionMode.Partial} + selectionOnDrag={controlMode === 'pointer' && !workflowReadOnly} + minZoom={0.25} > { + const ref = useRef(null) + const nodes = useNodes() + const { handleNodeContextmenuCancel } = usePanelInteractions() + const nodeMenu = useStore(s => s.nodeMenu) + const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node + + useClickAway(() => { + handleNodeContextmenuCancel() + }, ref) + + if (!nodeMenu || !currentNode) + return null + + return ( +
+ handleNodeContextmenuCancel()} + /> +
+ ) +} + +export default memo(PanelContextmenu) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx index e313d74034..d6913db526 100644 --- a/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/index.tsx @@ -1,19 +1,10 @@ import { memo, useCallback, - useMemo, useState, } from 'react' -import { useTranslation } from 'react-i18next' -import { useEdges } from 'reactflow' import type { OffsetOptions } from '@floating-ui/react' -import ChangeBlock from './change-block' -import { useStore } from '@/app/components/workflow/store' -import { - useNodesExtraData, - useNodesInteractions, - useNodesReadOnly, -} from '@/app/components/workflow/hooks' +import PanelOperatorPopup from './panel-operator-popup' import { DotsHorizontal } from '@/app/components/base/icons/src/vender/line/general' import { PortalToFollowElem, @@ -21,8 +12,6 @@ import { PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' import type { Node } from '@/app/components/workflow/types' -import { BlockEnum } from '@/app/components/workflow/types' -import { useGetLanguage } from '@/context/i18n' type PanelOperatorProps = { id: string @@ -43,35 +32,7 @@ const PanelOperator = ({ onOpenChange, inNode, }: PanelOperatorProps) => { - const { t } = useTranslation() - const language = useGetLanguage() - const edges = useEdges() - const { handleNodeDelete } = useNodesInteractions() - const { nodesReadOnly } = useNodesReadOnly() - const nodesExtraData = useNodesExtraData() - const buildInTools = useStore(s => s.buildInTools) - const customTools = useStore(s => s.customTools) const [open, setOpen] = useState(false) - const edge = edges.find(edge => edge.target === id) - const author = useMemo(() => { - if (data.type !== BlockEnum.Tool) - return nodesExtraData[data.type].author - - if (data.provider_type === 'builtin') - return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author - - return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author - }, [data, nodesExtraData, buildInTools, customTools]) - - const about = useMemo(() => { - if (data.type !== BlockEnum.Tool) - return nodesExtraData[data.type].about - - if (data.provider_type === 'builtin') - return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] - - return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] - }, [data, nodesExtraData, language, buildInTools, customTools]) const handleOpenChange = useCallback((newOpen: boolean) => { setOpen(newOpen) @@ -100,60 +61,11 @@ const PanelOperator = ({
-
-
- { - data.type !== BlockEnum.Start && !nodesReadOnly && ( - - ) - } - - {t('workflow.panel.helpLink')} - -
- { - data.type !== BlockEnum.Start && !nodesReadOnly && ( - <> -
-
-
handleNodeDelete(id)} - > - {t('common.operation.delete')} -
-
- - ) - } -
-
-
-
- {t('workflow.panel.about').toLocaleUpperCase()} -
-
{about}
-
- {t('workflow.panel.createdBy')} {author} -
-
-
-
+ setOpen(false)} + />
) diff --git a/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx new file mode 100644 index 0000000000..78e7657569 --- /dev/null +++ b/web/app/components/workflow/nodes/_base/components/panel-operator/panel-operator-popup.tsx @@ -0,0 +1,181 @@ +import { + memo, + useMemo, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useEdges } from 'reactflow' +import ChangeBlock from './change-block' +import { + canRunBySingle, +} from '@/app/components/workflow/utils' +import { useStore } from '@/app/components/workflow/store' +import { + useNodeDataUpdate, + useNodesExtraData, + useNodesInteractions, + useNodesReadOnly, + useNodesSyncDraft, +} from '@/app/components/workflow/hooks' +import ShortcutsName from '@/app/components/workflow/shortcuts-name' +import type { Node } from '@/app/components/workflow/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { useGetLanguage } from '@/context/i18n' + +type PanelOperatorPopupProps = { + id: string + data: Node['data'] + onClosePopup: () => void +} +const PanelOperatorPopup = ({ + id, + data, + onClosePopup, +}: PanelOperatorPopupProps) => { + const { t } = useTranslation() + const language = useGetLanguage() + const edges = useEdges() + const { + handleNodeDelete, + handleNodesDuplicate, + handleNodeSelect, + handleNodesCopy, + } = useNodesInteractions() + const { handleNodeDataUpdate } = useNodeDataUpdate() + const { handleSyncWorkflowDraft } = useNodesSyncDraft() + const { nodesReadOnly } = useNodesReadOnly() + const nodesExtraData = useNodesExtraData() + const buildInTools = useStore(s => s.buildInTools) + const customTools = useStore(s => s.customTools) + const edge = edges.find(edge => edge.target === id) + const author = useMemo(() => { + if (data.type !== BlockEnum.Tool) + return nodesExtraData[data.type].author + + if (data.provider_type === 'builtin') + return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author + + return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author + }, [data, nodesExtraData, buildInTools, customTools]) + + const about = useMemo(() => { + if (data.type !== BlockEnum.Tool) + return nodesExtraData[data.type].about + + if (data.provider_type === 'builtin') + return buildInTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] + + return customTools.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language] + }, [data, nodesExtraData, language, buildInTools, customTools]) + + const showChangeBlock = data.type !== BlockEnum.Start && !nodesReadOnly + + return ( +
+ { + (showChangeBlock || canRunBySingle(data.type)) && ( + <> +
+ { + canRunBySingle(data.type) && ( +
{ + handleNodeSelect(id) + handleNodeDataUpdate({ id, data: { _isSingleRun: true } }) + handleSyncWorkflowDraft(true) + onClosePopup() + }} + > + {t('workflow.panel.runThisStep')} +
+ ) + } + { + showChangeBlock && ( + + ) + } +
+
+ + ) + } + { + data.type !== BlockEnum.Start && data.type !== BlockEnum.End && !nodesReadOnly && ( + <> +
+
{ + onClosePopup() + handleNodesCopy() + }} + > + {t('workflow.common.copy')} + +
+
{ + onClosePopup() + handleNodesDuplicate() + }} + > + {t('workflow.common.duplicate')} + +
+
+
+
+
handleNodeDelete(id)} + > + {t('common.operation.delete')} + +
+
+
+ + ) + } + +
+
+
+
+ {t('workflow.panel.about').toLocaleUpperCase()} +
+
{about}
+
+ {t('workflow.panel.createdBy')} {author} +
+
+
+
+ ) +} + +export default memo(PanelOperatorPopup) diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index 01cbe3f35e..c5fcd5b99d 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -6,6 +6,7 @@ import { cloneElement, memo, useMemo, + useRef, } from 'react' import type { NodeProps } from '../../types' import { @@ -37,27 +38,30 @@ const BaseNode: FC = ({ data, children, }) => { + const nodeRef = useRef(null) const { nodesReadOnly } = useNodesReadOnly() const toolIcon = useToolIcon(data) + const showSelectedBorder = data.selected || data._isBundled const { showRunningBorder, showSuccessBorder, showFailedBorder, } = useMemo(() => { return { - showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !data.selected, - showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !data.selected, - showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !data.selected, + showRunningBorder: data._runningStatus === NodeRunningStatus.Running && !showSelectedBorder, + showSuccessBorder: data._runningStatus === NodeRunningStatus.Succeeded && !showSelectedBorder, + showFailedBorder: data._runningStatus === NodeRunningStatus.Failed && !showSelectedBorder, } - }, [data._runningStatus, data.selected]) + }, [data._runningStatus, showSelectedBorder]) return (
= ({ ${showSuccessBorder && '!border-[#12B76A]'} ${showFailedBorder && '!border-[#F04438]'} ${data._isInvalidConnection && '!border-[#F04438]'} + ${data._isBundled && '!shadow-lg'} `} > { - data.type !== BlockEnum.VariableAssigner && ( + data.type !== BlockEnum.VariableAssigner && !data._isCandidate && ( = ({ ) } { - data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && ( + data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && !data._isCandidate && ( = ({ ) } { - !data._runningStatus && !nodesReadOnly && ( + !data._runningStatus && !nodesReadOnly && !data._isCandidate && ( React.ReactNode + offset?: OffsetOptions +} +const AddBlock = ({ + renderTrigger, + offset, +}: AddBlockProps) => { + const { t } = useTranslation() + const store = useStoreApi() + const workflowStore = useWorkflowStore() + const nodesExtraData = useNodesExtraData() + const { nodesReadOnly } = useNodesReadOnly() + const { handlePaneContextmenuCancel } = usePanelInteractions() + const [open, setOpen] = useState(false) + const availableNextNodes = nodesExtraData[BlockEnum.Start].availableNextNodes + + const handleOpenChange = useCallback((open: boolean) => { + setOpen(open) + if (!open) + handlePaneContextmenuCancel() + }, [handlePaneContextmenuCancel]) + + const handleSelect = useCallback((type, toolDefaultValue) => { + const { + getNodes, + } = store.getState() + const nodes = getNodes() + const nodesWithSameType = nodes.filter(node => node.data.type === type) + const newNode = generateNewNode({ + data: { + ...NODES_INITIAL_DATA[type], + title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`), + ...(toolDefaultValue || {}), + _isCandidate: true, + }, + position: { + x: 0, + y: 0, + }, + }) + workflowStore.setState({ + candidateNode: newNode, + }) + }, [store, workflowStore, t]) + + const renderTriggerElement = useCallback((open: boolean) => { + return ( + +
+ +
+
+ ) + }, [nodesReadOnly, t]) + + return ( + + ) +} + +export default memo(AddBlock) diff --git a/web/app/components/workflow/operator/control.tsx b/web/app/components/workflow/operator/control.tsx new file mode 100644 index 0000000000..94d47129c5 --- /dev/null +++ b/web/app/components/workflow/operator/control.tsx @@ -0,0 +1,85 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import cn from 'classnames' +import { + useNodesReadOnly, + useWorkflow, +} from '../hooks' +import { useStore } from '../store' +import AddBlock from './add-block' +import TipPopup from './tip-popup' +import { + Cursor02C, + Hand02, +} from '@/app/components/base/icons/src/vender/line/editor' +import { + Cursor02C as Cursor02CSolid, + Hand02 as Hand02Solid, +} from '@/app/components/base/icons/src/vender/solid/editor' +import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout' + +const Control = () => { + const { t } = useTranslation() + const controlMode = useStore(s => s.controlMode) + const setControlMode = useStore(s => s.setControlMode) + const { handleLayout } = useWorkflow() + const { + nodesReadOnly, + getNodesReadOnly, + } = useNodesReadOnly() + + const goLayout = () => { + if (getNodesReadOnly()) + return + handleLayout() + } + + return ( +
+ +
+ +
setControlMode('pointer')} + > + { + controlMode === 'pointer' ? : + } +
+
+ +
setControlMode('hand')} + > + { + controlMode === 'hand' ? : + } +
+
+
+ +
+ +
+
+
+ ) +} + +export default memo(Control) diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 72fc5d2975..41465122b4 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -1,54 +1,23 @@ import { memo } from 'react' -import { useTranslation } from 'react-i18next' import { MiniMap } from 'reactflow' -import { - useNodesReadOnly, - useWorkflow, -} from '../hooks' import ZoomInOut from './zoom-in-out' -import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout' -import TooltipPlus from '@/app/components/base/tooltip-plus' +import Control from './control' const Operator = () => { - const { t } = useTranslation() - const { handleLayout } = useWorkflow() - const { - nodesReadOnly, - getNodesReadOnly, - } = useNodesReadOnly() - - const goLayout = () => { - if (getNodesReadOnly()) - return - handleLayout() - } - return ( -
+ <> -
+
- -
- -
-
+
-
+ ) } diff --git a/web/app/components/workflow/operator/tip-popup.tsx b/web/app/components/workflow/operator/tip-popup.tsx new file mode 100644 index 0000000000..ecd108dffc --- /dev/null +++ b/web/app/components/workflow/operator/tip-popup.tsx @@ -0,0 +1,34 @@ +import { memo } from 'react' +import ShortcutsName from '../shortcuts-name' +import TooltipPlus from '@/app/components/base/tooltip-plus' + +type TipPopupProps = { + title: string + children: React.ReactNode + shortcuts?: string[] +} +const TipPopup = ({ + title, + children, + shortcuts, +}: TipPopupProps) => { + return ( + + {title} + { + shortcuts && + } +
+ } + > + {children} + + ) +} + +export default memo(TipPopup) diff --git a/web/app/components/workflow/operator/zoom-in-out.tsx b/web/app/components/workflow/operator/zoom-in-out.tsx index 1fe4d5e41d..6839c1aa04 100644 --- a/web/app/components/workflow/operator/zoom-in-out.tsx +++ b/web/app/components/workflow/operator/zoom-in-out.tsx @@ -5,6 +5,8 @@ import { useCallback, useState, } from 'react' +import cn from 'classnames' +import { useKeyPress } from 'ahooks' import { useTranslation } from 'react-i18next' import { useReactFlow, @@ -14,13 +16,32 @@ import { useNodesSyncDraft, useWorkflowReadOnly, } from '../hooks' +import { + getKeyboardKeyCodeBySystem, + getKeyboardKeyNameBySystem, +} from '../utils' +import ShortcutsName from '../shortcuts-name' +import TipPopup from './tip-popup' import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger, } from '@/app/components/base/portal-to-follow-elem' -import { SearchLg } from '@/app/components/base/icons/src/vender/line/general' -import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows' +import { + ZoomIn, + ZoomOut, +} from '@/app/components/base/icons/src/vender/line/editor' + +enum ZoomType { + zoomIn = 'zoomIn', + zoomOut = 'zoomOut', + zoomToFit = 'zoomToFit', + zoomTo25 = 'zoomTo25', + zoomTo50 = 'zoomTo50', + zoomTo75 = 'zoomTo75', + zoomTo100 = 'zoomTo100', + zoomTo200 = 'zoomTo200', +} const ZoomInOut: FC = () => { const { t } = useTranslation() @@ -41,27 +62,29 @@ const ZoomInOut: FC = () => { const ZOOM_IN_OUT_OPTIONS = [ [ { - key: 'in', - text: t('workflow.operator.zoomIn'), + key: ZoomType.zoomTo200, + text: '200%', }, { - key: 'out', - text: t('workflow.operator.zoomOut'), + key: ZoomType.zoomTo100, + text: '100%', + }, + { + key: ZoomType.zoomTo75, + text: '75%', + }, + { + key: ZoomType.zoomTo50, + text: '50%', + }, + { + key: ZoomType.zoomTo25, + text: '25%', }, ], [ { - key: 'to50', - text: t('workflow.operator.zoomTo50'), - }, - { - key: 'to100', - text: t('workflow.operator.zoomTo100'), - }, - ], - [ - { - key: 'fit', + key: ZoomType.zoomToFit, text: t('workflow.operator.zoomToFit'), }, ], @@ -71,24 +94,99 @@ const ZoomInOut: FC = () => { if (workflowReadOnly) return - if (type === 'in') - zoomIn() - - if (type === 'out') - zoomOut() - - if (type === 'fit') + if (type === ZoomType.zoomToFit) fitView() - if (type === 'to50') + if (type === ZoomType.zoomTo25) + zoomTo(0.25) + + if (type === ZoomType.zoomTo50) zoomTo(0.5) - if (type === 'to100') + if (type === ZoomType.zoomTo75) + zoomTo(0.75) + + if (type === ZoomType.zoomTo100) zoomTo(1) + if (type === ZoomType.zoomTo200) + zoomTo(2) + handleSyncWorkflowDraft() } + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => { + e.preventDefault() + if (workflowReadOnly) + return + + fitView() + handleSyncWorkflowDraft() + }, { + exactMatch: true, + useCapture: true, + }) + + useKeyPress('shift.1', (e) => { + e.preventDefault() + if (workflowReadOnly) + return + + zoomTo(1) + handleSyncWorkflowDraft() + }, { + exactMatch: true, + useCapture: true, + }) + + useKeyPress('shift.2', (e) => { + e.preventDefault() + if (workflowReadOnly) + return + + zoomTo(2) + handleSyncWorkflowDraft() + }, { + exactMatch: true, + useCapture: true, + }) + + useKeyPress('shift.5', (e) => { + e.preventDefault() + if (workflowReadOnly) + return + + zoomTo(0.5) + handleSyncWorkflowDraft() + }, { + exactMatch: true, + useCapture: true, + }) + + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => { + e.preventDefault() + if (workflowReadOnly) + return + + zoomOut() + handleSyncWorkflowDraft() + }, { + exactMatch: true, + useCapture: true, + }) + + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => { + e.preventDefault() + if (workflowReadOnly) + return + + zoomIn() + handleSyncWorkflowDraft() + }, { + exactMatch: true, + useCapture: true, + }) + const handleTrigger = useCallback(() => { if (getWorkflowReadOnly()) return @@ -108,17 +206,47 @@ const ZoomInOut: FC = () => { >
- -
{parseFloat(`${zoom * 100}`).toFixed(0)}%
- +
+ +
{ + e.stopPropagation() + zoomOut() + }} + > + +
+
+
{parseFloat(`${zoom * 100}`).toFixed(0)}%
+ +
{ + e.stopPropagation() + zoomIn() + }} + > + +
+
+
-
+
{ ZOOM_IN_OUT_OPTIONS.map((options, i) => ( @@ -132,10 +260,30 @@ const ZoomInOut: FC = () => { options.map(option => (
handleZoom(option.key)} > {option.text} + { + option.key === ZoomType.zoomToFit && ( + + ) + } + { + option.key === ZoomType.zoomTo50 && ( + + ) + } + { + option.key === ZoomType.zoomTo100 && ( + + ) + } + { + option.key === ZoomType.zoomTo200 && ( + + ) + }
)) } diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx new file mode 100644 index 0000000000..eeae51c8d1 --- /dev/null +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -0,0 +1,123 @@ +import { + memo, + useRef, +} from 'react' +import cn from 'classnames' +import { useTranslation } from 'react-i18next' +import { useClickAway } from 'ahooks' +import ShortcutsName from './shortcuts-name' +import { useStore } from './store' +import { + useNodesInteractions, + usePanelInteractions, + useWorkflowStartRun, +} from './hooks' +import AddBlock from './operator/add-block' +import { exportAppConfig } from '@/service/apps' +import { useToastContext } from '@/app/components/base/toast' +import { useStore as useAppStore } from '@/app/components/app/store' + +const PanelContextmenu = () => { + const { t } = useTranslation() + const { notify } = useToastContext() + const ref = useRef(null) + const panelMenu = useStore(s => s.panelMenu) + const clipboardElements = useStore(s => s.clipboardElements) + const appDetail = useAppStore(s => s.appDetail) + const { handleNodesPaste } = useNodesInteractions() + const { handlePaneContextmenuCancel } = usePanelInteractions() + const { handleStartWorkflowRun } = useWorkflowStartRun() + + useClickAway(() => { + handlePaneContextmenuCancel() + }, ref) + + const onExport = async () => { + if (!appDetail) + return + try { + const { data } = await exportAppConfig(appDetail.id) + const a = document.createElement('a') + const file = new Blob([data], { type: 'application/yaml' }) + a.href = URL.createObjectURL(file) + a.download = `${appDetail.name}.yml` + a.click() + } + catch (e) { + notify({ type: 'error', message: t('app.exportFailed') }) + } + } + + const renderTrigger = () => { + return ( +
+ {t('workflow.common.addBlock')} +
+ ) + } + + if (!panelMenu) + return null + + return ( +
+
+ +
{ + handleStartWorkflowRun() + handlePaneContextmenuCancel() + }} + > + {t('workflow.common.run')} + +
+
+
+
+
{ + if (clipboardElements.length) { + handleNodesPaste() + handlePaneContextmenuCancel() + } + }} + > + {t('workflow.common.pasteHere')} + +
+
+
+
+
onExport()} + > + {t('app.export')} +
+
+
+ ) +} + +export default memo(PanelContextmenu) diff --git a/web/app/components/workflow/shortcuts-name.tsx b/web/app/components/workflow/shortcuts-name.tsx new file mode 100644 index 0000000000..dfd44940e0 --- /dev/null +++ b/web/app/components/workflow/shortcuts-name.tsx @@ -0,0 +1,32 @@ +import { memo } from 'react' +import cn from 'classnames' +import { getKeyboardKeyNameBySystem } from './utils' + +type ShortcutsNameProps = { + keys: string[] + className?: string +} +const ShortcutsName = ({ + keys, + className, +}: ShortcutsNameProps) => { + return ( +
+ { + keys.map(key => ( +
+ {getKeyboardKeyNameBySystem(key)} +
+ )) + } +
+ ) +} + +export default memo(ShortcutsName) diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts index 9aaece0c91..4ee28da4bc 100644 --- a/web/app/components/workflow/store.ts +++ b/web/app/components/workflow/store.ts @@ -75,6 +75,27 @@ type Shape = { setShortcutsDisabled: (shortcutsDisabled: boolean) => void showDebugAndPreviewPanel: boolean setShowDebugAndPreviewPanel: (showDebugAndPreviewPanel: boolean) => void + selection: null | { x1: number; y1: number; x2: number; y2: number } + setSelection: (selection: Shape['selection']) => void + bundleNodeSize: { width: number; height: number } | null + setBundleNodeSize: (bundleNodeSize: Shape['bundleNodeSize']) => void + controlMode: 'pointer' | 'hand' + setControlMode: (controlMode: Shape['controlMode']) => void + candidateNode?: Node + setCandidateNode: (candidateNode?: Node) => void + panelMenu?: { + top: number + left: number + } + setPanelMenu: (panelMenu: Shape['panelMenu']) => void + nodeMenu?: { + top: number + left: number + nodeId: string + } + setNodeMenu: (nodeMenu: Shape['nodeMenu']) => void + mousePosition: { pageX: number; pageY: number; elementX: number; elementY: number } + setMousePosition: (mousePosition: Shape['mousePosition']) => void } export const createWorkflowStore = () => { @@ -126,6 +147,23 @@ export const createWorkflowStore = () => { setShortcutsDisabled: shortcutsDisabled => set(() => ({ shortcutsDisabled })), showDebugAndPreviewPanel: false, setShowDebugAndPreviewPanel: showDebugAndPreviewPanel => set(() => ({ showDebugAndPreviewPanel })), + selection: null, + setSelection: selection => set(() => ({ selection })), + bundleNodeSize: null, + setBundleNodeSize: bundleNodeSize => set(() => ({ bundleNodeSize })), + controlMode: localStorage.getItem('workflow-operation-mode') === 'pointer' ? 'pointer' : 'hand', + setControlMode: (controlMode) => { + set(() => ({ controlMode })) + localStorage.setItem('workflow-operation-mode', controlMode) + }, + candidateNode: undefined, + setCandidateNode: candidateNode => set(() => ({ candidateNode })), + panelMenu: undefined, + setPanelMenu: panelMenu => set(() => ({ panelMenu })), + nodeMenu: undefined, + setNodeMenu: nodeMenu => set(() => ({ nodeMenu })), + mousePosition: { pageX: 0, pageY: 0, elementX: 0, elementY: 0 }, + setMousePosition: mousePosition => set(() => ({ mousePosition })), })) } diff --git a/web/app/components/workflow/style.css b/web/app/components/workflow/style.css index fc6130cc02..9ec8586ccc 100644 --- a/web/app/components/workflow/style.css +++ b/web/app/components/workflow/style.css @@ -4,4 +4,15 @@ .workflow-node-animation .react-flow__node { transition: transform 0.2s ease-in-out; +} + +#workflow-container .react-flow__nodesselection-rect { + border: 1px solid #528BFF; + background: rgba(21, 94, 239, 0.05); + cursor: move; +} + +#workflow-container .react-flow__selection { + border: 1px solid #528BFF; + background: rgba(21, 94, 239, 0.05); } \ No newline at end of file diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 50d6922c9c..f0b5e08c6c 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -37,6 +37,8 @@ export type CommonNodeType = { _isSingleRun?: boolean _runningStatus?: NodeRunningStatus _singleRunningStatus?: NodeRunningStatus + _isCandidate?: boolean + _isBundled?: boolean selected?: boolean title: string desc: string @@ -48,6 +50,7 @@ export type CommonEdgeType = { _connectedNodeIsHovering?: boolean _connectedNodeIsSelected?: boolean _runned?: boolean + _isBundled?: boolean sourceType: BlockEnum targetType: BlockEnum } diff --git a/web/app/components/workflow/utils.ts b/web/app/components/workflow/utils.ts index 6a5f0d9e47..d4f6f77d71 100644 --- a/web/app/components/workflow/utils.ts +++ b/web/app/components/workflow/utils.ts @@ -361,3 +361,48 @@ export const changeNodesAndEdgesId = (nodes: Node[], edges: Edge[]) => { return [newNodes, newEdges] as [Node[], Edge[]] } + +export const isMac = () => { + return navigator.userAgent.toUpperCase().includes('MAC') +} + +const specialKeysNameMap: Record = { + ctrl: '⌘', + alt: '⌥', +} + +export const getKeyboardKeyNameBySystem = (key: string) => { + if (isMac()) + return specialKeysNameMap[key] || key + + return key +} + +const specialKeysCodeMap: Record = { + ctrl: 'meta', +} + +export const getKeyboardKeyCodeBySystem = (key: string) => { + if (isMac()) + return specialKeysCodeMap[key] || key + + return key +} + +export const getTopLeftNodePosition = (nodes: Node[]) => { + let minX = Infinity + let minY = Infinity + + nodes.forEach((node) => { + if (node.position.x < minX) + minX = node.position.x + + if (node.position.y < minY) + minY = node.position.y + }) + + return { + x: minX, + y: minY, + } +} diff --git a/web/i18n/en-US/workflow.ts b/web/i18n/en-US/workflow.ts index 42a073d093..5093b6631d 100644 --- a/web/i18n/en-US/workflow.ts +++ b/web/i18n/en-US/workflow.ts @@ -52,6 +52,12 @@ const translation = { jinjaEditorPlaceholder: 'Type \'/\' or \'{\' to insert variable', viewOnly: 'View Only', showRunHistory: 'Show Run History', + copy: 'Copy', + duplicate: 'Duplicate', + addBlock: 'Add Block', + pasteHere: 'Paste Here', + pointerMode: 'Pointer Mode', + handMode: 'Hand Mode', }, errorMsg: { fieldRequired: '{{field}} is required', diff --git a/web/i18n/zh-Hans/workflow.ts b/web/i18n/zh-Hans/workflow.ts index 4c99499808..baae846376 100644 --- a/web/i18n/zh-Hans/workflow.ts +++ b/web/i18n/zh-Hans/workflow.ts @@ -52,6 +52,12 @@ const translation = { jinjaEditorPlaceholder: '输入 “/” 或 “{” 插入变量', viewOnly: '只读', showRunHistory: '显示运行历史', + copy: '拷贝', + duplicate: '复制', + addBlock: '添加节点', + pasteHere: '粘贴到这里', + pointerMode: '指针模式', + handMode: '手模式', }, errorMsg: { fieldRequired: '{{field}} 不能为空',