This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
import {
memo,
useMemo,
} from 'react'
import type { EdgeProps } from 'reactflow'
import {
BaseEdge,
Position,
getBezierPath,
} from 'reactflow'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import { getEdgeColor } from '@/app/components/workflow/utils'
import CustomEdgeLinearGradientRender from '@/app/components/workflow/custom-edge-linear-gradient-render'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
const CustomEdge = ({
id,
data,
sourceHandleId,
sourceX,
sourceY,
targetX,
targetY,
selected,
}: EdgeProps) => {
const [
edgePath,
] = getBezierPath({
sourceX: sourceX - 8,
sourceY,
sourcePosition: Position.Right,
targetX: targetX + 8,
targetY,
targetPosition: Position.Left,
curvature: 0.16,
})
const {
_sourceRunningStatus,
_targetRunningStatus,
} = data
const linearGradientId = useMemo(() => {
if (
(
_sourceRunningStatus === NodeRunningStatus.Succeeded
|| _sourceRunningStatus === NodeRunningStatus.Failed
|| _sourceRunningStatus === NodeRunningStatus.Exception
) && (
_targetRunningStatus === NodeRunningStatus.Succeeded
|| _targetRunningStatus === NodeRunningStatus.Failed
|| _targetRunningStatus === NodeRunningStatus.Exception
|| _targetRunningStatus === NodeRunningStatus.Running
)
)
return id
}, [_sourceRunningStatus, _targetRunningStatus, id])
const stroke = useMemo(() => {
if (selected)
return getEdgeColor(NodeRunningStatus.Running)
if (linearGradientId)
return `url(#${linearGradientId})`
if (data?._connectedNodeIsHovering)
return getEdgeColor(NodeRunningStatus.Running, sourceHandleId === ErrorHandleTypeEnum.failBranch)
return getEdgeColor()
}, [data._connectedNodeIsHovering, linearGradientId, selected, sourceHandleId])
return (
<>
{
linearGradientId && (
<CustomEdgeLinearGradientRender
id={linearGradientId}
startColor={getEdgeColor(_sourceRunningStatus)}
stopColor={getEdgeColor(_targetRunningStatus)}
position={{
x1: sourceX,
y1: sourceY,
x2: targetX,
y2: targetY,
}}
/>
)
}
<BaseEdge
id={id}
path={edgePath}
style={{
stroke,
strokeWidth: 2,
opacity: data._waitingRun ? 0.7 : 1,
}}
/>
</>
)
}
export default memo(CustomEdge)

View File

@@ -0,0 +1,66 @@
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { useUpdateNodeInternals } from 'reactflow'
import { NodeSourceHandle } from './node-handle'
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
import type { Node } from '@/app/components/workflow/types'
import { NodeRunningStatus } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type ErrorHandleOnNodeProps = Pick<Node, 'id' | 'data'>
const ErrorHandleOnNode = ({
id,
data,
}: ErrorHandleOnNodeProps) => {
const { t } = useTranslation()
const { error_strategy } = data
const updateNodeInternals = useUpdateNodeInternals()
useEffect(() => {
if (error_strategy === ErrorHandleTypeEnum.failBranch)
updateNodeInternals(id)
}, [error_strategy, id, updateNodeInternals])
if (!error_strategy)
return null
return (
<div className='relative px-3 pb-2 pt-1'>
<div className={cn(
'relative flex h-6 items-center justify-between rounded-md bg-workflow-block-parma-bg px-[5px]',
data._runningStatus === NodeRunningStatus.Exception && 'border-[0.5px] border-components-badge-status-light-warning-halo bg-state-warning-hover',
)}>
<div className='system-xs-medium-uppercase text-text-tertiary'>
{t('workflow.common.onFailure')}
</div>
<div className={cn(
'system-xs-medium text-text-secondary',
data._runningStatus === NodeRunningStatus.Exception && 'text-text-warning',
)}>
{
error_strategy === ErrorHandleTypeEnum.defaultValue && (
t('workflow.nodes.common.errorHandle.defaultValue.output')
)
}
{
error_strategy === ErrorHandleTypeEnum.failBranch && (
t('workflow.nodes.common.errorHandle.failBranch.title')
)
}
</div>
{
error_strategy === ErrorHandleTypeEnum.failBranch && (
<NodeSourceHandle
id={id}
data={data}
handleId={ErrorHandleTypeEnum.failBranch}
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2 after:!bg-workflow-link-line-failure-button-bg'
/>
)
}
</div>
</div>
)
}
export default ErrorHandleOnNode

