├── .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 | ![image](https://user-images.githubusercontent.com/954416/184598477-74997727-0d0d-48e5-9f29-1210812bd66c.png) 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 | --------------------------------------------------------------------------------