|
|
|
|
@@ -4,308 +4,523 @@ function bindWorkflowDesigner(elementId) {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function bindWorkflowDesignerToolbox(elementId) {
|
|
|
|
|
// Store state for this specific designer instance
|
|
|
|
|
const designer = {
|
|
|
|
|
draggedType: null,
|
|
|
|
|
draggedComponent: null,
|
|
|
|
|
selectedComponent: null,
|
|
|
|
|
connectionStart: null,
|
|
|
|
|
potentialConnectionStart: null,
|
|
|
|
|
// 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',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Get the designer container and canvas
|
|
|
|
|
// 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 = designerContainer.querySelector('.wkf-canvas');
|
|
|
|
|
const canvas = document.getElementById("c_" + elementId);
|
|
|
|
|
const canvas = document.getElementById(`c_${elementId}`);
|
|
|
|
|
|
|
|
|
|
// === Event delegation for components ===
|
|
|
|
|
if (!designerContainer || !canvas) {
|
|
|
|
|
console.error(`Workflow designer elements not found for ID: ${elementId}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
}
|
|
|
|
|
// Utility functions
|
|
|
|
|
const utils = {
|
|
|
|
|
|
|
|
|
|
// 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';
|
|
|
|
|
// 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));
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
// 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;
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
// 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();
|
|
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
return {
|
|
|
|
|
x: event.clientX - rect.left,
|
|
|
|
|
y: event.clientY - rect.top
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Create connection if we ended the drag over a connection point
|
|
|
|
|
if (designer.potentialConnectionStart) {
|
|
|
|
|
// 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))
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Remove highlighting from all connection points
|
|
|
|
|
// 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');
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
designer.draggedComponent = null;
|
|
|
|
|
designer.potentialConnectionStart = null;
|
|
|
|
|
|
|
|
|
|
// Update connections after drag ends
|
|
|
|
|
updateConnections(designerContainer);
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// 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
|
|
|
|
|
}, `#p_${elementId}`, "outerHTML");
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Deselect all components
|
|
|
|
|
deselectAll() {
|
|
|
|
|
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');
|
|
|
|
|
// 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');
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// 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();
|
|
|
|
|
// 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: componentId,
|
|
|
|
|
pointType: pointType
|
|
|
|
|
};
|
|
|
|
|
designer.connectionStart = { componentId, 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,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
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
|
|
|
|
|
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
|
|
|
|
|
point.style.background = '#3b82f6';
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
else {
|
|
|
|
|
const component = event.target.closest('.wkf-workflow-component');
|
|
|
|
|
if (!component) return;
|
|
|
|
|
// 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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
componentId = component.dataset.componentId
|
|
|
|
|
htmx.ajax('POST', '/workflows/select-component', {
|
|
|
|
|
target: `#p_${elementId}`,
|
|
|
|
|
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
|
|
|
|
swap: "outerHTML",
|
|
|
|
|
values: {
|
|
|
|
|
_id: elementId,
|
|
|
|
|
component_id: componentId
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Canvas drag over event (for dropping new components)
|
|
|
|
|
canvas.addEventListener('dragover', (event) => {
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
// 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),
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Public API
|
|
|
|
|
const api = {
|
|
|
|
|
// Cleanup function for proper disposal
|
|
|
|
|
destroy() {
|
|
|
|
|
designer.cancelAnimationFrame();
|
|
|
|
|
designer.eventListeners.forEach(cleanup => cleanup());
|
|
|
|
|
designer.eventListeners.clear();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
designer.draggedType = null;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Get current designer state
|
|
|
|
|
getState() {
|
|
|
|
|
return {
|
|
|
|
|
selectedComponent: designer.selectedComponent,
|
|
|
|
|
selectedConnection: designer.selectedConnection,
|
|
|
|
|
connectionStart: designer.connectionStart
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
// Force update all connections
|
|
|
|
|
updateConnections() {
|
|
|
|
|
connectionManager.updateAll();
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
// Select component programmatically
|
|
|
|
|
selectComponent(componentId) {
|
|
|
|
|
const component = designerContainer.querySelector(`[data-component-id="${componentId}"]`);
|
|
|
|
|
if (component) {
|
|
|
|
|
componentManager.select(component);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
// Initialize connections on load
|
|
|
|
|
setTimeout(() => connectionManager.updateAll(), 100);
|
|
|
|
|
|
|
|
|
|
return api;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@@ -335,7 +550,6 @@ function bindWorkflowDesignerSplitter(elementId) {
|
|
|
|
|
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
|
|
|
|
|
@@ -358,7 +572,6 @@ function bindWorkflowDesignerSplitter(elementId) {
|
|
|
|
|
properties.style.height = `${propertiesHeight}px`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
console.debug("newDesignerHeight", newDesignerHeight);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Mouse up event - stop dragging
|
|
|
|
|
@@ -399,16 +612,3 @@ function bindWorkflowDesignerSplitter(elementId) {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|