feat: add context menu & comment node (#2834)

* feat: add comment node

* useMemo
This commit is contained in:
heheer
2024-09-29 10:08:19 +08:00
committed by GitHub
parent 7bdff9ce9c
commit 7c829febec
14 changed files with 439 additions and 112 deletions

View File

@@ -0,0 +1,83 @@
import { Box, Flex } from '@chakra-ui/react';
import React from 'react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'react-i18next';
import { nodeTemplate2FlowNode } from '@/web/core/workflow/utils';
import { CommentNode } from '@fastgpt/global/core/workflow/template/system/comment';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
import { useReactFlow } from 'reactflow';
type ContextMenuProps = {
top: number;
left: number;
};
const ContextMenu = ({ top, left }: ContextMenuProps) => {
const { t } = useTranslation();
const setNodes = useContextSelector(WorkflowContext, (ctx) => ctx.setNodes);
const setMenu = useContextSelector(WorkflowContext, (ctx) => ctx.setMenu);
const { screenToFlowPosition } = useReactFlow();
const newNode = nodeTemplate2FlowNode({
template: CommentNode,
position: screenToFlowPosition({ x: left, y: top }),
t
});
return (
<Box position="relative">
<Box
position="absolute"
top={`${top - 6}px`}
left={`${left + 10}px`}
width={0}
height={0}
borderLeft="6px solid transparent"
borderRight="6px solid transparent"
borderBottom="6px solid white"
zIndex={2}
filter="drop-shadow(0px -1px 2px rgba(0, 0, 0, 0.1))"
/>
<Flex
position={'absolute'}
top={top}
left={left}
bg={'white'}
w={'120px'}
height={9}
p={1}
rounded={'md'}
boxShadow={'0px 2px 4px 0px #A1A7B340'}
className="context-menu"
alignItems={'center'}
color={'myGray.600'}
cursor={'pointer'}
_hover={{
color: 'primary.500'
}}
onClick={() => {
setMenu(null);
setNodes((state) => {
const newState = state
.map((node) => ({
...node,
selected: false
}))
// @ts-ignore
.concat(newNode);
return newState;
});
}}
zIndex={1}
>
<MyIcon name="comment" w={'16px'} h={'16px'} ml={1} />
<Box fontSize={'12px'} fontWeight={'500'} ml={1.5}>
{t('workflow:context_menu.add_comment')}
</Box>
</Flex>
</Box>
);
};
export default React.memo(ContextMenu);

View File

@@ -282,7 +282,8 @@ export const useWorkflow = () => {
setEdges,
onChangeNode,
onEdgesChange,
setHoverEdgeId
setHoverEdgeId,
setMenu
} = useContextSelector(WorkflowContext, (v) => v);
const { getIntersectingNodes } = useReactFlow();
@@ -599,7 +600,7 @@ export const useWorkflow = () => {
connect
});
},
[onConnect, t, toast, nodes]
[onConnect, t, toast]
);
/* edge */
@@ -613,6 +614,24 @@ export const useWorkflow = () => {
setHoverEdgeId(undefined);
}, [setHoverEdgeId]);
// context menu
const onPaneContextMenu = useCallback(
(e: any) => {
// Prevent native context menu from showing
e.preventDefault();
setMenu({
top: e.clientY - 64,
left: e.clientX - 12
});
},
[setMenu]
);
const onPaneClick = useCallback(() => {
setMenu(null);
}, [setMenu]);
return {
handleNodesChange,
handleEdgeChange,
@@ -624,7 +643,9 @@ export const useWorkflow = () => {
onEdgeMouseLeave,
helperLineHorizontal,
helperLineVertical,
onNodeDragStop
onNodeDragStop,
onPaneContextMenu,
onPaneClick
};
};

View File

