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..d551195 --- /dev/null +++ b/Canvas.js @@ -0,0 +1,189 @@ +// Canvas.js + +import React, { useState, useCallback, useEffect, useRef } from 'react'; +import { ReactFlow, MiniMap, Controls, Background, useReactFlow } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import Node, { deleteNode } from './Node'; +import { createEdge, deleteEdge } from './Edge'; +import { createConditionEdge, deleteConditionEdge } from './ConditionEdge'; +import { useGraphContext } from './GraphContext'; +import GraphControl from './GraphControl'; + +const nodeTypes = { textUpdater: Node }; // Keep this, assuming your Node component handles it correctly + +function Canvas() { + const { + nodes, + setNodes, + onNodesChange, + edges, + setEdges, + onEdgesChange, + addNode, + } = useGraphContext(); + + 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(() => { + if (contextMenu) { + const newPosition = screenToFlowPosition({ x: contextMenu.mouseX, y: contextMenu.mouseY }); + addNode(newPosition); + setContextMenu(null); + } + }, [contextMenu, addNode, screenToFlowPosition]); + + 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..fe6e344 --- /dev/null +++ b/ConditionEdge.js @@ -0,0 +1,62 @@ +// ConditionEdge.js + +import { addEdge } from '@xyflow/react'; + +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..d0b32df --- /dev/null +++ b/Edge.js @@ -0,0 +1,54 @@ +// Edge.js + +import { addEdge } from '@xyflow/react'; + +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..0bc86bb --- /dev/null +++ b/GraphApp.js @@ -0,0 +1,15 @@ +// GraphApp.js + +import React from 'react'; +import { ReactFlowProvider } from '@xyflow/react'; +import Canvas from './Canvas'; + +function App() { + return ( + + + + ); +} + +export default App; diff --git a/GraphContext.js b/GraphContext.js new file mode 100644 index 0000000..bb28762 --- /dev/null +++ b/GraphContext.js @@ -0,0 +1,67 @@ +// GraphContext.js + +import React, { createContext, useContext, useState } from 'react'; +import { useNodesState, useEdgesState} from '@xyflow/react'; + +const GraphContextContext = createContext(null); + +export const useGraphContext = () => { + return useContext(GraphContextContext); +}; + +export const GraphContextProvider = ({ children }) => { + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [serialNumber, setSerialNumber] = useState(0); + + // Add addNode here + const addNode = (newPosition) => { + const newNode = { + id: serialNumber.toString(), + type: 'textUpdater', + data: { + name: `Node ${serialNumber}`, + 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((nodes) => nodes.concat(newNode)); + setSerialNumber(serialNumber + 1); + }; + + + // Add clear function + const Clear = () => { + setNodes([]); + setEdges([]); + setSerialNumber(0); + }; + + + const value = { + nodes, + setNodes, + onNodesChange, + edges, + setEdges, + onEdgesChange, + serialNumber, + setSerialNumber, + addNode, + Clear, + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/GraphControl.js b/GraphControl.js new file mode 100644 index 0000000..af0bd43 --- /dev/null +++ b/GraphControl.js @@ -0,0 +1,194 @@ +// GraphControl.js + +import React, { useState, useEffect } from 'react'; +import { convertJsonToFlow, convertSubGraphToJson } from './JsonUtils'; +import { saveJsonToFile, loadJsonFromFile } from '../utils/jsonIO'; +import RunWindow from './RunWindow'; +import FileTransmit from './FileTransmit'; +import ConfigWindow from '../ConfigWindow'; +import { useGraphContext } from './GraphContext'; +import { useSelector, useDispatch } from 'react-redux'; +import { addSubGraph, updateSubGraph, removeSubGraph, initSubGraphs, setSubGraphs } from './subGraphSlice.store'; + +function GraphControl({ showConfig, setShowConfig, showRun, setShowRun }) { + const { + nodes, + setNodes, + setEdges, + serialNumber, + setSerialNumber, + Clear, + } = useGraphContext(); + const dispatch = useDispatch(); + const subGraphs = useSelector((state) => state.subGraphs.subGraphs); + + const [currentSubGraph, setCurrentSubGraph] = useState("root"); + + // Function to Update Redux with current graph state + const updateReduxWithCurrentGraph = () => { + const currentSubGraphJson = convertSubGraphToJson({ + graphName: currentSubGraph, + nodes: nodes, + serial_number: serialNumber, + }); + dispatch(updateSubGraph(currentSubGraphJson)); + }; + + // Effect to load subGraph from Redux whenever currentSubGraph changes + useEffect(() => { + const loadSubGraphData = () => { + const selectedSubGraph = subGraphs.find((graph) => graph.graphName === currentSubGraph); + if (selectedSubGraph) { + const processedData = convertJsonToFlow(selectedSubGraph); + if (processedData) { + setNodes(processedData.nodes); + setEdges(processedData.edges); + setSerialNumber(processedData.serialNumber); + } else { + Clear(); + } + } else { + Clear(); + } + }; + loadSubGraphData(); + }, [currentSubGraph, subGraphs, setNodes, setEdges, setSerialNumber]); + + + const handleNew = () => { + updateReduxWithCurrentGraph(); + dispatch(initSubGraphs()); + dispatch(updateSubGraph({ graphName: "root", nodes: [], serial_number: 0 })); + setCurrentSubGraph("root"); + Clear(); + }; + + const handleLoadSubGraph = (graphName) => { + updateReduxWithCurrentGraph(); + setCurrentSubGraph(graphName); // Switch the name, the effect will do the loading. + }; + + const handleAddSubGraph = () => { + updateReduxWithCurrentGraph(); + const newName = window.prompt("Enter the name for the new subgraph:"); + if(newName) { + const uniqueName = newName.trim() === "" ? `newGraph${Date.now()}` : newName; + dispatch(addSubGraph({ graphName: uniqueName, nodes: [], serial_number: 1 })); + } + }; + + const handleRenameSubGraph = () => { + updateReduxWithCurrentGraph(); + if(currentSubGraph === "root") return; + const newName = window.prompt("Enter the new name for the subgraph:", currentSubGraph); + if (newName && newName.trim() !== "" && newName !== currentSubGraph ) { + const currentGraph = subGraphs.find((graph) => graph.graphName === currentSubGraph); + if (currentGraph) { + dispatch(removeSubGraph(currentSubGraph)); + dispatch(addSubGraph({ ...currentGraph, graphName: newName })); + setCurrentSubGraph(newName); + } + } + + }; + + + const handleRemoveSubGraph = () => { + updateReduxWithCurrentGraph(); + if (currentSubGraph !== "root") { + dispatch(removeSubGraph(currentSubGraph)); + handleNew(); // Reset to root node + } + }; + + const handleSaveAll = async () => { + updateReduxWithCurrentGraph(); + try { + saveJsonToFile(subGraphs); + } catch (error) { + console.error('Error saving JSON:', error); + alert('Failed to save flow.'); + } + }; + + const handleLoad = async () => { + updateReduxWithCurrentGraph(); + try { + const newSubGraphs = await loadJsonFromFile(); + if (Array.isArray(newSubGraphs)) { + dispatch(setSubGraphs(newSubGraphs)); + } else { + alert('incorrect file form') + } + } catch (error) { + console.error('Error loading JSON:', error); + alert('Failed to load flow.'); + } + }; + + const handleRun = () => { + updateReduxWithCurrentGraph(); + setShowRun(true); + }; + + const handleConfig = () => { + updateReduxWithCurrentGraph(); + setShowConfig(true); + }; + + + const handleUploadComplete = () => { + console.log('Upload complete.'); + }; + + return ( + + ); +} + +export default GraphControl; \ No newline at end of file diff --git a/JsonUtils.js b/JsonUtils.js new file mode 100644 index 0000000..34b548c --- /dev/null +++ b/JsonUtils.js @@ -0,0 +1,97 @@ +// JsonUtils.js + +import NodeData from './NodeData'; +import { createEdge } from './Edge'; +import { createConditionEdge } from './ConditionEdge'; + +// Convert a single subGraph to a JSON object format +export const convertSubGraphToJson = (subGraph) => { + const nodesData = subGraph.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.toJson(), + ext, + }; + }); + + return { + graphName: subGraph.graphName, + nodes: nodesData, + serial_number: subGraph.serial_number, + }; +}; + +// Convert an array of subGraphs to JSON +export const convertFlowToJson = (subGraphs) => { + return subGraphs.map(convertSubGraphToJson) +} + +// Process flow data for a single subGraph +export const convertJsonToFlow = (subGraphData) => { + try { + const loadedNodes = (subGraphData.nodes || []).map((nodeData) => { + const node = NodeData.fromJson(nodeData); + 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, + }, + }; + }); + + const loadedEdges = []; + loadedNodes.forEach((node) => { + node.data.nexts.forEach((nextId) => { + const newEdge = createEdge(loadedEdges, null, { source: node.id, target: nextId }, loadedNodes, null); + if (newEdge) { + loadedEdges.push(newEdge); + } + }); + if (node.data.true_next) { + const newEdge = createConditionEdge(loadedEdges, null, { source: node.id, target: node.data.true_next, sourceHandle: 'true' }, loadedNodes, null); + if (newEdge) { + loadedEdges.push(newEdge); + } + } + if (node.data.false_next) { + const newEdge = createConditionEdge(loadedEdges, null, { source: node.id, target: node.data.false_next, sourceHandle: 'false' }, loadedNodes, null); + if (newEdge) { + loadedEdges.push(newEdge); + } + } + }); + + return { + nodes: loadedNodes, + edges: loadedEdges, + serialNumber: subGraphData.serial_number || 1, + } + } catch (error) { + console.error('Error processing JSON data:', error); + alert('Failed to process JSON data.'); + return null; + } +}; \ No newline at end of file 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..3a152ed --- /dev/null +++ b/Node.js @@ -0,0 +1,142 @@ +// Node.js + +import { memo, useCallback, useState, useEffect, useRef } from 'react'; +import NodeLayout from './NodeLayout'; +import { useGraphContext } from './GraphContext'; + +// 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, + } = useGraphContext(); + 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 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..e3eed50 --- /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 fromJson(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, + }, + }; + } + + toJson() { + 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..a3603a0 --- /dev/null +++ b/NodeLayout.js @@ -0,0 +1,148 @@ +import React, { useCallback } from 'react'; +import { Handle, Position, NodeResizeControl } from '@xyflow/react'; +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) && ( +
+ +