### What problem does this PR solve? feat: add FlowHeader and delete edge #918 ### Type of change - [x] New Feature (non-breaking change which adds functionality)tags/v0.7.0
| "classnames": "^2.5.1", | "classnames": "^2.5.1", | ||||
| "dagre": "^0.8.5", | "dagre": "^0.8.5", | ||||
| "dayjs": "^1.11.10", | "dayjs": "^1.11.10", | ||||
| "elkjs": "^0.9.3", | |||||
| "eventsource-parser": "^1.1.2", | "eventsource-parser": "^1.1.2", | ||||
| "i18next": "^23.7.16", | "i18next": "^23.7.16", | ||||
| "i18next-browser-languagedetector": "^8.0.0", | "i18next-browser-languagedetector": "^8.0.0", | ||||
| "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.767.tgz", | "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.4.767.tgz", | ||||
| "integrity": "sha512-nzzHfmQqBss7CE3apQHkHjXW77+8w3ubGCIoEijKCJebPufREaFETgGXWTkh32t259F3Kcq+R8MZdFdOJROgYw==" | "integrity": "sha512-nzzHfmQqBss7CE3apQHkHjXW77+8w3ubGCIoEijKCJebPufREaFETgGXWTkh32t259F3Kcq+R8MZdFdOJROgYw==" | ||||
| }, | }, | ||||
| "node_modules/elkjs": { | |||||
| "version": "0.9.3", | |||||
| "resolved": "https://registry.npmmirror.com/elkjs/-/elkjs-0.9.3.tgz", | |||||
| "integrity": "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==" | |||||
| }, | |||||
| "node_modules/elliptic": { | "node_modules/elliptic": { | ||||
| "version": "6.5.5", | "version": "6.5.5", | ||||
| "resolved": "https://registry.npmmirror.com/elliptic/-/elliptic-6.5.5.tgz", | "resolved": "https://registry.npmmirror.com/elliptic/-/elliptic-6.5.5.tgz", |
| "classnames": "^2.5.1", | "classnames": "^2.5.1", | ||||
| "dagre": "^0.8.5", | "dagre": "^0.8.5", | ||||
| "dayjs": "^1.11.10", | "dayjs": "^1.11.10", | ||||
| "elkjs": "^0.9.3", | |||||
| "eventsource-parser": "^1.1.2", | "eventsource-parser": "^1.1.2", | ||||
| "i18next": "^23.7.16", | "i18next": "^23.7.16", | ||||
| "i18next-browser-languagedetector": "^8.0.0", | "i18next-browser-languagedetector": "^8.0.0", |
| copied: '複製成功', | copied: '複製成功', | ||||
| comingSoon: '即將推出', | comingSoon: '即將推出', | ||||
| download: '下載', | download: '下載', | ||||
| close: '关闭', | |||||
| close: '關閉', | |||||
| preview: '預覽', | preview: '預覽', | ||||
| }, | }, | ||||
| login: { | login: { |
| // event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0, | // event.clientY >= pane.height - 200 ? pane.height - event.clientY : 0, | ||||
| // }); | // }); | ||||
| console.info('clientX:', event.clientX); | |||||
| console.info('clientY:', event.clientY); | |||||
| setMenu({ | setMenu({ | ||||
| id: node.id, | id: node.id, | ||||
| top: event.clientY - 72, | top: event.clientY - 72, |
| import { NodeContextMenu, useHandleNodeContextMenu } from './context-menu'; | import { NodeContextMenu, useHandleNodeContextMenu } from './context-menu'; | ||||
| import FlowDrawer from '../flow-drawer'; | import FlowDrawer from '../flow-drawer'; | ||||
| import { useHandleDrop, useShowDrawer } from '../hooks'; | |||||
| import { initialEdges, initialNodes } from '../mock'; | |||||
| import { getLayoutedElements } from '../utils'; | |||||
| import { | |||||
| useHandleDrop, | |||||
| useHandleKeyUp, | |||||
| useHandleSelectionChange, | |||||
| useShowDrawer, | |||||
| } from '../hooks'; | |||||
| import { dsl } from '../mock'; | |||||
| import { TextUpdaterNode } from './node'; | import { TextUpdaterNode } from './node'; | ||||
| const nodeTypes = { textUpdater: TextUpdaterNode }; | const nodeTypes = { textUpdater: TextUpdaterNode }; | ||||
| } | } | ||||
| function FlowCanvas({ sideWidth }: IProps) { | function FlowCanvas({ sideWidth }: IProps) { | ||||
| const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements( | |||||
| initialNodes, | |||||
| initialEdges, | |||||
| 'LR', | |||||
| ); | |||||
| const [nodes, setNodes] = useState<Node[]>(layoutedNodes); | |||||
| const [edges, setEdges] = useState<Edge[]>(layoutedEdges); | |||||
| const [nodes, setNodes] = useState<Node[]>(dsl.graph.nodes); | |||||
| const [edges, setEdges] = useState<Edge[]>(dsl.graph.edges); | |||||
| const { selectedEdges, selectedNodes } = useHandleSelectionChange(); | |||||
| const { ref, menu, onNodeContextMenu, onPaneClick } = | const { ref, menu, onNodeContextMenu, onPaneClick } = | ||||
| useHandleNodeContextMenu(sideWidth); | useHandleNodeContextMenu(sideWidth); | ||||
| const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); | const { drawerVisible, hideDrawer, showDrawer } = useShowDrawer(); | ||||
| const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop(setNodes); | const { onDrop, onDragOver, setReactFlowInstance } = useHandleDrop(setNodes); | ||||
| const { handleKeyUp } = useHandleKeyUp(selectedEdges, selectedNodes); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| console.info('nodes:', nodes); | console.info('nodes:', nodes); | ||||
| console.info('edges:', edges); | console.info('edges:', edges); | ||||
| onDragOver={onDragOver} | onDragOver={onDragOver} | ||||
| onNodeClick={onNodeClick} | onNodeClick={onNodeClick} | ||||
| onInit={setReactFlowInstance} | onInit={setReactFlowInstance} | ||||
| onKeyUp={handleKeyUp} | |||||
| > | > | ||||
| <Background /> | <Background /> | ||||
| <Controls /> | <Controls /> |
| import { useCallback, useLayoutEffect } from 'react'; | |||||
| import { getLayoutedElements } from './elk-utils'; | |||||
| export const elkOptions = { | |||||
| 'elk.algorithm': 'layered', | |||||
| 'elk.layered.spacing.nodeNodeBetweenLayers': '100', | |||||
| 'elk.spacing.nodeNode': '80', | |||||
| }; | |||||
| export const useLayoutGraph = ( | |||||
| initialNodes, | |||||
| initialEdges, | |||||
| setNodes, | |||||
| setEdges, | |||||
| ) => { | |||||
| const onLayout = useCallback(({ direction, useInitialNodes = false }) => { | |||||
| const opts = { 'elk.direction': direction, ...elkOptions }; | |||||
| const ns = initialNodes; | |||||
| const es = initialEdges; | |||||
| getLayoutedElements(ns, es, opts).then( | |||||
| ({ nodes: layoutedNodes, edges: layoutedEdges }) => { | |||||
| setNodes(layoutedNodes); | |||||
| setEdges(layoutedEdges); | |||||
| // window.requestAnimationFrame(() => fitView()); | |||||
| }, | |||||
| ); | |||||
| }, []); | |||||
| // Calculate the initial layout on mount. | |||||
| useLayoutEffect(() => { | |||||
| onLayout({ direction: 'RIGHT', useInitialNodes: true }); | |||||
| }, [onLayout]); | |||||
| }; |
| import ELK from 'elkjs/lib/elk.bundled.js'; | |||||
| import { Edge, Node } from 'reactflow'; | |||||
| const elk = new ELK(); | |||||
| export const getLayoutedElements = ( | |||||
| nodes: Node[], | |||||
| edges: Edge[], | |||||
| options = {}, | |||||
| ) => { | |||||
| const isHorizontal = options?.['elk.direction'] === 'RIGHT'; | |||||
| const graph = { | |||||
| id: 'root', | |||||
| layoutOptions: options, | |||||
| children: nodes.map((node) => ({ | |||||
| ...node, | |||||
| // Adjust the target and source handle positions based on the layout | |||||
| // direction. | |||||
| targetPosition: isHorizontal ? 'left' : 'top', | |||||
| sourcePosition: isHorizontal ? 'right' : 'bottom', | |||||
| // Hardcode a width and height for elk to use when layouting. | |||||
| width: 150, | |||||
| height: 50, | |||||
| })), | |||||
| edges: edges, | |||||
| }; | |||||
| return elk | |||||
| .layout(graph) | |||||
| .then((layoutedGraph) => ({ | |||||
| nodes: layoutedGraph.children.map((node) => ({ | |||||
| ...node, | |||||
| // React Flow expects a position property on the node instead of `x` | |||||
| // and `y` fields. | |||||
| position: { x: node.x, y: node.y }, | |||||
| })), | |||||
| edges: layoutedGraph.edges, | |||||
| })) | |||||
| .catch(console.error); | |||||
| }; |
| .flowHeader { | |||||
| padding: 20px; | |||||
| } |
| import { Button, Flex } from 'antd'; | |||||
| import { useSaveGraph } from '../hooks'; | |||||
| import styles from './index.less'; | |||||
| const FlowHeader = () => { | |||||
| const { saveGraph } = useSaveGraph(); | |||||
| return ( | |||||
| <Flex | |||||
| align="center" | |||||
| justify="end" | |||||
| gap={'large'} | |||||
| className={styles.flowHeader} | |||||
| > | |||||
| <Button> | |||||
| <b>Debug</b> | |||||
| </Button> | |||||
| <Button type="primary" onClick={saveGraph}> | |||||
| <b>Save</b> | |||||
| </Button> | |||||
| </Flex> | |||||
| ); | |||||
| }; | |||||
| export default FlowHeader; |
| import { useSetModalState } from '@/hooks/commonHooks'; | import { useSetModalState } from '@/hooks/commonHooks'; | ||||
| import React, { Dispatch, SetStateAction, useCallback, useState } from 'react'; | |||||
| import { Node, Position, ReactFlowInstance } from 'reactflow'; | |||||
| import React, { | |||||
| Dispatch, | |||||
| KeyboardEventHandler, | |||||
| SetStateAction, | |||||
| useCallback, | |||||
| useState, | |||||
| } from 'react'; | |||||
| import { | |||||
| Node, | |||||
| Position, | |||||
| ReactFlowInstance, | |||||
| useOnSelectionChange, | |||||
| useReactFlow, | |||||
| } from 'reactflow'; | |||||
| import { v4 as uuidv4 } from 'uuid'; | import { v4 as uuidv4 } from 'uuid'; | ||||
| export const useHandleDrag = () => { | export const useHandleDrag = () => { | ||||
| showDrawer, | showDrawer, | ||||
| }; | }; | ||||
| }; | }; | ||||
| export const useHandleSelectionChange = () => { | |||||
| const [selectedNodes, setSelectedNodes] = useState<string[]>([]); | |||||
| const [selectedEdges, setSelectedEdges] = useState<string[]>([]); | |||||
| useOnSelectionChange({ | |||||
| onChange: ({ nodes, edges }) => { | |||||
| setSelectedNodes(nodes.map((node) => node.id)); | |||||
| setSelectedEdges(edges.map((edge) => edge.id)); | |||||
| }, | |||||
| }); | |||||
| return { selectedEdges, selectedNodes }; | |||||
| }; | |||||
| export const useDeleteEdge = (selectedEdges: string[]) => { | |||||
| const { setEdges } = useReactFlow(); | |||||
| const deleteEdge = useCallback(() => { | |||||
| setEdges((edges) => | |||||
| edges.filter((edge) => selectedEdges.every((x) => x !== edge.id)), | |||||
| ); | |||||
| }, [setEdges, selectedEdges]); | |||||
| return deleteEdge; | |||||
| }; | |||||
| export const useHandleKeyUp = ( | |||||
| selectedEdges: string[], | |||||
| selectedNodes: string[], | |||||
| ) => { | |||||
| const deleteEdge = useDeleteEdge(selectedEdges); | |||||
| const handleKeyUp: KeyboardEventHandler = useCallback( | |||||
| (e) => { | |||||
| if (e.code === 'Delete') { | |||||
| deleteEdge(); | |||||
| } | |||||
| }, | |||||
| [deleteEdge], | |||||
| ); | |||||
| return { handleKeyUp }; | |||||
| }; | |||||
| export const useSaveGraph = () => { | |||||
| const saveGraph = useCallback(() => {}, []); | |||||
| return { saveGraph }; | |||||
| }; |
| import { ReactFlowProvider } from 'reactflow'; | import { ReactFlowProvider } from 'reactflow'; | ||||
| import FlowCanvas from './canvas'; | import FlowCanvas from './canvas'; | ||||
| import Sider from './flow-sider'; | import Sider from './flow-sider'; | ||||
| import FlowHeader from './header'; | |||||
| const { Content } = Layout; | const { Content } = Layout; | ||||
| <ReactFlowProvider> | <ReactFlowProvider> | ||||
| <Sider setCollapsed={setCollapsed} collapsed={collapsed}></Sider> | <Sider setCollapsed={setCollapsed} collapsed={collapsed}></Sider> | ||||
| <Layout> | <Layout> | ||||
| <FlowHeader></FlowHeader> | |||||
| <Content style={{ margin: '0 16px' }}> | <Content style={{ margin: '0 16px' }}> | ||||
| <FlowCanvas sideWidth={collapsed ? 0 : 200}></FlowCanvas> | <FlowCanvas sideWidth={collapsed ? 0 : 200}></FlowCanvas> | ||||
| </Content> | </Content> |
| ]; | ]; | ||||
| export const dsl = { | export const dsl = { | ||||
| graph: { | |||||
| nodes: [ | |||||
| { | |||||
| id: 'begin', | |||||
| type: 'textUpdater', | |||||
| position: { | |||||
| x: 50, | |||||
| y: 200, | |||||
| }, | |||||
| data: { | |||||
| label: 'Begin', | |||||
| }, | |||||
| sourcePosition: 'left', | |||||
| targetPosition: 'right', | |||||
| }, | |||||
| { | |||||
| id: 'Answer:China', | |||||
| type: 'textUpdater', | |||||
| position: { | |||||
| x: 150, | |||||
| y: 200, | |||||
| }, | |||||
| data: { | |||||
| label: 'Answer', | |||||
| }, | |||||
| sourcePosition: 'left', | |||||
| targetPosition: 'right', | |||||
| }, | |||||
| { | |||||
| id: 'Retrieval:China', | |||||
| type: 'textUpdater', | |||||
| position: { | |||||
| x: 250, | |||||
| y: 200, | |||||
| }, | |||||
| data: { | |||||
| label: 'Retrieval', | |||||
| }, | |||||
| sourcePosition: 'left', | |||||
| targetPosition: 'right', | |||||
| }, | |||||
| { | |||||
| id: 'Generate:China', | |||||
| type: 'textUpdater', | |||||
| position: { | |||||
| x: 100, | |||||
| y: 100, | |||||
| }, | |||||
| data: { | |||||
| label: 'Generate', | |||||
| }, | |||||
| sourcePosition: 'left', | |||||
| targetPosition: 'right', | |||||
| }, | |||||
| ], | |||||
| edges: [ | |||||
| { | |||||
| id: '7facb53d-65c9-43b3-ac55-339c445d3891', | |||||
| label: '', | |||||
| source: 'begin', | |||||
| target: 'Answer:China', | |||||
| markerEnd: { | |||||
| type: 'arrow', | |||||
| }, | |||||
| }, | |||||
| { | |||||
| id: '7ac83631-502d-410f-a6e7-bec6866a5e99', | |||||
| label: '', | |||||
| source: 'Generate:China', | |||||
| target: 'Answer:China', | |||||
| markerEnd: { | |||||
| type: 'arrow', | |||||
| }, | |||||
| }, | |||||
| { | |||||
| id: '0aaab297-5779-43ed-9281-2c4d3741566f', | |||||
| label: '', | |||||
| source: 'Answer:China', | |||||
| target: 'Retrieval:China', | |||||
| markerEnd: { | |||||
| type: 'arrow', | |||||
| }, | |||||
| }, | |||||
| { | |||||
| id: '3477f9f3-0a7d-400e-af96-a11ea7673183', | |||||
| label: '', | |||||
| source: 'Retrieval:China', | |||||
| target: 'Generate:China', | |||||
| markerEnd: { | |||||
| type: 'arrow', | |||||
| }, | |||||
| }, | |||||
| ], | |||||
| }, | |||||
| components: { | components: { | ||||
| begin: { | begin: { | ||||
| obj: { | obj: { |
| import { DSLComponents } from '@/interfaces/database/flow'; | import { DSLComponents } from '@/interfaces/database/flow'; | ||||
| import dagre from 'dagre'; | import dagre from 'dagre'; | ||||
| import { Edge, Node, Position } from 'reactflow'; | |||||
| import { Edge, MarkerType, Node, Position } from 'reactflow'; | |||||
| import { v4 as uuidv4 } from 'uuid'; | import { v4 as uuidv4 } from 'uuid'; | ||||
| const buildEdges = ( | const buildEdges = ( | ||||
| allEdges.push({ | allEdges.push({ | ||||
| id: uuidv4(), | id: uuidv4(), | ||||
| label: '', | label: '', | ||||
| type: 'step', | |||||
| // type: 'step', | |||||
| source: source, | source: source, | ||||
| target: target, | target: target, | ||||
| markerEnd: { | |||||
| type: MarkerType.Arrow, | |||||
| }, | |||||
| }); | }); | ||||
| } | } | ||||
| }); | }); |