feat: workflow interaction (#4214)

This commit is contained in:
zxhlyh 2024-05-09 17:18:51 +08:00 committed by GitHub
parent 487ce7c82a
commit 9b24f12bf5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 1955 additions and 431 deletions

View File

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

After

Width:  |  Height:  |  Size: 416 B

View File

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

After

Width:  |  Height:  |  Size: 474 B

View File

@ -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="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="#667085" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 906 B

View File

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

After

Width:  |  Height:  |  Size: 467 B

View File

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

After

Width:  |  Height:  |  Size: 443 B

View File

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

After

Width:  |  Height:  |  Size: 386 B

View File

@ -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" 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="#155EEF"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,81 @@
import {
memo,
} from 'react'
import produce from 'immer'
import {
useReactFlow,
useStoreApi,
useViewport,
} from 'reactflow'
import { useEventListener } from 'ahooks'
import {
useStore,
useWorkflowStore,
} from './store'
import CustomNode from './nodes'
const CandidateNode = () => {
const store = useStoreApi()
const reactflow = useReactFlow()
const workflowStore = useWorkflowStore()
const candidateNode = useStore(s => s.candidateNode)
const mousePosition = useStore(s => s.mousePosition)
const { zoom } = useViewport()
useEventListener('click', (e) => {
const { candidateNode, mousePosition } = workflowStore.getState()
if (candidateNode) {
e.preventDefault()
const {
getNodes,
setNodes,
} = store.getState()
const { screenToFlowPosition } = reactflow
const nodes = getNodes()
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
const newNodes = produce(nodes, (draft) => {
draft.push({
...candidateNode,
data: {
...candidateNode.data,
_isCandidate: false,
},
position: {
x,
y,
},
})
})
setNodes(newNodes)
workflowStore.setState({ candidateNode: undefined })
}
})
useEventListener('contextmenu', (e) => {
const { candidateNode } = workflowStore.getState()
if (candidateNode) {
e.preventDefault()
workflowStore.setState({ candidateNode: undefined })
}
})
if (!candidateNode)
return null
return (
<div
className='absolute z-10'
style={{
left: mousePosition.elementX,
top: mousePosition.elementY,
transform: `scale(${zoom})`,
transformOrigin: '0 0',
}}
>
<CustomNode {...candidateNode as any} />
</div>
)
}
export default memo(CandidateNode)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,37 @@
import type { MouseEvent } from 'react'
import { useCallback } from 'react'
import { useWorkflowStore } from '../store'
export const usePanelInteractions = () => {
const workflowStore = useWorkflowStore()
const handlePaneContextMenu = useCallback((e: MouseEvent) => {
e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({
panelMenu: {
top: e.clientY - y,
left: e.clientX - x,
},
})
}, [workflowStore])
const handlePaneContextmenuCancel = useCallback(() => {
workflowStore.setState({
panelMenu: undefined,
})
}, [workflowStore])
const handleNodeContextmenuCancel = useCallback(() => {
workflowStore.setState({
nodeMenu: undefined,
})
}, [workflowStore])
return {
handlePaneContextMenu,
handlePaneContextmenuCancel,
handleNodeContextmenuCancel,
}
}

View File

@ -0,0 +1,109 @@
import type { MouseEvent } from 'react'
import {
useCallback,
} from 'react'
import produce from 'immer'
import type {
OnSelectionChangeFunc,
} from 'reactflow'
import { useStoreApi } from 'reactflow'
import { useWorkflowStore } from '../store'
import type { Node } from '../types'
export const useSelectionInteractions = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const handleSelectionStart = useCallback(() => {
const {
getNodes,
setNodes,
edges,
setEdges,
userSelectionRect,
} = store.getState()
if (!userSelectionRect?.width || !userSelectionRect?.height) {
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if (node.data._isBundled)
node.data._isBundled = false
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => {
if (edge.data._isBundled)
edge.data._isBundled = false
})
})
setEdges(newEdges)
}
}, [store])
const handleSelectionChange = useCallback<OnSelectionChangeFunc>(({ nodes: nodesInSelection, edges: edgesInSelection }) => {
const {
getNodes,
setNodes,
edges,
setEdges,
userSelectionRect,
} = store.getState()
const nodes = getNodes()
if (!userSelectionRect?.width || !userSelectionRect?.height)
return
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
const nodeInSelection = nodesInSelection.find(n => n.id === node.id)
if (nodeInSelection)
node.data._isBundled = true
else
node.data._isBundled = false
})
})
setNodes(newNodes)
const newEdges = produce(edges, (draft) => {
draft.forEach((edge) => {
const edgeInSelection = edgesInSelection.find(e => e.id === edge.id)
if (edgeInSelection)
edge.data._isBundled = true
else
edge.data._isBundled = false
})
})
setEdges(newEdges)
}, [store])
const handleSelectionDrag = useCallback((_: MouseEvent, nodesWithDrag: Node[]) => {
const {
getNodes,
setNodes,
} = store.getState()
workflowStore.setState({
nodeAnimation: false,
})
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
const dragNode = nodesWithDrag.find(n => n.id === node.id)
if (dragNode)
node.position = dragNode.position
})
})
setNodes(newNodes)
}, [store, workflowStore])
return {
handleSelectionStart,
handleSelectionChange,
handleSelectionDrag,
}
}