View File

@@ -0,0 +1,73 @@
import {
memo,
} from 'react'
import {
Handle,
Position,
} from 'reactflow'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import type { Node } from '@/app/components/workflow/types'
import cn from '@/utils/classnames'
type NodeHandleProps = {
handleId: string
handleClassName?: string
} & Pick<Node, 'id' | 'data'>
export const NodeTargetHandle = memo(({
data,
handleId,
handleClassName,
}: NodeHandleProps) => {
const connected = data._connectedTargetHandleIds?.includes(handleId)
return (
<>
<Handle
id={handleId}
type='target'
position={Position.Left}
className={cn(
'z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:left-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
!connected && 'after:opacity-0',
(data.type === BlockEnum.Start
|| data.type === BlockEnum.TriggerWebhook
|| data.type === BlockEnum.TriggerSchedule
|| data.type === BlockEnum.TriggerPlugin) && 'opacity-0',
handleClassName,
)}
>
</Handle>
</>
)
})
NodeTargetHandle.displayName = 'NodeTargetHandle'
export const NodeSourceHandle = memo(({
data,
handleId,
handleClassName,
}: NodeHandleProps) => {
const connected = data._connectedSourceHandleIds?.includes(handleId)
return (
<Handle
id={handleId}
type='source'
position={Position.Right}
className={cn(
'group/handle z-[1] !h-4 !w-4 !rounded-none !border-none !bg-transparent !outline-none',
'after:absolute after:right-1.5 after:top-1 after:h-2 after:w-0.5 after:bg-workflow-link-line-handle',
'transition-all hover:scale-125',
!connected && 'after:opacity-0',
handleClassName,
)}
>
</Handle>
)
})
NodeSourceHandle.displayName = 'NodeSourceHandle'

View File

