mirror of
https://github.com/tiddly-gittly/TidGi-Desktop.git
synced 2025-12-30 05:41:54 -08:00
feat: add node and fix context menu position
This commit is contained in:
parent
40f35c3aa0
commit
dac905cd1e
8 changed files with 135 additions and 237 deletions
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
10
src/pages/Workflow/idUtils.ts
Normal file
10
src/pages/Workflow/idUtils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
// }
|
||||
// };
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2
src/type.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue