feat: add node and fix context menu position

This commit is contained in:
linonetwo 2023-07-13 11:09:36 +08:00 committed by lin onetwo
parent 40f35c3aa0
commit dac905cd1e
8 changed files with 135 additions and 237 deletions

View file

@ -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<ITheGraphProps> & 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<ITheGraphProps> & IGraphEditorProps)
// // Logic for applying autolayout
// };
const appReference = useRef<HTMLDivElement>(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<ITheGraphProps> & IGraphEditorProps)
// DEBUG: console library
console.log(`library`, library);
// DEBUG: console graph
console.log(`graph`, graph);
return (
<Container className={`the-graph-${theme}`}>
<TheGraph.App
ref={appReference}
readonly={readonly}
height={window.innerHeight}
width={window.innerWidth - sidebarWidth}
offsetX={sidebarWidth}
getMenuDef={getMenuDef}
onPanScale={handlePanScale}
{...props}
/>
<>
<TheGraphContainer className={`the-graph-${theme}`}>
<TheGraph.App
readonly={readonly}
height={window.innerHeight}
width={window.innerWidth}
offsetX={sidebarWidth}
getMenuDef={getMenuDef}
onPanScale={onPanScale}
onNodeSelection={onNodeSelection}
onEdgeSelection={onEdgeSelection}
{...props}
/>
</TheGraphContainer>
<ThumbnailContainer>
<TheGraph.nav.Component
height={162}
@ -141,11 +148,11 @@ export function GraphEditor(props: Partial<ITheGraphProps> & 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}
/>
</ThumbnailContainer>
<SearchComponents library={library} />
</Container>
<SearchComponents library={library} addNode={addNode} />
</>
);
}

View file

@ -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<OptionType[]>([]);
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);
}}
>
<NoFloIcon icon={option.icon} />
<NoFloIcon icon={option.component.icon} />
<SearchItemOptionText>
<ItemTitle>{option.title}</ItemTitle>
<ItemDescription>{option.description}</ItemDescription>
<ItemDescription>{option.component.description}</ItemDescription>
</SearchItemOptionText>
</Box>
)}

View file

@ -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;
}

View file

@ -30,7 +30,7 @@ export default function Workflow(): JSX.Element {
return graph && library
? (
<>
<GraphEditor theme={theme?.shouldUseDarkColors ? 'dark' : 'light'} library={library} graph={graph} />
<GraphEditor theme={theme?.shouldUseDarkColors ? 'dark' : 'light'} library={library} graph={graph} setGraph={setGraph} />
</>
)
: <div>{t('Loading')}</div>;

View file

@ -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);
// }
// };

View file

@ -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<HTMLDivElement>; graph: Graph; library?: TheGraph.IFBPLibrary }) {
const [selectedNodes, setSelectedNodes] = useState<any[]>([]);
const [selectedEdges, setSelectedEdges] = useState<any[]>([]);
export function useMouseEvents({ graph, library, setGraph }: { graph: Graph; library?: IFBPLibrary; setGraph: (graph: Graph) => void }) {
const [selectedNodes, setSelectedNodes] = useState<GraphNode[]>([]);
const [selectedEdges, setSelectedEdges] = useState<GraphEdge[]>([]);
const [_, triggerForceRerender] = useToggle();
const [icons, setIcons] = useState<any[]>([]);
const [pan, setPan] = useState<[number, number]>([0, 0]);
const [scale, setScale] = useState<number>(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,
};
}

View file

@ -1,70 +0,0 @@
import { useEffect, useState } from 'react';
export function useOnChange(props) {
const { graph, unsubscribeGraph, svgcontainer, subscribeGraph } = props;
const [selectedNodesHash, setSelectedNodesHash] = useState<any>({});
const [errorNodes, setErrorNodes] = useState<any[]>([]);
const [animatedEdges, setAnimatedEdges] = useState<any[]>([]);
// 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]);
}

2
src/type.d.ts vendored
View file

@ -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<HTMLDivElement>;