@@ -0,0 +1,141 @@
import type {
ReactElement,
} from 'react'
import {
cloneElement,
memo,
} from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import BlockIcon from '@/app/components/workflow/block-icon'
import type {
NodeProps,
} from '@/app/components/workflow/types'
import {
BlockEnum,
} from '@/app/components/workflow/types'
import { hasErrorHandleNode } from '@/app/components/workflow/utils'
import Tooltip from '@/app/components/base/tooltip'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
import {
NodeSourceHandle,
NodeTargetHandle,
} from '../node-handle'
import ErrorHandleOnNode from '../error-handle-on-node'
type NodeChildElement = ReactElement<Partial<NodeProps>>
type NodeCardProps = NodeProps & {
children?: NodeChildElement
}
const BaseCard = ({
id,
data,
children,
}: NodeCardProps) => {
const { t } = useTranslation()
return (
<div
className={cn(
'flex rounded-2xl border-[2px] border-transparent',
)}
style={{
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : 'auto',
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
}}
>
<div
className={cn(
'group relative pb-1 shadow-xs',
'rounded-[15px] border border-transparent',
'bg-workflow-block-bg hover:shadow-lg',
)}
style={{
width: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.width : '240px',
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
}}
>
<div className={cn(
'flex items-center rounded-t-2xl px-3 pb-2 pt-3',
)}>
<NodeTargetHandle
id={id}
data={data}
handleClassName='!top-4 !-left-[9px] !translate-y-0'
handleId='target'
/>
{
data.type !== BlockEnum.IfElse && data.type !== BlockEnum.QuestionClassifier && (
<NodeSourceHandle
id={id}
data={data}
handleClassName='!top-4 !-right-[9px] !translate-y-0'
handleId='source'
/>
)
}
<BlockIcon
className='mr-2 shrink-0'
type={data.type}
size='md'
/>
<div
title={data.title}
className='system-sm-semibold-uppercase mr-1 flex grow items-center truncate text-text-primary'
>
<div>
{data.title}
</div>
{
data.type === BlockEnum.Iteration && (data as IterationNodeType).is_parallel && (
<Tooltip popupContent={
<div className='w-[180px]'>
<div className='font-extrabold'>
{t('workflow.nodes.iteration.parallelModeEnableTitle')}
</div>
{t('workflow.nodes.iteration.parallelModeEnableDesc')}
</div>}
>
<div className='system-2xs-medium-uppercase ml-1 flex items-center justify-center rounded-[5px] border-[1px] border-text-warning px-[5px] py-[3px] text-text-warning '>
{t('workflow.nodes.iteration.parallelModeUpper')}
</div>
</Tooltip>
)
}
</div>
</div>
{
data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && children && (
cloneElement(children, { id, data })
)
}
{
(data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) && children && (
<div className='h-[calc(100%-42px)] w-full grow pb-1 pl-1 pr-1'>
{cloneElement(children, { id, data })}
</div>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnNode
id={id}
data={data}
/>
)
}
{
data.desc && data.type !== BlockEnum.Iteration && data.type !== BlockEnum.Loop && (
<div className='system-xs-regular whitespace-pre-line break-words px-3 pb-2 pt-1 text-text-tertiary'>
{data.desc}
</div>
)
}
</div>
</div>
)
}
export default memo(BaseCard)

View File

@@ -0,0 +1,12 @@
import { BlockEnum } from '@/app/components/workflow/types'
import QuestionClassifierNode from './question-classifier/node'
import IfElseNode from './if-else/node'
import IterationNode from './iteration/node'
import LoopNode from './loop/node'
export const NodeComponentMap: Record<string, any> = {
[BlockEnum.QuestionClassifier]: QuestionClassifierNode,
[BlockEnum.IfElse]: IfElseNode,
[BlockEnum.Iteration]: IterationNode,
[BlockEnum.Loop]: LoopNode,
}

View File

