I can delete a connection
This commit is contained in:
@@ -65,6 +65,12 @@ def post(session, _id: str, from_id: str, to_id: str):
|
|||||||
instance = InstanceManager.get(session, _id)
|
instance = InstanceManager.get(session, _id)
|
||||||
return instance.add_connection(from_id, to_id)
|
return instance.add_connection(from_id, to_id)
|
||||||
|
|
||||||
|
@rt(Routes.DeleteConnection)
|
||||||
|
def post(session, _id: str, from_id: str, to_id: str):
|
||||||
|
logger.debug(
|
||||||
|
f"Entering {Routes.DeleteConnection} with args {debug_session(session)}, {_id=}, {from_id=}, {to_id=}")
|
||||||
|
instance = InstanceManager.get(session, _id)
|
||||||
|
return instance.delete_connection(from_id, to_id)
|
||||||
|
|
||||||
@rt(Routes.ResizeDesigner)
|
@rt(Routes.ResizeDesigner)
|
||||||
def post(session, _id: str, designer_height: int):
|
def post(session, _id: str, designer_height: int):
|
||||||
|
|||||||
@@ -138,3 +138,33 @@
|
|||||||
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wkf-connection-path {
|
||||||
|
stroke: #3b82f6;
|
||||||
|
stroke-width: 2;
|
||||||
|
fill: none;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: stroke 0.2s ease, stroke-width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-connection-path-thick {
|
||||||
|
stroke: transparent;
|
||||||
|
stroke-width: 10;
|
||||||
|
fill: none;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-connection-path-arrowhead {
|
||||||
|
fill:#3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-connection-selected {
|
||||||
|
stroke: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wkf-connection-path-arrowhead-selected {
|
||||||
|
fill:#ef4444 !important;;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,308 +4,523 @@ function bindWorkflowDesigner(elementId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bindWorkflowDesignerToolbox(elementId) {
|
function bindWorkflowDesignerToolbox(elementId) {
|
||||||
// Store state for this specific designer instance
|
// Constants for configuration
|
||||||
const designer = {
|
const CONFIG = {
|
||||||
draggedType: null,
|
COMPONENT_WIDTH: 128,
|
||||||
draggedComponent: null,
|
COMPONENT_HEIGHT: 64,
|
||||||
selectedComponent: null,
|
DRAG_OFFSET: { x: 64, y: 40 },
|
||||||
connectionStart: null,
|
CONNECTION_POINT_RADIUS: 6,
|
||||||
potentialConnectionStart: null,
|
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 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
|
// Utility functions
|
||||||
designerContainer.addEventListener('dragstart', (event) => {
|
const utils = {
|
||||||
// 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
|
// Check if two rectangles overlap
|
||||||
if (event.target.closest('.wkf-workflow-component')) {
|
isOverlapping(rect1, circle) {
|
||||||
const component = event.target.closest('.wkf-workflow-component');
|
// Find the closest point on the rectangle to the circle's center
|
||||||
component.classList.add('dragging');
|
const closestX = Math.max(rect1.x, Math.min(circle.x, rect1.x + rect1.width));
|
||||||
designer.draggedComponent = component.dataset.componentId;
|
const closestY = Math.max(rect1.y, Math.min(circle.y, rect1.y + rect1.height));
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
|
||||||
|
|
||||||
// Create an invisible image to use as the drag image
|
// Calculate the distance between the circle's center and the closest point
|
||||||
const invisibleImg = new Image();
|
const deltaX = circle.x - closestX;
|
||||||
invisibleImg.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; // 1px transparent GIF
|
const deltaY = circle.y - closestY;
|
||||||
event.dataTransfer.setDragImage(invisibleImg, 0, 0);
|
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||||||
|
|
||||||
// Highlight all valid output points on other components
|
// Check if the distance is less than or equal to the circle's radius
|
||||||
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
|
return distanceSquared <= circle.radius * circle.radius;
|
||||||
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');
|
|
||||||
|
|
||||||
|
// Get mouse position relative to canvas
|
||||||
|
getCanvasPosition(event) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
const x = event.clientX - rect.left - 64;
|
return {
|
||||||
const y = event.clientY - rect.top - 40;
|
x: event.clientX - rect.left,
|
||||||
htmx.ajax('POST', '/workflows/move-component', {
|
y: event.clientY - rect.top
|
||||||
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
|
// Constrain position within canvas bounds
|
||||||
if (designer.potentialConnectionStart) {
|
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', {
|
// Create HTMX request with error handling
|
||||||
target: `#c_${elementId}`,
|
async makeRequest(url, values, targetId = `#c_${elementId}`, swap="innerHTML") {
|
||||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
try {
|
||||||
swap: "innerHTML",
|
return htmx.ajax('POST', url, {
|
||||||
values: {
|
target: targetId,
|
||||||
_id: elementId,
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
from_id: designer.potentialConnectionStart, // The output point we're hovering over
|
swap: swap,
|
||||||
to_id: draggedComponentId, // The component we're dragging
|
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 => {
|
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
|
// Select the clicked connection
|
||||||
updateConnections(designerContainer);
|
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
|
// Component management
|
||||||
designerContainer.addEventListener('click', (event) => {
|
const componentManager = {
|
||||||
// Handle canvas clicks to reset connection mode and deselect components
|
// Select a component
|
||||||
if (event.target === canvas || event.target.classList.contains('wkf-canvas')) {
|
select(component) {
|
||||||
if (designer.connectionStart) {
|
// Deselect all other components
|
||||||
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
|
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
|
||||||
point.style.background = '#3b82f6';
|
comp.classList.remove('selected');
|
||||||
});
|
});
|
||||||
designer.connectionStart = null;
|
|
||||||
}
|
// Select the clicked component
|
||||||
// Deselect components
|
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 => {
|
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
|
||||||
comp.classList.remove('selected');
|
comp.classList.remove('selected');
|
||||||
});
|
});
|
||||||
designer.selectedComponent = null;
|
designer.selectedComponent = null;
|
||||||
return;
|
},
|
||||||
}
|
|
||||||
|
|
||||||
// Handle component selection
|
// Update component position with constraints
|
||||||
const component = event.target.closest('.wkf-workflow-component');
|
updatePosition(component, x, y) {
|
||||||
if (component) {
|
const constrained = utils.constrainPosition(x, y);
|
||||||
event.stopPropagation();
|
component.style.left = constrained.x + 'px';
|
||||||
// Remove previous selection
|
component.style.top = constrained.y + 'px';
|
||||||
designerContainer.querySelectorAll('.wkf-workflow-component').forEach(comp => {
|
}
|
||||||
comp.classList.remove('selected');
|
};
|
||||||
|
|
||||||
|
// 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
|
// Handle drag end with cleanup
|
||||||
const connectionPoint = event.target.closest('.wkf-connection-point');
|
async onDragEnd(event) {
|
||||||
if (connectionPoint) {
|
if (!event.target.closest('.wkf-workflow-component')) return;
|
||||||
event.stopPropagation();
|
|
||||||
|
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 componentId = connectionPoint.dataset.componentId;
|
||||||
const pointType = connectionPoint.dataset.pointType;
|
const pointType = connectionPoint.dataset.pointType;
|
||||||
|
|
||||||
if (!designer.connectionStart) {
|
if (!designer.connectionStart) {
|
||||||
// Start connection from output point
|
// Start connection from output point
|
||||||
if (pointType === 'output') {
|
if (pointType === 'output') {
|
||||||
designer.connectionStart = {
|
designer.connectionStart = { componentId, pointType };
|
||||||
componentId: componentId,
|
|
||||||
pointType: pointType
|
|
||||||
};
|
|
||||||
connectionPoint.style.background = '#ef4444';
|
connectionPoint.style.background = '#ef4444';
|
||||||
console.log('Connection started from:', componentId);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Complete connection to input point
|
// Complete connection to input point
|
||||||
if (pointType === 'input' && componentId !== designer.connectionStart.componentId) {
|
if (pointType === 'input' && componentId !== designer.connectionStart.componentId) {
|
||||||
htmx.ajax('POST', '/workflows/add-connection', {
|
try {
|
||||||
target: `#c_${elementId}`,
|
await utils.makeRequest('/workflows/add-connection', {
|
||||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
from_id: designer.connectionStart.componentId,
|
||||||
swap: "innerHTML",
|
to_id: componentId
|
||||||
values: {
|
});
|
||||||
_id: elementId,
|
} catch (error) {
|
||||||
from_id: designer.connectionStart.componentId,
|
console.error('Failed to create connection:', error);
|
||||||
to_id: componentId,
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset connection mode
|
// Reset connection mode
|
||||||
designerContainer.querySelectorAll('.wkf-connection-point').forEach(point => {
|
connectionManager.clearHighlighting();
|
||||||
point.style.background = '#3b82f6';
|
|
||||||
});
|
|
||||||
designer.connectionStart = null;
|
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 {
|
// Event registration with cleanup tracking
|
||||||
const component = event.target.closest('.wkf-workflow-component');
|
function registerEventListener(element, event, handler, options = {}) {
|
||||||
if (!component) return;
|
const key = `${element.id || 'global'}-${event}`;
|
||||||
|
element.addEventListener(event, handler, options);
|
||||||
|
designer.eventListeners.set(key, () => element.removeEventListener(event, handler, options));
|
||||||
|
}
|
||||||
|
|
||||||
componentId = component.dataset.componentId
|
// Register all event listeners
|
||||||
htmx.ajax('POST', '/workflows/select-component', {
|
registerEventListener(designerContainer, 'dragstart', eventHandlers.onDragStart);
|
||||||
target: `#p_${elementId}`,
|
registerEventListener(designerContainer, 'drag', eventHandlers.onDrag);
|
||||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
registerEventListener(designerContainer, 'dragend', eventHandlers.onDragEnd);
|
||||||
swap: "outerHTML",
|
registerEventListener(designerContainer, 'click', eventHandlers.onClick);
|
||||||
values: {
|
registerEventListener(canvas, 'dragover', (event) => {
|
||||||
_id: elementId,
|
|
||||||
component_id: componentId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Canvas drag over event (for dropping new components)
|
|
||||||
canvas.addEventListener('dragover', (event) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = 'copy';
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
});
|
});
|
||||||
|
registerEventListener(canvas, 'drop', eventHandlers.onCanvasDrop);
|
||||||
|
registerEventListener(document, 'keydown', eventHandlers.onKeyDown);
|
||||||
|
|
||||||
// Canvas drop event (for creating new components)
|
// Public API
|
||||||
canvas.addEventListener('drop', (event) => {
|
const api = {
|
||||||
event.preventDefault();
|
// Cleanup function for proper disposal
|
||||||
if (designer.draggedType) {
|
destroy() {
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
designer.cancelAnimationFrame();
|
||||||
const x = event.clientX - rect.left - 64; // Center the component
|
designer.eventListeners.forEach(cleanup => cleanup());
|
||||||
const y = event.clientY - rect.top - 40;
|
designer.eventListeners.clear();
|
||||||
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;
|
// Get current designer state
|
||||||
}
|
getState() {
|
||||||
});
|
return {
|
||||||
|
selectedComponent: designer.selectedComponent,
|
||||||
|
selectedConnection: designer.selectedConnection,
|
||||||
|
connectionStart: designer.connectionStart
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
// Delete selected component with Delete key
|
// Force update all connections
|
||||||
document.addEventListener('keydown', (event) => {
|
updateConnections() {
|
||||||
if (event.key === 'Delete' && designer.selectedComponent) {
|
connectionManager.updateAll();
|
||||||
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
|
// Select component programmatically
|
||||||
function updateConnections(container) {
|
selectComponent(componentId) {
|
||||||
const connectionLines = container.querySelectorAll('.wkf-connection-line');
|
const component = designerContainer.querySelector(`[data-component-id="${componentId}"]`);
|
||||||
connectionLines.forEach(svg => {
|
if (component) {
|
||||||
const connectionData = svg.dataset;
|
componentManager.select(component);
|
||||||
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
|
// Initialize connections on load
|
||||||
return designer;
|
setTimeout(() => connectionManager.updateAll(), 100);
|
||||||
|
|
||||||
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -335,7 +550,6 @@ function bindWorkflowDesignerSplitter(elementId) {
|
|||||||
isResizing = true;
|
isResizing = true;
|
||||||
startY = e.clientY;
|
startY = e.clientY;
|
||||||
startDesignerHeight = parseInt(designer.style.height, 10) || designer.parentNode.getBoundingClientRect().height;
|
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.userSelect = 'none'; // Disable text selection
|
||||||
document.body.style.cursor = "row-resize"; // Change cursor style globally for horizontal splitter
|
document.body.style.cursor = "row-resize"; // Change cursor style globally for horizontal splitter
|
||||||
@@ -357,8 +571,7 @@ function bindWorkflowDesignerSplitter(elementId) {
|
|||||||
const propertiesHeight = Math.max(50, containerHeight - newDesignerHeight - splitter.offsetHeight);
|
const propertiesHeight = Math.max(50, containerHeight - newDesignerHeight - splitter.offsetHeight);
|
||||||
properties.style.height = `${propertiesHeight}px`;
|
properties.style.height = `${propertiesHeight}px`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.debug("newDesignerHeight", newDesignerHeight);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mouse up event - stop dragging
|
// 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;
|
|
||||||
}
|
|
||||||
@@ -124,6 +124,16 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
|
|
||||||
return self.refresh_designer()
|
return self.refresh_designer()
|
||||||
|
|
||||||
|
def delete_connection(self, from_id, to_id):
|
||||||
|
for connection in self._state.connections:
|
||||||
|
if connection.from_id == from_id and connection.to_id == to_id:
|
||||||
|
self._state.connections.remove(connection)
|
||||||
|
|
||||||
|
# update db
|
||||||
|
self._db.save_state(self._key, self._state)
|
||||||
|
|
||||||
|
return self.refresh_designer()
|
||||||
|
|
||||||
def set_designer_height(self, height):
|
def set_designer_height(self, height):
|
||||||
self._state.designer_height = height
|
self._state.designer_height = height
|
||||||
self._db.save_state(self._key, self._state)
|
self._db.save_state(self._key, self._state)
|
||||||
@@ -201,10 +211,12 @@ class WorkflowDesigner(BaseComponent):
|
|||||||
return f"""
|
return f"""
|
||||||
<svg class="wkf-connection-line" style="left: 0; top: 0; width: 100%; height: 100%;"
|
<svg class="wkf-connection-line" style="left: 0; top: 0; width: 100%; height: 100%;"
|
||||||
data-from-id="{conn.from_id}" data-to-id="{conn.to_id}">
|
data-from-id="{conn.from_id}" data-to-id="{conn.to_id}">
|
||||||
<path d="{path}" stroke="#3b82f6" stroke-width="2" fill="none" marker-end="url(#arrowhead)"/>
|
<path d="{path}" class="wkf-connection-path-thick"/>
|
||||||
|
<path d="{path}" class="wkf-connection-path" marker-end="url(#arrowhead)"/>
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||||
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6"/>
|
<polygon points="0 0, 10 3.5, 0 7" class="wkf-connection-path-arrowhead"/>
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class Routes:
|
|||||||
MoveComponent = "/move-component"
|
MoveComponent = "/move-component"
|
||||||
DeleteComponent = "/delete-component"
|
DeleteComponent = "/delete-component"
|
||||||
AddConnection = "/add-connection"
|
AddConnection = "/add-connection"
|
||||||
|
DeleteConnection = "/delete-connection"
|
||||||
ResizeDesigner = "/resize-designer"
|
ResizeDesigner = "/resize-designer"
|
||||||
SaveProperties = "/save-properties"
|
SaveProperties = "/save-properties"
|
||||||
CancelProperties = "/cancel-properties"
|
CancelProperties = "/cancel-properties"
|
||||||
|
|||||||
Reference in New Issue
Block a user