### 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
| 以上就是你需要總結的內容。`, | 以上就是你需要總結的內容。`, | ||||
| maxToken: '最大token數', | maxToken: '最大token數', | ||||
| maxTokenMessage: '最大token數是必填項', | maxTokenMessage: '最大token數是必填項', | ||||
| threshold: '臨界點', | |||||
| thresholdMessage: '臨界點是必填項', | |||||
| threshold: '閾值', | |||||
| thresholdMessage: '閾值是必填項', | |||||
| maxCluster: '最大聚類數', | maxCluster: '最大聚類數', | ||||
| maxClusterMessage: '最大聚類數是必填項', | maxClusterMessage: '最大聚類數是必填項', | ||||
| randomSeed: '隨機種子', | randomSeed: '隨機種子', |
| 以上就是你需要总结的内容。`, | 以上就是你需要总结的内容。`, | ||||
| maxToken: '最大token数', | maxToken: '最大token数', | ||||
| maxTokenMessage: '最大token数是必填项', | maxTokenMessage: '最大token数是必填项', | ||||
| threshold: '临界点', | |||||
| thresholdMessage: '临界点是必填项', | |||||
| threshold: '阈值', | |||||
| thresholdMessage: '阈值是必填项', | |||||
| maxCluster: '最大聚类数', | maxCluster: '最大聚类数', | ||||
| maxClusterMessage: '最大聚类数是必填项', | maxClusterMessage: '最大聚类数是必填项', | ||||
| randomSeed: '随机种子', | randomSeed: '随机种子', |
| .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; | |||||
| } | |||||
| } |
| 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 }; | |||||
| }; |
| Controls, | Controls, | ||||
| Edge, | Edge, | ||||
| Node, | Node, | ||||
| NodeMouseHandler, | |||||
| OnConnect, | OnConnect, | ||||
| OnEdgesChange, | OnEdgesChange, | ||||
| OnNodesChange, | OnNodesChange, | ||||
| } from 'reactflow'; | } from 'reactflow'; | ||||
| import 'reactflow/dist/style.css'; | 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'; | import { TextUpdaterNode } from './node'; | ||||
| const nodeTypes = { textUpdater: TextUpdaterNode }; | const nodeTypes = { textUpdater: TextUpdaterNode }; | ||||
| { id: '1-2', source: '1', target: '2', label: 'to the', type: 'step' }, | { 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 [nodes, setNodes] = useState<Node[]>(initialNodes); | ||||
| const [edges, setEdges] = useState<Edge[]>(initialEdges); | const [edges, setEdges] = useState<Edge[]>(initialEdges); | ||||
| const { ref, menu, onNodeContextMenu, onPaneClick } = | |||||
| useHandleNodeContextMenu(sideWidth); | |||||
| const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); | |||||
| const onNodesChange: OnNodesChange = useCallback( | const onNodesChange: OnNodesChange = useCallback( | ||||
| (changes) => setNodes((nds) => applyNodeChanges(changes, nds)), | (changes) => setNodes((nds) => applyNodeChanges(changes, nds)), | ||||
| [], | [], | ||||
| ); | ); | ||||
| const { handleDrop, allowDrop } = useHandleDrop(setNodes); | |||||
| const onNodeClick: NodeMouseHandler = useCallback(() => { | |||||
| showDrawer(); | |||||
| }, [showDrawer]); | |||||
| const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop(setNodes); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| console.info('nodes:', nodes); | console.info('nodes:', nodes); | ||||
| }, [nodes, edges]); | }, [nodes, edges]); | ||||
| return ( | return ( | ||||
| <div | |||||
| style={{ height: '100%', width: '100%' }} | |||||
| onDrop={handleDrop} | |||||
| onDragOver={allowDrop} | |||||
| > | |||||
| <div style={{ height: '100%', width: '100%' }}> | |||||
| <ReactFlow | <ReactFlow | ||||
| ref={ref} | |||||
| nodes={nodes} | nodes={nodes} | ||||
| onNodesChange={onNodesChange} | onNodesChange={onNodesChange} | ||||
| onNodeContextMenu={onNodeContextMenu} | |||||
| edges={edges} | edges={edges} | ||||
| onEdgesChange={onEdgesChange} | onEdgesChange={onEdgesChange} | ||||
| // fitView | |||||
| fitView | |||||
| onConnect={onConnect} | onConnect={onConnect} | ||||
| nodeTypes={nodeTypes} | nodeTypes={nodeTypes} | ||||
| onPaneClick={onPaneClick} | |||||
| onDrop={onDrop} | |||||
| onDragOver={onDragOver} | |||||
| onNodeClick={onNodeClick} | |||||
| onInit={setReactFlowInstance} | |||||
| > | > | ||||
| <Background /> | <Background /> | ||||
| <Controls /> | <Controls /> | ||||
| {Object.keys(menu).length > 0 && ( | |||||
| <NodeContextMenu onClick={onPaneClick} {...(menu as any)} /> | |||||
| )} | |||||
| </ReactFlow> | </ReactFlow> | ||||
| <FlowDrawer visible={drawerVisible} hideModal={hideDrawer}></FlowDrawer> | |||||
| </div> | </div> | ||||
| ); | ); | ||||
| } | } |
| 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; |
| import { Avatar, Card, Flex, Layout, Space } from 'antd'; | import { Avatar, Card, Flex, Layout, Space } from 'antd'; | ||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||
| import { useState } from 'react'; | |||||
| import { componentList } from '../mock'; | import { componentList } from '../mock'; | ||||
| import { useHandleDrag } from '../hooks'; | import { useHandleDrag } from '../hooks'; | ||||
| const { Sider } = Layout; | 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 ( | return ( | ||||
| <Sider | <Sider | ||||
| hoverable | hoverable | ||||
| draggable | draggable | ||||
| className={classNames(styles.operatorCard)} | className={classNames(styles.operatorCard)} | ||||
| onDragStart={handleDrag(x.name)} | |||||
| onDragStart={handleDragStart(x.name)} | |||||
| > | > | ||||
| <Flex justify="space-between" align="center"> | <Flex justify="space-between" align="center"> | ||||
| <Space size={15}> | <Space size={15}> | ||||
| ); | ); | ||||
| }; | }; | ||||
| export default FlowSider; | |||||
| export default FlowSide; |
| 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 = () => { | export const useHandleDrag = () => { | ||||
| const handleDrag = useCallback( | |||||
| const handleDragStart = useCallback( | |||||
| (operatorId: string) => (ev: React.DragEvent<HTMLDivElement>) => { | (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[]>>) => { | 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, | |||||
| }; | |||||
| }; | }; |
| import { Layout } from 'antd'; | import { Layout } from 'antd'; | ||||
| import { useState } from 'react'; | |||||
| import { ReactFlowProvider } from 'reactflow'; | |||||
| import FlowCanvas from './canvas'; | import FlowCanvas from './canvas'; | ||||
| import Sider from './flow-sider'; | import Sider from './flow-sider'; | ||||
| const { Content } = Layout; | const { Content } = Layout; | ||||
| function RagFlow() { | function RagFlow() { | ||||
| const [collapsed, setCollapsed] = useState(false); | |||||
| return ( | 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> | </Layout> | ||||
| ); | ); | ||||
| } | } |