Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Lumiflow - Visual Workflow Builder</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| .connection { | |
| pointer-events: none; | |
| } | |
| .connection-delete-btn { | |
| pointer-events: all; | |
| } | |
| .node { | |
| transition: transform 0.1s ease; | |
| } | |
| .node:hover { | |
| transform: scale(1.02); | |
| } | |
| .node-handle { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| cursor: crosshair; | |
| } | |
| .node-handle.input { | |
| left: -6px; | |
| } | |
| .node-handle.output { | |
| right: -6px; | |
| } | |
| .connection-line { | |
| stroke-width: 3; | |
| fill: none; | |
| } | |
| .connection-line.temp { | |
| stroke-dasharray: 5; | |
| } | |
| .flow-canvas { | |
| background-image: | |
| linear-gradient(#e5e7eb 1px, transparent 1px), | |
| linear-gradient(90deg, #e5e7eb 1px, transparent 1px); | |
| background-size: 20px 20px; | |
| } | |
| .dark .flow-canvas { | |
| background-image: | |
| linear-gradient(#374151 1px, transparent 1px), | |
| linear-gradient(90deg, #374151 1px, transparent 1px); | |
| } | |
| .node-config-panel { | |
| transition: all 0.3s ease; | |
| } | |
| .node-config-panel.hidden { | |
| transform: translateX(100%); | |
| opacity: 0; | |
| } | |
| .node-config-panel.visible { | |
| transform: translateX(0); | |
| opacity: 1; | |
| } | |
| </style> | |
| </head> | |
| <body class="bg-gray-100 dark:bg-gray-900 text-gray-800 dark:text-gray-200 transition-colors duration-300"> | |
| <div class="flex flex-col h-screen"> | |
| <!-- Header --> | |
| <header class="bg-blue-600 dark:bg-blue-800 text-white p-4 shadow-md"> | |
| <div class="container mx-auto flex justify-between items-center"> | |
| <div class="flex items-center space-x-2"> | |
| <i class="fas fa-project-diagram text-2xl"></i> | |
| <h1 class="text-2xl font-bold">Lumiflow</h1> | |
| </div> | |
| <div class="flex items-center space-x-4"> | |
| <button id="run-flow-btn" class="bg-green-500 hover:bg-green-600 px-4 py-2 rounded-md font-medium flex items-center space-x-2"> | |
| <i class="fas fa-play"></i> | |
| <span>Run Flow</span> | |
| </button> | |
| <button id="theme-toggle" class="p-2 rounded-full hover:bg-blue-700 dark:hover:bg-blue-900"> | |
| <i class="fas fa-moon dark:hidden"></i> | |
| <i class="fas fa-sun hidden dark:block"></i> | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <!-- Main Content --> | |
| <div class="flex flex-1 overflow-hidden"> | |
| <!-- Node Panel --> | |
| <div class="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 p-4 overflow-y-auto"> | |
| <h2 class="font-bold text-lg mb-4">Nodes</h2> | |
| <div class="mb-6"> | |
| <h3 class="font-semibold text-sm uppercase text-gray-500 dark:text-gray-400 mb-2 flex items-center"> | |
| <i class="fas fa-sign-in-alt mr-2"></i> | |
| Input | |
| </h3> | |
| <div class="space-y-2"> | |
| <div class="node-item bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md cursor-move hover:bg-blue-100 dark:hover:bg-blue-900/50" draggable="true" data-type="inject"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-bolt text-blue-500 mr-2"></i> | |
| <span>Inject</span> | |
| </div> | |
| </div> | |
| <div class="node-item bg-blue-50 dark:bg-blue-900/30 p-3 rounded-md cursor-move hover:bg-blue-100 dark:hover:bg-blue-900/50" draggable="true" data-type="http-in"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-globe text-blue-500 mr-2"></i> | |
| <span>HTTP In</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-6"> | |
| <h3 class="font-semibold text-sm uppercase text-gray-500 dark:text-gray-400 mb-2 flex items-center"> | |
| <i class="fas fa-cogs mr-2"></i> | |
| Processing | |
| </h3> | |
| <div class="space-y-2"> | |
| <div class="node-item bg-purple-50 dark:bg-purple-900/30 p-3 rounded-md cursor-move hover:bg-purple-100 dark:hover:bg-purple-900/50" draggable="true" data-type="function"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-code text-purple-500 mr-2"></i> | |
| <span>Function</span> | |
| </div> | |
| </div> | |
| <div class="node-item bg-purple-50 dark:bg-purple-900/30 p-3 rounded-md cursor-move hover:bg-purple-100 dark:hover:bg-purple-900/50" draggable="true" data-type="delay"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-clock text-purple-500 mr-2"></i> | |
| <span>Delay</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="mb-6"> | |
| <h3 class="font-semibold text-sm uppercase text-gray-500 dark:text-gray-400 mb-2 flex items-center"> | |
| <i class="fas fa-sign-out-alt mr-2"></i> | |
| Output | |
| </h3> | |
| <div class="space-y-2"> | |
| <div class="node-item bg-green-50 dark:bg-green-900/30 p-3 rounded-md cursor-move hover:bg-green-100 dark:hover:bg-green-900/50" draggable="true" data-type="debug"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-bug text-green-500 mr-2"></i> | |
| <span>Debug</span> | |
| </div> | |
| </div> | |
| <div class="node-item bg-green-50 dark:bg-green-900/30 p-3 rounded-md cursor-move hover:bg-green-100 dark:hover:bg-green-900/50" draggable="true" data-type="http-out"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-server text-green-500 mr-2"></i> | |
| <span>HTTP Out</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Flow Canvas --> | |
| <div class="flex-1 relative overflow-hidden"> | |
| <div id="flow-canvas" class="flow-canvas w-full h-full bg-white dark:bg-gray-900 relative overflow-auto"> | |
| <svg id="connections-svg" class="absolute top-0 left-0 w-full h-full pointer-events-none z-10"></svg> | |
| </div> | |
| </div> | |
| <!-- Configuration Panel --> | |
| <div id="config-panel" class="node-config-panel hidden w-80 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 p-4 overflow-y-auto"> | |
| <div class="flex justify-between items-center mb-4"> | |
| <h2 class="font-bold text-lg">Node Configuration</h2> | |
| <button id="close-config-btn" class="p-2 rounded-full hover:bg-gray-200 dark:hover:bg-gray-700"> | |
| <i class="fas fa-times"></i> | |
| </button> | |
| </div> | |
| <div id="node-config-content"> | |
| <div class="text-center py-10 text-gray-500"> | |
| <i class="fas fa-cog text-4xl mb-2"></i> | |
| <p>Select a node to configure</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Status Bar --> | |
| <footer class="bg-gray-200 dark:bg-gray-800 border-t border-gray-300 dark:border-gray-700 p-2 text-sm"> | |
| <div class="container mx-auto flex justify-between items-center"> | |
| <div> | |
| <span id="node-count">0 nodes</span> | |
| <span class="mx-2">•</span> | |
| <span id="connection-count">0 connections</span> | |
| </div> | |
| <div id="status-message" class="font-medium">Ready</div> | |
| </div> | |
| </footer> | |
| </div> | |
| <script> | |
| document.addEventListener('DOMContentLoaded', function() { | |
| // Theme toggle | |
| const themeToggle = document.getElementById('theme-toggle'); | |
| const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)'); | |
| const currentTheme = localStorage.getItem('theme') || (prefersDarkScheme.matches ? 'dark' : 'light'); | |
| if (currentTheme === 'dark') { | |
| document.body.classList.add('dark'); | |
| } | |
| themeToggle.addEventListener('click', function() { | |
| document.body.classList.toggle('dark'); | |
| const theme = document.body.classList.contains('dark') ? 'dark' : 'light'; | |
| localStorage.setItem('theme', theme); | |
| }); | |
| // Flow state | |
| const state = { | |
| nodes: [], | |
| connections: [], | |
| selectedNode: null, | |
| draggingNode: null, | |
| creatingConnection: null, | |
| tempConnection: null, | |
| offset: { x: 0, y: 0 }, | |
| nextNodeId: 1, | |
| nextConnectionId: 1 | |
| }; | |
| // DOM elements | |
| const flowCanvas = document.getElementById('flow-canvas'); | |
| const connectionsSvg = document.getElementById('connections-svg'); | |
| const configPanel = document.getElementById('config-panel'); | |
| const nodeConfigContent = document.getElementById('node-config-content'); | |
| const nodeCountEl = document.getElementById('node-count'); | |
| const connectionCountEl = document.getElementById('connection-count'); | |
| const statusMessageEl = document.getElementById('status-message'); | |
| const runFlowBtn = document.getElementById('run-flow-btn'); | |
| const closeConfigBtn = document.getElementById('close-config-btn'); | |
| // Node templates | |
| const nodeTemplates = { | |
| 'inject': { | |
| name: 'Inject', | |
| icon: 'bolt', | |
| color: 'blue', | |
| config: ` | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Name</label> | |
| <input type="text" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Payload</label> | |
| <select class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| <option>Timestamp</option> | |
| <option>String</option> | |
| <option>Number</option> | |
| <option>Boolean</option> | |
| <option>JSON</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="flex items-center"> | |
| <input type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400"> | |
| <span class="ml-2 text-sm">Repeat</span> | |
| </label> | |
| </div> | |
| <div class="repeat-options hidden"> | |
| <label class="block text-sm font-medium mb-1">Interval (ms)</label> | |
| <input type="number" value="1000" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| </div> | |
| </div> | |
| ` | |
| }, | |
| 'http-in': { | |
| name: 'HTTP In', | |
| icon: 'globe', | |
| color: 'blue', | |
| config: ` | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Method</label> | |
| <select class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| <option>GET</option> | |
| <option>POST</option> | |
| <option>PUT</option> | |
| <option>DELETE</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">URL</label> | |
| <input type="text" placeholder="/api/endpoint" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| </div> | |
| </div> | |
| ` | |
| }, | |
| 'function': { | |
| name: 'Function', | |
| icon: 'code', | |
| color: 'purple', | |
| config: ` | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Function Code</label> | |
| <textarea class="w-full h-40 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 font-mono text-sm">// Write your JavaScript code here | |
| // The 'msg' object contains the incoming message | |
| // Return the modified message object | |
| msg.payload = "Processed: " + msg.payload; | |
| return msg;</textarea> | |
| </div> | |
| </div> | |
| ` | |
| }, | |
| 'delay': { | |
| name: 'Delay', | |
| icon: 'clock', | |
| color: 'purple', | |
| config: ` | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Delay (ms)</label> | |
| <input type="number" value="1000" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| </div> | |
| <div> | |
| <label class="flex items-center"> | |
| <input type="checkbox" class="rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-blue-400"> | |
| <span class="ml-2 text-sm">Drop messages while delayed</span> | |
| </label> | |
| </div> | |
| </div> | |
| ` | |
| }, | |
| 'debug': { | |
| name: 'Debug', | |
| icon: 'bug', | |
| color: 'green', | |
| config: ` | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Output</label> | |
| <select class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| <option>Complete message object</option> | |
| <option>msg.payload</option> | |
| <option>msg.topic</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">To</label> | |
| <select class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| <option>Debug console</option> | |
| <option>System log</option> | |
| </select> | |
| </div> | |
| </div> | |
| ` | |
| }, | |
| 'http-out': { | |
| name: 'HTTP Out', | |
| icon: 'server', | |
| color: 'green', | |
| config: ` | |
| <div class="space-y-4"> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Status Code</label> | |
| <input type="number" value="200" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-medium mb-1">Headers</label> | |
| <textarea class="w-full h-20 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 font-mono text-sm">Content-Type: application/json</textarea> | |
| </div> | |
| </div> | |
| ` | |
| } | |
| }; | |
| // Initialize the app | |
| function init() { | |
| setupEventListeners(); | |
| updateCounters(); | |
| } | |
| // Set up event listeners | |
| function setupEventListeners() { | |
| // Node panel drag events | |
| document.querySelectorAll('.node-item').forEach(item => { | |
| item.addEventListener('dragstart', handleNodeDragStart); | |
| }); | |
| // Canvas events | |
| flowCanvas.addEventListener('dragover', handleCanvasDragOver); | |
| flowCanvas.addEventListener('drop', handleCanvasDrop); | |
| flowCanvas.addEventListener('click', handleCanvasClick); | |
| // Run flow button | |
| runFlowBtn.addEventListener('click', runFlow); | |
| // Close config panel | |
| closeConfigBtn.addEventListener('click', closeConfigPanel); | |
| // Keyboard events | |
| document.addEventListener('keydown', handleKeyDown); | |
| } | |
| // Handle node drag start from panel | |
| function handleNodeDragStart(e) { | |
| const nodeType = e.target.getAttribute('data-type'); | |
| e.dataTransfer.setData('application/node-type', nodeType); | |
| e.dataTransfer.effectAllowed = 'copy'; | |
| } | |
| // Handle canvas drag over | |
| function handleCanvasDragOver(e) { | |
| e.preventDefault(); | |
| e.dataTransfer.dropEffect = 'copy'; | |
| } | |
| // Handle canvas drop | |
| function handleCanvasDrop(e) { | |
| e.preventDefault(); | |
| const nodeType = e.dataTransfer.getData('application/node-type'); | |
| if (!nodeType) return; | |
| const rect = flowCanvas.getBoundingClientRect(); | |
| const x = e.clientX - rect.left; | |
| const y = e.clientY - rect.top; | |
| createNode(nodeType, x, y); | |
| } | |
| // Create a new node | |
| function createNode(type, x, y) { | |
| const template = nodeTemplates[type]; | |
| if (!template) return; | |
| const nodeId = state.nextNodeId++; | |
| const node = { | |
| id: nodeId, | |
| type: type, | |
| x: x, | |
| y: y, | |
| connections: { | |
| inputs: [], | |
| outputs: [] | |
| } | |
| }; | |
| const nodeEl = document.createElement('div'); | |
| nodeEl.className = `node absolute w-48 bg-white dark:bg-gray-700 rounded-lg shadow-md border border-${template.color}-300 dark:border-${template.color}-800 cursor-move z-20`; | |
| nodeEl.style.transform = `translate(${x}px, ${y}px)`; | |
| nodeEl.dataset.nodeId = nodeId; | |
| nodeEl.innerHTML = ` | |
| <div class="bg-${template.color}-100 dark:bg-${template.color}-900/50 px-3 py-2 rounded-t-lg flex justify-between items-center"> | |
| <div class="flex items-center"> | |
| <i class="fas fa-${template.icon} text-${template.color}-500 mr-2"></i> | |
| <span class="font-medium">${template.name}</span> | |
| </div> | |
| <button class="node-delete-btn p-1 rounded-full hover:bg-${template.color}-200 dark:hover:bg-${template.color}-800/50 text-gray-500 dark:text-gray-300"> | |
| <i class="fas fa-times text-xs"></i> | |
| </button> | |
| </div> | |
| <div class="p-3"> | |
| <div class="text-xs text-gray-500 dark:text-gray-400">${type}</div> | |
| </div> | |
| <div class="node-handle input absolute top-1/2 bg-${template.color}-500 border-2 border-white dark:border-gray-700"></div> | |
| <div class="node-handle output absolute top-1/2 bg-${template.color}-500 border-2 border-white dark:border-gray-700"></div> | |
| `; | |
| flowCanvas.appendChild(nodeEl); | |
| node.element = nodeEl; | |
| // Add event listeners to the node | |
| nodeEl.addEventListener('mousedown', startNodeDrag); | |
| nodeEl.querySelector('.node-delete-btn').addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| deleteNode(nodeId); | |
| }); | |
| // Add connection handle events | |
| const inputHandle = nodeEl.querySelector('.node-handle.input'); | |
| const outputHandle = nodeEl.querySelector('.node-handle.output'); | |
| inputHandle.addEventListener('mousedown', (e) => startConnection(e, nodeId, 'input')); | |
| outputHandle.addEventListener('mousedown', (e) => startConnection(e, nodeId, 'output')); | |
| state.nodes.push(node); | |
| updateCounters(); | |
| } | |
| // Start dragging a node | |
| function startNodeDrag(e) { | |
| if (e.target.classList.contains('node-delete-btn') || | |
| e.target.classList.contains('node-handle')) { | |
| return; | |
| } | |
| const nodeEl = e.currentTarget; | |
| const nodeId = parseInt(nodeEl.dataset.nodeId); | |
| const node = state.nodes.find(n => n.id === nodeId); | |
| if (!node) return; | |
| state.draggingNode = node; | |
| state.offset = { | |
| x: e.clientX - node.x, | |
| y: e.clientY - node.y | |
| }; | |
| document.addEventListener('mousemove', dragNode); | |
| document.addEventListener('mouseup', stopNodeDrag); | |
| nodeEl.style.zIndex = '30'; | |
| nodeEl.style.cursor = 'grabbing'; | |
| } | |
| // Drag a node | |
| function dragNode(e) { | |
| if (!state.draggingNode) return; | |
| const node = state.draggingNode; | |
| const nodeEl = node.element; | |
| node.x = e.clientX - state.offset.x; | |
| node.y = e.clientY - state.offset.y; | |
| nodeEl.style.transform = `translate(${node.x}px, ${node.y}px)`; | |
| // Update all connections for this node | |
| updateNodeConnections(node); | |
| } | |
| // Stop dragging a node | |
| function stopNodeDrag() { | |
| if (!state.draggingNode) return; | |
| const node = state.draggingNode; | |
| const nodeEl = node.element; | |
| document.removeEventListener('mousemove', dragNode); | |
| document.removeEventListener('mouseup', stopNodeDrag); | |
| nodeEl.style.zIndex = '20'; | |
| nodeEl.style.cursor = 'move'; | |
| state.draggingNode = null; | |
| } | |
| // Delete a node | |
| function deleteNode(nodeId) { | |
| // Remove all connections to/from this node | |
| const connectionsToRemove = state.connections.filter(conn => | |
| conn.sourceId === nodeId || conn.targetId === nodeId | |
| ); | |
| connectionsToRemove.forEach(conn => { | |
| deleteConnection(conn.id); | |
| }); | |
| // Remove the node | |
| const nodeIndex = state.nodes.findIndex(n => n.id === nodeId); | |
| if (nodeIndex !== -1) { | |
| const node = state.nodes[nodeIndex]; | |
| node.element.remove(); | |
| state.nodes.splice(nodeIndex, 1); | |
| } | |
| if (state.selectedNode && state.selectedNode.id === nodeId) { | |
| closeConfigPanel(); | |
| } | |
| updateCounters(); | |
| } | |
| // Start creating a connection | |
| function startConnection(e, nodeId, handleType) { | |
| e.stopPropagation(); | |
| const node = state.nodes.find(n => n.id === nodeId); | |
| if (!node) return; | |
| state.creatingConnection = { | |
| nodeId: nodeId, | |
| handleType: handleType, | |
| startX: e.clientX, | |
| startY: e.clientY | |
| }; | |
| // Create a temporary connection line | |
| state.tempConnection = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| state.tempConnection.classList.add('connection-line', 'temp'); | |
| state.tempConnection.setAttribute('stroke', '#6b7280'); | |
| connectionsSvg.appendChild(state.tempConnection); | |
| document.addEventListener('mousemove', updateTempConnection); | |
| document.addEventListener('mouseup', finishConnection); | |
| } | |
| // Update temporary connection while dragging | |
| function updateTempConnection(e) { | |
| if (!state.creatingConnection || !state.tempConnection) return; | |
| const node = state.nodes.find(n => n.id === state.creatingConnection.nodeId); | |
| if (!node) return; | |
| const nodeEl = node.element; | |
| const rect = nodeEl.getBoundingClientRect(); | |
| const canvasRect = flowCanvas.getBoundingClientRect(); | |
| const scrollLeft = flowCanvas.scrollLeft; | |
| const scrollTop = flowCanvas.scrollTop; | |
| let startX, startY; | |
| if (state.creatingConnection.handleType === 'input') { | |
| startX = rect.left - canvasRect.left + scrollLeft; | |
| startY = rect.top - canvasRect.top + rect.height / 2 + scrollTop; | |
| } else { | |
| startX = rect.left - canvasRect.left + rect.width + scrollLeft; | |
| startY = rect.top - canvasRect.top + rect.height / 2 + scrollTop; | |
| } | |
| const endX = e.clientX - canvasRect.left + scrollLeft; | |
| const endY = e.clientY - canvasRect.top + scrollTop; | |
| // Calculate the control points for the bezier curve | |
| const midX = (startX + endX) / 2; | |
| const ctrlX1 = midX; | |
| const ctrlX2 = midX; | |
| // Create the path data | |
| const pathData = `M${startX},${startY} C${ctrlX1},${startY} ${ctrlX2},${endY} ${endX},${endY}`; | |
| state.tempConnection.setAttribute('d', pathData); | |
| } | |
| // Finish creating a connection | |
| function finishConnection(e) { | |
| if (!state.creatingConnection) return; | |
| document.removeEventListener('mousemove', updateTempConnection); | |
| document.removeEventListener('mouseup', finishConnection); | |
| // Remove the temporary connection line | |
| if (state.tempConnection) { | |
| connectionsSvg.removeChild(state.tempConnection); | |
| state.tempConnection = null; | |
| } | |
| // Check if we're hovering over a node handle | |
| const canvasRect = flowCanvas.getBoundingClientRect(); | |
| const scrollLeft = flowCanvas.scrollLeft; | |
| const scrollTop = flowCanvas.scrollTop; | |
| const canvasX = e.clientX - canvasRect.left + scrollLeft; | |
| const canvasY = e.clientY - canvasRect.top + scrollTop; | |
| const elements = document.elementsFromPoint(e.clientX, e.clientY); | |
| const targetHandle = elements.find(el => el.classList.contains('node-handle')); | |
| if (!targetHandle) { | |
| state.creatingConnection = null; | |
| return; | |
| } | |
| const targetNodeEl = targetHandle.closest('.node'); | |
| if (!targetNodeEl) { | |
| state.creatingConnection = null; | |
| return; | |
| } | |
| const targetNodeId = parseInt(targetNodeEl.dataset.nodeId); | |
| const targetNode = state.nodes.find(n => n.id === targetNodeId); | |
| if (!targetNode) { | |
| state.creatingConnection = null; | |
| return; | |
| } | |
| // Determine if this is an input or output handle | |
| const targetHandleType = targetHandle.classList.contains('input') ? 'input' : 'output'; | |
| // Create the connection if valid | |
| if (state.creatingConnection.handleType === 'output' && targetHandleType === 'input') { | |
| // Output to input connection | |
| createConnection(state.creatingConnection.nodeId, targetNodeId); | |
| } else if (state.creatingConnection.handleType === 'input' && targetHandleType === 'output') { | |
| // Input to output connection (reverse) | |
| createConnection(targetNodeId, state.creatingConnection.nodeId); | |
| } | |
| state.creatingConnection = null; | |
| } | |
| // Create a connection between two nodes | |
| function createConnection(sourceId, targetId) { | |
| // Don't allow connections to self | |
| if (sourceId === targetId) return; | |
| // Check if connection already exists | |
| const existingConnection = state.connections.find(conn => | |
| conn.sourceId === sourceId && conn.targetId === targetId | |
| ); | |
| if (existingConnection) return; | |
| const connectionId = state.nextConnectionId++; | |
| const connection = { | |
| id: connectionId, | |
| sourceId: sourceId, | |
| targetId: targetId, | |
| element: null | |
| }; | |
| // Add to source and target nodes | |
| const sourceNode = state.nodes.find(n => n.id === sourceId); | |
| const targetNode = state.nodes.find(n => n.id === targetId); | |
| if (sourceNode && targetNode) { | |
| sourceNode.connections.outputs.push(connectionId); | |
| targetNode.connections.inputs.push(connectionId); | |
| } | |
| // Create the SVG path element | |
| const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| path.classList.add('connection-line'); | |
| path.setAttribute('stroke', '#3b82f6'); | |
| path.setAttribute('marker-end', 'url(#arrowhead)'); | |
| connectionsSvg.appendChild(path); | |
| // Create the delete button | |
| const deleteBtn = document.createElement('div'); | |
| deleteBtn.classList.add('connection-delete-btn', 'absolute', 'bg-white', 'dark:bg-gray-700', 'rounded-full', 'w-5', 'h-5', 'flex', 'items-center', 'justify-center', 'shadow-sm', 'border', 'border-gray-300', 'dark:border-gray-600', 'cursor-pointer', 'z-30'); | |
| deleteBtn.innerHTML = '<i class="fas fa-times text-xs text-gray-600 dark:text-gray-300"></i>'; | |
| deleteBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| deleteConnection(connectionId); | |
| }); | |
| flowCanvas.appendChild(deleteBtn); | |
| connection.path = path; | |
| connection.deleteBtn = deleteBtn; | |
| state.connections.push(connection); | |
| // Update the connection position | |
| updateConnection(connection); | |
| updateCounters(); | |
| } | |
| // Update a connection's position | |
| function updateConnection(connection) { | |
| const sourceNode = state.nodes.find(n => n.id === connection.sourceId); | |
| const targetNode = state.nodes.find(n => n.id === connection.targetId); | |
| if (!sourceNode || !targetNode) return; | |
| const sourceEl = sourceNode.element; | |
| const targetEl = targetNode.element; | |
| const sourceRect = sourceEl.getBoundingClientRect(); | |
| const targetRect = targetEl.getBoundingClientRect(); | |
| const canvasRect = flowCanvas.getBoundingClientRect(); | |
| // Calculate positions relative to the canvas | |
| const sourceX = sourceRect.left + sourceRect.width - canvasRect.left; | |
| const sourceY = sourceRect.top + sourceRect.height / 2 - canvasRect.top; | |
| const targetX = targetRect.left - canvasRect.left; | |
| const targetY = targetRect.top + targetRect.height / 2 - canvasRect.top; | |
| // Calculate the control points for the bezier curve | |
| const midX = (sourceX + targetX) / 2; | |
| const ctrlX1 = midX; | |
| const ctrlX2 = midX; | |
| // Create the path data | |
| const pathData = `M${sourceX},${sourceY} C${ctrlX1},${sourceY} ${ctrlX2},${targetY} ${targetX},${targetY}`; | |
| connection.path.setAttribute('d', pathData); | |
| // Position the delete button at the midpoint | |
| const btnX = (sourceX + targetX) / 2 - 10; | |
| const btnY = (sourceY + targetY) / 2 - 10; | |
| connection.deleteBtn.style.left = `${btnX}px`; | |
| connection.deleteBtn.style.top = `${btnY}px`; | |
| } | |
| // Update all connections for a node | |
| function updateNodeConnections(node) { | |
| // Output connections | |
| node.connections.outputs.forEach(connId => { | |
| const connection = state.connections.find(c => c.id === connId); | |
| if (connection) updateConnection(connection); | |
| }); | |
| // Input connections | |
| node.connections.inputs.forEach(connId => { | |
| const connection = state.connections.find(c => c.id === connId); | |
| if (connection) updateConnection(connection); | |
| }); | |
| } | |
| // Delete a connection | |
| function deleteConnection(connectionId) { | |
| const connectionIndex = state.connections.findIndex(c => c.id === connectionId); | |
| if (connectionIndex === -1) return; | |
| const connection = state.connections[connectionIndex]; | |
| // Remove from source and target nodes | |
| const sourceNode = state.nodes.find(n => n.id === connection.sourceId); | |
| const targetNode = state.nodes.find(n => n.id === connection.targetId); | |
| if (sourceNode) { | |
| const outputIndex = sourceNode.connections.outputs.indexOf(connectionId); | |
| if (outputIndex !== -1) { | |
| sourceNode.connections.outputs.splice(outputIndex, 1); | |
| } | |
| } | |
| if (targetNode) { | |
| const inputIndex = targetNode.connections.inputs.indexOf(connectionId); | |
| if (inputIndex !== -1) { | |
| targetNode.connections.inputs.splice(inputIndex, 1); | |
| } | |
| } | |
| // Remove DOM elements | |
| if (connection.path) { | |
| connectionsSvg.removeChild(connection.path); | |
| } | |
| if (connection.deleteBtn) { | |
| connection.deleteBtn.remove(); | |
| } | |
| // Remove from state | |
| state.connections.splice(connectionIndex, 1); | |
| updateCounters(); | |
| } | |
| // Handle canvas click | |
| function handleCanvasClick(e) { | |
| // Check if we clicked on a node | |
| const nodeEl = e.target.closest('.node'); | |
| if (nodeEl) { | |
| const nodeId = parseInt(nodeEl.dataset.nodeId); | |
| const node = state.nodes.find(n => n.id === nodeId); | |
| if (node) { | |
| selectNode(node); | |
| } | |
| } else { | |
| // Clicked on empty space, deselect | |
| closeConfigPanel(); | |
| } | |
| } | |
| // Select a node and show its config | |
| function selectNode(node) { | |
| state.selectedNode = node; | |
| // Highlight the selected node | |
| document.querySelectorAll('.node').forEach(el => { | |
| el.classList.remove('ring-2', 'ring-blue-500'); | |
| }); | |
| node.element.classList.add('ring-2', 'ring-blue-500'); | |
| // Show the config panel | |
| showConfigPanel(node); | |
| } | |
| // Show config panel for a node | |
| function showConfigPanel(node) { | |
| const template = nodeTemplates[node.type]; | |
| if (!template) return; | |
| nodeConfigContent.innerHTML = ` | |
| <div class="mb-4"> | |
| <h3 class="font-bold text-lg flex items-center"> | |
| <i class="fas fa-${template.icon} text-${template.color}-500 mr-2"></i> | |
| ${template.name} | |
| </h3> | |
| <div class="text-xs text-gray-500 dark:text-gray-400">ID: ${node.id}</div> | |
| </div> | |
| ${template.config} | |
| `; | |
| // Add event listeners for any dynamic elements in the config | |
| const repeatCheckbox = nodeConfigContent.querySelector('input[type="checkbox"]'); | |
| if (repeatCheckbox) { | |
| repeatCheckbox.addEventListener('change', (e) => { | |
| const repeatOptions = nodeConfigContent.querySelector('.repeat-options'); | |
| if (repeatOptions) { | |
| repeatOptions.classList.toggle('hidden', !e.target.checked); | |
| } | |
| }); | |
| } | |
| configPanel.classList.remove('hidden'); | |
| configPanel.classList.add('visible'); | |
| } | |
| // Close config panel | |
| function closeConfigPanel() { | |
| if (state.selectedNode) { | |
| state.selectedNode.element.classList.remove('ring-2', 'ring-blue-500'); | |
| state.selectedNode = null; | |
| } | |
| configPanel.classList.remove('visible'); | |
| configPanel.classList.add('hidden'); | |
| } | |
| // Handle keyboard events | |
| function handleKeyDown(e) { | |
| // Delete key | |
| if (e.key === 'Delete' && state.selectedNode) { | |
| deleteNode(state.selectedNode.id); | |
| } | |
| } | |
| // Run the flow | |
| function runFlow() { | |
| if (state.nodes.length === 0) { | |
| statusMessageEl.textContent = 'No nodes to run'; | |
| return; | |
| } | |
| statusMessageEl.textContent = 'Running flow...'; | |
| // Simulate flow execution | |
| setTimeout(() => { | |
| statusMessageEl.textContent = 'Flow completed successfully'; | |
| // Reset after 2 seconds | |
| setTimeout(() => { | |
| statusMessageEl.textContent = 'Ready'; | |
| }, 2000); | |
| }, 1500); | |
| } | |
| // Update node and connection counters | |
| function updateCounters() { | |
| nodeCountEl.textContent = `${state.nodes.length} node${state.nodes.length !== 1 ? 's' : ''}`; | |
| connectionCountEl.textContent = `${state.connections.length} connection${state.connections.length !== 1 ? 's' : ''}`; | |
| } | |
| // Initialize the app | |
| init(); | |
| // Add arrowhead marker to SVG for connection lines | |
| const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); | |
| const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); | |
| marker.setAttribute('id', 'arrowhead'); | |
| marker.setAttribute('markerWidth', '10'); | |
| marker.setAttribute('markerHeight', '7'); | |
| marker.setAttribute('refX', '9'); | |
| marker.setAttribute('refY', '3.5'); | |
| marker.setAttribute('orient', 'auto'); | |
| const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); | |
| polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); | |
| polygon.setAttribute('fill', '#3b82f6'); | |
| marker.appendChild(polygon); | |
| defs.appendChild(marker); | |
| connectionsSvg.appendChild(defs); | |
| }); | |
| </script> | |
| <p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=snapo/nodesworkflow" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
| </html> |