@@ -17,6 +17,7 @@ import { WorkflowContext } from '../context';
import { useWorkflow } from './hooks/useWorkflow';
import HelperLines from './components/HelperLines';
import FlowController from './components/FlowController';
import ContextMenu from './components/ContextMenu';
export const minZoom = 0.1;
export const maxZoom = 1.5;
@@ -57,14 +58,15 @@ const nodeTypes: Record<FlowNodeTypeEnum, any> = {
[FlowNodeTypeEnum.loop]: dynamic(() => import('./nodes/Loop/NodeLoop')),
[FlowNodeTypeEnum.loopStart]: dynamic(() => import('./nodes/Loop/NodeLoopStart')),
[FlowNodeTypeEnum.loopEnd]: dynamic(() => import('./nodes/Loop/NodeLoopEnd')),
[FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput'))
[FlowNodeTypeEnum.formInput]: dynamic(() => import('./nodes/NodeFormInput')),
[FlowNodeTypeEnum.comment]: dynamic(() => import('./nodes/NodeComment'))
};
const edgeTypes = {
[EDGE_TYPE]: ButtonEdge
};
const Workflow = () => {
const { nodes, edges, reactFlowWrapper, workflowControlMode } = useContextSelector(
const { nodes, edges, menu, reactFlowWrapper, workflowControlMode } = useContextSelector(
WorkflowContext,
(v) => v
);
@@ -79,7 +81,9 @@ const Workflow = () => {
onEdgeMouseLeave,
helperLineHorizontal,
helperLineVertical,
onNodeDragStop
onNodeDragStop,
onPaneContextMenu,
onPaneClick
} = useWorkflow();
const {
@@ -121,6 +125,7 @@ const Workflow = () => {
<NodeTemplatesModal isOpen={isOpenTemplate} onClose={onCloseTemplate} />
</>
{menu && <ContextMenu {...menu} />}
<ReactFlow
ref={reactFlowWrapper}
fitView
@@ -142,6 +147,8 @@ const Workflow = () => {
onEdgeMouseEnter={onEdgeMouseEnter}
onEdgeMouseLeave={onEdgeMouseLeave}
panOnScrollSpeed={2}
onPaneContextMenu={onPaneContextMenu}
onPaneClick={onPaneClick}
{...(workflowControlMode === 'select'
? {
selectionMode: SelectionMode.Full,

View File

@@ -0,0 +1,128 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import NodeCard from './render/NodeCard';
import { NodeProps } from 'reactflow';
import { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node';
import { Box, Textarea } from '@chakra-ui/react';
import { useContextSelector } from 'use-context-selector';
import { WorkflowContext } from '../../context';
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import MyIcon from '@fastgpt/web/components/common/Icon';
import { useTranslation } from 'react-i18next';
const NodeComment = ({ data, selected }: NodeProps<FlowNodeItemType>) => {
const { nodeId, inputs } = data;
const { commentText, commentSize } = useMemo(
() => ({
commentText: inputs.find((item) => item.key === NodeInputKeyEnum.commentText),
commentSize: inputs.find((item) => item.key === NodeInputKeyEnum.commentSize)
}),
[inputs]
);
const onChangeNode = useContextSelector(WorkflowContext, (ctx) => ctx.onChangeNode);
const { t } = useTranslation();
const [size, setSize] = useState<{
width: number;
height: number;
}>(commentSize?.value);
const initialY = useRef(0);
const initialX = useRef(0);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
initialY.current = e.clientY;
initialX.current = e.clientX;
const handleMouseMove = (e: MouseEvent) => {
const deltaY = e.clientY - initialY.current;
const deltaX = e.clientX - initialX.current;
setSize((prevSize) => ({
width: prevSize.width + deltaX < 240 ? 240 : prevSize.width + deltaX,
height: prevSize.height + deltaY < 140 ? 140 : prevSize.height + deltaY
}));
initialY.current = e.clientY;
initialX.current = e.clientX;
commentSize &&
onChangeNode({
nodeId: nodeId,
type: 'updateInput',
key: NodeInputKeyEnum.commentSize,
value: {
...commentSize,
value: {
width: size.width + deltaX,
height: size.height + deltaY
}
}
});
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
},
[commentSize, nodeId, onChangeNode, size.height, size.width]
);
return (
<NodeCard
selected={false}
{...data}
minW={`${size.width}px`}
minH={`${size.height}px`}
menuForbid={{
debug: true
}}
border={'none'}
rounded={'none'}
bg={'#D8E9FF'}
boxShadow={
'0px 4px 10px 0px rgba(19, 51, 107, 0.10), 0px 0px 1px 0px rgba(19, 51, 107, 0.10)'
}
>
<Box w={'full'} h={'full'} position={'relative'}>
<Box
position={'absolute'}
right={'0'}
bottom={'-2'}
zIndex={9}
cursor={'nwse-resize'}
px={'2px'}
className="nodrag"
onMouseDown={handleMouseDown}
>
<MyIcon name={'common/editor/resizer'} width={'14px'} height={'14px'} />
</Box>
<Textarea
value={commentText?.value}
border={'none'}
rounded={'none'}
minH={`${size.height}px`}
minW={`${size.width}px`}
resize={'none'}
placeholder={t('workflow:enter_comment')}
onChange={(e) => {
commentText &&
onChangeNode({
nodeId: nodeId,
type: 'updateInput',
key: NodeInputKeyEnum.commentText,
value: {
...commentText,
value: e.target.value
}
});
}}
/>
</Box>
</NodeCard>
);
};
export default React.memo(NodeComment);

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useMemo } from 'react';
import { Box, Button, Card, Flex, Image } from '@chakra-ui/react';
import { Box, Button, Card, Flex, FlexProps, Image } from '@chakra-ui/react';
import MyIcon from '@fastgpt/web/components/common/Icon';
import Avatar from '@fastgpt/web/components/common/Avatar';
import type { FlowNodeItemType } from '@fastgpt/global/core/workflow/type/node.d';
@@ -39,7 +39,7 @@ type Props = FlowNodeItemType & {
copy?: boolean;
delete?: boolean;
};
};
} & Omit<FlexProps, 'children'>;
const NodeCard = (props: Props) => {
const { t } = useTranslation();
@@ -62,7 +62,8 @@ const NodeCard = (props: Props) => {
isTool = false,
isError = false,
debugResult,
isFolded
isFolded,
...customStyle
} = props;
const nodeList = useContextSelector(WorkflowContext, (v) => v.nodeList);
@@ -156,120 +157,136 @@ const NodeCard = (props: Props) => {
/* Node header */
const Header = useMemo(() => {
const showHeader = node?.flowNodeType !== FlowNodeTypeEnum.comment;
return (
<Box position={'relative'}>
{/* debug */}
<Box px={4} py={3}>
{/* tool target handle */}
<ToolTargetHandle show={showToolHandle} nodeId={nodeId} />
{showHeader && (
<Box px={4} py={3}>
{/* tool target handle */}
<ToolTargetHandle show={showToolHandle} nodeId={nodeId} />
{/* avatar and name */}
<Flex alignItems={'center'}>
{node?.flowNodeType !== FlowNodeTypeEnum.stopTool && (
<Box
mr={2}
cursor={'pointer'}
rounded={'sm'}
_hover={{ bg: 'myGray.200' }}
onClick={() => {
onChangeNode({
nodeId,
type: 'attr',
key: 'isFolded',
value: !isFolded
});
}}
>
<MyIcon
name={!isFolded ? 'core/chat/chevronDown' : 'core/chat/chevronRight'}
w={'24px'}
h={'24px'}
color={'myGray.500'}
/>
</Box>
)}
<Avatar src={avatar} borderRadius={'sm'} objectFit={'contain'} w={'30px'} h={'30px'} />
<Box ml={3} fontSize={'md'} fontWeight={'medium'}>
{t(name as any)}
</Box>
<MyIcon
className="controller-rename"
display={'none'}
name={'edit'}
w={'14px'}
cursor={'pointer'}
ml={1}
color={'myGray.500'}
_hover={{ color: 'primary.600' }}
onClick={() => {
onOpenCustomTitleModal({
defaultVal: name,
onSuccess: (e) => {
if (!e) {
return toast({
title: t('app:modules.Title is required'),
status: 'warning'
});
}
{/* avatar and name */}
<Flex alignItems={'center'}>
{node?.flowNodeType !== FlowNodeTypeEnum.stopTool && (
<Box
mr={2}
cursor={'pointer'}
rounded={'sm'}
_hover={{ bg: 'myGray.200' }}
onClick={() => {
onChangeNode({
nodeId,
type: 'attr',
key: 'name',
value: e
key: 'isFolded',
value: !isFolded
});
}
});
}}
/>
<Box flex={1} />
{hasNewVersion && (
<MyTooltip label={t('app:app.modules.click to update')}>
<Button
bg={'yellow.50'}
color={'yellow.600'}
variant={'ghost'}
h={8}
px={2}
rounded={'6px'}
fontSize={'xs'}
fontWeight={'medium'}
cursor={'pointer'}
_hover={{ bg: 'yellow.100' }}
onClick={onOpenConfirmSync(onClickSyncVersion)}
}}
>
<Box>{t('app:app.modules.has new version')}</Box>
<QuestionOutlineIcon ml={1} />
</Button>
</MyTooltip>
)}
{!!nodeTemplate?.diagram && !hasNewVersion && (
<MyTooltip
label={
<Image src={nodeTemplate?.diagram} w={'100%'} minH={['auto', '200px']} alt={''} />
}
>
<Box
fontSize={'sm'}
color={'primary.700'}
p={1}
rounded={'sm'}
cursor={'default'}
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
>
{t('common:core.module.Diagram')}
<MyIcon
name={!isFolded ? 'core/chat/chevronDown' : 'core/chat/chevronRight'}
w={'24px'}
h={'24px'}
color={'myGray.500'}
/>
</Box>
</MyTooltip>
)}
</Flex>
<MenuRender nodeId={nodeId} menuForbid={menuForbid} nodeList={nodeList} />
<NodeIntro nodeId={nodeId} intro={intro} />
</Box>
)}
<Avatar
src={avatar}
borderRadius={'sm'}
objectFit={'contain'}
w={'30px'}
h={'30px'}
/>
<Box ml={3} fontSize={'md'} fontWeight={'medium'}>
{t(name as any)}
</Box>
<MyIcon
className="controller-rename"
display={'none'}
name={'edit'}
w={'14px'}
cursor={'pointer'}
ml={1}
color={'myGray.500'}
_hover={{ color: 'primary.600' }}
onClick={() => {
onOpenCustomTitleModal({
defaultVal: name,
onSuccess: (e) => {
if (!e) {
return toast({
title: t('app:modules.Title is required'),
status: 'warning'
});
}
onChangeNode({
nodeId,
type: 'attr',
key: 'name',
value: e
});
}
});
}}
/>
<Box flex={1} />
{hasNewVersion && (
<MyTooltip label={t('app:app.modules.click to update')}>
<Button
bg={'yellow.50'}
color={'yellow.600'}
variant={'ghost'}
h={8}
px={2}
rounded={'6px'}
fontSize={'xs'}
fontWeight={'medium'}
cursor={'pointer'}
_hover={{ bg: 'yellow.100' }}
onClick={onOpenConfirmSync(onClickSyncVersion)}
>
<Box>{t('app:app.modules.has new version')}</Box>
<QuestionOutlineIcon ml={1} />
</Button>
</MyTooltip>
)}
{!!nodeTemplate?.diagram && !hasNewVersion && (
<MyTooltip
label={
<Image
src={nodeTemplate?.diagram}
w={'100%'}
minH={['auto', '200px']}
alt={''}
/>
}
>
<Box
fontSize={'sm'}
color={'primary.700'}
p={1}
rounded={'sm'}
cursor={'default'}
_hover={{ bg: 'rgba(17, 24, 36, 0.05)' }}
>
{t('common:core.module.Diagram')}
</Box>
</MyTooltip>
)}
</Flex>
<NodeIntro nodeId={nodeId} intro={intro} />
</Box>
)}
<MenuRender nodeId={nodeId} menuForbid={menuForbid} nodeList={nodeList} />
<ConfirmSyncModal />
</Box>
);
}, [
node?.flowNodeType,
showToolHandle,
nodeId,
node?.flowNodeType,
isFolded,
avatar,
t,
@@ -278,9 +295,10 @@ const NodeCard = (props: Props) => {
onOpenConfirmSync,
onClickSyncVersion,
nodeTemplate?.diagram,
intro,
menuForbid,
nodeList,
intro,
ConfirmSyncModal,
onChangeNode,
onOpenCustomTitleModal,
toast
@@ -334,6 +352,7 @@ const NodeCard = (props: Props) => {
: {
borderColor: selected ? 'primary.600' : 'borderColor.base'
})}
{...customStyle}
>
<NodeDebugResponse nodeId={nodeId} debugResult={debugResult} />
{Header}

View File

@@ -174,6 +174,11 @@ type WorkflowContextType = {
//
workflowControlMode?: 'drag' | 'select';
setWorkflowControlMode: (value?: SetState<'drag' | 'select'> | undefined) => void;
menu: {
top: number;
left: number;
} | null;
setMenu: (value: React.SetStateAction<{ top: number; left: number } | null>) => void;
};
type DebugDataType = {
@@ -313,6 +318,10 @@ export const WorkflowContext = createContext<WorkflowContextType>({
},
onSwitchCloudVersion: function (appVersion: AppVersionSchemaType): boolean {
throw new Error('Function not implemented.');
},
menu: null,
setMenu: function (value: React.SetStateAction<{ top: number; left: number } | null>): void {
throw new Error('Function not implemented.');
}
});
@@ -973,6 +982,8 @@ const WorkflowContextProvider = ({
};
}, [edges, nodes]);
const [menu, setMenu] = useState<{ top: number; left: number } | null>(null);
const value = {
appId,
reactFlowWrapper,
@@ -1032,7 +1043,10 @@ const WorkflowContextProvider = ({
setShowHistoryModal,
// chat test
setWorkflowTestData
setWorkflowTestData,
menu,
setMenu
};
return (