Files
MyManagingTools/src/components/workflows/assets/Workflows.js

765 lines
28 KiB
JavaScript

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
}, `#p_${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 = Math.floor(totalWidth * 0.2); // 20% minimum
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 = '';
}
});
}