diff --git a/web/src/locales/zh-traditional.ts b/web/src/locales/zh-traditional.ts index 03aae27cc..d12babd58 100644 --- a/web/src/locales/zh-traditional.ts +++ b/web/src/locales/zh-traditional.ts @@ -247,8 +247,8 @@ export default { 以上就是你需要總結的內容。`, maxToken: '最大token數', maxTokenMessage: '最大token數是必填項', - threshold: '臨界點', - thresholdMessage: '臨界點是必填項', + threshold: '閾值', + thresholdMessage: '閾值是必填項', maxCluster: '最大聚類數', maxClusterMessage: '最大聚類數是必填項', randomSeed: '隨機種子', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 215f0a953..a32e6e4b2 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -264,8 +264,8 @@ export default { 以上就是你需要总结的内容。`, maxToken: '最大token数', maxTokenMessage: '最大token数是必填项', - threshold: '临界点', - thresholdMessage: '临界点是必填项', + threshold: '阈值', + thresholdMessage: '阈值是必填项', maxCluster: '最大聚类数', maxClusterMessage: '最大聚类数是必填项', randomSeed: '随机种子', diff --git a/web/src/pages/flow/canvas/context-menu/index.less b/web/src/pages/flow/canvas/context-menu/index.less new file mode 100644 index 000000000..a674e0018 --- /dev/null +++ b/web/src/pages/flow/canvas/context-menu/index.less @@ -0,0 +1,18 @@ +.contextMenu { + background: white; + border-style: solid; + box-shadow: 10px 19px 20px rgba(0, 0, 0, 10%); + position: absolute; + z-index: 10; + button { + border: none; + display: block; + padding: 0.5em; + text-align: left; + width: 100%; + } + + button:hover { + background: white; + } +} diff --git a/web/src/pages/flow/canvas/context-menu/index.tsx b/web/src/pages/flow/canvas/context-menu/index.tsx new file mode 100644 index 000000000..c03e87cdd --- /dev/null +++ b/web/src/pages/flow/canvas/context-menu/index.tsx @@ -0,0 +1,108 @@ +import { useCallback, useRef, useState } from 'react'; +import { NodeMouseHandler, useReactFlow } from 'reactflow'; + +import styles from './index.less'; + +export interface INodeContextMenu { + id: string; + top: number; + left: number; + right?: number; + bottom?: number; + [key: string]: unknown; +} + +export function NodeContextMenu({ + id, + top, + left, + right, + bottom, + ...props +}: INodeContextMenu) { + const { getNode, setNodes, addNodes, setEdges } = useReactFlow(); + + const duplicateNode = useCallback(() => { + const node = getNode(id); + const position = { + x: node?.position?.x || 0 + 50, + y: node?.position?.y || 0 + 50, + }; + + addNodes({ + ...(node || {}), + data: node?.data, + selected: false, + dragging: false, + id: `${node?.id}-copy`, + position, + }); + }, [id, getNode, addNodes]); + + const deleteNode = useCallback(() => { + setNodes((nodes) => nodes.filter((node) => node.id !== id)); + setEdges((edges) => edges.filter((edge) => edge.source !== id)); + }, [id, setNodes, setEdges]); + + return ( +
+

+ node: {id} +

+ + +
+ ); +} + +export const useHandleNodeContextMenu = (sideWidth: number) => { + const [menu, setMenu] = useState({} as INodeContextMenu); + const ref = useRef(null); + + const onNodeContextMenu: NodeMouseHandler = useCallback( + (event, node) => { + // Prevent native context menu from showing + event.preventDefault(); + + // Calculate position of the context menu. We want to make sure it + // doesn't get positioned off-screen. + const pane = ref.current?.getBoundingClientRect(); + // setMenu({ + // id: node.id, + // top: event.clientY < pane.height - 200 ? event.clientY : 0, + // left: event.clientX < pane.width - 200 ? event.clientX : 0, + // right: event.clientX >= pane.width - 200 ? pane.width - event.clientX : 0, + // bottom: + // event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0, + // }); + + console.info('clientX:', event.clientX); + console.info('clientY:', event.clientY); + + setMenu({ + id: node.id, + top: event.clientY - 72, + left: event.clientX - sideWidth, + // top: event.clientY < pane.height - 200 ? event.clientY - 72 : 0, + // left: event.clientX < pane.width - 200 ? event.clientX : 0, + }); + }, + [sideWidth], + ); + + // Close the context menu if it's open whenever the window is clicked. + const onPaneClick = useCallback( + () => setMenu({} as INodeContextMenu), + [setMenu], + ); + + return { onNodeContextMenu, menu, onPaneClick, ref }; +}; diff --git a/web/src/pages/flow/canvas/index.tsx b/web/src/pages/flow/canvas/index.tsx index 1ad3226a1..a8e66a611 100644 --- a/web/src/pages/flow/canvas/index.tsx +++ b/web/src/pages/flow/canvas/index.tsx @@ -4,6 +4,7 @@ import ReactFlow, { Controls, Edge, Node, + NodeMouseHandler, OnConnect, OnEdgesChange, OnNodesChange, @@ -13,7 +14,10 @@ import ReactFlow, { } from 'reactflow'; import 'reactflow/dist/style.css'; -import { useHandleDrop } from '../hooks'; +import { NodeContextMenu, useHandleNodeContextMenu } from './context-menu'; + +import FlowDrawer from '../flow-drawer'; +import { useHandleDrop, useShowDrawer } from '../hooks'; import { TextUpdaterNode } from './node'; const nodeTypes = { textUpdater: TextUpdaterNode }; @@ -42,9 +46,17 @@ const initialEdges = [ { id: '1-2', source: '1', target: '2', label: 'to the', type: 'step' }, ]; -function FlowCanvas() { +interface IProps { + sideWidth: number; + showDrawer(): void; +} + +function FlowCanvas({ sideWidth }: IProps) { const [nodes, setNodes] = useState(initialNodes); const [edges, setEdges] = useState(initialEdges); + const { ref, menu, onNodeContextMenu, onPaneClick } = + useHandleNodeContextMenu(sideWidth); + const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); const onNodesChange: OnNodesChange = useCallback( (changes) => setNodes((nds) => applyNodeChanges(changes, nds)), @@ -60,7 +72,11 @@ function FlowCanvas() { [], ); - const { handleDrop, allowDrop } = useHandleDrop(setNodes); + const onNodeClick: NodeMouseHandler = useCallback(() => { + showDrawer(); + }, [showDrawer]); + + const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop(setNodes); useEffect(() => { console.info('nodes:', nodes); @@ -68,23 +84,30 @@ function FlowCanvas() { }, [nodes, edges]); return ( -
+
+ {Object.keys(menu).length > 0 && ( + + )} +
); } diff --git a/web/src/pages/flow/flow-drawer/index.tsx b/web/src/pages/flow/flow-drawer/index.tsx new file mode 100644 index 000000000..7e395b257 --- /dev/null +++ b/web/src/pages/flow/flow-drawer/index.tsx @@ -0,0 +1,20 @@ +import { IModalProps } from '@/interfaces/common'; +import { Drawer } from 'antd'; + +const FlowDrawer = ({ visible, hideModal }: IModalProps) => { + return ( + +

Some contents...

+
+ ); +}; + +export default FlowDrawer; diff --git a/web/src/pages/flow/flow-sider/index.tsx b/web/src/pages/flow/flow-sider/index.tsx index 439c1265a..e82043e9d 100644 --- a/web/src/pages/flow/flow-sider/index.tsx +++ b/web/src/pages/flow/flow-sider/index.tsx @@ -1,6 +1,5 @@ import { Avatar, Card, Flex, Layout, Space } from 'antd'; import classNames from 'classnames'; -import { useState } from 'react'; import { componentList } from '../mock'; import { useHandleDrag } from '../hooks'; @@ -8,9 +7,13 @@ import styles from './index.less'; const { Sider } = Layout; -const FlowSider = () => { - const [collapsed, setCollapsed] = useState(true); - const { handleDrag } = useHandleDrag(); +interface IProps { + setCollapsed: (width: boolean) => void; + collapsed: boolean; +} + +const FlowSide = ({ setCollapsed, collapsed }: IProps) => { + const { handleDragStart } = useHandleDrag(); return ( { hoverable draggable className={classNames(styles.operatorCard)} - onDragStart={handleDrag(x.name)} + onDragStart={handleDragStart(x.name)} > @@ -45,4 +48,4 @@ const FlowSider = () => { ); }; -export default FlowSider; +export default FlowSide; diff --git a/web/src/pages/flow/hooks.ts b/web/src/pages/flow/hooks.ts index 3fde00654..23754145f 100644 --- a/web/src/pages/flow/hooks.ts +++ b/web/src/pages/flow/hooks.ts @@ -1,47 +1,75 @@ -import React, { Dispatch, SetStateAction, useCallback } from 'react'; -import { Node } from 'reactflow'; +import { useSetModalState } from '@/hooks/commonHooks'; +import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; +import { Node, ReactFlowInstance } from 'reactflow'; +import { v4 as uuidv4 } from 'uuid'; export const useHandleDrag = () => { - const handleDrag = useCallback( + const handleDragStart = useCallback( (operatorId: string) => (ev: React.DragEvent) => { - console.info(ev.clientX, ev.pageY); - ev.dataTransfer.setData('operatorId', operatorId); - ev.dataTransfer.setData('startClientX', ev.clientX.toString()); - ev.dataTransfer.setData('startClientY', ev.clientY.toString()); + ev.dataTransfer.setData('application/reactflow', operatorId); + ev.dataTransfer.effectAllowed = 'move'; }, [], ); - return { handleDrag }; + return { handleDragStart }; }; export const useHandleDrop = (setNodes: Dispatch>) => { - const allowDrop = (ev: React.DragEvent) => { - ev.preventDefault(); - }; + const [reactFlowInstance, setReactFlowInstance] = + useState>(); - const handleDrop = useCallback( - (ev: React.DragEvent) => { - ev.preventDefault(); - const operatorId = ev.dataTransfer.getData('operatorId'); - const startClientX = ev.dataTransfer.getData('startClientX'); - const startClientY = ev.dataTransfer.getData('startClientY'); - console.info(operatorId); - console.info(ev.pageX, ev.pageY); - console.info(ev.clientX, ev.clientY); - console.info(ev.movementX, ev.movementY); - const x = ev.clientX - 200; - const y = ev.clientY - 72; - setNodes((pre) => { - return pre.concat({ - id: operatorId, - position: { x, y }, - data: { label: operatorId }, - }); + const onDragOver = useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + }, []); + + const onDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + + const type = event.dataTransfer.getData('application/reactflow'); + + // check if the dropped element is valid + if (typeof type === 'undefined' || !type) { + return; + } + + // reactFlowInstance.project was renamed to reactFlowInstance.screenToFlowPosition + // and you don't need to subtract the reactFlowBounds.left/top anymore + // details: https://reactflow.dev/whats-new/2023-11-10 + const position = reactFlowInstance?.screenToFlowPosition({ + x: event.clientX, + y: event.clientY, }); + const newNode = { + id: uuidv4(), + type, + position: position || { + x: 0, + y: 0, + }, + data: { label: `${type} node` }, + }; + + setNodes((nds) => nds.concat(newNode)); }, - [setNodes], + [reactFlowInstance, setNodes], ); - return { handleDrop, allowDrop }; + return { onDrop, onDragOver, setReactFlowInstance }; +}; + +export const useShowDrawer = () => { + const { + visible: drawerVisible, + hideModal: hideDrawer, + showModal: showDrawer, + } = useSetModalState(); + + return { + drawerVisible, + hideDrawer, + showDrawer, + }; }; diff --git a/web/src/pages/flow/index.tsx b/web/src/pages/flow/index.tsx index d4c7b653f..5aa32abd7 100644 --- a/web/src/pages/flow/index.tsx +++ b/web/src/pages/flow/index.tsx @@ -1,18 +1,24 @@ import { Layout } from 'antd'; +import { useState } from 'react'; +import { ReactFlowProvider } from 'reactflow'; import FlowCanvas from './canvas'; import Sider from './flow-sider'; const { Content } = Layout; function RagFlow() { + const [collapsed, setCollapsed] = useState(false); + return ( - - - - - - - + + + + + + + + + ); }