@@ -0,0 +1,103 @@
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import { NodeSourceHandle } from '../../node-handle'
import { isEmptyRelatedOperator } from '@/app/components/workflow/nodes/if-else/utils'
import type { Condition, IfElseNodeType } from '@/app/components/workflow/nodes/if-else/types'
import ConditionValue from '@/app/components/workflow/nodes/if-else/components/condition-value'
import ConditionFilesListValue from '@/app/components/workflow/nodes/if-else/components/condition-files-list-value'
const i18nPrefix = 'workflow.nodes.ifElse'
const IfElseNode: FC<NodeProps<IfElseNodeType>> = (props) => {
const { data } = props
const { t } = useTranslation()
const { cases } = data
const casesLength = cases.length
const checkIsConditionSet = useCallback((condition: Condition) => {
if (!condition.variable_selector || condition.variable_selector.length === 0)
return false
if (condition.sub_variable_condition) {
const isSet = condition.sub_variable_condition.conditions.every((c) => {
if (!c.comparison_operator)
return false
if (isEmptyRelatedOperator(c.comparison_operator!))
return true
return !!c.value
})
return isSet
}
else {
if (isEmptyRelatedOperator(condition.comparison_operator!))
return true
return !!condition.value
}
}, [])
const conditionNotSet = (<div className='flex h-6 items-center space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary'>
{t(`${i18nPrefix}.conditionNotSetup`)}
</div>)
return (
<div className='px-3'>
{
cases.map((caseItem, index) => (
<div key={caseItem.case_id}>
<div className='relative flex h-6 items-center px-1'>
<div className='flex w-full items-center justify-between'>
<div className='text-[10px] font-semibold text-text-tertiary'>
{casesLength > 1 && `CASE ${index + 1}`}
</div>
<div className='text-[12px] font-semibold text-text-secondary'>{index === 0 ? 'IF' : 'ELIF'}</div>
</div>
<NodeSourceHandle
{...props}
handleId={caseItem.case_id}
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2'
/>
</div>
<div className='space-y-0.5'>
{caseItem.conditions.map((condition, i) => (
<div key={condition.id} className='relative'>
{
checkIsConditionSet(condition)
? (
(!isEmptyRelatedOperator(condition.comparison_operator!) && condition.sub_variable_condition)
? (
<ConditionFilesListValue condition={condition} />
)
: (
<ConditionValue
variableSelector={condition.variable_selector!}
operator={condition.comparison_operator!}
value={condition.value}
/>
)
)
: conditionNotSet}
{i !== caseItem.conditions.length - 1 && (
<div className='absolute bottom-[-10px] right-1 z-10 text-[10px] font-medium uppercase leading-4 text-text-accent'>{t(`${i18nPrefix}.${caseItem.logical_operator}`)}</div>
)}
</div>
))}
</div>
</div>
))
}
<div className='relative flex h-6 items-center px-1'>
<div className='w-full text-right text-xs font-semibold text-text-secondary'>ELSE</div>
<NodeSourceHandle
{...props}
handleId='false'
handleClassName='!top-1/2 !-right-[21px] !-translate-y-1/2'
/>
</div>
</div>
)
}
export default React.memo(IfElseNode)

View File

@@ -0,0 +1,18 @@
import type { NodeProps } from 'reactflow'
import BaseNode from './base'
import { NodeComponentMap } from './constants'
const CustomNode = (props: NodeProps) => {
const nodeData = props.data
const NodeComponent = NodeComponentMap[nodeData.type]
return (
<>
<BaseNode { ...props }>
{ NodeComponent && <NodeComponent /> }
</BaseNode>
</>
)
}
export default CustomNode

View File

