├── .env
├── .gitignore
├── LICENSE
├── README.md
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── favicon.ico
├── index.html
└── robots.txt
├── src
├── App.tsx
├── components
│ ├── AutoSizeInput.tsx
│ ├── ClearModal.tsx
│ ├── Controls.tsx
│ ├── HelpModal.tsx
│ ├── InputSocket.tsx
│ ├── LoadModal.tsx
│ ├── Modal.tsx
│ ├── Node.tsx
│ ├── NodeContainer.tsx
│ ├── NodePicker.tsx
│ ├── OutputSocket.tsx
│ └── SaveModal.tsx
├── graph.json
├── hooks
│ ├── useChangeNodeData.ts
│ └── useOnPressKey.ts
├── index.css
├── index.tsx
├── react-app-env.d.ts
├── transformers
│ ├── behaveToFlow.ts
│ ├── flowToBehave.test.ts
│ └── flowToBehave.ts
└── util
│ ├── autoLayout.ts
│ ├── calculateNewEdge.ts
│ ├── colors.ts
│ ├── customNodeTypes.tsx
│ ├── getNodeSpecJSON.ts
│ ├── getPickerFilters.ts
│ ├── getSocketsByNodeTypeAndHandleType.ts
│ ├── hasPositionMetaData.ts
│ ├── isHandleConnected.ts
│ ├── isValidConnection.ts
│ └── sleep.ts
├── tailwind.config.js
└── tsconfig.json
/.env:
--------------------------------------------------------------------------------
1 | FAST_REFRESH=false
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 | .vscode
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Stuart Lee
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Behave Flow
2 |
3 | Behave Flow is a UI for editing [behave-graph](https://github.com/bhouston/behave-graph) behaviour graphs using [react-flow](https://github.com/wbkd/react-flow).
4 |
5 | 
6 |
7 |
8 | It's currently under active development, and likely to change rapidly as work continues.
9 |
10 | A live demo of the current state of the project can be found at https://behave-flow.netlify.app.
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "behavior-flow",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@fortawesome/fontawesome-svg-core": "^6.1.2",
7 | "@fortawesome/free-solid-svg-icons": "^6.1.2",
8 | "@fortawesome/react-fontawesome": "^0.2.0",
9 | "@testing-library/jest-dom": "^5.16.4",
10 | "@testing-library/react": "^13.3.0",
11 | "@testing-library/user-event": "^13.5.0",
12 | "@types/jest": "^27.5.2",
13 | "@types/node": "^16.11.47",
14 | "@types/react": "^18.0.15",
15 | "@types/react-dom": "^18.0.6",
16 | "behave-graph": "^0.9.9",
17 | "classnames": "^2.3.1",
18 | "downshift": "^6.1.7",
19 | "react": "^18.2.0",
20 | "react-dom": "^18.2.0",
21 | "react-scripts": "5.0.1",
22 | "reactflow": "^11.1.1",
23 | "typescript": "^4.7.4",
24 | "uuid": "^8.3.2",
25 | "web-vitals": "^2.1.4"
26 | },
27 | "scripts": {
28 | "start": "react-scripts start",
29 | "build": "react-scripts build",
30 | "test": "react-scripts test",
31 | "eject": "react-scripts eject"
32 | },
33 | "eslintConfig": {
34 | "extends": [
35 | "react-app",
36 | "react-app/jest"
37 | ]
38 | },
39 | "browserslist": {
40 | "production": [
41 | ">0.2%",
42 | "not dead",
43 | "not op_mini all"
44 | ],
45 | "development": [
46 | "last 1 chrome version",
47 | "last 1 firefox version",
48 | "last 1 safari version"
49 | ]
50 | },
51 | "devDependencies": {
52 | "@types/uuid": "^8.3.4",
53 | "autoprefixer": "^10.4.8",
54 | "postcss": "^8.4.14",
55 | "tailwindcss": "^3.1.7"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beeglebug/behave-flow/98e7d4022b3b24f64ee418dfa6281cb524c2fa28/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Behave Flow
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { MouseEvent as ReactMouseEvent, useCallback, useState } from "react";
2 | import ReactFlow, {
3 | Background,
4 | BackgroundVariant,
5 | Connection,
6 | OnConnectStartParams,
7 | useEdgesState,
8 | useNodesState,
9 | XYPosition,
10 | } from "reactflow";
11 | import { v4 as uuidv4 } from "uuid";
12 | import { behaveToFlow } from "./transformers/behaveToFlow";
13 | import { customNodeTypes } from "./util/customNodeTypes";
14 | import Controls from "./components/Controls";
15 | import rawGraphJSON from "./graph.json";
16 | import { GraphJSON } from "behave-graph";
17 | import NodePicker from "./components/NodePicker";
18 | import { getNodePickerFilters } from "./util/getPickerFilters";
19 | import { calculateNewEdge } from "./util/calculateNewEdge";
20 |
21 | const graphJSON = rawGraphJSON as GraphJSON;
22 |
23 | const [initialNodes, initialEdges] = behaveToFlow(graphJSON);
24 |
25 | function Flow() {
26 | const [nodePickerVisibility, setNodePickerVisibility] =
27 | useState();
28 | const [lastConnectStart, setLastConnectStart] =
29 | useState();
30 | const [nodes, , onNodesChange] = useNodesState(initialNodes);
31 | const [edges, , onEdgesChange] = useEdgesState(initialEdges);
32 |
33 | const onConnect = useCallback(
34 | (connection: Connection) => {
35 | if (connection.source === null) return;
36 | if (connection.target === null) return;
37 |
38 | const newEdge = {
39 | id: uuidv4(),
40 | source: connection.source,
41 | target: connection.target,
42 | sourceHandle: connection.sourceHandle,
43 | targetHandle: connection.targetHandle,
44 | };
45 | onEdgesChange([
46 | {
47 | type: "add",
48 | item: newEdge,
49 | },
50 | ]);
51 | },
52 | [onEdgesChange]
53 | );
54 |
55 | const handleAddNode = useCallback(
56 | (nodeType: string, position: XYPosition) => {
57 | closeNodePicker();
58 | const newNode = {
59 | id: uuidv4(),
60 | type: nodeType,
61 | position,
62 | data: {},
63 | };
64 | onNodesChange([
65 | {
66 | type: "add",
67 | item: newNode,
68 | },
69 | ]);
70 |
71 | if (lastConnectStart === undefined) return;
72 |
73 | // add an edge if we started on a socket
74 | const originNode = nodes.find(
75 | (node) => node.id === lastConnectStart.nodeId
76 | );
77 | if (originNode === undefined) return;
78 | onEdgesChange([
79 | {
80 | type: "add",
81 | item: calculateNewEdge(
82 | originNode,
83 | nodeType,
84 | newNode.id,
85 | lastConnectStart
86 | ),
87 | },
88 | ]);
89 | },
90 | [lastConnectStart, nodes, onEdgesChange, onNodesChange]
91 | );
92 |
93 | const handleStartConnect = (
94 | e: ReactMouseEvent,
95 | params: OnConnectStartParams
96 | ) => {
97 | setLastConnectStart(params);
98 | };
99 |
100 | const handleStopConnect = (e: MouseEvent) => {
101 | const element = e.target as HTMLElement;
102 | if (element.classList.contains("react-flow__pane")) {
103 | setNodePickerVisibility({ x: e.clientX, y: e.clientY });
104 | } else {
105 | setLastConnectStart(undefined);
106 | }
107 | };
108 |
109 | const closeNodePicker = () => {
110 | setLastConnectStart(undefined);
111 | setNodePickerVisibility(undefined);
112 | };
113 |
114 | const handlePaneClick = () => closeNodePicker();
115 |
116 | const handlePaneContextMenu = (e: ReactMouseEvent) => {
117 | e.preventDefault();
118 | setNodePickerVisibility({ x: e.clientX, y: e.clientY });
119 | };
120 |
121 | return (
122 |
136 |
137 |
142 | {nodePickerVisibility && (
143 |
149 | )}
150 |
151 | );
152 | }
153 |
154 | export default Flow;
155 |
--------------------------------------------------------------------------------
/src/components/AutoSizeInput.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CSSProperties,
3 | FC,
4 | HTMLProps,
5 | useCallback,
6 | useEffect,
7 | useRef,
8 | useState,
9 | } from "react";
10 |
11 | export type AutoSizeInputProps = HTMLProps & {
12 | minWidth?: number;
13 | };
14 |
15 | const baseStyles: CSSProperties = {
16 | position: "absolute",
17 | top: 0,
18 | left: 0,
19 | visibility: "hidden",
20 | height: 0,
21 | width: "auto",
22 | whiteSpace: "pre",
23 | };
24 |
25 | export const AutoSizeInput: FC = ({
26 | minWidth = 30,
27 | ...props
28 | }) => {
29 | const inputRef = useRef(null);
30 | const measureRef = useRef(null);
31 | const [styles, setStyles] = useState({});
32 |
33 | // grab the font size of the input on ref mount
34 | const setRef = useCallback((input: HTMLInputElement | null) => {
35 | if (input) {
36 | const styles = window.getComputedStyle(input);
37 | setStyles({
38 | fontSize: styles.getPropertyValue("font-size"),
39 | paddingLeft: styles.getPropertyValue("padding-left"),
40 | paddingRight: styles.getPropertyValue("padding-right"),
41 | });
42 | }
43 | inputRef.current = input;
44 | }, []);
45 |
46 | // measure the text on change and update input
47 | useEffect(() => {
48 | if (measureRef.current === null) return;
49 | if (inputRef.current === null) return;
50 |
51 | const width = measureRef.current.clientWidth;
52 | inputRef.current.style.width = Math.max(minWidth, width) + "px";
53 | }, [props.value, minWidth, styles]);
54 |
55 | return (
56 | <>
57 |
58 |
59 | {props.value}
60 |
61 | >
62 | );
63 | };
64 |
--------------------------------------------------------------------------------
/src/components/ClearModal.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { useReactFlow } from "reactflow";
3 | import { Modal } from "./Modal";
4 |
5 | export type ClearModalProps = {
6 | open?: boolean;
7 | onClose: () => void;
8 | };
9 |
10 | export const ClearModal: FC = ({ open = false, onClose }) => {
11 | const instance = useReactFlow();
12 |
13 | const handleClear = () => {
14 | instance.setNodes([]);
15 | instance.setEdges([]);
16 | // TODO better way to call fit vew after edges render
17 | setTimeout(() => {
18 | instance.fitView();
19 | }, 100);
20 | onClose();
21 | };
22 |
23 | return (
24 |
33 | Are you sure?
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/components/Controls.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | DefaultLogger,
3 | Engine,
4 | ManualLifecycleEventEmitter,
5 | readGraphFromJSON,
6 | registerCoreProfile,
7 | registerSceneProfile,
8 | Registry,
9 | } from "behave-graph";
10 | import { useState } from "react";
11 | import { ClearModal } from "./ClearModal";
12 | import { HelpModal } from "./HelpModal";
13 | import {
14 | faDownload,
15 | faPlay,
16 | faQuestion,
17 | faTrash,
18 | faUpload,
19 | } from "@fortawesome/free-solid-svg-icons";
20 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
21 |
22 | import { LoadModal } from "./LoadModal";
23 | import { SaveModal } from "./SaveModal";
24 | import { flowToBehave } from "../transformers/flowToBehave";
25 | import { useReactFlow, Controls, ControlButton } from "reactflow";
26 | import { sleep } from "../util/sleep";
27 |
28 | const CustomControls = () => {
29 | const [loadModalOpen, setLoadModalOpen] = useState(false);
30 | const [saveModalOpen, setSaveModalOpen] = useState(false);
31 | const [helpModalOpen, setHelpModalOpen] = useState(false);
32 | const [clearModalOpen, setClearModalOpen] = useState(false);
33 | const instance = useReactFlow();
34 |
35 | const handleRun = async () => {
36 | const registry = new Registry();
37 | const logger = new DefaultLogger();
38 | const manualLifecycleEventEmitter = new ManualLifecycleEventEmitter();
39 | registerCoreProfile(registry, logger, manualLifecycleEventEmitter);
40 | registerSceneProfile(registry);
41 |
42 | const nodes = instance.getNodes();
43 | const edges = instance.getEdges();
44 | const graphJson = flowToBehave(nodes, edges);
45 | const graph = readGraphFromJSON(graphJson, registry);
46 |
47 | const engine = new Engine(graph);
48 |
49 |
50 | if (manualLifecycleEventEmitter.startEvent.listenerCount > 0) {
51 | manualLifecycleEventEmitter.startEvent.emit();
52 | await engine.executeAllAsync(5);
53 | }
54 |
55 | if (manualLifecycleEventEmitter.tickEvent.listenerCount > 0) {
56 | const iterations = 20;
57 | const tickDuration = 0.01;
58 | for (let tick = 0; tick < iterations; tick++) {
59 | manualLifecycleEventEmitter.tickEvent.emit();
60 | engine.executeAllSync(tickDuration);
61 | await sleep( tickDuration );
62 | }
63 | }
64 |
65 | if (manualLifecycleEventEmitter.endEvent.listenerCount > 0) {
66 | manualLifecycleEventEmitter.endEvent.emit();
67 | await engine.executeAllAsync(5);
68 | }
69 | };
70 |
71 | return (
72 | <>
73 |
74 | setHelpModalOpen(true)}>
75 |
76 |
77 | setLoadModalOpen(true)}>
78 |
79 |
80 | setSaveModalOpen(true)}>
81 |
82 |
83 | setClearModalOpen(true)}>
84 |
85 |
86 | handleRun()}>
87 |
88 |
89 |
90 | setLoadModalOpen(false)} />
91 | setSaveModalOpen(false)} />
92 | setHelpModalOpen(false)} />
93 | setClearModalOpen(false)}
96 | />
97 | >
98 | );
99 | };
100 |
101 | export default CustomControls;
102 |
--------------------------------------------------------------------------------
/src/components/HelpModal.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from "react";
2 | import { Modal } from "./Modal";
3 |
4 | export type HelpModalProps = {
5 | open?: boolean;
6 | onClose: () => void;
7 | };
8 |
9 | export const HelpModal: FC = ({ open = false, onClose }) => {
10 | return (
11 |
17 | Right click anywhere to add a new node.
18 |
19 | Drag a connection into empty space to add a new node and connect it to
20 | the source.
21 |
22 |
23 | Click and drag on a socket to connect to another socket of the same
24 | type.
25 |
26 |
27 | Left click to select nodes or connections, backspace to delete selected
28 | nodes or connections.
29 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/InputSocket.tsx:
--------------------------------------------------------------------------------
1 | import { faCaretRight } from "@fortawesome/free-solid-svg-icons";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { Connection, Handle, Position, useReactFlow } from "reactflow";
4 | import cx from "classnames";
5 | import { colors, valueTypeColorMap } from "../util/colors";
6 | import { InputSocketSpecJSON } from "behave-graph";
7 | import { isValidConnection } from "../util/isValidConnection";
8 | import { AutoSizeInput } from "./AutoSizeInput";
9 |
10 | export type InputSocketProps = {
11 | connected: boolean;
12 | value: any | undefined;
13 | onChange: (key: string, value: any) => void;
14 | } & InputSocketSpecJSON;
15 |
16 | export default function InputSocket({
17 | connected,
18 | value,
19 | onChange,
20 | name,
21 | valueType,
22 | defaultValue,
23 | }: InputSocketProps) {
24 | const instance = useReactFlow();
25 | const isFlowSocket = valueType === "flow";
26 |
27 | let colorName = valueTypeColorMap[valueType];
28 | if (colorName === undefined) {
29 | colorName = "red";
30 | }
31 |
32 | const [backgroundColor, borderColor] = colors[colorName];
33 | const showName = isFlowSocket === false || name !== "flow";
34 |
35 | return (
36 |
37 | {isFlowSocket && (
38 |
39 | )}
40 | {showName &&
{name}
}
41 | {isFlowSocket === false && connected === false && (
42 | <>
43 | {valueType === "string" && (
44 |
onChange(name, e.currentTarget.value)}
49 | />
50 | )}
51 | {valueType === "number" && (
52 | onChange(name, e.currentTarget.value)}
57 | />
58 | )}
59 | {valueType === "float" && (
60 | onChange(name, e.currentTarget.value)}
65 | />
66 | )}
67 | {valueType === "integer" && (
68 | onChange(name, e.currentTarget.value)}
73 | />
74 | )}
75 | {valueType === "boolean" && (
76 | onChange(name, e.currentTarget.checked)}
81 | />
82 | )}
83 | >
84 | )}
85 |
91 | isValidConnection(connection, instance)
92 | }
93 | />
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/components/LoadModal.tsx:
--------------------------------------------------------------------------------
1 | import { GraphJSON } from "behave-graph";
2 | import { FC, useState } from "react";
3 | import { useReactFlow } from "reactflow";
4 | import { behaveToFlow } from "../transformers/behaveToFlow";
5 | import { autoLayout } from "../util/autoLayout";
6 | import { hasPositionMetaData } from "../util/hasPositionMetaData";
7 | import { Modal } from "./Modal";
8 |
9 | import Branch from "behave-graph/dist/graphs/core/flow/Branch.json";
10 | import Delay from "behave-graph/dist/graphs/core/async/Delay.json";
11 | import HelloWorld from "behave-graph/dist/graphs/core//HelloWorld.json";
12 | import Polynomial from "behave-graph/dist/graphs/core/logic/Polynomial.json";
13 | import SetGet from "behave-graph/dist/graphs/core/variables/SetGet.json";
14 |
15 | // TODO remove when json types fixed in behave-graph
16 | const examples = {
17 | branch: Branch as unknown as GraphJSON,
18 | delay: Delay as unknown as GraphJSON,
19 | helloWorld: HelloWorld as unknown as GraphJSON,
20 | polynomial: Polynomial as unknown as GraphJSON,
21 | setGet: SetGet as unknown as GraphJSON,
22 | } as Record;
23 |
24 | export type LoadModalProps = {
25 | open?: boolean;
26 | onClose: () => void;
27 | };
28 |
29 | export const LoadModal: FC = ({ open = false, onClose }) => {
30 | const [value, setValue] = useState();
31 | const [selected, setSelected] = useState("");
32 |
33 | const instance = useReactFlow();
34 |
35 | const handleLoad = () => {
36 | let graph;
37 | if (value !== undefined) {
38 | graph = JSON.parse(value) as GraphJSON;
39 | } else if (selected !== "") {
40 | graph = examples[selected];
41 | }
42 |
43 | if (graph === undefined) return;
44 |
45 | const [nodes, edges] = behaveToFlow(graph);
46 |
47 | if (hasPositionMetaData(graph) === false) {
48 | autoLayout(nodes, edges);
49 | }
50 |
51 | instance.setNodes(nodes);
52 | instance.setEdges(edges);
53 |
54 | // TODO better way to call fit vew after edges render
55 | setTimeout(() => {
56 | instance.fitView();
57 | }, 100);
58 |
59 | handleClose();
60 | };
61 |
62 | const handleClose = () => {
63 | setValue(undefined);
64 | setSelected("");
65 | onClose();
66 | };
67 |
68 | return (
69 |
78 |
85 | or
86 |
100 |
101 | );
102 | };
103 |
--------------------------------------------------------------------------------
/src/components/Modal.tsx:
--------------------------------------------------------------------------------
1 | import { FC, PropsWithChildren } from "react";
2 | import { useOnPressKey } from "../hooks/useOnPressKey";
3 |
4 | export type ModalAction = {
5 | label: string;
6 | onClick: () => void;
7 | };
8 |
9 | export type ModalProps = {
10 | open?: boolean;
11 | onClose: () => void;
12 | title: string;
13 | actions: ModalAction[];
14 | };
15 |
16 | export const Modal: FC> = ({
17 | open = false,
18 | onClose,
19 | title,
20 | children,
21 | actions,
22 | }) => {
23 | useOnPressKey("Escape", onClose);
24 |
25 | if (open === false) return null;
26 |
27 | const actionColors = {
28 | primary: "bg-teal-400 hover:bg-teal-500",
29 | secondary: "bg-gray-400 hover:bg-gray-500",
30 | };
31 |
32 | return (
33 | <>
34 |
38 |
39 |
40 |
{title}
41 |
42 |
{children}
43 |
44 | {actions.map((action, ix) => (
45 |
57 | ))}
58 |
59 |
60 | >
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/components/Node.tsx:
--------------------------------------------------------------------------------
1 | import { NodeProps as FlowNodeProps, useEdges } from "reactflow";
2 | import { NodeSpecJSON } from "behave-graph";
3 | import InputSocket from "./InputSocket";
4 | import NodeContainer from "./NodeContainer";
5 | import OutputSocket from "./OutputSocket";
6 | import { useChangeNodeData } from "../hooks/useChangeNodeData";
7 | import { isHandleConnected } from "../util/isHandleConnected";
8 |
9 | type NodeProps = FlowNodeProps & {
10 | spec: NodeSpecJSON;
11 | };
12 |
13 | const getPairs = (arr1: T[], arr2: U[]) => {
14 | const max = Math.max(arr1.length, arr2.length);
15 | const pairs = [];
16 | for (let i = 0; i < max; i++) {
17 | const pair: [T | undefined, U | undefined] = [arr1[i], arr2[i]];
18 | pairs.push(pair);
19 | }
20 | return pairs;
21 | };
22 |
23 | export const Node = ({ id, data, spec, selected }: NodeProps) => {
24 | const edges = useEdges();
25 | const handleChange = useChangeNodeData(id);
26 | const pairs = getPairs(spec.inputs, spec.outputs);
27 | return (
28 |
33 | {pairs.map(([input, output], ix) => (
34 |
38 | {input && (
39 |
45 | )}
46 | {output && (
47 |
51 | )}
52 |
53 | ))}
54 |
55 | );
56 | };
57 |
--------------------------------------------------------------------------------
/src/components/NodeContainer.tsx:
--------------------------------------------------------------------------------
1 | import { NodeSpecJSON } from "behave-graph";
2 | import { PropsWithChildren } from "react";
3 | import cx from "classnames";
4 |
5 | import { categoryColorMap, colors } from "../util/colors";
6 |
7 | type NodeProps = {
8 | title: string;
9 | category?: NodeSpecJSON["category"];
10 | selected: boolean;
11 | };
12 |
13 | export default function NodeContainer({
14 | title,
15 | category = "None",
16 | selected,
17 | children,
18 | }: PropsWithChildren) {
19 | let colorName = categoryColorMap[category];
20 | if (colorName === undefined) {
21 | colorName = "red";
22 | }
23 | let [backgroundColor, borderColor, textColor] = colors[colorName];
24 | if (selected) {
25 | borderColor = "border-gray-800";
26 | }
27 | return (
28 |
34 |
35 | {title}
36 |
37 |
40 | {children}
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/src/components/NodePicker.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useReactFlow, XYPosition } from "reactflow";
3 | import { useOnPressKey } from "../hooks/useOnPressKey";
4 | import rawSpecJson from "behave-graph/dist/node-spec.json";
5 | import { NodeSpecJSON } from "behave-graph";
6 |
7 | const specJSON = rawSpecJson as NodeSpecJSON[];
8 |
9 | const nodes = specJSON;
10 |
11 | export type NodePickerFilters = {
12 | handleType: "source" | "target";
13 | valueType: string;
14 | };
15 |
16 | type NodePickerProps = {
17 | position: XYPosition;
18 | filters?: NodePickerFilters;
19 | onPickNode: (type: string, position: XYPosition) => void;
20 | onClose: () => void;
21 | };
22 |
23 | const NodePicker = ({
24 | position,
25 | onPickNode,
26 | onClose,
27 | filters,
28 | }: NodePickerProps) => {
29 | const [search, setSearch] = useState("");
30 | const instance = useReactFlow();
31 |
32 | useOnPressKey("Escape", onClose);
33 |
34 | let filtered = nodes;
35 | if (filters !== undefined) {
36 | filtered = filtered.filter((node) => {
37 | const sockets =
38 | filters?.handleType === "source" ? node.outputs : node.inputs;
39 | return sockets.some((socket) => socket.valueType === filters?.valueType);
40 | });
41 | }
42 |
43 | filtered = filtered.filter((node) => {
44 | const term = search.toLowerCase();
45 | return node.type.toLowerCase().includes(term);
46 | });
47 |
48 | return (
49 |
53 |
Add Node
54 |
55 | setSearch(e.target.value)}
62 | />
63 |
64 |
65 | {filtered.map(({ type }) => (
66 |
onPickNode(type, instance.project(position))}
70 | >
71 | {type}
72 |
73 | ))}
74 |
75 |
76 | );
77 | };
78 |
79 | export default NodePicker;
80 |
--------------------------------------------------------------------------------
/src/components/OutputSocket.tsx:
--------------------------------------------------------------------------------
1 | import { faCaretRight } from "@fortawesome/free-solid-svg-icons";
2 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3 | import { Connection, Handle, Position, useReactFlow } from "reactflow";
4 | import cx from "classnames";
5 | import { colors, valueTypeColorMap } from "../util/colors";
6 | import { OutputSocketSpecJSON } from "behave-graph";
7 | import { isValidConnection } from "../util/isValidConnection";
8 |
9 | export type OutputSocketProps = {
10 | connected: boolean;
11 | } & OutputSocketSpecJSON;
12 |
13 | export default function OutputSocket({
14 | connected,
15 | valueType,
16 | name,
17 | }: OutputSocketProps) {
18 | const instance = useReactFlow();
19 | const isFlowSocket = valueType === "flow";
20 | let colorName = valueTypeColorMap[valueType];
21 | if (colorName === undefined) {
22 | colorName = "red";
23 | }
24 | const [backgroundColor, borderColor] = colors[colorName];
25 | const showName = isFlowSocket === false || name !== "flow";
26 |
27 | return (
28 |
29 | {showName &&
{name}
}
30 | {isFlowSocket && (
31 |
37 | )}
38 |
39 |
45 | isValidConnection(connection, instance)
46 | }
47 | />
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/src/components/SaveModal.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useMemo, useRef, useState } from "react";
2 | import { useEdges, useNodes } from "reactflow";
3 | import { flowToBehave } from "../transformers/flowToBehave";
4 | import { Modal } from "./Modal";
5 |
6 | export type SaveModalProps = { open?: boolean; onClose: () => void };
7 |
8 | export const SaveModal: FC = ({ open = false, onClose }) => {
9 | const ref = useRef(null);
10 | const [copied, setCopied] = useState(false);
11 |
12 | const edges = useEdges();
13 | const nodes = useNodes();
14 |
15 | const flow = useMemo(() => flowToBehave(nodes, edges), [nodes, edges]);
16 |
17 | const jsonString = JSON.stringify(flow, null, 2);
18 |
19 | const handleCopy = () => {
20 | ref.current?.select();
21 | document.execCommand("copy");
22 | ref.current?.blur();
23 | setCopied(true);
24 | setInterval(() => {
25 | setCopied(false);
26 | }, 1000);
27 | };
28 |
29 | return (
30 |
39 |
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/src/graph.json:
--------------------------------------------------------------------------------
1 | {
2 | "nodes": [
3 | {
4 | "id": "7569b164-1b58-4520-bd1c-0ca1386c554b",
5 | "type": "debug/log",
6 | "metadata": {
7 | "positionX": "-217",
8 | "positionY": "413"
9 | },
10 | "parameters": {
11 | "text": {
12 | "value": "Finished"
13 | }
14 | }
15 | },
16 | {
17 | "id": "9c8f417c-4a96-4be0-8b7b-5c0542ba3a0f",
18 | "type": "lifecycle/onEnd",
19 | "metadata": {
20 | "positionX": "-430",
21 | "positionY": "421"
22 | },
23 | "flows": {
24 | "flow": {
25 | "nodeId": "7569b164-1b58-4520-bd1c-0ca1386c554b",
26 | "socket": "flow"
27 | }
28 | }
29 | },
30 | {
31 | "id": "fb0d04f5-0915-4408-bee0-87b1f3931ea7",
32 | "type": "debug/log",
33 | "metadata": {
34 | "positionX": "-287",
35 | "positionY": "196"
36 | },
37 | "parameters": {
38 | "text": {
39 | "value": "Ticking"
40 | }
41 | }
42 | },
43 | {
44 | "id": "1fe32e5d-e14e-455a-9a49-7c21a79dd9da",
45 | "type": "lifecycle/onTick",
46 | "metadata": {
47 | "positionX": "-550",
48 | "positionY": "168"
49 | },
50 | "flows": {
51 | "flow": {
52 | "nodeId": "fb0d04f5-0915-4408-bee0-87b1f3931ea7",
53 | "socket": "flow"
54 | }
55 | }
56 | },
57 | {
58 | "id": "0",
59 | "type": "lifecycle/onStart",
60 | "metadata": {
61 | "positionX": "-579",
62 | "positionY": "-67"
63 | },
64 | "flows": {
65 | "flow": {
66 | "nodeId": "1",
67 | "socket": "flow"
68 | }
69 | }
70 | },
71 | {
72 | "id": "1",
73 | "type": "debug/log",
74 | "metadata": {
75 | "positionX": "-422",
76 | "positionY": "-67"
77 | },
78 | "parameters": {
79 | "text": {
80 | "value": "Starting Sequence..."
81 | }
82 | },
83 | "flows": {
84 | "flow": {
85 | "nodeId": "2",
86 | "socket": "flow"
87 | }
88 | }
89 | },
90 | {
91 | "id": "2",
92 | "type": "flow/sequence",
93 | "metadata": {
94 | "positionX": "-164",
95 | "positionY": "-67"
96 | },
97 | "flows": {
98 | "1": {
99 | "nodeId": "3",
100 | "socket": "flow"
101 | },
102 | "2": {
103 | "nodeId": "4",
104 | "socket": "flow"
105 | },
106 | "3": {
107 | "nodeId": "5",
108 | "socket": "flow"
109 | }
110 | }
111 | },
112 | {
113 | "id": "3",
114 | "type": "debug/log",
115 | "metadata": {
116 | "positionX": "32",
117 | "positionY": "-99"
118 | },
119 | "parameters": {
120 | "text": {
121 | "value": "First Sequence Output!"
122 | }
123 | },
124 | "flows": {
125 | "flow": {
126 | "nodeId": "6",
127 | "socket": "flow"
128 | }
129 | }
130 | },
131 | {
132 | "id": "4",
133 | "type": "debug/log",
134 | "metadata": {
135 | "positionX": "26",
136 | "positionY": "40"
137 | },
138 | "parameters": {
139 | "text": {
140 | "value": "Second Sequence Output!"
141 | }
142 | }
143 | },
144 | {
145 | "id": "5",
146 | "type": "debug/log",
147 | "metadata": {
148 | "positionX": "21",
149 | "positionY": "179"
150 | },
151 | "parameters": {
152 | "text": {
153 | "value": "Third Sequence Output!"
154 | }
155 | }
156 | },
157 | {
158 | "id": "6",
159 | "type": "debug/log",
160 | "metadata": {
161 | "positionX": "321",
162 | "positionY": "-98"
163 | },
164 | "parameters": {
165 | "text": {
166 | "value": "Downstream of First Sequence!"
167 | }
168 | }
169 | }
170 | ],
171 | "variables": [],
172 | "customEvents": []
173 | }
--------------------------------------------------------------------------------
/src/hooks/useChangeNodeData.ts:
--------------------------------------------------------------------------------
1 | import { useCallback } from "react";
2 | import { useReactFlow } from "reactflow";
3 |
4 | export const useChangeNodeData = (id: string) => {
5 | const instance = useReactFlow();
6 |
7 | return useCallback(
8 | (key: string, value: any) => {
9 | instance.setNodes((nodes) =>
10 | nodes.map((n) => {
11 | if (n.id !== id) return n;
12 | return {
13 | ...n,
14 | data: {
15 | ...n.data,
16 | [key]: value,
17 | },
18 | };
19 | })
20 | );
21 | },
22 | [instance, id]
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/src/hooks/useOnPressKey.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 |
3 | export const useOnPressKey = (
4 | key: string,
5 | callback: (e: KeyboardEvent) => void
6 | ) => {
7 | useEffect(() => {
8 | const handleKeyDown = (e: KeyboardEvent) => {
9 | if (e.code === key) {
10 | callback(e);
11 | }
12 | };
13 | document.addEventListener("keydown", handleKeyDown);
14 | return () => document.removeEventListener("keydown", handleKeyDown);
15 | }, [key, callback]);
16 | };
17 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | #root {
8 | height: 100%;
9 | }
10 |
11 | /* fixes issue with tailwind resetting flow control buttons */
12 | button,
13 | [type='button'] {
14 | background-color: #fff;
15 | }
16 |
17 | select {
18 | appearance: none;
19 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%236B7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3E%3C/svg%3E");
20 | background-position: right 0.25rem center;
21 | background-repeat: no-repeat;
22 | background-size: 1.5em 1.5em;
23 | }
24 |
25 | .react-flow__handle {
26 | width: 10px;
27 | height: 10px;
28 | }
29 |
30 | .react-flow__handle-right {
31 | right: -6px;
32 | }
33 |
34 | .react-flow__handle-left {
35 | left: -6px;
36 | }
37 |
38 | .react-flow__edge-path {
39 | stroke: #b1b1b7;
40 | stroke-width: 2;
41 | cursor: pointer;
42 | }
43 |
44 | input[type='number'] {
45 | -moz-appearance: textfield;
46 | }
47 |
48 | input::-webkit-outer-spin-button,
49 | input::-webkit-inner-spin-button {
50 | -webkit-appearance: none;
51 | }
52 |
53 | .react-flow__edge-path {
54 | stroke: #555;
55 | }
56 |
57 | .react-flow__edge.selected .react-flow__edge-path {
58 | stroke: #fff;
59 | }
60 |
61 | .node-picker ::-webkit-scrollbar {
62 | @apply w-2;
63 | }
64 |
65 | .node-picker ::-webkit-scrollbar-track {
66 | @apply bg-gray-700;
67 | }
68 |
69 | .node-picker ::-webkit-scrollbar-thumb {
70 | @apply bg-gray-600;
71 | }
72 |
73 | .react-flow .react-flow__controls {
74 | display: flex;
75 | top: 0;
76 | right: 0;
77 | left: auto;
78 | bottom: auto;
79 | }
80 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 |
5 | import "reactflow/dist/style.css";
6 | import "./index.css";
7 |
8 | const root = ReactDOM.createRoot(
9 | document.getElementById("root") as HTMLElement
10 | );
11 | root.render(
12 |
13 |
14 |
15 | );
16 |
--------------------------------------------------------------------------------
/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src/transformers/behaveToFlow.ts:
--------------------------------------------------------------------------------
1 | import { GraphJSON } from "behave-graph";
2 | import { Edge, Node } from "reactflow";
3 | import { v4 as uuidv4 } from "uuid";
4 |
5 | export const behaveToFlow = (graph: GraphJSON): [Node[], Edge[]] => {
6 | const nodes: Node[] = [];
7 | const edges: Edge[] = [];
8 |
9 | graph.nodes?.forEach((nodeJSON) => {
10 | const node: Node = {
11 | id: nodeJSON.id,
12 | type: nodeJSON.type,
13 | position: {
14 | x: nodeJSON.metadata?.positionX
15 | ? Number(nodeJSON.metadata?.positionX)
16 | : 0,
17 | y: nodeJSON.metadata?.positionY
18 | ? Number(nodeJSON.metadata?.positionY)
19 | : 0,
20 | },
21 | data: {} as { [key: string]: any },
22 | };
23 |
24 | nodes.push(node);
25 |
26 | if (nodeJSON.parameters) {
27 | for (const [inputKey, input] of Object.entries(nodeJSON.parameters)) {
28 | if ("link" in input && input.link !== undefined) {
29 | edges.push({
30 | id: uuidv4(),
31 | source: input.link.nodeId,
32 | sourceHandle: input.link.socket,
33 | target: nodeJSON.id,
34 | targetHandle: inputKey,
35 | });
36 | }
37 | if ("value" in input) {
38 | node.data[inputKey] = input.value;
39 | }
40 | }
41 | }
42 |
43 | if (nodeJSON.flows) {
44 | for (const [inputKey, link] of Object.entries(nodeJSON.flows)) {
45 | edges.push({
46 | id: uuidv4(),
47 | source: nodeJSON.id,
48 | sourceHandle: inputKey,
49 | target: link.nodeId,
50 | targetHandle: link.socket,
51 | });
52 | }
53 | }
54 | });
55 |
56 | return [nodes, edges];
57 | };
58 |
--------------------------------------------------------------------------------
/src/transformers/flowToBehave.test.ts:
--------------------------------------------------------------------------------
1 | import { flowToBehave } from "./flowToBehave";
2 | import rawFlowGraph from "../graph.json";
3 | import { GraphJSON } from "behave-graph";
4 | import { behaveToFlow } from "./behaveToFlow";
5 |
6 | const flowGraph = rawFlowGraph as GraphJSON;
7 |
8 | const [nodes, edges] = behaveToFlow(flowGraph);
9 |
10 | it("transforms from flow to behave", () => {
11 | const output = flowToBehave(nodes, edges);
12 | expect(output).toEqual(flowGraph);
13 | });
14 |
--------------------------------------------------------------------------------
/src/transformers/flowToBehave.ts:
--------------------------------------------------------------------------------
1 | import { GraphJSON, NodeJSON } from "behave-graph";
2 | import { Edge, Node } from "reactflow";
3 | import { getNodeSpecJSON } from "../util/getNodeSpecJSON";
4 |
5 | const nodeSpecJSON = getNodeSpecJSON();
6 |
7 | const isNullish = (value: any): value is null | undefined =>
8 | value === undefined || value === null;
9 |
10 | export const flowToBehave = (nodes: Node[], edges: Edge[]): GraphJSON => {
11 | const graph: GraphJSON = { nodes: [], variables: [], customEvents: [] };
12 |
13 | nodes.forEach((node) => {
14 | if (node.type === undefined) return;
15 |
16 | const nodeSpec = nodeSpecJSON.find(
17 | (nodeSpec) => nodeSpec.type === node.type
18 | );
19 |
20 | if (nodeSpec === undefined) return;
21 |
22 | const behaveNode: NodeJSON = {
23 | id: node.id,
24 | type: node.type,
25 | metadata: {
26 | positionX: String(node.position.x),
27 | positionY: String(node.position.y),
28 | },
29 | };
30 |
31 | Object.entries(node.data).forEach(([key, value]) => {
32 | if (behaveNode.parameters === undefined) {
33 | behaveNode.parameters = {};
34 | }
35 | behaveNode.parameters[key] = { value: value as string };
36 | });
37 |
38 | edges
39 | .filter((edge) => edge.target === node.id)
40 | .forEach((edge) => {
41 | const inputSpec = nodeSpec.inputs.find(
42 | (input) => input.name === edge.targetHandle
43 | );
44 | if (inputSpec && inputSpec.valueType === "flow") {
45 | // skip flows
46 | return;
47 | }
48 | if (behaveNode.parameters === undefined) {
49 | behaveNode.parameters = {};
50 | }
51 | if (isNullish(edge.targetHandle)) return;
52 | if (isNullish(edge.sourceHandle)) return;
53 |
54 | // TODO: some of these are flow outputs, and should be saved differently. -Ben, Oct 11, 2022
55 | behaveNode.parameters[edge.targetHandle] = {
56 | link: { nodeId: edge.source, socket: edge.sourceHandle },
57 | };
58 | });
59 |
60 | edges
61 | .filter((edge) => edge.source === node.id)
62 | .forEach((edge) => {
63 | const outputSpec = nodeSpec.outputs.find(
64 | (output) => output.name === edge.sourceHandle
65 | );
66 | if (outputSpec && outputSpec.valueType !== "flow") {
67 | return;
68 | }
69 | if (behaveNode.flows === undefined) {
70 | behaveNode.flows = {};
71 | }
72 | if (isNullish(edge.targetHandle)) return;
73 | if (isNullish(edge.sourceHandle)) return;
74 |
75 | // TODO: some of these are flow outputs, and should be saved differently. -Ben, Oct 11, 2022
76 | behaveNode.flows[edge.sourceHandle] = {
77 | nodeId: edge.target,
78 | socket: edge.targetHandle,
79 | };
80 | });
81 |
82 | // TODO filter out any orphan nodes at this point, to avoid errors further down inside behave-graph
83 |
84 | graph.nodes?.push(behaveNode);
85 | });
86 |
87 | return graph;
88 | };
89 |
--------------------------------------------------------------------------------
/src/util/autoLayout.ts:
--------------------------------------------------------------------------------
1 | import { Edge, Node } from "reactflow";
2 |
3 | export const autoLayout = (nodes: Node[], edges: Edge[]) => {
4 | let x = 0;
5 | nodes.forEach((node) => {
6 | node.position.x = x;
7 | x += 200;
8 | });
9 | };
10 |
--------------------------------------------------------------------------------
/src/util/calculateNewEdge.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from "uuid";
2 | import { Node, OnConnectStartParams } from "reactflow";
3 | import { getSocketsByNodeTypeAndHandleType } from "./getSocketsByNodeTypeAndHandleType";
4 | import { getNodeSpecJSON } from "./getNodeSpecJSON";
5 |
6 | const specJSON = getNodeSpecJSON();
7 |
8 | export const calculateNewEdge = (
9 | originNode: Node,
10 | destinationNodeType: string,
11 | destinationNodeId: string,
12 | connection: OnConnectStartParams
13 | ) => {
14 | const sockets = getSocketsByNodeTypeAndHandleType(
15 | specJSON,
16 | originNode.type,
17 | connection.handleType
18 | );
19 | const originSocket = sockets?.find(
20 | (socket) => socket.name === connection.handleId
21 | );
22 |
23 | const newSockets = getSocketsByNodeTypeAndHandleType(
24 | specJSON,
25 | destinationNodeType,
26 | connection.handleType === "source" ? "target" : "source"
27 | );
28 | const newSocket = newSockets?.find(
29 | (socket) => socket.valueType === originSocket?.valueType
30 | );
31 |
32 | if (connection.handleType === "source") {
33 | return {
34 | id: uuidv4(),
35 | source: connection.nodeId ?? "",
36 | sourceHandle: connection.handleId,
37 | target: destinationNodeId,
38 | targetHandle: newSocket?.name,
39 | };
40 | }
41 |
42 | return {
43 | id: uuidv4(),
44 | target: connection.nodeId ?? "",
45 | targetHandle: connection.handleId,
46 | source: destinationNodeId,
47 | sourceHandle: newSocket?.name,
48 | };
49 | };
50 |
--------------------------------------------------------------------------------
/src/util/colors.ts:
--------------------------------------------------------------------------------
1 | import { NodeSpecJSON } from "behave-graph";
2 |
3 | export const colors: Record = {
4 | red: ["bg-orange-700", "border-orange-700", "text-white"],
5 | green: ["bg-green-600", "border-green-600", "text-white"],
6 | lime: ["bg-lime-500", "border-lime-500", "text-white"],
7 | purple: ["bg-purple-500", "border-purple-500", "text-white"],
8 | blue: ["bg-cyan-600", "border-cyan-600", "text-white"],
9 | gray: ["bg-gray-500", "border-gray-500", "text-white"],
10 | white: ["bg-white", "border-white", "text-gray-700"],
11 | };
12 |
13 | export const valueTypeColorMap: Record = {
14 | flow: "white",
15 | number: "green",
16 | float: "green",
17 | integer: "lime",
18 | boolean: "red",
19 | string: "purple",
20 | };
21 |
22 | export const categoryColorMap: Record = {
23 | Event: "red",
24 | Logic: "green",
25 | Variable: "purple",
26 | Query: "purple",
27 | Action: "blue",
28 | Flow: "gray",
29 | Time: "gray",
30 | None: "gray",
31 | };
32 |
--------------------------------------------------------------------------------
/src/util/customNodeTypes.tsx:
--------------------------------------------------------------------------------
1 | import { NodeTypes } from "reactflow";
2 | import { Node } from "../components/Node";
3 | import { getNodeSpecJSON } from "./getNodeSpecJSON";
4 |
5 | const spec = getNodeSpecJSON();
6 |
7 | export const customNodeTypes = spec.reduce((nodes, node) => {
8 | nodes[node.type] = (props) => ;
9 | return nodes;
10 | }, {} as NodeTypes);
11 |
--------------------------------------------------------------------------------
/src/util/getNodeSpecJSON.ts:
--------------------------------------------------------------------------------
1 | import {
2 | NodeSpecJSON,
3 | registerCoreProfile,
4 | registerSceneProfile,
5 | Registry,
6 | writeNodeSpecsToJSON,
7 | } from "behave-graph";
8 |
9 | let nodeSpecJSON: NodeSpecJSON[] | undefined = undefined;
10 |
11 | export const getNodeSpecJSON = (): NodeSpecJSON[] => {
12 | if (nodeSpecJSON === undefined) {
13 | const registry = new Registry();
14 | registerCoreProfile(registry);
15 | registerSceneProfile(registry);
16 | nodeSpecJSON = writeNodeSpecsToJSON(registry);
17 | }
18 | return nodeSpecJSON;
19 | };
20 |
--------------------------------------------------------------------------------
/src/util/getPickerFilters.ts:
--------------------------------------------------------------------------------
1 | import { Node, OnConnectStartParams } from "reactflow";
2 | import { NodePickerFilters } from "../components/NodePicker";
3 | import { getSocketsByNodeTypeAndHandleType } from "./getSocketsByNodeTypeAndHandleType";
4 | import { getNodeSpecJSON } from "./getNodeSpecJSON";
5 |
6 | const specJSON = getNodeSpecJSON();
7 |
8 | export const getNodePickerFilters = (
9 | nodes: Node[],
10 | params: OnConnectStartParams | undefined
11 | ): NodePickerFilters | undefined => {
12 | if (params === undefined) return;
13 |
14 | const originNode = nodes.find((node) => node.id === params.nodeId);
15 | if (originNode === undefined) return;
16 |
17 | const sockets = getSocketsByNodeTypeAndHandleType(
18 | specJSON,
19 | originNode.type,
20 | params.handleType
21 | );
22 |
23 | const socket = sockets?.find((socket) => socket.name === params.handleId);
24 |
25 | if (socket === undefined) return;
26 |
27 | return {
28 | handleType: params.handleType === "source" ? "target" : "source",
29 | valueType: socket.valueType,
30 | };
31 | };
32 |
--------------------------------------------------------------------------------
/src/util/getSocketsByNodeTypeAndHandleType.ts:
--------------------------------------------------------------------------------
1 | import { NodeSpecJSON } from "behave-graph";
2 |
3 | export const getSocketsByNodeTypeAndHandleType = (
4 | nodes: NodeSpecJSON[],
5 | nodeType: string | undefined,
6 | handleType: "source" | "target" | null
7 | ) => {
8 | const nodeSpec = nodes.find((node) => node.type === nodeType);
9 | if (nodeSpec === undefined) return;
10 | return handleType === "source" ? nodeSpec.outputs : nodeSpec.inputs;
11 | };
12 |
--------------------------------------------------------------------------------
/src/util/hasPositionMetaData.ts:
--------------------------------------------------------------------------------
1 | import { GraphJSON } from "behave-graph";
2 |
3 | export const hasPositionMetaData = (graph: GraphJSON): boolean => {
4 | if (graph.nodes === undefined) return false;
5 | return graph.nodes.some(
6 | (node) =>
7 | node.metadata?.positionX !== undefined ||
8 | node.metadata?.positionY !== undefined
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/util/isHandleConnected.ts:
--------------------------------------------------------------------------------
1 | import { Edge } from "reactflow";
2 |
3 | export const isHandleConnected = (
4 | edges: Edge[],
5 | nodeId: string,
6 | handleId: string,
7 | type: "source" | "target"
8 | ) => {
9 | return edges.some(
10 | (edge) => edge[type] === nodeId && edge[`${type}Handle`] === handleId
11 | );
12 | };
13 |
--------------------------------------------------------------------------------
/src/util/isValidConnection.ts:
--------------------------------------------------------------------------------
1 | import { Connection, ReactFlowInstance } from "reactflow";
2 | import { getSocketsByNodeTypeAndHandleType } from "./getSocketsByNodeTypeAndHandleType";
3 | import { isHandleConnected } from "./isHandleConnected";
4 | import { getNodeSpecJSON } from "./getNodeSpecJSON";
5 |
6 | const specJSON = getNodeSpecJSON();
7 |
8 | export const isValidConnection = (
9 | connection: Connection,
10 | instance: ReactFlowInstance
11 | ) => {
12 | if (connection.source === null || connection.target === null) return false;
13 |
14 | const sourceNode = instance.getNode(connection.source);
15 | const targetNode = instance.getNode(connection.target);
16 | const edges = instance.getEdges();
17 |
18 | if (sourceNode === undefined || targetNode === undefined) return false;
19 |
20 | const sourceSockets = getSocketsByNodeTypeAndHandleType(
21 | specJSON,
22 | sourceNode.type,
23 | "source"
24 | );
25 |
26 | const sourceSocket = sourceSockets?.find(
27 | (socket) => socket.name === connection.sourceHandle
28 | );
29 |
30 | const targetSockets = getSocketsByNodeTypeAndHandleType(
31 | specJSON,
32 | targetNode.type,
33 | "target"
34 | );
35 |
36 | const targetSocket = targetSockets?.find(
37 | (socket) => socket.name === connection.targetHandle
38 | );
39 |
40 | if (sourceSocket === undefined || targetSocket === undefined) return false;
41 |
42 | // only flow sockets can have two inputs
43 | if (
44 | targetSocket.valueType !== "flow" &&
45 | isHandleConnected(edges, targetNode.id, targetSocket.name, "target")
46 | ) {
47 | return false;
48 | }
49 |
50 | return sourceSocket.valueType === targetSocket.valueType;
51 | };
52 |
--------------------------------------------------------------------------------
/src/util/sleep.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-promise-executor-return */
2 | export function sleep(durationInSeconds: number) {
3 | return new Promise((resolve) =>
4 | setTimeout(resolve, Math.round(durationInSeconds * 1000))
5 | );
6 | }
7 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,jsx,ts,tsx}"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | };
9 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------