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
54 changed files with 1955 additions and 431 deletions

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