View File

@ -0,0 +1,88 @@
import { useCallback } from 'react'
import { useStoreApi } from 'reactflow'
import { useWorkflowStore } from '../store'
import {
BlockEnum,
WorkflowRunningStatus,
} from '../types'
import {
useIsChatMode,
useNodesSyncDraft,
useWorkflowInteractions,
useWorkflowRun,
} from './index'
import { useFeaturesStore } from '@/app/components/base/features/hooks'
export const useWorkflowStartRun = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const featuresStore = useFeaturesStore()
const isChatMode = useIsChatMode()
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const { handleRun } = useWorkflowRun()
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const handleWorkflowStartRunInWorkflow = useCallback(async () => {
const {
workflowRunningData,
} = workflowStore.getState()
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
return
const { getNodes } = store.getState()
const nodes = getNodes()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const startVariables = startNode?.data.variables || []
const fileSettings = featuresStore!.getState().features.file
const {
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setShowInputsPanel,
} = workflowStore.getState()
if (showDebugAndPreviewPanel) {
handleCancelDebugAndPreviewPanel()
return
}
if (!startVariables.length && !fileSettings?.image?.enabled) {
await doSyncWorkflowDraft()
handleRun({ inputs: {}, files: [] })
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(false)
}
else {
setShowDebugAndPreviewPanel(true)
setShowInputsPanel(true)
}
}, [store, workflowStore, featuresStore, handleCancelDebugAndPreviewPanel, handleRun, doSyncWorkflowDraft])
const handleWorkflowStartRunInChatflow = useCallback(async () => {
const {
showDebugAndPreviewPanel,
setShowDebugAndPreviewPanel,
setHistoryWorkflowData,
} = workflowStore.getState()
if (showDebugAndPreviewPanel)
handleCancelDebugAndPreviewPanel()
else
setShowDebugAndPreviewPanel(true)
setHistoryWorkflowData(undefined)
}, [workflowStore, handleCancelDebugAndPreviewPanel])
const handleStartWorkflowRun = useCallback(() => {
if (!isChatMode)
handleWorkflowStartRunInWorkflow()
else
handleWorkflowStartRunInChatflow()
}, [isChatMode, handleWorkflowStartRunInWorkflow, handleWorkflowStartRunInChatflow])
return {
handleStartWorkflowRun,
handleWorkflowStartRunInWorkflow,
handleWorkflowStartRunInChatflow,
}
}

View File

