### What problem does this PR solve? feat: fixed issue with threshold translation #882 feat: add NodeContextMenu ### Type of change - [ ] New Feature (non-breaking change which adds functionality)tags/v0.7.0
| @@ -247,8 +247,8 @@ export default { | |||
| 以上就是你需要總結的內容。`, | |||
| maxToken: '最大token數', | |||
| maxTokenMessage: '最大token數是必填項', | |||
| threshold: '臨界點', | |||
| thresholdMessage: '臨界點是必填項', | |||
| threshold: '閾值', | |||
| thresholdMessage: '閾值是必填項', | |||
| maxCluster: '最大聚類數', | |||
| maxClusterMessage: '最大聚類數是必填項', | |||
| randomSeed: '隨機種子', | |||
| @@ -264,8 +264,8 @@ export default { | |||
| 以上就是你需要总结的内容。`, | |||
| maxToken: '最大token数', | |||
| maxTokenMessage: '最大token数是必填项', | |||
| threshold: '临界点', | |||
| thresholdMessage: '临界点是必填项', | |||
| threshold: '阈值', | |||
| thresholdMessage: '阈值是必填项', | |||
| maxCluster: '最大聚类数', | |||
| maxClusterMessage: '最大聚类数是必填项', | |||
| randomSeed: '随机种子', | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -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 ( | |||
| <div | |||
| style={{ top, left, right, bottom }} | |||
| className={styles.contextMenu} | |||
| {...props} | |||
| > | |||
| <p style={{ margin: '0.5em' }}> | |||
| <small>node: {id}</small> | |||
| </p> | |||
| <button onClick={duplicateNode} type={'button'}> | |||
| duplicate | |||
| </button> | |||
| <button onClick={deleteNode} type={'button'}> | |||
| delete | |||
| </button> | |||
| </div> | |||
| ); | |||
| } | |||
| export const useHandleNodeContextMenu = (sideWidth: number) => { | |||
| const [menu, setMenu] = useState<INodeContextMenu>({} as INodeContextMenu); | |||
| const ref = useRef<any>(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 }; | |||
| }; | |||
| @@ -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<Node[]>(initialNodes); | |||
| const [edges, setEdges] = useState<Edge[]>(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 ( | |||
| <div | |||
| style={{ height: '100%', width: '100%' }} | |||
| onDrop={handleDrop} | |||
| onDragOver={allowDrop} | |||
| > | |||
| <div style={{ height: '100%', width: '100%' }}> | |||
| <ReactFlow | |||
| ref={ref} | |||
| nodes={nodes} | |||
| onNodesChange={onNodesChange} | |||
| onNodeContextMenu={onNodeContextMenu} | |||
| edges={edges} | |||
| onEdgesChange={onEdgesChange} | |||
| // fitView | |||
| fitView | |||
| onConnect={onConnect} | |||
| nodeTypes={nodeTypes} | |||
| onPaneClick={onPaneClick} | |||
| onDrop={onDrop} | |||
| onDragOver={onDragOver} | |||
| onNodeClick={onNodeClick} | |||
| onInit={setReactFlowInstance} | |||
| > | |||
| <Background /> | |||
| <Controls /> | |||
| {Object.keys(menu).length > 0 && ( | |||
| <NodeContextMenu onClick={onPaneClick} {...(menu as any)} /> | |||
| )} | |||
| </ReactFlow> | |||
| <FlowDrawer visible={drawerVisible} hideModal={hideDrawer}></FlowDrawer> | |||
| </div> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,20 @@ | |||
| import { IModalProps } from '@/interfaces/common'; | |||
| import { Drawer } from 'antd'; | |||
| const FlowDrawer = ({ visible, hideModal }: IModalProps<any>) => { | |||
| return ( | |||
| <Drawer | |||
| title="Basic Drawer" | |||
| placement="right" | |||
| // closable={false} | |||
| onClose={hideModal} | |||
| open={visible} | |||
| getContainer={false} | |||
| mask={false} | |||
| > | |||
| <p>Some contents...</p> | |||
| </Drawer> | |||
| ); | |||
| }; | |||
| export default FlowDrawer; | |||
| @@ -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 ( | |||
| <Sider | |||
| @@ -27,7 +30,7 @@ const FlowSider = () => { | |||
| hoverable | |||
| draggable | |||
| className={classNames(styles.operatorCard)} | |||
| onDragStart={handleDrag(x.name)} | |||
| onDragStart={handleDragStart(x.name)} | |||
| > | |||
| <Flex justify="space-between" align="center"> | |||
| <Space size={15}> | |||
| @@ -45,4 +48,4 @@ const FlowSider = () => { | |||
| ); | |||
| }; | |||
| export default FlowSider; | |||
| export default FlowSide; | |||
| @@ -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<HTMLDivElement>) => { | |||
| 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<SetStateAction<Node[]>>) => { | |||
| const allowDrop = (ev: React.DragEvent<HTMLDivElement>) => { | |||
| ev.preventDefault(); | |||
| }; | |||
| const [reactFlowInstance, setReactFlowInstance] = | |||
| useState<ReactFlowInstance<any, any>>(); | |||
| const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => { | |||
| event.preventDefault(); | |||
| event.dataTransfer.dropEffect = 'move'; | |||
| }, []); | |||
| const onDrop = useCallback( | |||
| (event: React.DragEvent<HTMLDivElement>) => { | |||
| event.preventDefault(); | |||
| const type = event.dataTransfer.getData('application/reactflow'); | |||
| // check if the dropped element is valid | |||
| if (typeof type === 'undefined' || !type) { | |||
| return; | |||
| } | |||
| const handleDrop = useCallback( | |||
| (ev: React.DragEvent<HTMLDivElement>) => { | |||
| 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 }, | |||
| }); | |||
| // 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, | |||
| }; | |||
| }; | |||
| @@ -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 ( | |||
| <Layout style={{ minHeight: '100vh' }}> | |||
| <Sider></Sider> | |||
| <Layout> | |||
| <Content style={{ margin: '0 16px' }}> | |||
| <FlowCanvas></FlowCanvas> | |||
| </Content> | |||
| </Layout> | |||
| <Layout> | |||
| <ReactFlowProvider> | |||
| <Sider setCollapsed={setCollapsed} collapsed={collapsed}></Sider> | |||
| <Layout> | |||
| <Content style={{ margin: '0 16px' }}> | |||
| <FlowCanvas sideWidth={collapsed ? 0 : 200}></FlowCanvas> | |||
| </Content> | |||
| </Layout> | |||
| </ReactFlowProvider> | |||
| </Layout> | |||
| ); | |||
| } | |||