From dac905cd1eda7d67213cdc9cd60f776bdacbb4ee Mon Sep 17 00:00:00 2001 From: linonetwo Date: Thu, 13 Jul 2023 11:09:36 +0800 Subject: [PATCH] feat: add node and fix context menu position --- src/pages/Workflow/GraphEditor.tsx | 63 ++++--- .../Workflow/components/SearchComponents.tsx | 39 ++-- src/pages/Workflow/idUtils.ts | 10 ++ src/pages/Workflow/index.tsx | 2 +- src/pages/Workflow/library.ts | 18 ++ src/pages/Workflow/mouseEvents.ts | 168 +++++------------- src/pages/Workflow/onChange.ts | 70 -------- src/type.d.ts | 2 + 8 files changed, 135 insertions(+), 237 deletions(-) create mode 100644 src/pages/Workflow/idUtils.ts delete mode 100644 src/pages/Workflow/onChange.ts diff --git a/src/pages/Workflow/GraphEditor.tsx b/src/pages/Workflow/GraphEditor.tsx index 3b8c44c8..bbdb0091 100644 --- a/src/pages/Workflow/GraphEditor.tsx +++ b/src/pages/Workflow/GraphEditor.tsx @@ -10,15 +10,22 @@ import '@fortawesome/fontawesome-free/js/all.js'; import '@fortawesome/fontawesome-free/css/all.css'; import '@fortawesome/fontawesome-free/css/v4-font-face.css'; -import { useRef } from 'react'; import { SearchComponents } from './components/SearchComponents'; import { useMenu } from './menu'; import { useMouseEvents } from './mouseEvents'; import { useSubscribeGraph } from './subscribe'; -const Container = styled.main` +const TheGraphContainer = styled.main` + /** + logic inside the-graph calculate mouse event position xy using window.innerWidth, + so we have to let it be full-screen so it can calculate correctly. + And we hide the left side overflow to let it looks like it's not full-screen (when left sidebar opened). + */ + width: ${window.innerWidth - sidebarWidth}px; + overflow-x: hidden; + left: ${sidebarWidth}px; .the-graph-app > svg, .the-graph-app > canvas { - /* left: ${sidebarWidth}px!important; */ + left: -${sidebarWidth}px !important; } &.the-graph-light .the-graph-app, &.the-graph-dark .the-graph-app { background-color: ${({ theme }) => theme.palette.background.default}; @@ -69,13 +76,14 @@ export interface IGraphEditorProps { selectedNodes?: any[]; selectedNodesHash?: any; + setGraph: (graph: Graph) => void; snap?: number; theme: 'light' | 'dark'; width?: number; } export function GraphEditor(props: Partial & IGraphEditorProps) { - const { library, theme, graph, readonly = false } = props; + const { library, theme, graph, readonly = false, setGraph } = props; // const initializeAutolayouter = () => { // // Logic for initializing autolayouter // const auto = klayNoflo.init({ @@ -88,27 +96,21 @@ export function GraphEditor(props: Partial & IGraphEditorProps) // // Logic for applying autolayout // }; - const appReference = useRef(null); - // methods const { subscribeGraph, unsubscribeGraph } = useSubscribeGraph({ readonly }); const { pan, scale, - handleEdgeSelection, - handleNodeSelection, - handlePanScale, + onEdgeSelection, + onNodeSelection, + onPanScale, // triggerAutolayout, // applyAutolayout, - triggerFit, addNode, - getPan, - focusNode, - registerComponent, } = useMouseEvents({ graph, library, - appReference, + setGraph, }); const { addMenu, addMenuCallback, addMenuAction, getMenuDef } = useMenu(); @@ -121,19 +123,24 @@ export function GraphEditor(props: Partial & IGraphEditorProps) // DEBUG: console library console.log(`library`, library); + // DEBUG: console graph + console.log(`graph`, graph); return ( - - + <> + + + & IGraphEditorProps) graph={graph} onTap={fitGraphInView} onPanTo={panEditorTo} - viewrectangle={[...pan, window.innerWidth, window.innerHeight]} + viewrectangle={[pan[0] + sidebarWidth, pan[1], window.innerWidth , window.innerHeight]} viewscale={scale} /> - - + + ); } diff --git a/src/pages/Workflow/components/SearchComponents.tsx b/src/pages/Workflow/components/SearchComponents.tsx index d551bb18..6da0f144 100644 --- a/src/pages/Workflow/components/SearchComponents.tsx +++ b/src/pages/Workflow/components/SearchComponents.tsx @@ -1,22 +1,13 @@ import { sidebarWidth } from '@/constants/style'; import { Autocomplete, autocompleteClasses, Box, createFilterOptions, TextField } from '@material-ui/core'; +import { PropertyMap } from 'fbp-graph/lib/Types'; import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; -import type { IFBPLibrary } from 'the-graph'; +import type { IFBPLibrary, INoFloUIComponent } from 'the-graph'; +import { makeNewID } from '../idUtils'; import { NoFloIcon } from './NoFloIcon'; -interface SearchBarProps { - library?: IFBPLibrary; -} - -interface OptionType { - description: string; - groupName: string; - icon: string; - title: string; -} - const SearchBarWrapper = styled.div` position: absolute; left: ${sidebarWidth}px; @@ -62,7 +53,18 @@ const filterOptions = createFilterOptions({ stringify: (option: OptionType) => option.groupName + option.title, }); -export function SearchComponents({ library }: SearchBarProps) { +interface OptionType { + component: INoFloUIComponent; + groupName: string; + title: string; +} + +interface SearchBarProps { + addNode: (component: INoFloUIComponent) => void; + library?: IFBPLibrary; +} + +export function SearchComponents({ library, addNode }: SearchBarProps) { const [options, setOptions] = useState([]); const { t } = useTranslation(); const components = useMemo(() => Object.values(library ?? {}), [library]); @@ -73,11 +75,9 @@ export function SearchComponents({ library }: SearchBarProps) { return { groupName: splitName[0], title: splitName[1] ?? component.name, - icon: component.icon, - description: component.description, + component, }; }); - setOptions(newOptions); }, [components]); @@ -99,11 +99,14 @@ export function SearchComponents({ library }: SearchBarProps) { }} component='li' {...props} + onClick={() => { + addNode(option.component); + }} > - + {option.title} - {option.description} + {option.component.description} )} diff --git a/src/pages/Workflow/idUtils.ts b/src/pages/Workflow/idUtils.ts new file mode 100644 index 00000000..f8cef8c6 --- /dev/null +++ b/src/pages/Workflow/idUtils.ts @@ -0,0 +1,10 @@ +/** + * @param componentName `node.component` + * @url https://github.com/flowhub/the-graph/blob/b4ca641f4ace6181e14068f84658c502166022fb/the-graph-editor/clipboard.js + */ +export function makeNewID(componentName: string) { + let number_ = 60_466_176; // 36^5 + number_ = Math.floor(Math.random() * number_); + const id = `${componentName}_${number_.toString(36)}`; + return id; +} diff --git a/src/pages/Workflow/index.tsx b/src/pages/Workflow/index.tsx index 1813c07b..66169042 100644 --- a/src/pages/Workflow/index.tsx +++ b/src/pages/Workflow/index.tsx @@ -30,7 +30,7 @@ export default function Workflow(): JSX.Element { return graph && library ? ( <> - + ) :
{t('Loading')}
; diff --git a/src/pages/Workflow/library.ts b/src/pages/Workflow/library.ts index 9a1f3327..07b5eb0f 100644 --- a/src/pages/Workflow/library.ts +++ b/src/pages/Workflow/library.ts @@ -92,3 +92,21 @@ export async function getBrowserComponentLibrary() { }); return libraryToLoad; } + +// const registerComponent = (definition: Component, generated: boolean) => { +// const component = getComponent(definition.name); +// if (component && generated) { +// return; +// } +// if (library === undefined) return; +// library[definition.name] = definition; +// // debounceLibraryRefesh(); +// if (definition.name.includes('/')) { +// const unnamespaced = unnamespace(definition.name); +// registerComponent({ +// ...definition, +// name: unnamespaced, +// unnamespaced: true, +// }, false); +// } +// }; \ No newline at end of file diff --git a/src/pages/Workflow/mouseEvents.ts b/src/pages/Workflow/mouseEvents.ts index 3547da5a..38366fb5 100644 --- a/src/pages/Workflow/mouseEvents.ts +++ b/src/pages/Workflow/mouseEvents.ts @@ -1,9 +1,10 @@ /* eslint-disable @typescript-eslint/strict-boolean-expressions */ +import useToggle from 'beautiful-react-hooks/useToggle'; import type { Graph } from 'fbp-graph'; -import { RefObject, useCallback, useState } from 'react'; -import TheGraph from 'the-graph'; -import type { Component } from 'noflo'; -import { PropertyMap } from 'fbp-graph/lib/Types'; +import { GraphEdge, GraphNode } from 'fbp-graph/lib/Types'; +import { useCallback, useState } from 'react'; +import { IFBPLibrary, INoFloUIComponent } from 'the-graph'; +import { makeNewID } from './idUtils'; const unnamespace = (name: string) => { if (!name.includes('/')) { @@ -12,161 +13,88 @@ const unnamespace = (name: string) => { return name.split('/').pop() as string; }; -export function useMouseEvents({ graph, library, appReference }: { appReference: RefObject; graph: Graph; library?: TheGraph.IFBPLibrary }) { - const [selectedNodes, setSelectedNodes] = useState([]); - const [selectedEdges, setSelectedEdges] = useState([]); +export function useMouseEvents({ graph, library, setGraph }: { graph: Graph; library?: IFBPLibrary; setGraph: (graph: Graph) => void }) { + const [selectedNodes, setSelectedNodes] = useState([]); + const [selectedEdges, setSelectedEdges] = useState([]); + const [_, triggerForceRerender] = useToggle(); const [icons, setIcons] = useState([]); const [pan, setPan] = useState<[number, number]>([0, 0]); const [scale, setScale] = useState(1); - const handleEdgeSelection = useCallback((itemKey: any, item: any, toggle: boolean) => { - if (itemKey === undefined) { + const onEdgeSelection = useCallback((edgeID: string, edge: GraphEdge, toggle: boolean) => { + if (edgeID === undefined) { setSelectedEdges([]); } else if (toggle) { - setSelectedEdges((edges) => { - const index = edges.indexOf(item); + setSelectedEdges((previousEdges) => { + const index = previousEdges.indexOf(edge); const isSelected = index !== -1; - const shallowClone = [...edges]; + const shallowClone = [...previousEdges]; if (isSelected) { shallowClone.splice(index, 1); } else { - shallowClone.push(item); + shallowClone.push(edge); } return shallowClone; }); } else { - setSelectedEdges([item]); + setSelectedEdges([edge]); } }, []); - const handleNodeSelection = useCallback((itemKey: any, item: any, toggle: boolean) => { - if (itemKey === undefined) { + const onNodeSelection = useCallback((nodeID: string, node: GraphNode, toggle: boolean) => { + if (nodeID === undefined) { setSelectedNodes([]); } else if (toggle) { - setSelectedNodes((nodes) => { - const index = nodes.indexOf(item); + setSelectedNodes((previousNodes) => { + const index = previousNodes.indexOf(node); const isSelected = index !== -1; - const shallowClone = [...nodes]; + const shallowClone = [...previousNodes]; if (isSelected) { shallowClone.splice(index, 1); } else { - shallowClone.push(item); + shallowClone.push(node); } return shallowClone; }); } else { - setSelectedNodes([item]); + setSelectedNodes([node]); } }, []); - const handlePanScale = useCallback((x: number, y: number, scale: number) => { + const onPanScale = useCallback((x: number, y: number, scale: number) => { setPan([-x, -y]); setScale(scale); }, []); - // const triggerAutolayout = () => { - // const portInfo = graphView ? graphView.portInfo : null; - // autolayouter.layout({ - // graph, - // portInfo, - // direction: 'RIGHT', - // options: { - // intCoordinates: true, - // algorithm: 'de.cau.cs.kieler.klay.layered', - // layoutHierarchy: true, - // spacing: 36, - // borderSpacing: 20, - // edgeSpacingFactor: 0.2, - // inLayerSpacingFactor: 2, - // nodePlace: 'BRANDES_KOEPF', - // nodeLayering: 'NETWORK_SIMPLEX', - // edgeRouting: 'POLYLINE', - // crossMin: 'LAYER_SWEEP', - // direction: 'RIGHT', - // }, - // }); - // }; - - // const applyAutolayout = (keilerGraph) => { - // graph.startTransaction('autolayout'); - // TheGraph.autolayout.applyToGraph(graph, keilerGraph, { snap }); - // graph.endTransaction('autolayout'); - // triggerFit(); - // }; - - const triggerFit = () => { - if (appReference.current) { - appReference.current.triggerFit(); - } - }; - - const addNode = (id: string, component: string, metadata?: PropertyMap | undefined) => { - if (graph) { - graph.addNode(id, component, metadata); - } - }; - - const getPan = () => { - if (!appReference.current) { - return [0, 0]; - } - return [appReference.current.state.x, appReference.current.state.y]; - }; - - const focusNode = (node) => { - appReference.current.focusNode(node); - }; - - const getComponent = (name: string): Component | undefined => { - return library?.[name]; - }; - - const registerComponent = (definition: Component, generated: boolean) => { - const component = getComponent(definition.name); - if (component && generated) { - return; - } - if (library === undefined) return; - library[definition.name] = definition; - // debounceLibraryRefesh(); - if (definition.name.includes('/')) { - const unnamespaced = unnamespace(definition.name); - registerComponent({ - ...definition, - name: unnamespaced, - unnamespaced: true, - }, false); - } - }; - - // const debounceLibraryRefesh = () => { - // // Breaking the "no debounce" rule, this fixes #76 for subgraphs - // if (props.debounceLibraryRefeshTimer) { - // clearTimeout(props.debounceLibraryRefeshTimer); - // } - // props.debounceLibraryRefeshTimer = setTimeout(() => { - // if (graphView) { - // graphView.markDirty({ libraryDirty: true }); - // } - // }, 200); - // }; + const addNode = useCallback((component: INoFloUIComponent) => { + const componentName = component.name; + const id = makeNewID(componentName); + graph.startTransaction('addnode'); + const nameParts = componentName.split('/'); + graph.addNode(id, componentName, { + label: nameParts.at(-1), + x: Math.floor((-pan[0] + 334) / scale), + y: Math.floor((-pan[1] + 100) / scale), + }); + // Add IIPs for default values + component.inports?.forEach?.((port) => { + const value = port.default; + if (value !== undefined) { + graph.addInitial(value, id, port.name); + } + }); + graph.endTransaction('addnode'); + // useState to trigger rerender + setGraph(graph); + }, [graph, pan, scale, setGraph]); return { pan, scale, - handleEdgeSelection, - handleNodeSelection, - handlePanScale, - // triggerAutolayout, - // applyAutolayout, - triggerFit, + onEdgeSelection, + onNodeSelection, + onPanScale, addNode, - getPan, - focusNode, - registerComponent, - getComponent, - // toJSON, - // debounceLibraryRefesh, }; } diff --git a/src/pages/Workflow/onChange.ts b/src/pages/Workflow/onChange.ts deleted file mode 100644 index 197ee17c..00000000 --- a/src/pages/Workflow/onChange.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useEffect, useState } from 'react'; - -export function useOnChange(props) { - const { graph, unsubscribeGraph, svgcontainer, subscribeGraph } = props; - const [selectedNodesHash, setSelectedNodesHash] = useState({}); - const [errorNodes, setErrorNodes] = useState([]); - const [animatedEdges, setAnimatedEdges] = useState([]); - - // useEffect(() => { - // // equivalent to `graphChanged` in the Polymer component - - // if (graph) { - // unsubscribeGraph(graph); - // } - - // if (props.appView) { - // ReactDOM.unmountComponentAtNode(svgcontainer); - // } - - // if (graph) { - // subscribeGraph(graph); - // } - // }, [props.graph]); - - // useEffect(() => { - // // equivalent to `selectedNodesChanged` in the Polymer component - // const newSelectedNodesHash = {}; - // props.selectedNodes.forEach((item) => { - // newSelectedNodesHash[item.id] = true; - // }); - // setSelectedNodesHash(newSelectedNodesHash); - // }, [props.selectedNodes]); - - // useEffect(() => { - // // equivalent to `selectedNodesHashChanged` in the Polymer component - // if (graphView) { - // graphView.setSelectedNodes(selectedNodesHash); - // } - // }, [selectedNodesHash, graphView]); - - // useEffect(() => { - // // equivalent to `errorNodesChanged` in the Polymer component - // if (graphView) { - // graphView.setErrorNodes(props.errorNodes); - // } - // }, [props.errorNodes, graphView]); - - // useEffect(() => { - // // equivalent to `selectedEdgesChanged` in the Polymer component - // if (graphView) { - // graphView.setSelectedEdges(props.selectedEdges); - // } - // }, [props.selectedEdges, graphView]); - - // useEffect(() => { - // // equivalent to `animatedEdgesChanged` in the Polymer component - // if (graphView) { - // graphView.setAnimatedEdges(props.animatedEdges); - // } - // }, [props.animatedEdges, graphView]); - - // useEffect(() => { - // // equivalent to `iconsChanged` in the Polymer component - // if (graphView) { - // Object.keys(props.icons).forEach((nodeId) => { - // graphView.updateIcon(nodeId, props.icons[nodeId]); - // }); - // } - // }, [props.icons, graphView]); -} diff --git a/src/type.d.ts b/src/type.d.ts index e296b16d..b1a24f7f 100644 --- a/src/type.d.ts +++ b/src/type.d.ts @@ -23,6 +23,8 @@ declare module 'the-graph' { height: number | string; library?: IFBPLibrary; offsetX?: number; + onEdgeSelection: (edgeID: string, edge: any, toggle: boolean) => void; + onNodeSelection: (nodeID: string, node: any, toggle: boolean) => void; onPanScale: (x: number, y: number, scale: number) => void; readonly: boolean; ref?: RefObject;