@ -6,19 +6,24 @@ import {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
import { setAutoFreeze } from 'immer'
import {
useEventListener,
useKeyPress,
} from 'ahooks'
import ReactFlow, {
Background,
ReactFlowProvider,
SelectionMode,
useEdgesState,
useNodesState,
useOnViewportChange,
} from 'reactflow'
import type { Viewport } from 'reactflow'
import type {
Viewport,
} from 'reactflow'
import 'reactflow/dist/style.css'
import './style.css'
import type {
@ -31,9 +36,12 @@ import {
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
usePanelInteractions,
useSelectionInteractions,
useWorkflow,
useWorkflowInit,
useWorkflowReadOnly,
useWorkflowStartRun,
} from './hooks'
import Header from './header'
import CustomNode from './nodes'
@ -43,8 +51,15 @@ import CustomConnectionLine from './custom-connection-line'
import Panel from './panel'
import Features from './features'
import HelpLine from './help-line'
import { useStore } from './store'
import CandidateNode from './candidate-node'
import PanelContextmenu from './panel-contextmenu'
import NodeContextmenu from './node-contextmenu'
import {
useStore,
useWorkflowStore,
} from './store'
import {
getKeyboardKeyCodeBySystem,
initialEdges,
initialNodes,
} from './utils'
@ -71,9 +86,12 @@ const Workflow: FC<WorkflowProps> = memo(({
edges: originalEdges,
viewport,
}) => {
const workflowContainerRef = useRef<HTMLDivElement>(null)
const workflowStore = useWorkflowStore()
const [nodes, setNodes] = useNodesState(originalNodes)
const [edges, setEdges] = useEdgesState(originalEdges)
const showFeaturesPanel = useStore(state => state.showFeaturesPanel)
const controlMode = useStore(s => s.controlMode)
const nodeAnimation = useStore(s => s.nodeAnimation)
const {
handleSyncWorkflowDraft,
@ -118,6 +136,25 @@ const Workflow: FC<WorkflowProps> = memo(({
}
}, [handleSyncWorkflowDraftWhenPageClose])
useEventListener('keydown', (e) => {
if ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))
e.preventDefault()
})
useEventListener('mousemove', (e) => {
const containerClientRect = workflowContainerRef.current?.getBoundingClientRect()
if (containerClientRect) {
workflowStore.setState({
mousePosition: {
pageX: e.clientX,
pageY: e.clientY,
elementX: e.clientX - containerClientRect.left,
elementY: e.clientY - containerClientRect.top,
},
})
}
})
const {
handleNodeDragStart,
handleNodeDrag,
@ -128,11 +165,11 @@ const Workflow: FC<WorkflowProps> = memo(({
handleNodeConnect,
handleNodeConnectStart,
handleNodeConnectEnd,
handleNodeDuplicateSelected,
handleNodeCopySelected,
handleNodeCut,
handleNodeDeleteSelected,
handleNodePaste,
handleNodeContextMenu,
handleNodesCopy,
handleNodesPaste,
handleNodesDuplicate,
handleNodesDelete,
} = useNodesInteractions()
const {
handleEdgeEnter,
@ -140,9 +177,18 @@ const Workflow: FC<WorkflowProps> = memo(({
handleEdgeDelete,
handleEdgesChange,
} = useEdgesInteractions()
const {
handleSelectionStart,
handleSelectionChange,
handleSelectionDrag,
} = useSelectionInteractions()
const {
handlePaneContextMenu,
} = usePanelInteractions()
const {
isValidConnection,
} = useWorkflow()
const { handleStartWorkflowRun } = useWorkflowStartRun()
useOnViewportChange({
onEnd: () => {
@ -150,12 +196,12 @@ const Workflow: FC<WorkflowProps> = memo(({
},
})
useKeyPress(['delete', 'backspace'], handleNodeDeleteSelected)
useKeyPress(['delete', 'backspace'], handleEdgeDelete)
useKeyPress(['ctrl.c', 'meta.c'], handleNodeCopySelected)
useKeyPress(['ctrl.x', 'meta.x'], handleNodeCut)
useKeyPress(['ctrl.v', 'meta.v'], handleNodePaste)
useKeyPress(['ctrl.alt.d', 'meta.shift.d'], handleNodeDuplicateSelected)
useKeyPress('delete', handleNodesDelete)
useKeyPress('delete', handleEdgeDelete)
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, handleNodesCopy, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, handleNodesPaste, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, handleNodesDuplicate, { exactMatch: true, useCapture: true })
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, handleStartWorkflowRun, { exactMatch: true, useCapture: true })
return (
<div
@ -165,13 +211,17 @@ const Workflow: FC<WorkflowProps> = memo(({
${workflowReadOnly && 'workflow-panel-animation'}
${nodeAnimation && 'workflow-node-animation'}
`}
ref={workflowContainerRef}
>
<CandidateNode />
<Header />
<Panel />
<Operator />
{
showFeaturesPanel && <Features />
}
<PanelContextmenu />
<NodeContextmenu />
<HelpLine />
<ReactFlow
nodeTypes={nodeTypes}
@ -184,12 +234,17 @@ const Workflow: FC<WorkflowProps> = memo(({
onNodeMouseEnter={handleNodeEnter}
onNodeMouseLeave={handleNodeLeave}
onNodeClick={handleNodeClick}
onNodeContextMenu={handleNodeContextMenu}
onConnect={handleNodeConnect}
onConnectStart={handleNodeConnectStart}
onConnectEnd={handleNodeConnectEnd}
onEdgeMouseEnter={handleEdgeEnter}
onEdgeMouseLeave={handleEdgeLeave}
onEdgesChange={handleEdgesChange}
onSelectionStart={handleSelectionStart}
onSelectionChange={handleSelectionChange}
onSelectionDrag={handleSelectionDrag}
onPaneContextMenu={handlePaneContextMenu}
connectionLineComponent={CustomConnectionLine}
defaultViewport={viewport}
multiSelectionKeyCode={null}
@ -198,11 +253,15 @@ const Workflow: FC<WorkflowProps> = memo(({
nodesConnectable={!nodesReadOnly}
nodesFocusable={!nodesReadOnly}
edgesFocusable={!nodesReadOnly}
panOnDrag={!workflowReadOnly}
panOnDrag={controlMode === 'hand' && !workflowReadOnly}
zoomOnPinch={!workflowReadOnly}
zoomOnScroll={!workflowReadOnly}
zoomOnDoubleClick={!workflowReadOnly}
isValidConnection={isValidConnection}
selectionKeyCode={null}
selectionMode={SelectionMode.Partial}
selectionOnDrag={controlMode === 'pointer' && !workflowReadOnly}
minZoom={0.25}
>
<Background
gap={[14, 14]}

View File

@ -0,0 +1,44 @@
import {
memo,
useRef,
} from 'react'
import { useClickAway } from 'ahooks'
import { useNodes } from 'reactflow'
import PanelOperatorPopup from './nodes/_base/components/panel-operator/panel-operator-popup'
import type { Node } from './types'
import { useStore } from './store'
import { usePanelInteractions } from './hooks'
const PanelContextmenu = () => {
const ref = useRef(null)
const nodes = useNodes()
const { handleNodeContextmenuCancel } = usePanelInteractions()
const nodeMenu = useStore(s => s.nodeMenu)
const currentNode = nodes.find(node => node.id === nodeMenu?.nodeId) as Node
useClickAway(() => {
handleNodeContextmenuCancel()
}, ref)
if (!nodeMenu || !currentNode)
return null
return (
<div
className='absolute z-[9]'
style={{
left: nodeMenu.left,
top: nodeMenu.top,
}}
ref={ref}
>
<PanelOperatorPopup
id={currentNode.id}
data={currentNode.data}
onClosePopup={() => handleNodeContextmenuCancel()}
/>
</div>
)
}
export default memo(PanelContextmenu)

View File

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

View File

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

View File

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

View File

@ -0,0 +1,110 @@
import {
memo,
useCallback,
useState,
} from 'react'
import cn from 'classnames'
import { useStoreApi } from 'reactflow'
import { useTranslation } from 'react-i18next'
import type { OffsetOptions } from '@floating-ui/react'
import {
generateNewNode,
} from '../utils'
import {
useNodesExtraData,
useNodesReadOnly,
usePanelInteractions,
} from '../hooks'
import { NODES_INITIAL_DATA } from '../constants'
import { useWorkflowStore } from '../store'
import TipPopup from './tip-popup'
import BlockSelector from '@/app/components/workflow/block-selector'
import { Plus } from '@/app/components/base/icons/src/vender/line/general'
import type {
OnSelectBlock,
} from '@/app/components/workflow/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
type AddBlockProps = {
renderTrigger?: (open: boolean) => React.ReactNode
offset?: OffsetOptions
}
const AddBlock = ({
renderTrigger,
offset,
}: AddBlockProps) => {
const { t } = useTranslation()
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const nodesExtraData = useNodesExtraData()
const { nodesReadOnly } = useNodesReadOnly()
const { handlePaneContextmenuCancel } = usePanelInteractions()
const [open, setOpen] = useState(false)
const availableNextNodes = nodesExtraData[BlockEnum.Start].availableNextNodes
const handleOpenChange = useCallback((open: boolean) => {
setOpen(open)
if (!open)
handlePaneContextmenuCancel()
}, [handlePaneContextmenuCancel])
const handleSelect = useCallback<OnSelectBlock>((type, toolDefaultValue) => {
const {
getNodes,
} = store.getState()
const nodes = getNodes()
const nodesWithSameType = nodes.filter(node => node.data.type === type)
const newNode = generateNewNode({
data: {
...NODES_INITIAL_DATA[type],
title: nodesWithSameType.length > 0 ? `${t(`workflow.blocks.${type}`)} ${nodesWithSameType.length + 1}` : t(`workflow.blocks.${type}`),
...(toolDefaultValue || {}),
_isCandidate: true,
},
position: {
x: 0,
y: 0,
},
})
workflowStore.setState({
candidateNode: newNode,
})
}, [store, workflowStore, t])
const renderTriggerElement = useCallback((open: boolean) => {
return (
<TipPopup
title={t('workflow.common.addBlock')}
>
<div className={cn(
'flex items-center justify-center w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
open && '!bg-black/5',
)}>
<Plus className='w-4 h-4' />
</div>
</TipPopup>
)
}, [nodesReadOnly, t])
return (
<BlockSelector
open={open}
onOpenChange={handleOpenChange}
disabled={nodesReadOnly}
onSelect={handleSelect}
placement='top-start'
offset={offset ?? {
mainAxis: 4,
crossAxis: -8,
}}
trigger={renderTrigger || renderTriggerElement}
popupClassName='!min-w-[256px]'
availableBlocksTypes={availableNextNodes}
/>
)
}
export default memo(AddBlock)

View File

@ -0,0 +1,85 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import {
useNodesReadOnly,
useWorkflow,
} from '../hooks'
import { useStore } from '../store'
import AddBlock from './add-block'
import TipPopup from './tip-popup'
import {
Cursor02C,
Hand02,
} from '@/app/components/base/icons/src/vender/line/editor'
import {
Cursor02C as Cursor02CSolid,
Hand02 as Hand02Solid,
} from '@/app/components/base/icons/src/vender/solid/editor'
import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout'
const Control = () => {
const { t } = useTranslation()
const controlMode = useStore(s => s.controlMode)
const setControlMode = useStore(s => s.setControlMode)
const { handleLayout } = useWorkflow()
const {
nodesReadOnly,
getNodesReadOnly,
} = useNodesReadOnly()
const goLayout = () => {
if (getNodesReadOnly())
return
handleLayout()
}
return (
<div className='flex items-center p-0.5 rounded-lg border-[0.5px] border-gray-100 bg-white shadow-lg text-gray-500'>
<AddBlock />
<div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
<TipPopup title={t('workflow.common.pointerMode')}>
<div
className={cn(
'flex items-center justify-center mr-[1px] w-8 h-8 rounded-lg cursor-pointer',
controlMode === 'pointer' ? 'bg-primary-50 text-primary-600' : 'hover:bg-black/5 hover:text-gray-700',
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
)}
onClick={() => setControlMode('pointer')}
>
{
controlMode === 'pointer' ? <Cursor02CSolid className='w-4 h-4' /> : <Cursor02C className='w-4 h-4' />
}
</div>
</TipPopup>
<TipPopup title={t('workflow.common.handMode')}>
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer',
controlMode === 'hand' ? 'bg-primary-50 text-primary-600' : 'hover:bg-black/5 hover:text-gray-700',
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
)}
onClick={() => setControlMode('hand')}
>
{
controlMode === 'hand' ? <Hand02Solid className='w-4 h-4' /> : <Hand02 className='w-4 h-4' />
}
</div>
</TipPopup>
<div className='mx-[3px] w-[1px] h-3.5 bg-gray-200'></div>
<TipPopup title={t('workflow.panel.organizeBlocks')}>
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-lg hover:bg-black/5 hover:text-gray-700 cursor-pointer',
`${nodesReadOnly && '!cursor-not-allowed opacity-50'}`,
)}
onClick={goLayout}
>
<OrganizeGrid className='w-4 h-4' />
</div>
</TipPopup>
</div>
)
}
export default memo(Control)

View File

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

View File

@ -0,0 +1,34 @@
import { memo } from 'react'
import ShortcutsName from '../shortcuts-name'
import TooltipPlus from '@/app/components/base/tooltip-plus'
type TipPopupProps = {
title: string
children: React.ReactNode
shortcuts?: string[]
}
const TipPopup = ({
title,
children,
shortcuts,
}: TipPopupProps) => {
return (
<TooltipPlus
offset={4}
hideArrow
popupClassName='!p-0 !bg-gray-25'
popupContent={
<div className='flex items-center gap-1 px-2 h-6 text-xs font-medium text-gray-700 rounded-lg border-[0.5px] border-black/5'>
{title}
{
shortcuts && <ShortcutsName keys={shortcuts} className='!text-[11px]' />
}
</div>
}
>
{children}
</TooltipPlus>
)
}
export default memo(TipPopup)

View File

@ -5,6 +5,8 @@ import {
useCallback,
useState,
} from 'react'
import cn from 'classnames'
import { useKeyPress } from 'ahooks'
import { useTranslation } from 'react-i18next'
import {
useReactFlow,
@ -14,13 +16,32 @@ import {
useNodesSyncDraft,
useWorkflowReadOnly,
} from '../hooks'
import {
getKeyboardKeyCodeBySystem,
getKeyboardKeyNameBySystem,
} from '../utils'
import ShortcutsName from '../shortcuts-name'
import TipPopup from './tip-popup'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { SearchLg } from '@/app/components/base/icons/src/vender/line/general'
import { ChevronDown } from '@/app/components/base/icons/src/vender/line/arrows'
import {
ZoomIn,
ZoomOut,
} from '@/app/components/base/icons/src/vender/line/editor'
enum ZoomType {
zoomIn = 'zoomIn',
zoomOut = 'zoomOut',
zoomToFit = 'zoomToFit',
zoomTo25 = 'zoomTo25',
zoomTo50 = 'zoomTo50',
zoomTo75 = 'zoomTo75',
zoomTo100 = 'zoomTo100',
zoomTo200 = 'zoomTo200',
}
const ZoomInOut: FC = () => {
const { t } = useTranslation()
@ -41,27 +62,29 @@ const ZoomInOut: FC = () => {
const ZOOM_IN_OUT_OPTIONS = [
[
{
key: 'in',
text: t('workflow.operator.zoomIn'),
key: ZoomType.zoomTo200,
text: '200%',
},
{
key: 'out',
text: t('workflow.operator.zoomOut'),
key: ZoomType.zoomTo100,
text: '100%',
},
{
key: ZoomType.zoomTo75,
text: '75%',
},
{
key: ZoomType.zoomTo50,
text: '50%',
},
{
key: ZoomType.zoomTo25,
text: '25%',
},
],
[
{
key: 'to50',
text: t('workflow.operator.zoomTo50'),
},
{
key: 'to100',
text: t('workflow.operator.zoomTo100'),
},
],
[
{
key: 'fit',
key: ZoomType.zoomToFit,
text: t('workflow.operator.zoomToFit'),
},
],
@ -71,24 +94,99 @@ const ZoomInOut: FC = () => {
if (workflowReadOnly)
return
if (type === 'in')
zoomIn()
if (type === 'out')
zoomOut()
if (type === 'fit')
if (type === ZoomType.zoomToFit)
fitView()
if (type === 'to50')
if (type === ZoomType.zoomTo25)
zoomTo(0.25)
if (type === ZoomType.zoomTo50)
zoomTo(0.5)
if (type === 'to100')
if (type === ZoomType.zoomTo75)
zoomTo(0.75)
if (type === ZoomType.zoomTo100)
zoomTo(1)
if (type === ZoomType.zoomTo200)
zoomTo(2)
handleSyncWorkflowDraft()
}
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
e.preventDefault()
if (workflowReadOnly)
return
fitView()
handleSyncWorkflowDraft()
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('shift.1', (e) => {
e.preventDefault()
if (workflowReadOnly)
return
zoomTo(1)
handleSyncWorkflowDraft()
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('shift.2', (e) => {
e.preventDefault()
if (workflowReadOnly)
return
zoomTo(2)
handleSyncWorkflowDraft()
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress('shift.5', (e) => {
e.preventDefault()
if (workflowReadOnly)
return
zoomTo(0.5)
handleSyncWorkflowDraft()
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
e.preventDefault()
if (workflowReadOnly)
return
zoomOut()
handleSyncWorkflowDraft()
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
e.preventDefault()
if (workflowReadOnly)
return
zoomIn()
handleSyncWorkflowDraft()
}, {
exactMatch: true,
useCapture: true,
})
const handleTrigger = useCallback(() => {
if (getWorkflowReadOnly())
return
@ -108,17 +206,47 @@ const ZoomInOut: FC = () => {
>
<PortalToFollowElemTrigger asChild onClick={handleTrigger}>
<div className={`
flex items-center px-2 h-8 cursor-pointer text-[13px] hover:bg-gray-50 rounded-lg
${open && 'bg-gray-50'}
p-0.5 h-9 cursor-pointer text-[13px] text-gray-500 font-medium rounded-lg bg-white shadow-lg border-[0.5px] border-gray-100
${workflowReadOnly && '!cursor-not-allowed opacity-50'}
`}>
<SearchLg className='mr-1 w-4 h-4' />
<div className='w-[34px]'>{parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
<ChevronDown className='ml-1 w-4 h-4' />
<div className={cn(
'flex items-center justify-between w-[98px] h-8 hover:bg-gray-50 rounded-lg',
open && 'bg-gray-50',
)}>
<TipPopup
title={t('workflow.operator.zoomOut')}
shortcuts={['ctrl', '-']}
>
<div
className='flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer hover:bg-black/5'
onClick={(e) => {
e.stopPropagation()
zoomOut()
}}
>
<ZoomOut className='w-4 h-4' />
</div>
</TipPopup>
<div className='w-[34px]'>{parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
<TipPopup
title={t('workflow.operator.zoomIn')}
shortcuts={['ctrl', '+']}
>
<div
className='flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer hover:bg-black/5'
onClick={(e) => {
e.stopPropagation()
zoomIn()
}}
>
<ZoomIn className='w-4 h-4' />
</div>
</TipPopup>
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[168px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'>
<div className='w-[145px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'>
{
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
<Fragment key={i}>
@ -132,10 +260,30 @@ const ZoomInOut: FC = () => {
options.map(option => (
<div
key={option.key}
className='flex items-center px-3 h-8 rounded-lg hover:bg-gray-50 cursor-pointer text-sm text-gray-700'
className='flex items-center justify-between px-3 h-8 rounded-lg hover:bg-gray-50 cursor-pointer text-sm text-gray-700'
onClick={() => handleZoom(option.key)}
>
{option.text}
{
option.key === ZoomType.zoomToFit && (
<ShortcutsName keys={[`${getKeyboardKeyNameBySystem('ctrl')}`, '1']} />
)
}
{
option.key === ZoomType.zoomTo50 && (
<ShortcutsName keys={['shift', '5']} />
)
}
{
option.key === ZoomType.zoomTo100 && (
<ShortcutsName keys={['shift', '1']} />
)
}
{
option.key === ZoomType.zoomTo200 && (
<ShortcutsName keys={['shift', '2']} />
)
}
</div>
))
}

View File

@ -0,0 +1,123 @@
import {
memo,
useRef,
} from 'react'
import cn from 'classnames'
import { useTranslation } from 'react-i18next'
import { useClickAway } from 'ahooks'
import ShortcutsName from './shortcuts-name'
import { useStore } from './store'
import {
useNodesInteractions,
usePanelInteractions,
useWorkflowStartRun,
} from './hooks'
import AddBlock from './operator/add-block'
import { exportAppConfig } from '@/service/apps'
import { useToastContext } from '@/app/components/base/toast'
import { useStore as useAppStore } from '@/app/components/app/store'
const PanelContextmenu = () => {
const { t } = useTranslation()
const { notify } = useToastContext()
const ref = useRef(null)
const panelMenu = useStore(s => s.panelMenu)
const clipboardElements = useStore(s => s.clipboardElements)
const appDetail = useAppStore(s => s.appDetail)
const { handleNodesPaste } = useNodesInteractions()
const { handlePaneContextmenuCancel } = usePanelInteractions()
const { handleStartWorkflowRun } = useWorkflowStartRun()
useClickAway(() => {
handlePaneContextmenuCancel()
}, ref)
const onExport = async () => {
if (!appDetail)
return
try {
const { data } = await exportAppConfig(appDetail.id)
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
a.href = URL.createObjectURL(file)
a.download = `${appDetail.name}.yml`
a.click()
}
catch (e) {
notify({ type: 'error', message: t('app.exportFailed') })
}
}
const renderTrigger = () => {
return (
<div
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
>
{t('workflow.common.addBlock')}
</div>
)
}
if (!panelMenu)
return null
return (
<div
className='absolute w-[200px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-xl z-[9]'
style={{
left: panelMenu.left,
top: panelMenu.top,
}}
ref={ref}
>
<div className='p-1'>
<AddBlock
renderTrigger={renderTrigger}
offset={{
mainAxis: -36,
crossAxis: -4,
}}
/>
<div
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
onClick={() => {
handleStartWorkflowRun()
handlePaneContextmenuCancel()
}}
>
{t('workflow.common.run')}
<ShortcutsName keys={['alt', 'r']} />
</div>
</div>
<div className='h-[1px] bg-gray-100'></div>
<div className='p-1'>
<div
className={cn(
'flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer',
!clipboardElements.length ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50',
)}
onClick={() => {
if (clipboardElements.length) {
handleNodesPaste()
handlePaneContextmenuCancel()
}
}}
>
{t('workflow.common.pasteHere')}
<ShortcutsName keys={['ctrl', 'v']} />
</div>
</div>
<div className='h-[1px] bg-gray-100'></div>
<div className='p-1'>
<div
className='flex items-center justify-between px-3 h-8 text-sm text-gray-700 rounded-lg cursor-pointer hover:bg-gray-50'
onClick={() => onExport()}
>
{t('app.export')}
</div>
</div>
</div>
)
}
export default memo(PanelContextmenu)

View File

@ -0,0 +1,32 @@
import { memo } from 'react'
import cn from 'classnames'
import { getKeyboardKeyNameBySystem } from './utils'
type ShortcutsNameProps = {
keys: string[]
className?: string
}
const ShortcutsName = ({
keys,
className,
}: ShortcutsNameProps) => {
return (
<div className={cn(
'flex items-center gap-0.5 h-4 text-xs text-gray-400',
className,
)}>
{
keys.map(key => (
<div
key={key}
className='capitalize'
>
{getKeyboardKeyNameBySystem(key)}
</div>
))
}
</div>
)
}
export default memo(ShortcutsName)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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