function bindWorkflowDesigner(elementId) { bindWorkflowDesignerToolbox(elementId) bindWorkflowDesignerSplitter(elementId) bindWorkflowProperties(elementId) } function bindWorkflowDesignerToolbox(elementId) { // Constants for configuration const CONFIG = { COMPONENT_WIDTH: 128, COMPONENT_HEIGHT: 64, DRAG_OFFSET: { x: 64, y: 40 }, CONNECTION_POINT_RADIUS: 6, INVISIBLE_DRAG_IMAGE: 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', }; // Designer state with better organization const designer = { // Drag state draggedType: null, draggedComponent: null, // Selection state selectedComponent: null, selectedConnection: null, // Connection state connectionStart: null, potentialConnectionStart: null, // Performance optimization lastUpdateTime: 0, animationFrame: null, // Cleanup tracking eventListeners: new Map(), // State methods reset() { this.draggedType = null; this.draggedComponent = null; this.connectionStart = null; this.potentialConnectionStart = null; this.cancelAnimationFrame(); }, cancelAnimationFrame() { if (this.animationFrame) { cancelAnimationFrame(this.animationFrame); this.animationFrame = null; } } }; // Get DOM elements with error handling const designerContainer = document.getElementById(elementId); const canvas = document.getElementById(`c_${elementId}`); if (!designerContainer || !canvas) { console.error(`Workflow designer elements not found for ID: ${elementId}`); return null; } // Utility functions const utils = { // Check if two rectangles overlap isOverlapping(rect1, circle) { // Find the closest point on the rectangle to the circle's center const closestX = Math.max(rect1.x, Math.min(circle.x, rect1.x + rect1.width)); const closestY = Math.max(rect1.y, Math.min(circle.y, rect1.y + rect1.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; }, // Get mouse position relative to canvas getCanvasPosition(event) { const rect = canvas.getBoundingClientRect(); return { x: event.clientX - rect.left, y: event.clientY - rect.top }; }, // Constrain position within canvas bounds constrainPosition(x, y) { const canvasRect = canvas.getBoundingClientRect(); return { x: Math.max(0, Math.min(x, canvasRect.width - CONFIG.COMPONENT_WIDTH)), y: Math.max(0, Math.min(y, canvasRect.height - CONFIG.COMPONENT_HEIGHT)) }; }, // Create HTMX request with error handling async makeRequest(url, values, targetId = `#c_${elementId}`, swap="innerHTML") { try { return htmx.ajax('POST', url, { target: targetId, headers: { "Content-Type": "application/x-www-form-urlencoded" }, swap: swap, values: { _id: elementId, ...values } }); } catch (error) { console.error('HTMX request failed:', error); throw error; } } }; // Connection management const connectionManager = { // Update all connections with performance optimization updateAll() { designer.cancelAnimationFrame(); designer.animationFrame = requestAnimationFrame(() => { const connectionLines = designerContainer.querySelectorAll('.wkf-connection-line'); connectionLines.forEach(svg => { const { fromId, toId } = svg.dataset; if (fromId && toId) { this.updateLine(svg, fromId, toId); } }); }); }, // Update a specific connection line updateLine(svg, fromId, toId) { const fromComp = designerContainer.querySelector(`[data-component-id="${fromId}"]`); const toComp = designerContainer.querySelector(`[data-component-id="${toId}"]`); if (!fromComp || !toComp) return; // Calculate connection points const fromX = parseInt(fromComp.style.left) + CONFIG.COMPONENT_WIDTH; const fromY = parseInt(fromComp.style.top) + CONFIG.COMPONENT_HEIGHT / 2; const toX = parseInt(toComp.style.left); const toY = parseInt(toComp.style.top) + CONFIG.COMPONENT_HEIGHT / 2; // Create smooth curved path const midX = (fromX + toX) / 2; const path = `M ${fromX} ${fromY} C ${midX} ${fromY}, ${midX} ${toY}, ${toX} ${toY}`; // Update the path element const pathElement = svg.querySelector('.wkf-connection-path'); if (pathElement) { pathElement.setAttribute('d', path); } }, // Clear all connection highlighting clearHighlighting() { designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { point.classList.remove('potential-connection', 'potential-start'); point.style.background = '#3b82f6'; }); }, // Select a connection select(connectionPath) { // Deselect all other connections designerContainer.querySelectorAll('.wkf-connection-line path').forEach(path => { path.classList.remove('wkf-connection-selected'); }); // Select the clicked connection connectionPath.classList.add('wkf-connection-selected'); // Store connection data const connectionSvg = connectionPath.closest('.wkf-connection-line'); designer.selectedConnection = { fromId: connectionSvg.dataset.fromId, toId: connectionSvg.dataset.toId }; }, // Deselect all connections deselectAll() { designerContainer.querySelectorAll('.wkf-connection-line path').forEach(path => { path.classList.remove('wkf-connection-selected'); }); designer.selectedConnection = null; } }; // Component management const componentManager = { // Select a component select(component) { // Deselect all other components designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => { comp.classList.remove('selected'); }); // Select the clicked component component.classList.add('selected'); designer.selectedComponent = component.dataset.componentId; // Also trigger server-side selection utils.makeRequest('/workflows/select-component', { component_id: designer.selectedComponent }, `#ppc_${elementId}`, "outerHTML"); }, // Deselect all components deselectAll() { designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => { comp.classList.remove('selected'); }); designer.selectedComponent = null; }, // Update component position with constraints updatePosition(component, x, y) { const constrained = utils.constrainPosition(x, y); component.style.left = constrained.x + 'px'; component.style.top = constrained.y + 'px'; } }; // Event handlers with improved organization const eventHandlers = { // Handle drag start for both toolbox items and components onDragStart(event) { const toolboxItem = event.target.closest('.wkf-toolbox-item'); const component = event.target.closest('.wkf-workflow-component'); if (toolboxItem) { designer.draggedType = toolboxItem.dataset.type; event.dataTransfer.effectAllowed = 'copy'; return; } if (component) { component.classList.add('dragging'); designer.draggedComponent = component.dataset.componentId; event.dataTransfer.effectAllowed = 'move'; // Use invisible drag image const invisibleImg = new Image(); invisibleImg.src = CONFIG.INVISIBLE_DRAG_IMAGE; event.dataTransfer.setDragImage(invisibleImg, 0, 0); // Highlight potential connection points designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => { if (point.dataset.pointType === 'output' && point.dataset.componentId !== designer.draggedComponent) { point.classList.add('potential-connection'); } }); } }, // Handle drag with immediate updates onDrag(event) { if (!event.target.closest('.wkf-workflow-component')) return; if (event.clientX === 0 && event.clientY === 0) return; const component = event.target.closest('.wkf-workflow-component'); const position = utils.getCanvasPosition(event); const x = position.x - CONFIG.DRAG_OFFSET.x; const y = position.y - CONFIG.DRAG_OFFSET.y; // Update position immediately for responsive feel componentManager.updatePosition(component, x, y); // Check for potential connections eventHandlers.checkPotentialConnections(component); // Update connections with requestAnimationFrame for smooth rendering connectionManager.updateAll(); }, // Check for potential connections during drag checkPotentialConnections(component) { 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; const pointRect = point.getBoundingClientRect(); const pointCircle = { x: pointRect.left + pointRect.width / 2, y: pointRect.top + pointRect.height / 2, radius: CONFIG.CONNECTION_POINT_RADIUS }; if (point !== designer.potentialConnectionStart && utils.isOverlapping(componentRect, pointCircle)) { // Clear previous potential starts outputPoints.forEach(otherPoint => { otherPoint.classList.remove('potential-start'); }); designer.potentialConnectionStart = point.dataset.componentId; point.classList.add('potential-start'); } }); }, // Handle drag end with cleanup async onDragEnd(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 position = utils.getCanvasPosition(event); const x = position.x - CONFIG.DRAG_OFFSET.x; const y = position.y - CONFIG.DRAG_OFFSET.y; const constrained = utils.constrainPosition(x, y); try { // Move component await utils.makeRequest('/workflows/move-component', { component_id: designer.draggedComponent, x: constrained.x, y: constrained.y }); // Create connection if applicable if (designer.potentialConnectionStart) { await utils.makeRequest('/workflows/add-connection', { from_id: designer.potentialConnectionStart, to_id: draggedComponentId }); } } catch (error) { console.error('Failed to update component:', error); } // Cleanup connectionManager.clearHighlighting(); designer.reset(); connectionManager.updateAll(); } }, // Handle clicks with improved event delegation onClick(event) { // Connection point handling const connectionPoint = event.target.closest('.wkf-connection-point'); if (connectionPoint) { event.stopPropagation(); eventHandlers.handleConnectionPoint(connectionPoint); } // Connection selection const connectionPath = event.target.closest('.wkf-connection-line path'); if (connectionPath) { event.stopPropagation(); componentManager.deselectAll(); // get the visible connection path const visibleConnectionPath = connectionPath.parentElement.querySelector('.wkf-connection-path'); connectionManager.select(visibleConnectionPath); return; } // Canvas click - reset everything if (event.target === canvas || event.target.classList.contains('wkf-canvas')) { designer.reset(); connectionManager.clearHighlighting(); connectionManager.deselectAll(); componentManager.deselectAll(); return; } // Component selection const component = event.target.closest('.wkf-workflow-component'); if (component) { event.stopPropagation(); connectionManager.deselectAll(); componentManager.select(component); return; } }, // Handle connection point interactions async handleConnectionPoint(connectionPoint) { const componentId = connectionPoint.dataset.componentId; const pointType = connectionPoint.dataset.pointType; if (!designer.connectionStart) { // Start connection from output point if (pointType === 'output') { designer.connectionStart = { componentId, pointType }; connectionPoint.style.background = '#ef4444'; } } else { // Complete connection to input point if (pointType === 'input' && componentId !== designer.connectionStart.componentId) { try { await utils.makeRequest('/workflows/add-connection', { from_id: designer.connectionStart.componentId, to_id: componentId }); } catch (error) { console.error('Failed to create connection:', error); } } // Reset connection mode connectionManager.clearHighlighting(); designer.connectionStart = null; } }, // Handle canvas drop for new components async onCanvasDrop(event) { event.preventDefault(); if (designer.draggedType) { const position = utils.getCanvasPosition(event); const x = position.x - CONFIG.DRAG_OFFSET.x; const y = position.y - CONFIG.DRAG_OFFSET.y; const constrained = utils.constrainPosition(x, y); try { await utils.makeRequest('/workflows/add-component', { component_type: designer.draggedType, x: constrained.x, y: constrained.y }); } catch (error) { console.error('Failed to add component:', error); } designer.draggedType = null; } }, // Handle keyboard shortcuts async onKeyDown(event) { if (event.key === 'Delete' || event.key === 'Suppr') { try { if (designer.selectedComponent) { await utils.makeRequest('/workflows/delete-component', { component_id: designer.selectedComponent }); designer.selectedComponent = null; } else if (designer.selectedConnection) { await utils.makeRequest('/workflows/delete-connection', { from_id: designer.selectedConnection.fromId, to_id: designer.selectedConnection.toId }); designer.selectedConnection = null; } } catch (error) { console.error('Failed to delete:', error); } } } }; // Event registration with cleanup tracking function registerEventListener(element, event, handler, options = {}) { const key = `${element.id || 'global'}-${event}`; element.addEventListener(event, handler, options); designer.eventListeners.set(key, () => element.removeEventListener(event, handler, options)); } // Register all event listeners registerEventListener(designerContainer, 'dragstart', eventHandlers.onDragStart); registerEventListener(designerContainer, 'drag', eventHandlers.onDrag); registerEventListener(designerContainer, 'dragend', eventHandlers.onDragEnd); registerEventListener(designerContainer, 'click', eventHandlers.onClick); registerEventListener(canvas, 'dragover', (event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; }); registerEventListener(canvas, 'drop', eventHandlers.onCanvasDrop); registerEventListener(document, 'keydown', eventHandlers.onKeyDown); // Public API const api = { // Cleanup function for proper disposal destroy() { designer.cancelAnimationFrame(); designer.eventListeners.forEach(cleanup => cleanup()); designer.eventListeners.clear(); }, // Get current designer state getState() { return { selectedComponent: designer.selectedComponent, selectedConnection: designer.selectedConnection, connectionStart: designer.connectionStart }; }, // Force update all connections updateConnections() { connectionManager.updateAll(); }, // Select component programmatically selectComponent(componentId) { const component = designerContainer.querySelector(`[data-component-id="${componentId}"]`); if (component) { componentManager.select(component); } } }; // Initialize connections on load setTimeout(() => connectionManager.updateAll(), 100); return api; } /** * 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; 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`; } }); // 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 bindWorkflowProperties(elementId) { let isDragging = false; let isResizing = false; let startX = 0; let startWidths = {}; let resizeType = ''; console.debug("Binding Properties component for "+ elementId) properties_component = document.getElementById(`p_${elementId}`); if (properties_component == null) { console.error(`'Component ' p_${elementId}' is not found !' `) return } const totalWidth = properties_component.getBoundingClientRect().width console.debug("totalWidth", totalWidth) const minPropertiesWidth = 352; // this value avoid scroll bars const inputSection = document.getElementById(`pi_${elementId}`); const propertiesSection = document.getElementById(`pp_${elementId}`); const outputSection = document.getElementById(`po_${elementId}`); const dragHandle = document.getElementById(`ppt_${elementId}`); const leftHandle = document.getElementById(`ppl_${elementId}`); const rightHandle = document.getElementById(`ppr_${elementId}`); // Drag and drop for moving properties section dragHandle.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startWidths = { input: parseInt(inputSection.style.width), properties: parseInt(propertiesSection.style.width), output: parseInt(outputSection.style.width) }; e.preventDefault(); }); // Left resize handle leftHandle.addEventListener('mousedown', (e) => { isResizing = true; resizeType = 'left'; startX = e.clientX; startWidths = { input: parseInt(inputSection.style.width), properties: parseInt(propertiesSection.style.width), output: parseInt(outputSection.style.width) }; e.preventDefault(); }); // Right resize handle rightHandle.addEventListener('mousedown', (e) => { isResizing = true; resizeType = 'right'; startX = e.clientX; startWidths = { input: parseInt(inputSection.style.width), properties: parseInt(propertiesSection.style.width), output: parseInt(outputSection.style.width) }; e.preventDefault(); }); // Mouse move document.addEventListener('mousemove', (e) => { if (isDragging) { const deltaX = e.clientX - startX; let newInputWidth = startWidths.input + deltaX; let newOutputWidth = startWidths.output - deltaX; // Constraints if (newInputWidth < 0) { newInputWidth = 0; newOutputWidth = totalWidth - startWidths.properties; } if (newOutputWidth < 0) { newOutputWidth = 0; newInputWidth = totalWidth - startWidths.properties; } inputSection.style.width = newInputWidth + 'px'; outputSection.style.width = newOutputWidth + 'px'; } if (isResizing) { const deltaX = e.clientX - startX; let newInputWidth = startWidths.input; let newPropertiesWidth = startWidths.properties; let newOutputWidth = startWidths.output; if (resizeType === 'left') { newInputWidth = startWidths.input + deltaX; newPropertiesWidth = startWidths.properties - deltaX; if (newInputWidth < 0) { newInputWidth = 0; newPropertiesWidth = startWidths.input + startWidths.properties; } if (newPropertiesWidth < minPropertiesWidth) { newPropertiesWidth = minPropertiesWidth; newInputWidth = totalWidth - minPropertiesWidth - startWidths.output; } } else if (resizeType === 'right') { newPropertiesWidth = startWidths.properties + deltaX; newOutputWidth = startWidths.output - deltaX; if (newOutputWidth < 0) { newOutputWidth = 0; newPropertiesWidth = startWidths.properties + startWidths.output; } if (newPropertiesWidth < minPropertiesWidth) { newPropertiesWidth = minPropertiesWidth; newOutputWidth = totalWidth - startWidths.input - minPropertiesWidth; } } inputSection.style.width = newInputWidth + 'px'; propertiesSection.style.width = newPropertiesWidth + 'px'; outputSection.style.width = newOutputWidth + 'px'; } }); // Mouse up document.addEventListener('mouseup', () => { if (isDragging || isResizing) { // Send HTMX request with new dimensions const currentWidths = { input_width: parseInt(inputSection.style.width), properties_width: parseInt(propertiesSection.style.width), output_width: parseInt(outputSection.style.width) }; try { htmx.ajax('POST', '/workflows/update-properties-layout', { target: `#${elementId}`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, swap: "outerHTML", values: { _id: elementId, ...currentWidths } }); } catch (error) { console.error('HTMX request failed:', error); throw error; } isDragging = false; isResizing = false; resizeType = ''; } }); }