function bindWorkflowDesigner(elementId) { bindWorkflowDesignerToolbox(elementId) bindWorkflowDesignerSplitter(elementId) } function bindWorkflowDesignerToolbox(elementId) { // Store state for this specific designer instance const designer = { draggedType: null, draggedComponent: null, selectedComponent: null, connectionStart: null, potentialConnectionStart: null, }; // Get the designer container and canvas const designerContainer = document.getElementById(elementId); //const canvas = designerContainer.querySelector('.wkf-canvas'); const canvas = document.getElementById("c_" + elementId); // === Event delegation for components === // Use event delegation for all component-related events designerContainer.addEventListener('dragstart', (event) => { // Handle toolbox items if (event.target.closest('.wkf-toolbox-item')) { designer.draggedType = event.target.closest('.wkf-toolbox-item').dataset.type; event.dataTransfer.effectAllowed = 'copy'; } // Handle components if (event.target.closest('.wkf-workflow-component')) { const component = event.target.closest('.wkf-workflow-component'); component.classList.add('dragging'); designer.draggedComponent = component.dataset.componentId; event.dataTransfer.effectAllowed = 'move'; // Create an invisible image to use as the drag image const invisibleImg = new Image(); invisibleImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // 1px transparent GIF event.dataTransfer.setDragImage(invisibleImg, 0, 0); // Highlight all valid output points on other components designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { if (point.dataset.pointType === 'output' && point.dataset.componentId !== designer.draggedComponent) { point.classList.add('potential-connection'); } }); } }); designerContainer.addEventListener('drag', (event) => { if (!event.target.closest('.wkf-workflow-component')) return; if (event.clientX === 0 && event.clientY === 0) return; // Ignore invalid drag events const component = event.target.closest('.wkf-workflow-component'); const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left - 64; const y = event.clientY - rect.top - 40; component.style.left = Math.max(0, x) + 'px'; component.style.top = Math.max(0, y) + 'px'; const componentRect = component.getBoundingClientRect(); const componentId = component.dataset.componentId; const outputPoints = designerContainer.querySelectorAll('.wkf-connection-point[data-point-type="output"]'); outputPoints.forEach(point => { if (point.dataset.componentId === componentId) return; // Skip points from the same component const pointRect = point.getBoundingClientRect(); const pointCircle = { x: pointRect.left + pointRect.width / 2, y: pointRect.top + pointRect.height / 2, radius: 6 }; if (point != designer.potentialConnectionStart && _isOverlapping(componentRect, pointCircle)) { console.debug("overlapping !") outputPoints.forEach(other_point => { other_point.classList.remove("potential-start") }); designer.potentialConnectionStart = point.dataset.componentId; point.classList.add('potential-start'); } }); // Update connections in real-time updateConnections(designerContainer); }); designerContainer.addEventListener('dragend', (event) => { if (!event.target.closest('.wkf-workflow-component')) return; if (designer.draggedComponent) { const component = event.target.closest('.wkf-workflow-component'); const draggedComponentId = component.dataset.componentId; component.classList.remove('dragging'); const rect = canvas.getBoundingClientRect(); const x = event.clientX - rect.left - 64; const y = event.clientY - rect.top - 40; htmx.ajax('POST', '/workflows/move-component', { target: `#c_${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "innerHTML", values: { _id: elementId, component_id: designer.draggedComponent, x: Math.max(0, x), y: Math.max(0, y), } }); // Create connection if we ended the drag over a connection point if (designer.potentialConnectionStart) { htmx.ajax('POST', '/workflows/add-connection', { target: `#c_${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "innerHTML", values: { _id: elementId, from_id: designer.potentialConnectionStart, // The output point we're hovering over to_id: draggedComponentId, // The component we're dragging } }); } // Remove highlighting from all connection points designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { point.classList.remove('potential-connection'); }); designer.draggedComponent = null; designer.potentialConnectionStart = null; // Update connections after drag ends updateConnections(designerContainer); } }); // Handle component clicks using event delegation designerContainer.addEventListener('click', (event) => { // Handle canvas clicks to reset connection mode and deselect components if (event.target === canvas || event.target.classList.contains('wkf-canvas')) { if (designer.connectionStart) { designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { point.style.background = '#3b82f6'; }); designer.connectionStart = null; } // Deselect components designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => { comp.classList.remove('selected'); }); designer.selectedComponent = null; return; } // Handle component selection const component = event.target.closest('.wkf-workflow-component'); if (component) { event.stopPropagation(); // Remove previous selection designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => { comp.classList.remove('selected'); }); // Select current component component.classList.add('selected'); designer.selectedComponent = component.dataset.componentId; } // Handle connection points const connectionPoint = event.target.closest('.wkf-connection-point'); if (connectionPoint) { event.stopPropagation(); const componentId = connectionPoint.dataset.componentId; const pointType = connectionPoint.dataset.pointType; if (!designer.connectionStart) { // Start connection from output point if (pointType === 'output') { designer.connectionStart = { componentId: componentId, pointType: pointType }; connectionPoint.style.background = '#ef4444'; console.log('Connection started from:', componentId); } } else { // Complete connection to input point if (pointType === 'input' && componentId !== designer.connectionStart.componentId) { htmx.ajax('POST', '/workflows/add-connection', { target: `#c_${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "innerHTML", values: { _id: elementId, from_id: designer.connectionStart.componentId, to_id: componentId, } }); } // Reset connection mode designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { point.style.background = '#3b82f6'; }); designer.connectionStart = null; } } else { const component = event.target.closest('.wkf-workflow-component'); if (!component) return; componentId = component.dataset.componentId htmx.ajax('POST', '/workflows/select-connection', { target: `#c_${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "innerHTML", values: { _id: elementId, component_id: componentId } }); } }); // Canvas drag over event (for dropping new components) canvas.addEventListener('dragover', (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; }); // Canvas drop event (for creating new components) canvas.addEventListener('drop', (event) => { event.preventDefault(); if (designer.draggedType) { const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX - rect.left - 64; // Center the component const y = event.clientY - rect.top - 40; htmx.ajax('POST', '/workflows/add-component', { target: `#c_${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "innerHTML", values: { _id: elementId, component_type: designer.draggedType, x: Math.max(0, x), y: Math.max(0, y), } }); designer.draggedType = null; } }); // Delete selected component with Delete key document.addEventListener('keydown', (event) => { if (event.key === 'Delete' && designer.selectedComponent) { htmx.ajax('POST', '/workflows/delete-component', { target: `#c_${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "innerHTML", values: { _id: elementId, component_id: designer.selectedComponent, } }); } }); // Function to update all connection lines for this designer function updateConnections(container) { const connectionLines = container.querySelectorAll('.wkf-connection-line'); connectionLines.forEach(svg => { const connectionData = svg.dataset; if (connectionData.fromId && connectionData.toId) { updateConnectionLine(svg, connectionData.fromId, connectionData.toId, container); } }); } // Function to update a single connection line function updateConnectionLine(svg, fromId, toId, container) { const fromComp = container.querySelector(`[data-component-id="${fromId}"]`); const toComp = container.querySelector(`[data-component-id="${toId}"]`); if (!fromComp || !toComp) return; // Get current positions const fromX = parseInt(fromComp.style.left) + 128; // component width + output point const fromY = parseInt(fromComp.style.top) + 32; // component height / 2 const toX = parseInt(toComp.style.left); const toY = parseInt(toComp.style.top) + 32; // Create curved path const midX = (fromX + toX) / 2; const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; // Update the path const pathElement = svg.querySelector('path'); if (pathElement) { pathElement.setAttribute('d', path); } } // Return the designer object for possible external use return designer; } /** * Binds drag resize functionality to a workflow designer splitter * @param {string} elementId - The base ID of the workflow designer element */ function bindWorkflowDesignerSplitter(elementId) { // Get the elements const designer = document.getElementById(`d_${elementId}`); const splitter = document.getElementById(`s_${elementId}`); const properties = document.getElementById(`p_${elementId}`); const designerMinHeight = parseInt(designer.style.minHeight, 10) || 230; if (!designer || !splitter) { console.error("Cannot find all required elements for workflow designer splitter"); return; } // Initialize drag state let isResizing = false; let startY = 0; let startDesignerHeight = 0; // Mouse down event - start dragging splitter.addEventListener('mousedown', (e) => { e.preventDefault(); isResizing = true; startY = e.clientY; startDesignerHeight = parseInt(designer.style.height, 10) || designer.parentNode.getBoundingClientRect().height; console.debug("startDesignerHeight", startDesignerHeight); document.body.style.userSelect = 'none'; // Disable text selection document.body.style.cursor = "row-resize"; // Change cursor style globally for horizontal splitter splitter.classList.add('wkf-splitter-active'); // Add class for visual feedback }); // Mouse move event - update heights while dragging document.addEventListener('mousemove', (e) => { if (!isResizing) return; // Calculate new height const deltaY = e.clientY - startY; const newDesignerHeight = Math.max(designerMinHeight, startDesignerHeight + deltaY); // Enforce minimum height designer.style.height = `${newDesignerHeight}px`; // Update properties panel height if it exists if (properties) { const containerHeight = designer.parentNode.getBoundingClientRect().height; const propertiesHeight = Math.max(50, containerHeight - newDesignerHeight - splitter.offsetHeight); properties.style.height = `${propertiesHeight}px`; } console.debug("newDesignerHeight", newDesignerHeight); }); // Mouse up event - stop dragging document.addEventListener('mouseup', () => { if (!isResizing) return; isResizing = false; document.body.style.cursor = ""; // Reset cursor document.body.style.userSelect = ""; // Re-enable text selection splitter.classList.remove('wkf-splitter-active'); // Store the current state const designerHeight = parseInt(designer.style.height, 10); saveDesignerHeight(elementId, designerHeight); }); // Handle case when mouse leaves the window document.addEventListener('mouseleave', () => { if (isResizing) { isResizing = false; document.body.style.cursor = ""; // Reset cursor document.body.style.userSelect = ""; // Re-enable text selection splitter.classList.remove('wkf-splitter-active'); } }); // Function to save the designer height function saveDesignerHeight(id, height) { htmx.ajax('POST', '/workflows/resize-designer', { target: `#${elementId}`, headers: {"Content-Type": "application/x-www-form-urlencoded"}, swap: "outerHTML", values: { _id: elementId, designer_height: height, } }); } } function _isOverlapping(rect, circle) { // Find the closest point on the rectangle to the circle's center const closestX = Math.max(rect.x, Math.min(circle.x, rect.x + rect.width)); const closestY = Math.max(rect.y, Math.min(circle.y, rect.y + rect.height)); // Calculate the distance between the circle's center and the closest point const deltaX = circle.x - closestX; const deltaY = circle.y - closestY; const distanceSquared = deltaX * deltaX + deltaY * deltaY; // Check if the distance is less than or equal to the circle's radius return distanceSquared <= circle.radius * circle.radius; }