diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 4294bde..0000000 --- a/.gitignore +++ /dev/null @@ -1,26 +0,0 @@ -package-lock.json - -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -node_modules -dist -dist-ssr -*.local - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea -.DS_Store -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? diff --git a/Canvas.js b/Canvas.js new file mode 100644 index 0000000..61b12f5 --- /dev/null +++ b/Canvas.js @@ -0,0 +1,182 @@ +// Canvas.js +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import ReactFlow, { MiniMap, Controls, Background, useReactFlow } from 'reactflow'; +import 'reactflow/dist/style.css'; +import Node, { addNode, deleteNode } from './Node'; +import { createEdge, deleteEdge } from './Edge'; +import { createConditionEdge, deleteConditionEdge } from './ConditionEdge'; +import { useGraphManager } from './GraphManager'; +import Panel from './Panel'; +const nodeTypes = { textUpdater: Node }; + +function Canvas() { + const { + nodes, + setNodes, + onNodesChange, + edges, + setEdges, + onEdgesChange, + nodeIdCounter, + setNodeIdCounter, + } = useGraphManager(); + + const [contextMenu, setContextMenu] = useState(null); + const { screenToFlowPosition } = useReactFlow(); + const menuBarRef = useRef(null); + const [canvasHeight, setCanvasHeight] = useState(window.innerHeight); + const [showConfig, setShowConfig] = useState(false); + const [showRun, setShowRun] = useState(false); + + + useEffect(() => { + const handleResize = () => { + if (menuBarRef.current) { + const menuBarHeight = menuBarRef.current.offsetHeight; + setCanvasHeight(window.innerHeight - menuBarHeight - 10); + } + }; + + window.addEventListener('resize', handleResize); + handleResize(); + + return () => window.removeEventListener('resize', handleResize); + }, []); + + const handleAddNode = useCallback(() => { + const newPosition = screenToFlowPosition({ x: contextMenu.mouseX, y: contextMenu.mouseY }); + addNode(nodes, setNodes, nodeIdCounter, setNodeIdCounter, newPosition); + setContextMenu(null); + }, [contextMenu, nodeIdCounter, setNodes, screenToFlowPosition, nodes, setNodeIdCounter]); + + const handleDeleteNode = useCallback(() => { + if (contextMenu && contextMenu.nodeId) { + deleteNode(nodes, setNodes, edges, setEdges, contextMenu.nodeId); + } + setContextMenu(null); + }, [contextMenu, setNodes, setEdges, nodes, edges]); + + const handleDeleteEdge = useCallback(() => { + if (contextMenu && contextMenu.edgeId) { + const edge = edges.find((e) => e.id === contextMenu.edgeId); + if (edge) { + if (edge.sourceHandle === 'true' || edge.sourceHandle === 'false') { + deleteConditionEdge(edges, setEdges, contextMenu.edgeId, nodes, setNodes); + } else { + deleteEdge(edges, setEdges, contextMenu.edgeId, nodes, setNodes); + } + } + } + setContextMenu(null); + }, [contextMenu, setEdges, edges, nodes, setNodes]); + + const handleNodeContextMenu = useCallback((event, node) => { + event.preventDefault(); + setContextMenu({ + mouseX: event.clientX, + mouseY: event.clientY, + nodeId: node.id, + isNode: true, + isEdge: false, + }); + }, []); + + const handlePaneContextMenu = useCallback((event) => { + event.preventDefault(); + setContextMenu({ + mouseX: event.clientX, + mouseY: event.clientY, + isNode: false, + isEdge: false, + }); + }, []); + + const handleEdgeContextMenu = useCallback((event, edge) => { + event.preventDefault(); + setContextMenu({ + mouseX: event.clientX, + mouseY: event.clientY, + edgeId: edge.id, + isNode: false, + isEdge: true, + }); + }, []); + + const handleCloseContextMenu = () => { + setContextMenu(null); + }; + + const onConnect = useCallback((params) => { + const sourceNode = nodes.find(node => node.id === params.source); + + if (params.sourceHandle === 'true') { + if (sourceNode.data.true_next !== null) { + alert('True port already has a connection.'); + return; + } + createConditionEdge(edges, setEdges, params, nodes, setNodes); + } else if (params.sourceHandle === 'false') { + if (sourceNode.data.false_next !== null) { + alert('False port already has a connection.'); + return; + } + createConditionEdge(edges, setEdges, params, nodes, setNodes); + } else { + createEdge(edges, setEdges, params, nodes, setNodes); + } + }, [setEdges, edges, nodes, setNodes]); + + return ( +
+
+ +
+ +
+ + + + + +
+ {contextMenu && ( +
+ {contextMenu.isNode ? ( + + ) : contextMenu.isEdge ? ( + + ) : ( + + )} + +
+ )} +
+ ); +} + +export default Canvas; \ No newline at end of file diff --git a/ConditionEdge.js b/ConditionEdge.js new file mode 100644 index 0000000..7fb9f60 --- /dev/null +++ b/ConditionEdge.js @@ -0,0 +1,62 @@ +// ConditionEdge.js + +import { addEdge } from 'reactflow'; + +export const createConditionEdge = (edges, setEdges, params, nodes, setNodes) => { + const { source, target, sourceHandle } = params; + + const newEdge = { + ...params, + id: `e${source}-${target}`, + animated: false, + style: { stroke: sourceHandle === 'true' ? 'green' : 'red', strokeWidth: 2 }, + markerEnd: { + type: 'arrowclosed', + width: 20, + height: 20, + }, + }; + + setEdges((eds) => addEdge(newEdge, eds)); + + setNodes((nds) => + nds.map((node) => { + if (node.id === source) { + const newData = { + ...node.data, + ...(sourceHandle === 'true' ? { true_next: target } : { false_next: target }) + }; + return { ...node, data: newData }; + } else if (node.id === target) { + return { ...node, prevs: [...(node.prevs || []), source] }; + } + return node; + }) + ); + + return newEdge; +}; + +export const deleteConditionEdge = (edges, setEdges, edgeId, nodes, setNodes) => { + const edge = edges.find((e) => e.id === edgeId); + if (!edge) return; + + setEdges((eds) => eds.filter((edge) => edge.id !== edgeId)); + + setNodes((nds) => + nds.map((node) => { + if (node.id === edge.source) { + const newData = { + ...node.data, + ...(edge.sourceHandle === 'true' + ? { true_next: null } + : { false_next: null }) + }; + return { ...node, data: newData }; + } else if (node.id === edge.target) { + return { ...node, prevs: node.prevs.filter((id) => id !== edge.source) }; + } + return node; + }) + ); +}; diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 922e683..0000000 --- a/Dockerfile +++ /dev/null @@ -1,18 +0,0 @@ -FROM node:20 - -# Create and set the working directory -WORKDIR /app - -# Copy all files to the container -COPY . . - -# Install dependencies -RUN npm install - -RUN npm run build - -# Expose the port the app runs on -EXPOSE 3000 - -# Start the application -CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"] \ No newline at end of file diff --git a/Edge.js b/Edge.js new file mode 100644 index 0000000..ae80dd1 --- /dev/null +++ b/Edge.js @@ -0,0 +1,54 @@ +// Edge.js + +import { addEdge } from 'reactflow'; + +export const createEdge = (edges, setEdges, params, nodes, setNodes) => { + const { source, target } = params; + + const newEdge = { + ...params, + id: `e${source}-${target}`, + animated: false, + style: { stroke: '#000000', strokeWidth: 2 }, + markerEnd: { + type: 'arrowclosed', + width: 20, + height: 20, + }, + }; + + setEdges((eds) => addEdge(newEdge, eds)); + + setNodes((nds) => + nds.map((node) => { + if (node.id === source) { + const newData = { ...node.data, nexts: [...node.data.nexts, target] }; + return { ...node, data: newData }; + } else if (node.id === target) { + return { ...node, prevs: [...(node.prevs || []), source] }; + } + return node; + }) + ); + + return newEdge; +}; + +export const deleteEdge = (edges, setEdges, edgeId, nodes, setNodes) => { + const edge = edges.find((e) => e.id === edgeId); + if (!edge) return; + + setEdges((eds) => eds.filter((edge) => edge.id !== edgeId)); + + setNodes((nds) => + nds.map((node) => { + if (node.id === edge.source) { + const newData = { ...node.data, nexts: node.data.nexts.filter((id) => id !== edge.target) }; + return { ...node, data: newData }; + } else if (node.id === edge.target) { + return { ...node, prevs: node.prevs.filter((id) => id !== edge.source) }; + } + return node; + }) + ); +}; diff --git a/FileTransmit.js b/FileTransmit.js new file mode 100644 index 0000000..bc8bbb9 --- /dev/null +++ b/FileTransmit.js @@ -0,0 +1,128 @@ +// FileTransmit.js + +import React, { useRef } from 'react'; +import SERVER_URL from '../config'; +import ConfigManager from '../ConfigManager'; + +function FileTransmit({ onUploadComplete }) { + const fileInputRef = useRef(); + + // Get the username from ConfigManager + const { username } = ConfigManager.getSettings(); + + const handleUploadClick = () => { + fileInputRef.current.click(); + }; + + const handleFileChange = async (event) => { + const files = event.target.files; + if (files.length > 0) { + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + try { + // Use the username in the API path + const response = await fetch(`${SERVER_URL}/upload/${encodeURIComponent(username)}`, { + method: 'POST', + body: formData, + }); + + if (response.ok) { + alert('Files successfully uploaded'); + if (onUploadComplete) { + onUploadComplete(); + } + } else { + const errorData = await response.json(); + alert('Upload failed: ' + errorData.error); + } + } catch (error) { + alert('Upload failed: ' + error.message); + } finally { + event.target.value = null; + } + } + }; + + const handleDownloadClick = async () => { + try { + // Send GET request to the backend to download the zip file + const response = await fetch(`${SERVER_URL}/download/${encodeURIComponent(username)}`); + + if (response.ok) { + // Create a Blob from the response data (the zip file) + const blob = await response.blob(); + + // Create a download link and simulate a click to trigger the download + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${username}_workspace.zip`; // Set the default file name + a.click(); + + // Clean up the object URL + URL.revokeObjectURL(url); + } else { + const errorData = await response.json(); + alert('Download failed: ' + errorData.error); + } + } catch (error) { + alert('Download failed: ' + error.message); + } + }; + + // function to handle cache cleanup with username + const handleCleanCacheClick = async () => { + try { + // Use the username in the API path + const response = await fetch(`${SERVER_URL}/clean-cache/${encodeURIComponent(username)}`, { + method: 'POST' + }); + + if (response.ok) { + alert('Cache successfully cleaned'); + } else { + const errorData = await response.json(); + alert('Clean cache failed: ' + errorData.error); + } + } catch (error) { + alert('Clean cache failed: ' + error.message); + } + }; + + // Check if username is valid + const isUsernameValid = username && username.length > 0; + + return ( +
+
+ + + + + +
+ {isUsernameValid ? `User: ${username}` : 'User: undefined'} +
+
+
+ ); +} + +export default FileTransmit; \ No newline at end of file diff --git a/GraphApp.css b/GraphApp.css new file mode 100644 index 0000000..74b5e05 --- /dev/null +++ b/GraphApp.css @@ -0,0 +1,38 @@ +.App { + text-align: center; +} + +.App-logo { + height: 40vmin; + pointer-events: none; +} + +@media (prefers-reduced-motion: no-preference) { + .App-logo { + animation: App-logo-spin infinite 20s linear; + } +} + +.App-header { + background-color: #282c34; + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: calc(10px + 2vmin); + color: white; +} + +.App-link { + color: #61dafb; +} + +@keyframes App-logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/GraphApp.js b/GraphApp.js new file mode 100644 index 0000000..19f6537 --- /dev/null +++ b/GraphApp.js @@ -0,0 +1,15 @@ +// GraphApp.js + +import React from 'react'; +import { ReactFlowProvider } from 'reactflow'; +import Canvas from './Canvas'; + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/GraphManager.js b/GraphManager.js new file mode 100644 index 0000000..b3e280b --- /dev/null +++ b/GraphManager.js @@ -0,0 +1,32 @@ +// GraphManager.js +import React, { createContext, useContext, useState } from 'react'; +import { useNodesState, useEdgesState} from 'reactflow'; + +const GraphManagerContext = createContext(null); + +export const useGraphManager = () => { + return useContext(GraphManagerContext); +}; + +export const GraphManagerProvider = ({ children }) => { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [nodeIdCounter, setNodeIdCounter] = useState(1); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + const value = { + nodes, + setNodes, + onNodesChange, + edges, + setEdges, + onEdgesChange, + nodeIdCounter, + setNodeIdCounter, + }; + + return ( + + {children} + + ); +}; diff --git a/JsonUtils.js b/JsonUtils.js new file mode 100644 index 0000000..e2dcea3 --- /dev/null +++ b/JsonUtils.js @@ -0,0 +1,176 @@ +// JsonUtils.js + +import NodeData from './NodeData'; +import { createEdge } from './Edge'; +import { createConditionEdge } from './ConditionEdge'; + +// Convert nodes to a JSON object format +export const convertFlowToJson = (nodes, nodeIdCounter) => { + const nodesData = nodes.map((node) => { + // Create a unique set from nexts, then convert it back to an array + const uniqueNexts = Array.from(new Set(node.data.nexts || [])); + + const ext = { + pos_x: node.position.x, + pos_y: node.position.y, + width: node.data.width || 200, + height: node.data.height || 200, + info: node.data.info || '', + }; + + // Return a new node with the updated unique 'nexts' array + const nodeData = NodeData.fromReactFlowNode({ + ...node, + data: { + ...node.data, + nexts: uniqueNexts, + }, + }); + + return { + ...nodeData.toDict(), + ext, + }; + }); + + const flowData = { + nodes: nodesData, + node_counter: nodeIdCounter, + }; + + return flowData; +}; + +// Save the JSON object to a file +export const saveJsonToFile = (flowData) => { + try { + const blob = new Blob([JSON.stringify(flowData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'flow.json'; + document.body.appendChild(a); // Append the link to the body + a.click(); // Simulate a click on the link + document.body.removeChild(a); // Remove the link from the body + URL.revokeObjectURL(url); // Revoke the object URL + alert('Flow saved!'); + } catch (error) { + console.error('Error saving JSON:', error); + alert('Failed to save flow.'); + } +}; + +// Original saveJson function - keeps the original interface +export const saveJson = async (nodes, nodeIdCounter) => { + try { + // Convert nodes to JSON + const flowData = convertFlowToJson(nodes, nodeIdCounter); + // Save the JSON data to a file + saveJsonToFile(flowData); + } catch (error) { + console.error('Error in saveJson:', error); + alert('Failed to save flow.'); + } +}; + +// Read and process JSON file +export const readJsonFile = (event) => { + return new Promise((resolve, reject) => { + const file = event.target.files[0]; + if (!file) { + reject(new Error('No file selected.')); + return; + } + const reader = new FileReader(); + reader.onload = (e) => { + try { + const contents = e.target.result; + resolve(JSON.parse(contents)); + } catch (error) { + reject(new Error('Error parsing JSON.')); + } + }; + reader.onerror = () => reject(new Error('Error reading file.')); + reader.readAsText(file); + }); +}; + +// Process flow data +export const processFlowData = (flowData, setEdges, setNodes, setNodeIdCounter) => { + try { + const loadedNodes = (flowData.nodes || []).map((nodeData) => { + const node = NodeData.fromDict(nodeData); + // Create a new object that includes ext properties and updated width/height + return { + ...node.toReactFlowNode(), + position: { x: nodeData.ext.pos_x, y: nodeData.ext.pos_y }, + data: { + ...node.toReactFlowNode().data, + width: nodeData.ext.width, + height: nodeData.ext.height, + info: nodeData.ext.info, + }, + + }; + }); + + // First, set the nodes + setNodes(loadedNodes); + + // Then, create edges + const loadedEdges = []; + loadedNodes.forEach((node) => { + node.data.nexts.forEach((nextId) => { + const newEdge = createEdge(loadedEdges, setEdges, { source: node.id, target: nextId }, loadedNodes, setNodes); + if (newEdge) { + loadedEdges.push(newEdge); + } + }); + if (node.data.true_next) { + const newEdge = createConditionEdge(loadedEdges, setEdges, { source: node.id, target: node.data.true_next, sourceHandle: 'true' }, loadedNodes, setNodes); + if (newEdge) { + loadedEdges.push(newEdge); + } + } + if (node.data.false_next) { + const newEdge = createConditionEdge(loadedEdges, setEdges, { source: node.id, target: node.data.false_next, sourceHandle: 'false' }, loadedNodes, setNodes); + if (newEdge) { + loadedEdges.push(newEdge); + } + } + }); + + setEdges(loadedEdges); + + // Set node counter + setNodeIdCounter(flowData.node_counter || 1); + } catch (error) { + console.error('Error processing JSON data:', error); + alert('Failed to process JSON data.'); + } +}; + +// Load JSON file and process +export const loadJson = (setEdges, setNodes, setNodeIdCounter) => { + const fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = '.json'; + fileInput.style.display = 'none'; + document.body.appendChild(fileInput); + + fileInput.addEventListener('change', async (event) => { + try { + const flowData = await readJsonFile(event); + if (flowData) { + processFlowData(flowData, setEdges, setNodes, setNodeIdCounter); + } + } catch (error) { + console.error('Error loading JSON:', error); + alert('Failed to load flow.'); + } finally { + document.body.removeChild(fileInput); // Clean up + } + }); + + fileInput.click(); +}; diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 870a1b4..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 HomunMage - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/Node.js b/Node.js new file mode 100644 index 0000000..d5b6084 --- /dev/null +++ b/Node.js @@ -0,0 +1,164 @@ +// Node.js + +import { memo, useCallback, useState, useEffect, useRef } from 'react'; +import NodeLayout from './NodeLayout'; +import { useGraphManager } from './GraphManager'; + +// Helper functions to remove references +export const removePrevs = (nodes, nodeId) => { + const nodeToRemovePrevs = nodes.find(node => node.id === nodeId); + if (!nodeToRemovePrevs) return nodes; + + try { + nodeToRemovePrevs.prevs.forEach(prevId => { + const prevNode = nodes.find(node => node.id === prevId); + if (prevNode) { + prevNode.data.nexts = prevNode.data.nexts.filter(id => id !== nodeId); + if (prevNode.data.true_next === nodeId) prevNode.data.true_next = null; + if (prevNode.data.false_next === nodeId) prevNode.data.false_next = null; + } + }); + nodeToRemovePrevs.prevs = []; + } + catch (error) { + // workaround if a node have no prevs + } + return nodes; +}; + +export const removeTrueFalse = (nodes, nodeId) => { + const nodeToRemoveTrueFalse = nodes.find(node => node.id === nodeId); + if (!nodeToRemoveTrueFalse) return nodes; + + const trueNextId = nodeToRemoveTrueFalse.data.true_next; + if (trueNextId) { + const trueNextNode = nodes.find(node => node.id === trueNextId); + if (trueNextNode) { + trueNextNode.prevs = trueNextNode.prevs.filter(id => id !== nodeId); + } + } + + const falseNextId = nodeToRemoveTrueFalse.data.false_next; + if (falseNextId) { + const falseNextNode = nodes.find(node => node.id === falseNextId); + if (falseNextNode) { + falseNextNode.prevs = falseNextNode.prevs.filter(id => id !== nodeId); + } + } + + nodeToRemoveTrueFalse.data.true_next = null; + nodeToRemoveTrueFalse.data.false_next = null; + return nodes; +}; + +export const removeNexts = (nodes, nodeId) => { + const nodeToRemoveNexts = nodes.find(node => node.id === nodeId); + if (!nodeToRemoveNexts) return nodes; + + nodeToRemoveNexts.data.nexts.forEach(nextId => { + const nextNode = nodes.find(node => node.id === nextId); + if (nextNode) { + nextNode.prevs = nextNode.prevs.filter(id => id !== nodeId); + } + }); + + nodeToRemoveNexts.data.nexts = []; + return nodes; +}; + +function Node({ data, isConnectable, id, prevs }) { + const { + setNodes, + } = useGraphManager(); + const [nodeData, setNodeData] = useState(data); + const changeBuffer = useRef({}); + + + useEffect(() => { + setNodeData(data); + }, [data]); + + const handleChange = useCallback((event) => { + const name = event.target.name; + const value = event.target.value; + const isComposingEvent = event.nativeEvent.isComposing; + + if (isComposingEvent) { + changeBuffer.current = { ...changeBuffer.current, [name]: value }; + } + else { + updateNodeData((prevData) => ({ ...prevData, ...changeBuffer.current, [name]: value })); + changeBuffer.current = {}; + } + }, [id, setNodes]); + + const updateNodeData = (updateFn) => { + setNodes((nds) => { + return nds.map((node) => { + if (node.id === id) { + return { + ...node, + data: updateFn(node.data), + }; + } + return node; + }); + }); + }; + + const onResize = useCallback((width, height) => { + updateNodeData((prevData) => ({ ...prevData, width, height })); + }, [id, setNodes]); + + return ( + + ); +} + +export const addNode = (nodes, setNodes, nodeIdCounter, setNodeIdCounter, newPosition) => { + const newNode = { + id: nodeIdCounter.toString(), + type: 'textUpdater', + data: { + name: `Node ${nodeIdCounter}`, + description: '', + type: 'STEP', + ext: { info: '' }, // Initialize ext.info for new nodes + nexts: [], + true_next: null, + false_next: null, + width: 200, + height: 200 + }, + position: newPosition, + prevs: [] + }; + setNodes((nds) => nds.concat(newNode)); + setNodeIdCounter(nodeIdCounter + 1); +}; + +export const deleteNode = (nodes, setNodes, edges, setEdges, nodeId) => { + let updatedNodes = [...nodes]; + const nodeToDelete = updatedNodes.find((node) => node.id === nodeId); + if (!nodeToDelete) return; + + // Use helper functions to clean up references + updatedNodes = removePrevs(updatedNodes, nodeId); + updatedNodes = removeTrueFalse(updatedNodes, nodeId); + updatedNodes = removeNexts(updatedNodes, nodeId); + + // Remove the node itself + updatedNodes = updatedNodes.filter((node) => node.id !== nodeId); + setNodes(updatedNodes); + + // Remove edges connected to this node + setEdges((eds) => eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)); +}; + +export default memo(Node); \ No newline at end of file diff --git a/NodeData.js b/NodeData.js new file mode 100644 index 0000000..7bb9f40 --- /dev/null +++ b/NodeData.js @@ -0,0 +1,76 @@ +// NodeData.js + +class NodeData { + constructor({ + uniq_id = '', + nexts = [], + type = 'START', + name = '', + description = '', + tool = '', + true_next = null, + false_next = null, + }) { + this.uniq_id = uniq_id; + this.nexts = nexts; + this.type = type; + this.name = name; + this.description = description; + this.tool = tool; + this.true_next = true_next; + this.false_next = false_next; + } + + static fromReactFlowNode(node) { + return new NodeData({ + uniq_id: node.id, + nexts: node.data.nexts || [], + type: node.data.type || 'STEP', + name: node.data.name, + description: node.data.description || '', + tool: node.data.tool || '', + true_next: node.data.true_next || null, + false_next: node.data.false_next || null, + }); + } + + static fromDict(data) { + return new NodeData(data); + } + + toReactFlowNode() { + return { + id: this.uniq_id, + type: 'textUpdater', + data: { + name: this.name, + description: this.description, + nexts: this.nexts, + type: this.type, + tool: this.tool, + true_next: this.true_next, + false_next: this.false_next, + }, + }; + } + + toDict() { + const { + uniq_id, nexts, type, name, description, + tool, true_next, false_next, + } = this; + + return { + uniq_id, + nexts, + type, + name, + description, + tool, + true_next, + false_next, + }; + } +} + +export default NodeData; diff --git a/NodeLayout.js b/NodeLayout.js new file mode 100644 index 0000000..a2a3505 --- /dev/null +++ b/NodeLayout.js @@ -0,0 +1,148 @@ +import React, { useCallback } from 'react'; +import { Handle, Position, NodeResizeControl } from 'reactflow'; +import ResizeIcon from './ResizeIcon'; + +const handleStyle = { + width: 6, + height: 6, + borderRadius: '50%', + background: '#555', +}; + +function NodeLayout({ data, isConnectable, handleChange, onResize }) { + const handleResize = useCallback( + (evt, { width, height }) => { + onResize(width, height); + }, + [onResize] + ); + + return ( +
+ + + + + + + +
+
+ + +
+ {data.type !== 'START' && ( + <> + {['STEP', 'CONDITION', 'INFO'].includes(data.type) && ( +
+ + +
+ )} + {data.type === 'STEP' && ( +
+ + +
+ )} + {['STEP', 'TOOL', 'CONDITION', 'INFO'].includes(data.type) && ( +
+ +