@@ -0,0 +1,28 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import { RiHome5Fill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '../../node-handle'
const IterationStartNode = ({ id, data }: NodeProps) => {
const { t } = useTranslation()
return (
<div className='nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg shadow-xs'>
<Tooltip popupContent={t('workflow.blocks.iteration-start')} asChild={false}>
<div className='flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
<RiHome5Fill className='h-3 w-3 text-text-primary-on-surface' />
</div>
</Tooltip>
<NodeSourceHandle
id={id}
data={data}
handleClassName='!top-1/2 !-right-[9px] !-translate-y-1/2'
handleId='source'
/>
</div>
)
}
export default memo(IterationStartNode)

View File

@@ -0,0 +1,33 @@
import type { FC } from 'react'
import {
memo,
} from 'react'
import {
Background,
useViewport,
} from 'reactflow'
import type { IterationNodeType } from '@/app/components/workflow/nodes/iteration/types'
import cn from '@/utils/classnames'
import type { NodeProps } from '@/app/components/workflow/types'
const Node: FC<NodeProps<IterationNodeType>> = ({
id,
}) => {
const { zoom } = useViewport()
return (
<div className={cn(
'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg',
)}>
<Background
id={`iteration-background-${id}`}
className='!z-0 rounded-2xl'
gap={[14 / zoom, 14 / zoom]}
size={2 / zoom}
color='var(--color-workflow-canvas-workflow-dot-color)'
/>
</div>
)
}
export default memo(Node)

View File

@@ -0,0 +1,28 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import { RiHome5Fill } from '@remixicon/react'
import Tooltip from '@/app/components/base/tooltip'
import { NodeSourceHandle } from '../../node-handle'
const LoopStartNode = ({ id, data }: NodeProps) => {
const { t } = useTranslation()
return (
<div className='nodrag group mt-1 flex h-11 w-11 items-center justify-center rounded-2xl border border-workflow-block-border bg-workflow-block-bg'>
<Tooltip popupContent={t('workflow.blocks.loop-start')} asChild={false}>
<div className='flex h-6 w-6 items-center justify-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-util-colors-blue-brand-blue-brand-500'>
<RiHome5Fill className='h-3 w-3 text-text-primary-on-surface' />
</div>
</Tooltip>
<NodeSourceHandle
id={id}
data={data}
handleClassName='!top-1/2 !-right-[9px] !-translate-y-1/2'
handleId='source'
/>
</div>
)
}
export default memo(LoopStartNode)

View File

@@ -0,0 +1,69 @@
import { useCallback } from 'react'
import { produce } from 'immer'
import { useStoreApi } from 'reactflow'
import type {
Node,
} from '@/app/components/workflow/types'
import {
LOOP_PADDING,
} from '@/app/components/workflow/constants'
export const useNodeLoopInteractions = () => {
const store = useStoreApi()
const handleNodeLoopRerender = useCallback((nodeId: string) => {
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
const currentNode = nodes.find(n => n.id === nodeId)!
const childrenNodes = nodes.filter(n => n.parentId === nodeId)
let rightNode: Node
let bottomNode: Node
childrenNodes.forEach((n) => {
if (rightNode) {
if (n.position.x + n.width! > rightNode.position.x + rightNode.width!)
rightNode = n
}
else {
rightNode = n
}
if (bottomNode) {
if (n.position.y + n.height! > bottomNode.position.y + bottomNode.height!)
bottomNode = n
}
else {
bottomNode = n
}
})
const widthShouldExtend = rightNode! && currentNode.width! < rightNode.position.x + rightNode.width!
const heightShouldExtend = bottomNode! && currentNode.height! < bottomNode.position.y + bottomNode.height!
if (widthShouldExtend || heightShouldExtend) {
const newNodes = produce(nodes, (draft) => {
draft.forEach((n) => {
if (n.id === nodeId) {
if (widthShouldExtend) {
n.data.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
n.width = rightNode.position.x + rightNode.width! + LOOP_PADDING.right
}
if (heightShouldExtend) {
n.data.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
n.height = bottomNode.position.y + bottomNode.height! + LOOP_PADDING.bottom
}
}
})
})
setNodes(newNodes)
}
}, [store])
return {
handleNodeLoopRerender,
}
}

View File

@@ -0,0 +1,48 @@
import type { FC } from 'react'
import {
memo,
useEffect,
} from 'react'
import {
Background,
useNodesInitialized,
useViewport,
} from 'reactflow'
import type { LoopNodeType } from '@/app/components/workflow/nodes/loop/types'
import cn from '@/utils/classnames'
import type { NodeProps } from '@/app/components/workflow/types'
import { useNodeLoopInteractions } from './hooks'
const Node: FC<NodeProps<LoopNodeType>> = ({
id,
data: _data,
}) => {
const { zoom } = useViewport()
const nodesInitialized = useNodesInitialized()
const { handleNodeLoopRerender } = useNodeLoopInteractions()
useEffect(() => {
if (nodesInitialized)
handleNodeLoopRerender(id)
}, [nodesInitialized, id, handleNodeLoopRerender])
return (
<div className={cn(
'relative h-full min-h-[90px] w-full min-w-[240px] rounded-2xl bg-workflow-canvas-workflow-bg',
)}
// style={{
// width: data.width || 'auto',
// }}
>
<Background
id={`loop-background-${id}`}
className='!z-0 rounded-2xl'
gap={[14 / zoom, 14 / zoom]}
size={2 / zoom}
color='var(--color-workflow-canvas-workflow-dot-color)'
/>
</div>
)
}
export default memo(Node)

View File

@@ -0,0 +1,44 @@
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import InfoPanel from '@/app/components/workflow/nodes/_base/components/info-panel'
import type { QuestionClassifierNodeType } from '@/app/components/workflow/nodes/question-classifier/types'
import { NodeSourceHandle } from '../../node-handle'
const i18nPrefix = 'workflow.nodes.questionClassifiers'
const Node: FC<NodeProps<QuestionClassifierNodeType>> = (props) => {
const { t } = useTranslation()
const { data } = props
const topics = data.classes
return (
<div className='mb-1 px-3 py-1'>
{
!!topics.length && (
<div className='mt-2 space-y-0.5'>
{topics.map((topic, index) => (
<div
key={index}
className='relative'
>
<InfoPanel
title={`${t(`${i18nPrefix}.class`)} ${index + 1}`}
content={''}
/>
<NodeSourceHandle
{...props}
handleId={topic.id}
handleClassName='!top-1/2 !-translate-y-1/2 !-right-[21px]'
/>
</div>
))}
</div>
)
}
</div>
)
}
export default React.memo(Node)

View File

@@ -0,0 +1,68 @@
import {
memo,
useRef,
} from 'react'
import { useTranslation } from 'react-i18next'
import type { NodeProps } from 'reactflow'
import {
NoteEditor,
NoteEditorContextProvider,
} from '@/app/components/workflow/note-node/note-editor'
import { THEME_MAP } from '@/app/components/workflow/note-node/constants'
import type { NoteNodeType } from '@/app/components/workflow/note-node/types'
import cn from '@/utils/classnames'
const NoteNode = ({
data,
}: NodeProps<NoteNodeType>) => {
const { t } = useTranslation()
const ref = useRef<HTMLDivElement | null>(null)
const theme = data.theme
return (
<div
className={cn(
'relative flex flex-col rounded-md border shadow-xs hover:shadow-md',
THEME_MAP[theme].bg,
data.selected ? THEME_MAP[theme].border : 'border-black/5',
)}
style={{
width: data.width,
height: data.height,
}}
ref={ref}
>
<NoteEditorContextProvider
value={data.text}
editable={false}
>
<>
<div
className={cn(
'h-2 shrink-0 rounded-t-md opacity-50',
THEME_MAP[theme].title,
)}></div>
<div className='grow overflow-y-auto px-3 py-2.5'>
<div className={cn(
data.selected && 'nodrag nopan nowheel cursor-text',
)}>
<NoteEditor
containerElement={ref.current}
placeholder={t('workflow.nodes.note.editor.placeholder') || ''}
/>
</div>
</div>
{
data.showAuthor && (
<div className='p-3 pt-0 text-xs text-text-tertiary'>
{data.author}
</div>
)
}
</>
</NoteEditorContextProvider>
</div>
)
}
export default memo(NoteNode)

View File

@@ -0,0 +1,212 @@
import type { FC } from 'react'
import {
Fragment,
memo,
useCallback,
useState,
} from 'react'
import {
RiZoomInLine,
RiZoomOutLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useReactFlow,
useViewport,
} from 'reactflow'
import ShortcutsName from '@/app/components/workflow/shortcuts-name'
import Divider from '@/app/components/base/divider'
import TipPopup from '@/app/components/workflow/operator/tip-popup'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
enum ZoomType {
zoomIn = 'zoomIn',
zoomOut = 'zoomOut',
zoomToFit = 'zoomToFit',
zoomTo25 = 'zoomTo25',
zoomTo50 = 'zoomTo50',
zoomTo75 = 'zoomTo75',
zoomTo100 = 'zoomTo100',
zoomTo200 = 'zoomTo200',
}
const ZoomInOut: FC = () => {
const { t } = useTranslation()
const {
zoomIn,
zoomOut,
zoomTo,
fitView,
} = useReactFlow()
const { zoom } = useViewport()
const [open, setOpen] = useState(false)
const ZOOM_IN_OUT_OPTIONS = [
[
{
key: ZoomType.zoomTo200,
text: '200%',
},
{
key: ZoomType.zoomTo100,
text: '100%',
},
{
key: ZoomType.zoomTo75,
text: '75%',
},
{
key: ZoomType.zoomTo50,
text: '50%',
},
{
key: ZoomType.zoomTo25,
text: '25%',
},
],
[
{
key: ZoomType.zoomToFit,
text: t('workflow.operator.zoomToFit'),
},
],
]
const handleZoom = (type: string) => {
if (type === ZoomType.zoomToFit)
fitView()
if (type === ZoomType.zoomTo25)
zoomTo(0.25)
if (type === ZoomType.zoomTo50)
zoomTo(0.5)
if (type === ZoomType.zoomTo75)
zoomTo(0.75)
if (type === ZoomType.zoomTo100)
zoomTo(1)
if (type === ZoomType.zoomTo200)
zoomTo(2)
}
const handleTrigger = useCallback(() => {
setOpen(v => !v)
}, [])
return (
<PortalToFollowElem
placement='top-start'
open={open}
onOpenChange={setOpen}
offset={{
mainAxis: 4,
crossAxis: -2,
}}
>
<PortalToFollowElemTrigger asChild>
<div
className={cn(
'h-9 cursor-pointer rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg',
'p-0.5 text-[13px] shadow-lg backdrop-blur-[5px]',
'hover:bg-state-base-hover',
)}
>
<div className={cn(
'flex h-8 w-[98px] items-center justify-between rounded-lg',
)}>
<TipPopup
title={t('workflow.operator.zoomOut')}
shortcuts={['ctrl', '-']}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom <= 0.25 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
onClick={(e) => {
if (zoom <= 0.25)
return
e.stopPropagation()
zoomOut()
}}
>
<RiZoomOutLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
</div>
</TipPopup>
<div onClick={handleTrigger} className={cn('system-sm-medium w-[34px] text-text-tertiary hover:text-text-secondary')}>{Number.parseFloat(`${zoom * 100}`).toFixed(0)}%</div>
<TipPopup
title={t('workflow.operator.zoomIn')}
shortcuts={['ctrl', '+']}
>
<div
className={`flex h-8 w-8 items-center justify-center rounded-lg ${zoom >= 2 ? 'cursor-not-allowed' : 'cursor-pointer hover:bg-black/5'}`}
onClick={(e) => {
if (zoom >= 2)
return
e.stopPropagation()
zoomIn()
}}
>
<RiZoomInLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
</div>
</TipPopup>
</div>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-10'>
<div className='w-[145px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
{
ZOOM_IN_OUT_OPTIONS.map((options, i) => (
<Fragment key={i}>
{
i !== 0 && (
<Divider className='m-0' />
)
}
<div className='p-1'>
{
options.map(option => (
<div
key={option.key}
className='system-md-regular flex h-8 cursor-pointer items-center justify-between space-x-1 rounded-lg py-1.5 pl-3 pr-2 text-text-secondary hover:bg-state-base-hover'
onClick={() => handleZoom(option.key)}
>
<span>{option.text}</span>
<div className='flex items-center space-x-0.5'>
{
option.key === ZoomType.zoomToFit && (
<ShortcutsName keys={['ctrl', '1']} />
)
}
{
option.key === ZoomType.zoomTo50 && (
<ShortcutsName keys={['shift', '5']} />
)
}
{
option.key === ZoomType.zoomTo100 && (
<ShortcutsName keys={['shift', '1']} />
)
}
</div>
</div>
))
}
</div>
</Fragment>
))
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(ZoomInOut)

View File

@@ -0,0 +1,147 @@
'use client'
import {
useCallback,
useState,
} from 'react'
import ReactFlow, {
Background,
MiniMap,
ReactFlowProvider,
SelectionMode,
applyEdgeChanges,
applyNodeChanges,
} from 'reactflow'
import type {
EdgeChange,
NodeChange,
Viewport,
} from 'reactflow'
import 'reactflow/dist/style.css'
import '../style.css'
import { CUSTOM_ITERATION_START_NODE } from '@/app/components/workflow/nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '@/app/components/workflow/nodes/loop-start/constants'
import { CUSTOM_SIMPLE_NODE } from '@/app/components/workflow/simple-node/constants'
import CustomConnectionLine from '@/app/components/workflow/custom-connection-line'
import {
CUSTOM_EDGE,
CUSTOM_NODE,
ITERATION_CHILDREN_Z_INDEX,
} from '@/app/components/workflow/constants'
import cn from '@/utils/classnames'
import {
initialEdges,
initialNodes,
} from '@/app/components/workflow/utils/workflow-init'
import type {
Edge,
Node,
} from '@/app/components/workflow/types'
import { CUSTOM_NOTE_NODE } from '@/app/components/workflow/note-node/constants'
import CustomNode from './components/nodes'
import CustomEdge from './components/custom-edge'
import ZoomInOut from './components/zoom-in-out'
import IterationStartNode from './components/nodes/iteration-start'
import LoopStartNode from './components/nodes/loop-start'
import CustomNoteNode from './components/note-node'
const nodeTypes = {
[CUSTOM_NODE]: CustomNode,
[CUSTOM_NOTE_NODE]: CustomNoteNode,
[CUSTOM_SIMPLE_NODE]: CustomNode,
[CUSTOM_ITERATION_START_NODE]: IterationStartNode,
[CUSTOM_LOOP_START_NODE]: LoopStartNode,
}
const edgeTypes = {
[CUSTOM_EDGE]: CustomEdge,
}
type WorkflowPreviewProps = {
nodes: Node[]
edges: Edge[]
viewport: Viewport
className?: string
}
const WorkflowPreview = ({
nodes,
edges,
viewport,
className,
}: WorkflowPreviewProps) => {
const [nodesData, setNodesData] = useState(() => initialNodes(nodes, edges))
const [edgesData, setEdgesData] = useState(() => initialEdges(edges, nodes))
const onNodesChange = useCallback(
(changes: NodeChange[]) => setNodesData(nds => applyNodeChanges(changes, nds)),
[],
)
const onEdgesChange = useCallback(
(changes: EdgeChange[]) => setEdgesData(eds => applyEdgeChanges(changes, eds)),
[],
)
return (
<div
id='workflow-container'
className={cn(
'relative h-full w-full',
className,
)}
>
<>
<MiniMap
pannable
zoomable
style={{
width: 102,
height: 72,
}}
maskColor='var(--color-workflow-minimap-bg)'
className='!absolute !bottom-14 !left-4 z-[9] !m-0 !h-[72px] !w-[102px] !rounded-lg !border-[0.5px]
!border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5'
/>
<div className='absolute bottom-4 left-4 z-[9] mt-1 flex items-center gap-2'>
<ZoomInOut />
</div>
</>
<ReactFlow
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
nodes={nodesData}
onNodesChange={onNodesChange}
edges={edgesData}
onEdgesChange={onEdgesChange}
connectionLineComponent={CustomConnectionLine}
connectionLineContainerStyle={{ zIndex: ITERATION_CHILDREN_Z_INDEX }}
defaultViewport={viewport}
multiSelectionKeyCode={null}
deleteKeyCode={null}
nodesDraggable
nodesConnectable={false}
nodesFocusable={false}
edgesFocusable={false}
panOnScroll={false}
selectionKeyCode={null}
selectionMode={SelectionMode.Partial}
minZoom={0.25}
>
<Background
gap={[14, 14]}
size={2}
className='bg-workflow-canvas-workflow-bg'
color='var(--color-workflow-canvas-workflow-dot-color)'
/>
</ReactFlow>
</div>
)
}
const WorkflowPreviewWrapper = (props: WorkflowPreviewProps) => {
return (
<ReactFlowProvider>
<WorkflowPreview {...props} />
</ReactFlowProvider>
)
}
export default WorkflowPreviewWrapper