Commit ea76f462 authored by StyleZhang's avatar StyleZhang

block-selector

parent 0759b29c
'use client'
import { useCallback, useRef, useState } from 'react'
import { createContext, useContext } from 'use-context-selector'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import {
FloatingPortal,
flip,
offset,
shift,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react'
import type { OnSelect } from './types'
import BlockSelector from './index'
type UpdateParams = {
from?: string
placement?: Placement
offset?: OffsetOptions
open?: boolean
className?: string
callback?: OnSelect
}
export type BlockSelectorContextValue = {
from: string
open: boolean
setOpen: (open: boolean) => void
referenceRef: any
floatingRef: any
floatingStyles: React.CSSProperties
getFloatingProps: any
handleToggle: (v: UpdateParams) => void
}
export const BlockSelectorContext = createContext<BlockSelectorContextValue>({
from: '',
open: false,
setOpen: () => {},
referenceRef: null,
floatingRef: null,
floatingStyles: {},
getFloatingProps: () => {},
handleToggle: () => {},
})
export const useBlockSelectorContext = () => useContext(BlockSelectorContext)
type BlockSelectorContextProviderProps = {
children: React.ReactNode
}
export const BlockSelectorContextProvider = ({
children,
}: BlockSelectorContextProviderProps) => {
const [from, setFrom] = useState('node')
const [open, setOpen] = useState(false)
const [placement, setPlacement] = useState<Placement>('top')
const [offsetValue, setOffsetValue] = useState<OffsetOptions>(0)
const [className, setClassName] = useState<string>('')
const callbackRef = useRef<OnSelect | undefined>(undefined)
const { refs, floatingStyles, context } = useFloating({
placement,
strategy: 'fixed',
open,
onOpenChange: setOpen,
middleware: [
flip(),
shift(),
offset(offsetValue),
],
})
const dismiss = useDismiss(context)
const { getFloatingProps } = useInteractions([
dismiss,
])
const handleToggle = useCallback(({
from,
open,
placement,
offset,
className,
callback,
}: UpdateParams) => {
setFrom(from || 'node')
if (open !== undefined)
setOpen(open)
else
setOpen(v => !v)
setPlacement(placement || 'top')
setOffsetValue(offset || 0)
setClassName(className || '')
callbackRef.current = callback
}, [])
const handleSelect = useCallback<OnSelect>((type) => {
if (callbackRef.current)
callbackRef.current(type)
setOpen(v => !v)
}, [])
return (
<BlockSelectorContext.Provider value={{
from,
open,
setOpen,
handleToggle,
referenceRef: refs.setReference,
floatingRef: refs.setFloating,
floatingStyles,
getFloatingProps,
}}>
{children}
{
open && (from === 'node' || from === 'panel') && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className='z-[1000]'
>
<BlockSelector
className={className}
onSelect={handleSelect}
/>
</div>
</FloatingPortal>
)
}
</BlockSelectorContext.Provider>
)
}
import type { FC } from 'react' import type { FC } from 'react'
import { memo } from 'react' import {
memo,
useState,
} from 'react'
import type {
OffsetOptions,
Placement,
} from '@floating-ui/react'
import type { BlockEnum } from '../types'
import Tabs from './tabs' import Tabs from './tabs'
import type { OnSelect } from './types' import {
import { SearchLg } from '@/app/components/base/icons/src/vender/line/general' PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
Plus02,
SearchLg,
} from '@/app/components/base/icons/src/vender/line/general'
type NodeSelectorProps = { type NodeSelectorProps = {
onSelect: OnSelect onSelect: (type: BlockEnum) => void
className?: string trigger?: (open: boolean) => React.ReactNode
placement?: Placement
offset?: OffsetOptions
popupClassName?: string
asChild?: boolean
} }
const NodeSelector: FC<NodeSelectorProps> = ({ const NodeSelector: FC<NodeSelectorProps> = ({
onSelect, onSelect,
className, trigger,
placement = 'right',
offset = 6,
popupClassName,
asChild,
}) => { }) => {
const [open, setOpen] = useState(false)
return ( return (
<div className={`w-[256px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg ${className}`}> <PortalToFollowElem
placement={placement}
offset={offset}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger asChild={asChild} onClick={() => setOpen(v => !v)}>
{
trigger
? trigger(open)
: (
<div
className={`
hidden absolute -right-2 top-4 items-center justify-center
w-4 h-4 rounded-full bg-primary-600 cursor-pointer z-10 group-hover:flex
${open && '!flex'}
`}
>
<Plus02 className='w-2.5 h-2.5 text-white' />
</div>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={`w-[256px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg ${popupClassName}`}>
<div className='px-2 pt-2'> <div className='px-2 pt-2'>
<div className='flex items-center px-2 rounded-lg bg-gray-100'> <div className='flex items-center px-2 rounded-lg bg-gray-100'>
<SearchLg className='shrink-0 ml-[1px] mr-[5px] w-3.5 h-3.5 text-gray-400' /> <SearchLg className='shrink-0 ml-[1px] mr-[5px] w-3.5 h-3.5 text-gray-400' />
...@@ -25,6 +73,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({ ...@@ -25,6 +73,8 @@ const NodeSelector: FC<NodeSelectorProps> = ({
</div> </div>
<Tabs onSelect={onSelect} /> <Tabs onSelect={onSelect} />
</div> </div>
</PortalToFollowElemContent>
</PortalToFollowElem>
) )
} }
......
...@@ -4,7 +4,7 @@ import { ...@@ -4,7 +4,7 @@ import {
useState, useState,
} from 'react' } from 'react'
import BlockIcon from '../block-icon' import BlockIcon from '../block-icon'
import type { OnSelect } from './types' import type { BlockEnum } from '../types'
import { import {
BLOCK_CLASSIFICATIONS, BLOCK_CLASSIFICATIONS,
BLOCK_GROUP_BY_CLASSIFICATION, BLOCK_GROUP_BY_CLASSIFICATION,
...@@ -12,7 +12,7 @@ import { ...@@ -12,7 +12,7 @@ import {
} from './constants' } from './constants'
export type TabsProps = { export type TabsProps = {
onSelect: OnSelect onSelect: (type: BlockEnum) => void
} }
const Tabs: FC<TabsProps> = ({ const Tabs: FC<TabsProps> = ({
onSelect, onSelect,
......
import type { BlockEnum } from '../types'
export type OnSelect = (type: BlockEnum) => void
...@@ -23,7 +23,6 @@ import DebugAndPreview from './debug-and-preview' ...@@ -23,7 +23,6 @@ import DebugAndPreview from './debug-and-preview'
import ZoomInOut from './zoom-in-out' import ZoomInOut from './zoom-in-out'
import CustomEdge from './custom-edge' import CustomEdge from './custom-edge'
import type { Node } from './types' import type { Node } from './types'
import { BlockSelectorContextProvider } from './block-selector/context'
const nodeTypes = { const nodeTypes = {
custom: CustomNode, custom: CustomNode,
...@@ -97,9 +96,7 @@ const WorkflowWrap: FC<WorkflowWrapProps> = ({ ...@@ -97,9 +96,7 @@ const WorkflowWrap: FC<WorkflowWrapProps> = ({
handleAddNextNode, handleAddNextNode,
handleUpdateNodeData, handleUpdateNodeData,
}}> }}>
<BlockSelectorContextProvider>
<Workflow /> <Workflow />
</BlockSelectorContextProvider>
</WorkflowContext.Provider> </WorkflowContext.Provider>
) )
} }
......
import { useState } from 'react'
import {
flip,
offset,
shift,
useDismiss,
useFloating,
useInteractions,
} from '@floating-ui/react'
export const useAddBranch = () => {
const [isOpen, setIsOpen] = useState(false)
const [dismissEnable, setDismissEnable] = useState(true)
const { refs, floatingStyles, context } = useFloating({
placement: 'bottom',
strategy: 'fixed',
open: isOpen,
onOpenChange: setIsOpen,
middleware: [
flip(),
shift(),
offset(4),
],
})
const dismiss = useDismiss(context, {
enabled: dismissEnable,
})
const { getFloatingProps } = useInteractions([
dismiss,
])
return {
refs,
floatingStyles,
getFloatingProps,
isOpen,
setIsOpen,
setDismissEnable,
}
}
import type { FC, MouseEvent } from 'react'
import {
memo,
useMemo,
} from 'react'
import { FloatingPortal } from '@floating-ui/react'
import { useBlockSelectorContext } from '../../../../block-selector/context'
import type {
BlockEnum,
Node,
} from '../../../../types'
import { useAddBranch } from './hooks'
import { Plus02 } from '@/app/components/base/icons/src/vender/line/general'
type AddNodeProps = {
outgoers: Node[]
onAddNextNode: (type: BlockEnum) => void
branches?: { id: string; name: string }[]
}
const AddNode: FC<AddNodeProps> = ({
onAddNextNode,
branches,
}) => {
const {
refs,
isOpen,
setIsOpen,
setDismissEnable,
floatingStyles,
getFloatingProps,
} = useAddBranch()
const {
from,
open,
referenceRef,
handleToggle,
} = useBlockSelectorContext()
const hasBranches = branches && !!branches.length
const handleAdd = (e: MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
if (hasBranches)
return setIsOpen(v => !v)
handleToggle({
placement: 'right',
offset: 6,
callback: onAddNextNode,
})
}
const buttonRef = useMemo(() => {
if (hasBranches)
return refs.setReference
if (from === 'node')
return referenceRef
return null
}, [from, hasBranches, referenceRef, refs.setReference])
const buttonShouldShow = useMemo(() => {
if (hasBranches && isOpen)
return true
return open && from === 'node'
}, [from, hasBranches, isOpen, open])
return (
<>
<div
ref={buttonRef}
onClick={handleAdd}
className={`
hidden absolute -right-2 top-4 items-center justify-center
w-4 h-4 rounded-full bg-primary-600 cursor-pointer z-10 group-hover:flex
${buttonShouldShow && '!flex'}
`}
>
<Plus02 className='w-2.5 h-2.5 text-white' />
</div>
{
isOpen && hasBranches && (
<FloatingPortal>
<div
ref={refs.setFloating}
style={floatingStyles}
{...getFloatingProps()}
className='p-1 w-[108px] rounded-lg border-[0.5px] border-gray-200 bg-white shadow-lg'
>
{
branches.map(branch => (
<div
key={branch.id}
className='flex items-center px-3 pr-2 h-[30px] text-[13px] font-medium text-gray-700 cursor-pointer rounded-lg hover:bg-gray-50'
onClick={() => {
setDismissEnable(false)
handleToggle({
open: true,
placement: 'right',
offset: 6,
callback: onAddNextNode,
})
}}
>
{branch.name}
</div>
))
}
</div>
</FloatingPortal>
)
}
</>
)
}
export default memo(AddNode)
...@@ -9,7 +9,7 @@ import BlockIcon from '../../../block-icon' ...@@ -9,7 +9,7 @@ import BlockIcon from '../../../block-icon'
import type { Node } from '../../../types' import type { Node } from '../../../types'
import { BlockEnum } from '../../../types' import { BlockEnum } from '../../../types'
import { useWorkflowContext } from '../../../context' import { useWorkflowContext } from '../../../context'
import { useBlockSelectorContext } from '../../../block-selector/context' import BlockSelector from '../../../block-selector'
import { Plus } from '@/app/components/base/icons/src/vender/line/general' import { Plus } from '@/app/components/base/icons/src/vender/line/general'
import Button from '@/app/components/base/button' import Button from '@/app/components/base/button'
...@@ -19,23 +19,43 @@ type NextStepProps = { ...@@ -19,23 +19,43 @@ type NextStepProps = {
const NextStep: FC<NextStepProps> = ({ const NextStep: FC<NextStepProps> = ({
selectedNode, selectedNode,
}) => { }) => {
const {
from,
open,
referenceRef,
handleToggle,
} = useBlockSelectorContext()
const { const {
nodes, nodes,
edges, edges,
handleAddNextNode,
} = useWorkflowContext() } = useWorkflowContext()
const outgoers = useMemo(() => { const outgoers = useMemo(() => {
return getOutgoers(selectedNode, nodes, edges) return getOutgoers(selectedNode, nodes, edges)
}, [selectedNode, nodes, edges]) }, [selectedNode, nodes, edges])
const handleSelectBlock = useCallback((type: BlockEnum) => {
handleAddNextNode(selectedNode, type) const renderAddNextNodeTrigger = useCallback((open: boolean) => {
}, [selectedNode, handleAddNextNode]) return (
<div
className={`
flex items-center px-2 w-[328px] h-9 rounded-lg border border-dashed border-gray-200 bg-gray-50
hover:bg-gray-100 text-xs text-gray-500 cursor-pointer
${open && '!bg-gray-100'}
`}
>
<div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-gray-200'>
<Plus className='w-3 h-3' />
</div>
SELECT NEXT BLOCK
</div>
)
}, [])
const renderChangeCurrentNodeTrigger = useCallback((open: boolean) => {
return (
<Button
className={`
hidden group-hover:flex px-2 py-0 h-6 bg-white text-xs text-gray-700 font-medium rounded-md
${open && '!bg-gray-100 !flex'}
`}
>
Change
</Button>
)
}, [])
return ( return (
<div className='flex py-1'> <div className='flex py-1'>
...@@ -55,54 +75,28 @@ const NextStep: FC<NextStepProps> = ({ ...@@ -55,54 +75,28 @@ const NextStep: FC<NextStepProps> = ({
className='shrink-0 mr-1.5' className='shrink-0 mr-1.5'
/> />
<div className='grow'>{outgoer.data.name}</div> <div className='grow'>{outgoer.data.name}</div>
<div <BlockSelector
ref={from === 'panel' ? referenceRef : null} onSelect={() => {}}
onClick={() => { placement='top-end'
handleToggle({ offset={{
from: 'panel',
className: 'w-[328px]',
placement: 'top-end',
offset: {
mainAxis: 6, mainAxis: 6,
crossAxis: 8, crossAxis: 8,
},
})
}} }}
> trigger={renderChangeCurrentNodeTrigger}
<Button popupClassName='!w-[328px]'
className={` />
hidden group-hover:flex px-2 py-0 h-6 bg-white text-xs text-gray-700 font-medium rounded-md
${open && '!bg-gray-100 !flex'}
`}
>
Change
</Button>
</div>
</div> </div>
)) ))
} }
{ {
(!outgoers.length || selectedNode.data.type === BlockEnum.IfElse) && ( (!outgoers.length || selectedNode.data.type === BlockEnum.IfElse) && (
<div <BlockSelector
onClick={() => { onSelect={() => {}}
handleToggle({ placement='top'
from: 'panel', offset={0}
className: 'w-[328px]', trigger={renderAddNextNodeTrigger}
callback: handleSelectBlock, popupClassName='!w-[328px]'
}) />
}}
ref={from === 'panel' ? referenceRef : null}
className={`
flex items-center px-2 w-[328px] h-9 rounded-lg border border-dashed border-gray-200 bg-gray-50
hover:bg-gray-100 text-xs text-gray-500 cursor-pointer
${open && from === 'panel' && '!bg-gray-100'}
`}
>
<div className='flex items-center justify-center mr-1.5 w-5 h-5 rounded-[5px] bg-gray-200'>
<Plus className='w-3 h-3' />
</div>
SELECT NEXT BLOCK
</div>
) )
} }
</div> </div>
......
...@@ -5,16 +5,13 @@ import type { ...@@ -5,16 +5,13 @@ import type {
import { import {
cloneElement, cloneElement,
memo, memo,
useCallback,
useMemo, useMemo,
} from 'react' } from 'react'
import type { NodeProps } from 'reactflow' import type { NodeProps } from 'reactflow'
import { getOutgoers } from 'reactflow'
import { useWorkflowContext } from '../../context' import { useWorkflowContext } from '../../context'
import { BlockEnum } from '../../types'
import NodeControl from '../../node-control' import NodeControl from '../../node-control'
import BlockIcon from '../../block-icon' import BlockIcon from '../../block-icon'
import AddNode from './components/add-node/index' import BlockSelector from '../../block-selector'
type BaseNodeProps = { type BaseNodeProps = {
children: ReactElement children: ReactElement
...@@ -27,34 +24,12 @@ const BaseNode: FC<BaseNodeProps> = ({ ...@@ -27,34 +24,12 @@ const BaseNode: FC<BaseNodeProps> = ({
}) => { }) => {
const { const {
nodes, nodes,
edges,
selectedNodeId, selectedNodeId,
handleSelectedNodeIdChange, handleSelectedNodeIdChange,
handleAddNextNode,
} = useWorkflowContext() } = useWorkflowContext()
const currentNode = useMemo(() => { const currentNode = useMemo(() => {
return nodes.find(node => node.id === nodeId) return nodes.find(node => node.id === nodeId)
}, [nodeId, nodes]) }, [nodeId, nodes])
const outgoers = useMemo(() => {
return getOutgoers(currentNode!, nodes, edges)
}, [currentNode, nodes, edges])
const handleSelectBlock = useCallback((type: BlockEnum) => {
handleAddNextNode(currentNode!, type)
}, [currentNode, handleAddNextNode])
const branches = useMemo(() => {
if (data.type === BlockEnum.IfElse) {
return [
{
id: '1',
name: 'Is True',
},
{
id: '2',
name: 'Is False',
},
]
}
}, [data])
return ( return (
<div <div
...@@ -80,10 +55,9 @@ const BaseNode: FC<BaseNodeProps> = ({ ...@@ -80,10 +55,9 @@ const BaseNode: FC<BaseNodeProps> = ({
<div className='px-3 pt-1 pb-1 text-xs text-gray-500'> <div className='px-3 pt-1 pb-1 text-xs text-gray-500'>
Define the initial parameters for launching a workflow Define the initial parameters for launching a workflow
</div> </div>
<AddNode <BlockSelector
outgoers={outgoers} onSelect={() => {}}
branches={branches} asChild
onAddNextNode={handleSelectBlock}
/> />
</div> </div>
) )
......
...@@ -25,7 +25,7 @@ const CustomNode = ({ ...@@ -25,7 +25,7 @@ const CustomNode = ({
type='target' type='target'
position={Position.Left} position={Position.Left}
className={` className={`
!top-4 !left-0 !w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none !translate-y-0 z-[1] !top-[17px] !left-0 !w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none !translate-y-0 z-[1]
after:absolute after:w-0.5 after:h-2 after:-left-0.5 after:top-1 after:bg-primary-500 after:absolute after:w-0.5 after:h-2 after:-left-0.5 after:top-1 after:bg-primary-500
${data.type === BlockEnum.Start && 'opacity-0'} ${data.type === BlockEnum.Start && 'opacity-0'}
`} `}
...@@ -41,7 +41,7 @@ const CustomNode = ({ ...@@ -41,7 +41,7 @@ const CustomNode = ({
type='source' type='source'
position={Position.Right} position={Position.Right}
className={` className={`
!top-4 !right-0 !w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none !translate-y-0 !top-[17px] !right-0 !w-4 !h-4 !bg-transparent !rounded-none !outline-none !border-none !translate-y-0 z-[1]
after:absolute after:w-0.5 after:h-2 after:-right-0.5 after:top-1 after:bg-primary-500 after:absolute after:w-0.5 after:h-2 after:-right-0.5 after:top-1 after:bg-primary-500
`} `}
/> />
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment