corrad-bp/components/process-flow/ProcessFlowCanvas.vue
Md Afiq Iskandar fa016ac1b3 Update Nuxt Configuration and Enhance ProcessFlowCanvas Component
- Added optimization settings for 'vue-toastification' in nuxt.config.js to improve dependency handling.
- Wrapped VueFlow component in a <client-only> tag within ProcessFlowCanvas.vue to prevent server-side rendering issues, ensuring proper rendering of flow components only on the client side.
- Maintained existing functionality while enhancing the user experience in the process flow interface.
2025-07-17 09:47:45 +08:00

1475 lines
46 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, onMounted, computed, shallowRef, watch, defineExpose, nextTick, markRaw } from 'vue';
import { VueFlow, useVueFlow, Panel } from '@vue-flow/core';
import { Background } from '@vue-flow/background';
import { Controls } from '@vue-flow/controls';
import { MiniMap } from '@vue-flow/minimap';
import { nodeTypes as customNodeTypes, nodeStyles } from './ProcessFlowNodes';
import InteractiveArrowEdge from './InteractiveArrowEdge.vue';
import '@vue-flow/core/dist/style.css';
import '@vue-flow/core/dist/theme-default.css';
import '@vue-flow/controls/dist/style.css';
import '@vue-flow/minimap/dist/style.css';
// Add Material Icons import
const materialIconsLink = document.createElement('link');
materialIconsLink.href = 'https://fonts.googleapis.com/icon?family=Material+Icons';
materialIconsLink.rel = 'stylesheet';
document.head.appendChild(materialIconsLink);
const props = defineProps({
initialNodes: {
type: Array,
default: () => []
},
initialEdges: {
type: Array,
default: () => []
},
highlightedNodeId: {
type: String,
default: null
}
});
const emit = defineEmits(['nodeClick', 'edgeClick', 'paneClick', 'nodesChange', 'edgesChange', 'nodeSelected', 'edgeSelected']);
// Get the flow instance
const { flowInstance } = useVueFlow();
// Initialize Vue Flow
const {
nodes,
edges,
addNodes,
addEdges,
onNodesChange,
onEdgesChange,
onConnect,
fitView,
project,
removeNodes,
removeEdges,
updateNodeInternals,
setViewport,
getViewport
} = useVueFlow({
defaultEdgeOptions: {
animated: true,
type: 'custom',
style: {
stroke: '#555',
strokeWidth: 2
},
markerEnd: 'url(#arrow)'
},
deleteKeyCode: 'Delete',
selectionKeyCode: 'Shift',
multiSelectionKeyCode: 'Control',
connectionMode: 'strict',
edgeUpdaterRadius: 12,
edgesUpdatable: true,
defaultMarkerColor: '#555',
isValidConnection: (connection) => {
return true;
}
});
// Define custom edge types - use markRaw to prevent reactivity issues
const edgeTypes = {
custom: markRaw(InteractiveArrowEdge)
};
// Default nodes if empty
const defaultNodes = [];
// Default edges if empty
const defaultEdges = [];
// Flow configuration
const flowOptions = ref({
defaultZoom: 1,
minZoom: 0.2,
maxZoom: 4,
fitViewOnInit: true,
snapToGrid: true,
snapGrid: [15, 15],
edgeUpdaterRadius: 10,
connectionMode: 'strict',
connectionRadius: 25,
elevateEdgesOnSelect: true,
nodesDraggable: true,
nodesConnectable: true,
elementsSelectable: true,
selectNodesOnDrag: false,
panOnDrag: [0, 2],
panOnScroll: false,
zoomOnScroll: true,
zoomOnPinch: true,
zoomOnDoubleClick: false,
connectOnClick: false
});
// Use shallowRef for selected node to avoid unnecessary reactivity
const selectedNode = shallowRef(null);
// State management for preventing recursive updates
const isUpdatingNodes = ref(false);
const isUpdatingEdges = ref(false);
// Handle node selection
const onNodeClick = ({ node }) => {
// Check if node exists and has required properties
if (!node || !node.id) {
console.warn('Invalid node clicked - Missing required properties');
return;
}
try {
// Create a plain object copy of the node to avoid reactivity issues
const nodeData = {
id: node.id,
type: node.type,
label: node.label || (node.data && node.data.label) || '',
data: node.data ? JSON.parse(JSON.stringify(node.data)) : {},
position: node.dimensions ? {
x: node.dimensions.x || 0,
y: node.dimensions.y || 0
} : { x: 0, y: 0 }
};
// console.log('Processed node data:', nodeData);
selectedNode.value = nodeData;
emit('nodeSelected', nodeData);
} catch (error) {
console.error('Error processing node data:', error);
}
};
// Handle edge click
const onEdgeClick = (event, edge) => {
// Handle different parameter formats Vue Flow might send
let actualEdge = edge;
// Sometimes Vue Flow passes the edge in the event object
if (!actualEdge && event && typeof event === 'object') {
if (event.edge) {
actualEdge = event.edge;
} else if (event.id) {
// The event itself might be the edge
actualEdge = event;
}
}
// Add comprehensive null check for edge
if (!actualEdge || !actualEdge.id) {
console.warn('Invalid edge data received in onEdgeClick:', { event, edge, actualEdge });
return;
}
// Create a simplified copy of the edge data
const edgeData = {
id: actualEdge.id,
source: actualEdge.source,
target: actualEdge.target,
label: actualEdge.label || '',
sourceNode: nodes.value.find(node => node.id === actualEdge.source),
targetNode: nodes.value.find(node => node.id === actualEdge.target)
};
emit('edgeSelected', edgeData);
emit('edgeClick', actualEdge);
};
// Handle pane (background) click
const onPaneClick = () => {
selectedNode.value = null;
emit('paneClick');
};
// Window resize handler
const resizeFlow = () => {
setTimeout(() => {
fitView();
}, 200);
};
onMounted(() => {
// Initialize with provided nodes or default ones
if (props.initialNodes.length) {
addNodes(props.initialNodes);
} else {
addNodes(defaultNodes);
}
// Initialize with provided edges or default ones
if (props.initialEdges.length) {
addEdges(props.initialEdges);
} else {
addEdges(defaultEdges);
}
// Setup window resize handler
window.addEventListener('resize', resizeFlow);
// Initial fit view
setTimeout(() => {
fitView();
}, 100);
});
// Center on a specific node
const centerOnNode = (nodeId, nodePosition) => {
if (!nodePosition) {
// Try to find the node position from current nodes
const targetNode = nodes.value.find(n => n.id === nodeId);
if (targetNode) {
nodePosition = targetNode.position;
} else {
console.warn('Could not find node to highlight:', nodeId, 'Available nodes:', nodes.value.map(n => n.id));
return;
}
}
try {
// Use the already available setViewport from useVueFlow
// Calculate the center position
// Account for the container size and panel offsets
const containerRect = document.querySelector('.vue-flow')?.getBoundingClientRect();
const centerX = containerRect ? containerRect.width / 2 : 400;
const centerY = containerRect ? containerRect.height / 2 : 300;
// Calculate new viewport to center the node
const newViewport = {
x: centerX - nodePosition.x - 100, // Offset for left panel
y: centerY - nodePosition.y,
zoom: 1.2
};
setViewport(newViewport, { duration: 800 });
} catch (error) {
console.error('Error in centerOnNode:', error);
}
};
// The centerOnNode method will be exposed along with other methods below
// Watch for changes to highlightedNodeId and apply highlighting
watch(() => props.highlightedNodeId, (newHighlightedId, oldHighlightedId) => {
// Remove highlight from previously highlighted node
if (oldHighlightedId) {
const oldNode = nodes.value.find(node => node.id === oldHighlightedId);
if (oldNode) {
// Use node data approach for highlighting
if (oldNode.data) {
oldNode.data.highlighted = false;
}
// Also try class approach
if (oldNode.class) {
oldNode.class = oldNode.class.replace(' highlighted-node', '').trim();
}
// Try to find and modify the actual DOM element
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${oldHighlightedId}"]`);
if (nodeElement) {
nodeElement.classList.remove('highlighted-node');
nodeElement.style.removeProperty('box-shadow');
nodeElement.style.removeProperty('z-index');
nodeElement.style.removeProperty('transition');
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.classList.remove('highlighted-node');
customNode.style.removeProperty('box-shadow');
customNode.style.removeProperty('z-index');
customNode.style.removeProperty('transition');
}
}
});
}
}
// Add highlight to new highlighted node
if (newHighlightedId) {
const newNode = nodes.value.find(node => node.id === newHighlightedId);
if (newNode) {
// Use node data approach for highlighting
if (!newNode.data) {
newNode.data = {};
}
newNode.data.highlighted = true;
// Also try class approach
if (!newNode.class?.includes('highlighted-node')) {
newNode.class = (newNode.class || '').trim() + ' highlighted-node';
}
// Try to find and modify the actual DOM element directly with inline styles
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${newHighlightedId}"]`);
if (nodeElement) {
nodeElement.classList.add('highlighted-node');
nodeElement.setAttribute('data-highlighted', 'true');
// Apply subtle inline styles for immediate visual effect
nodeElement.style.boxShadow = '0 0 0 2px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.3)';
nodeElement.style.zIndex = '999';
nodeElement.style.transition = 'all 0.3s ease-in-out';
const customNode = nodeElement.querySelector('.custom-node');
if (customNode) {
customNode.classList.add('highlighted-node');
customNode.setAttribute('data-highlighted', 'true');
// Apply subtle styles to custom node as well
customNode.style.boxShadow = '0 0 0 2px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.3)';
customNode.style.zIndex = '999';
customNode.style.transition = 'all 0.3s ease-in-out';
}
} else {
console.warn('Could not find DOM element for node:', newHighlightedId);
}
});
// Force node update
updateNodeInternals([newHighlightedId]);
} else {
console.warn('Could not find node to highlight:', newHighlightedId, 'Available nodes:', nodes.value.map(n => ({ id: n.id, type: n.type })));
}
}
});
// Watch for changes to initialNodes prop and update the canvas
watch(() => props.initialNodes, async (newNodes, oldNodes) => {
if (newNodes && Array.isArray(newNodes) && !isUpdatingNodes.value) {
isUpdatingNodes.value = true;
try {
// Instead of clearing all nodes, sync them intelligently
const currentNodeIds = new Set(nodes.value.map(n => n.id));
const newNodeIds = new Set(newNodes.map(n => n.id));
// Remove nodes that are no longer in the new list
const nodesToRemove = nodes.value.filter(node => !newNodeIds.has(node.id));
if (nodesToRemove.length > 0) {
removeNodes(nodesToRemove);
}
// Add new nodes that aren't already present
const nodesToAdd = newNodes.filter(node => !currentNodeIds.has(node.id));
if (nodesToAdd.length > 0) {
addNodes([...nodesToAdd]); // Create a copy to avoid reactivity issues
}
// Update existing nodes that have changed
newNodes.forEach(newNode => {
const existingNode = nodes.value.find(n => n.id === newNode.id);
if (existingNode) {
// Always update the existing node to ensure latest data is reflected
// This is critical for property changes to be visible in the canvas
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
// Always call updateNodeInternals to force re-render
// This ensures property changes are reflected immediately
updateNodeInternals([newNode.id]);
}
});
// Fit view only if we added new nodes and this is significant change
if (nodesToAdd.length > 0) {
await nextTick();
setTimeout(() => {
fitView();
}, 100);
}
} finally {
// Use a small delay to prevent immediate re-triggering
setTimeout(() => {
isUpdatingNodes.value = false;
}, 50);
}
}
}, { deep: true });
// Watch for changes to initialEdges prop and update the canvas
// This watcher depends on nodes being already present
watch(() => [props.initialEdges, nodes.value.length], async ([newEdges, nodeCount]) => {
if (newEdges && Array.isArray(newEdges) && nodeCount > 0 && !isUpdatingEdges.value) {
isUpdatingEdges.value = true;
try {
// Instead of clearing all edges, sync them intelligently
const currentEdgeIds = new Set(edges.value.map(e => e.id));
const newEdgeIds = new Set(newEdges.map(e => e.id));
// CRITICAL: Be more conservative about edge removal
// Only remove edges that are definitely not in the new list AND whose nodes don't exist
const edgesToRemove = edges.value.filter(edge => {
const isInNewList = newEdgeIds.has(edge.id);
if (isInNewList) {
return false; // Don't remove if it's in the new list
}
// Double-check that both source and target nodes still exist
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
// Only remove if nodes don't exist (orphaned edges)
const shouldRemove = !sourceExists || !targetExists;
if (shouldRemove) {
console.log(`🗑️ Removing orphaned edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
}
return shouldRemove;
});
if (edgesToRemove.length > 0) {
console.log('🗑️ Removing edges:', edgesToRemove.map(e => `${e.source}->${e.target} (${e.id})`));
removeEdges(edgesToRemove);
}
if (newEdges.length > 0) {
// Verify all nodes exist before adding edges
const validEdges = newEdges.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (!sourceExists || !targetExists) {
console.warn(`⚠️ Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
// Add new edges that aren't already present
const edgesToAdd = validEdges.filter(edge => !currentEdgeIds.has(edge.id));
if (edgesToAdd.length > 0) {
console.log(' Adding new edges:', edgesToAdd.map(e => `${e.source}->${e.target} (${e.id})`));
// Ensure all edges have proper handle specifications
const edgesWithHandles = edgesToAdd.map(edge => {
// IMPORTANT: If edge already has sourceHandle and targetHandle, preserve them exactly as they are
if (edge.sourceHandle && edge.targetHandle) {
console.log(`🔗 Edge ${edge.id} already has handles: ${edge.sourceHandle} -> ${edge.targetHandle}`);
return edge;
}
// Otherwise, generate default handles based on node types
const sourceNode = nodes.value.find(n => n.id === edge.source);
const targetNode = nodes.value.find(n => n.id === edge.target);
let sourceHandle = edge.sourceHandle;
let targetHandle = edge.targetHandle;
// Generate default source handle if missing
if (!sourceHandle && sourceNode) {
if (sourceNode.type === 'start') {
sourceHandle = `${edge.source}-bottom`; // Start nodes prefer bottom output
} else if (sourceNode.type === 'gateway') {
sourceHandle = `${edge.source}-right`; // Gateway nodes prefer right output for first connection
} else {
sourceHandle = `${edge.source}-bottom`; // Most nodes prefer bottom output
}
}
// Generate default target handle if missing
if (!targetHandle && targetNode) {
if (targetNode.type === 'end') {
targetHandle = `${edge.target}-top`; // End nodes prefer top input
} else {
targetHandle = `${edge.target}-top`; // Most nodes prefer top input
}
}
console.log(`🔗 Generated handles for edge ${edge.id}: ${sourceHandle} -> ${targetHandle}`);
return {
...edge,
sourceHandle,
targetHandle
};
});
addEdges([...edgesWithHandles]); // Create a copy to avoid reactivity issues
}
// Update existing edges that have changed - IMPORTANT: preserve handle positions
let updatedEdgeCount = 0;
newEdges.forEach(newEdge => {
const existingEdge = edges.value.find(e => e.id === newEdge.id);
if (existingEdge) {
// Check if the edge has actually changed before updating
const hasChanges = (
existingEdge.label !== newEdge.label ||
JSON.stringify(existingEdge.style) !== JSON.stringify(newEdge.style)
);
if (hasChanges) {
Object.assign(existingEdge, {
label: newEdge.label,
// CRITICAL: Preserve existing handles if they exist
sourceHandle: existingEdge.sourceHandle || newEdge.sourceHandle,
targetHandle: existingEdge.targetHandle || newEdge.targetHandle,
style: newEdge.style ? { ...newEdge.style } : undefined
});
updatedEdgeCount++;
}
}
});
if (updatedEdgeCount > 0) {
console.log('🔄 Updated existing edges:', updatedEdgeCount);
}
}
} finally {
// Use a small delay to prevent immediate re-triggering
setTimeout(() => {
isUpdatingEdges.value = false;
}, 50);
}
} else if (newEdges && Array.isArray(newEdges) && newEdges.length > 0 && nodeCount === 0) {
console.log('⚠️ ProcessFlowCanvas: Edges provided but no nodes yet, waiting...');
}
}, { deep: true });
// Handle node changes
onNodesChange((changes) => {
emit('nodesChange', changes, nodes.value);
});
// Handle edge changes
onEdgesChange((changes) => {
emit('edgesChange', changes, edges.value);
});
// Handle new connections
const handleConnect = (connection) => {
if (!connection.source || !connection.target) return;
// Try to determine if this is coming from a gateway
const sourceNode = nodes.value.find(node => node.id === connection.source);
let label = '';
// If the source is a gateway, we should add a label based on conditions
if (sourceNode && sourceNode.type === 'gateway') {
// Check if there's a default path label
if (sourceNode.data && sourceNode.data.defaultPath) {
label = sourceNode.data.defaultPath;
}
// For existing gateway connections, check if we should use a condition's output
const existingEdges = edges.value.filter(edge => edge.source === connection.source);
if (existingEdges.length === 0 && sourceNode.data.conditions && sourceNode.data.conditions.length > 0) {
// If this is the first connection and we have conditions, use the first condition's output
const firstCondition = sourceNode.data.conditions[0];
if (firstCondition && firstCondition.output) {
label = firstCondition.output;
}
} else if (sourceNode.data.conditions) {
// If we already have connections, try to find an unused condition
const usedOutputs = existingEdges.map(edge => edge.label);
const unusedCondition = sourceNode.data.conditions.find(condition =>
condition.output && !usedOutputs.includes(condition.output)
);
if (unusedCondition) {
label = unusedCondition.output;
}
}
}
const newEdge = {
id: `${connection.source}-${connection.target}-${Date.now()}`,
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
type: 'custom',
animated: true,
style: {
stroke: '#555',
strokeWidth: 2
},
markerEnd: 'url(#arrow)',
label: label
};
// Add the edge directly to Vue Flow for immediate visual feedback
addEdges([newEdge]);
// Emit the edge change in the format the parent expects
emit('edgesChange', [{ type: 'add', id: newEdge.id }], edges.value);
};
// Handle node removal
const onNodeDelete = (event) => {
// Check if we have a node in the event
if (event && event.node) {
removeNodes([event.node]);
emit('nodesChange', nodes.value);
}
};
// Handle edge removal
const onEdgeDelete = (event) => {
// Check if we have an edge in the event
if (event && event.edge) {
removeEdges([event.edge]);
emit('edgesChange', edges.value);
}
};
// Handle delete key press to remove selected elements
const onDeleteKeyPress = () => {
const { getSelectedNodes, getSelectedEdges } = flowInstance.value;
const selectedNodes = getSelectedNodes();
const selectedEdges = getSelectedEdges();
if (selectedNodes.length > 0) {
removeNodes(selectedNodes);
emit('nodesChange', nodes.value);
}
if (selectedEdges.length > 0) {
removeEdges(selectedEdges);
emit('edgesChange', edges.value);
}
};
// Handle drop event
const onDrop = (event) => {
event.preventDefault();
event.stopPropagation();
try {
// Get the dragged component data
const componentData = JSON.parse(event.dataTransfer.getData('text/plain'));
if (!componentData) return;
// Get the Vue Flow wrapper element
const flowWrapper = event.currentTarget;
const bounds = flowWrapper.getBoundingClientRect();
// Calculate the position relative to the wrapper
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top
});
// Create new node (both process nodes and shapes are treated as nodes in Vue Flow)
const newNode = {
id: `${componentData.type}-${Date.now()}`,
type: componentData.type,
position,
label: componentData.label,
data: {
...componentData.data,
label: componentData.label
}
};
// For shape nodes, ensure they don't have connection handles
if (componentData.data?.isShape) {
// Shapes should not be connectable
newNode.connectable = false;
}
// Add to Vue Flow for immediate visual feedback
addNodes([newNode]);
// IMPORTANT: Also emit the node to be added to the process store
// This ensures the node persists in the application state and can be saved
emit('nodesChange', [{ type: 'add', id: newNode.id, item: newNode }], nodes.value);
} catch (error) {
console.error('Error handling drop:', error);
}
};
// Handle drag over
const onDragOver = (event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = 'copy';
};
// Define methods to expose to parent components
defineExpose({
updateNode,
addNode,
removeNode,
fitView,
syncCanvas,
centerOnNode,
// Add Vue Flow save/restore methods
toObject,
fromObject
});
// Update an existing node
function updateNode(nodeId, newData) {
const nodeToUpdate = nodes.value.find(node => node.id === nodeId);
if (!nodeToUpdate) {
console.warn(`Canvas updateNode: Node ${nodeId} not found`);
return;
}
// Update the node properties - handle all provided data
if (newData.label !== undefined) {
nodeToUpdate.label = newData.label;
}
// Update the node data - merge with existing data
if (newData.data !== undefined) {
nodeToUpdate.data = {
...nodeToUpdate.data,
...newData.data
};
}
// Update position if provided
if (newData.position !== undefined) {
nodeToUpdate.position = { ...newData.position };
}
// Update style if provided
if (newData.style !== undefined) {
nodeToUpdate.style = {
...(nodeToUpdate.style || {}),
...newData.style
};
// Also apply style directly to DOM element for immediate visual feedback
nextTick(() => {
const nodeElement = document.querySelector(`[data-id="${nodeId}"]`);
if (nodeElement) {
// Apply styles directly to the DOM element
Object.entries(newData.style).forEach(([key, value]) => {
nodeElement.style[key] = value;
});
}
});
}
// Force re-render by updating node internals
updateNodeInternals([nodeId]);
return nodeToUpdate;
}
// Add a new node to the canvas
function addNode(node) {
addNodes([node]);
return node;
}
// Remove a node from the canvas
function removeNode(nodeId) {
const nodeToRemove = nodes.value.find(node => node.id === nodeId);
if (!nodeToRemove) return;
removeNodes([nodeToRemove]);
return nodeToRemove;
}
// Manual sync function for explicit canvas updates
function syncCanvas(newNodes, newEdges) {
console.log('🔄 Manual canvas sync requested - nodes:', newNodes?.length || 0, 'edges:', newEdges?.length || 0);
// Use a small delay to ensure any pending Vue Flow operations complete
setTimeout(async () => {
try {
// Sync nodes first
if (newNodes && Array.isArray(newNodes)) {
const currentNodeIds = new Set(nodes.value.map(n => n.id));
const newNodeIds = new Set(newNodes.map(n => n.id));
console.log('📊 Current canvas nodes:', currentNodeIds.size, 'New nodes:', newNodeIds.size);
// Remove nodes that are no longer in the new list
const nodesToRemove = nodes.value.filter(node => !newNodeIds.has(node.id));
if (nodesToRemove.length > 0) {
console.log('🗑️ Removing nodes:', nodesToRemove.map(n => n.id));
removeNodes(nodesToRemove);
}
// Add new nodes that aren't already present
const nodesToAdd = newNodes.filter(node => !currentNodeIds.has(node.id));
if (nodesToAdd.length > 0) {
console.log(' Adding new nodes:', nodesToAdd.map(n => n.id));
addNodes([...nodesToAdd]);
}
// Update existing nodes - force update all properties
newNodes.forEach(newNode => {
const existingNode = nodes.value.find(n => n.id === newNode.id);
if (existingNode) {
// Always update to ensure property changes are reflected
Object.assign(existingNode, {
label: newNode.label,
data: { ...newNode.data },
position: { ...newNode.position }
});
// Always force re-render for property changes
updateNodeInternals([newNode.id]);
}
});
// Wait for nodes to be fully processed before handling edges
await nextTick();
await new Promise(resolve => setTimeout(resolve, 100));
}
console.log('📊 Canvas state after node sync - nodes:', nodes.value.length, 'edges:', edges.value.length);
// Sync edges after nodes are updated - CRITICAL: Only if we have nodes
if (newEdges && Array.isArray(newEdges) && nodes.value.length > 0) {
const currentEdgeIds = new Set(edges.value.map(e => e.id));
const newEdgeIds = new Set(newEdges.map(e => e.id));
console.log('📊 Current canvas edges:', currentEdgeIds.size, 'New edges:', newEdgeIds.size);
// CRITICAL: Only remove edges that are definitely not in the new list
// Be more conservative about edge removal to prevent accidental deletions
const edgesToRemove = edges.value.filter(edge => {
const shouldRemove = !newEdgeIds.has(edge.id);
if (shouldRemove) {
// Double-check that both source and target nodes still exist
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
// Only remove if the edge is truly not needed OR if nodes don't exist
return !sourceExists || !targetExists;
}
return false;
});
if (edgesToRemove.length > 0) {
console.log('🗑️ Removing edges:', edgesToRemove.map(e => `${e.source}->${e.target} (${e.id})`));
removeEdges(edgesToRemove);
}
// Add new edges that aren't already present
const edgesToAdd = newEdges.filter(edge => !currentEdgeIds.has(edge.id));
if (edgesToAdd.length > 0) {
console.log(' Adding new edges:', edgesToAdd.map(e => `${e.source}->${e.target} (${e.id})`));
// Verify nodes exist and add handles
const validEdges = edgesToAdd.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (!sourceExists || !targetExists) {
console.warn(`⚠️ Skipping edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
console.log('✅ Valid edges to add:', validEdges.length);
const edgesWithHandles = validEdges.map(edge => {
// If edge already has sourceHandle and targetHandle, use them
if (edge.sourceHandle && edge.targetHandle) {
console.log(`🔗 Edge ${edge.id} already has handles: ${edge.sourceHandle} -> ${edge.targetHandle}`);
return edge;
}
const sourceNode = nodes.value.find(n => n.id === edge.source);
const targetNode = nodes.value.find(n => n.id === edge.target);
let sourceHandle = edge.sourceHandle;
let targetHandle = edge.targetHandle;
if (!sourceHandle && sourceNode) {
if (sourceNode.type === 'start') {
sourceHandle = `${edge.source}-bottom`;
} else if (sourceNode.type === 'gateway') {
sourceHandle = `${edge.source}-right`;
} else {
sourceHandle = `${edge.source}-bottom`;
}
}
if (!targetHandle && targetNode) {
if (targetNode.type === 'end') {
targetHandle = `${edge.target}-top`;
} else {
targetHandle = `${edge.target}-top`;
}
}
console.log(`🔗 Generated handles for edge ${edge.id}: ${sourceHandle} -> ${targetHandle}`);
return { ...edge, sourceHandle, targetHandle };
});
if (edgesWithHandles.length > 0) {
console.log('✅ Adding edges with handles:', edgesWithHandles.length);
addEdges([...edgesWithHandles]);
}
}
// Update existing edges - preserve handles and only update changed properties
let updatedEdgeCount = 0;
newEdges.forEach(newEdge => {
const existingEdge = edges.value.find(e => e.id === newEdge.id);
if (existingEdge) {
// Check if update is needed
const needsUpdate = (
existingEdge.label !== newEdge.label ||
JSON.stringify(existingEdge.style) !== JSON.stringify(newEdge.style)
);
if (needsUpdate) {
Object.assign(existingEdge, {
label: newEdge.label,
// CRITICAL: Preserve existing handles if they exist
sourceHandle: existingEdge.sourceHandle || newEdge.sourceHandle,
targetHandle: existingEdge.targetHandle || newEdge.targetHandle,
style: newEdge.style ? { ...newEdge.style } : undefined
});
updatedEdgeCount++;
}
}
});
if (updatedEdgeCount > 0) {
console.log('🔄 Updated existing edges:', updatedEdgeCount);
}
} else if (newEdges && Array.isArray(newEdges) && newEdges.length > 0) {
console.warn('⚠️ Cannot add edges: nodes not ready. Node count:', nodes.value.length);
}
console.log('✅ Canvas sync completed - final state: nodes:', nodes.value.length, 'edges:', edges.value.length);
} catch (error) {
console.error('❌ Error during canvas sync:', error);
}
}, 50); // Small delay to allow any pending operations to complete
}
// Add Vue Flow save method - captures complete flow state including viewport
function toObject() {
try {
// Create flow object using only essential data, excluding Vue Flow internals
const flowObject = {
nodes: nodes.value.map(node => ({
id: node.id,
type: node.type,
label: node.label,
position: { ...node.position },
data: { ...node.data }
// Exclude: events, dragging, dimensions, handleBounds, computedPosition, etc.
})),
edges: edges.value.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label || '',
type: edge.type || 'smoothstep',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data ? { ...edge.data } : {}
// Exclude: events, sourceNode, targetNode, sourceX, sourceY, targetX, targetY
})),
viewport: getViewport()
};
return flowObject;
} catch (error) {
console.error('Error capturing flow state:', error);
// Return a safe fallback object
return {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 }
};
}
}
// Add Vue Flow restore method - properly restores complete flow state
function fromObject(flowObject) {
if (!flowObject) {
console.warn('No flow object provided for restoration');
return Promise.resolve();
}
if (!flowObject.nodes && !flowObject.edges) {
console.warn('Flow object contains no nodes or edges');
return Promise.resolve();
}
return new Promise((resolve) => {
try {
// Clear updating flags to ensure clean restoration
isUpdatingNodes.value = false;
isUpdatingEdges.value = false;
// Wait for any pending operations to complete
setTimeout(async () => {
try {
// 1. First, clear existing state
if (nodes.value.length > 0) {
removeNodes([...nodes.value]);
}
if (edges.value.length > 0) {
removeEdges([...edges.value]);
}
// Wait for clearing to complete
await nextTick();
// 2. Restore nodes first
if (flowObject.nodes && Array.isArray(flowObject.nodes) && flowObject.nodes.length > 0) {
const nodesToRestore = flowObject.nodes.map(node => ({
id: node.id,
type: node.type,
label: node.label || '',
position: node.position || { x: 0, y: 0 },
data: node.data || {}
// Only include essential properties needed for Vue Flow
}));
addNodes(nodesToRestore);
await nextTick();
// Wait a bit more for nodes to be fully initialized
await new Promise(resolve => setTimeout(resolve, 100));
}
// 3. Restore edges after nodes are ready
if (flowObject.edges && Array.isArray(flowObject.edges) && flowObject.edges.length > 0 && nodes.value.length > 0) {
// Verify all edges have valid source and target nodes
const validEdges = flowObject.edges.filter(edge => {
const sourceExists = nodes.value.some(node => node.id === edge.source);
const targetExists = nodes.value.some(node => node.id === edge.target);
if (!sourceExists || !targetExists) {
console.warn(`Skipping invalid edge ${edge.id}: source ${edge.source} exists: ${sourceExists}, target ${edge.target} exists: ${targetExists}`);
return false;
}
return true;
});
if (validEdges.length > 0) {
// Clean edge data before adding
const cleanEdges = validEdges.map(edge => ({
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
label: edge.label || '',
type: edge.type || 'custom',
animated: edge.animated !== undefined ? edge.animated : true,
data: edge.data || {}
// Only include essential properties
}));
addEdges(cleanEdges);
await nextTick();
}
}
// 4. Finally, restore viewport position and zoom
if (flowObject.viewport && typeof flowObject.viewport === 'object') {
const viewport = {
x: flowObject.viewport.x || 0,
y: flowObject.viewport.y || 0,
zoom: flowObject.viewport.zoom || 1
};
setViewport(viewport, { duration: 0 }); // No animation for restore
}
resolve();
} catch (error) {
console.error('Error during flow state restoration:', error);
resolve(); // Resolve anyway to prevent hanging
}
}, 100); // Small delay to ensure clean state
} catch (error) {
console.error('Error in fromObject:', error);
resolve(); // Resolve anyway to prevent hanging
}
});
}
</script>
<template>
<div
class="process-flow-container"
@drop="onDrop"
@dragover="onDragOver"
>
<client-only>
<VueFlow
v-if="nodes.length > 0"
v-bind="flowOptions"
class="bg-slate-50 process-flow"
:nodeTypes="customNodeTypes"
:edgeTypes="edgeTypes"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
@connect="handleConnect"
@nodeDoubleClick="onNodeDelete"
@edgeDoubleClick="onEdgeDelete"
@keyup.delete="onDeleteKeyPress"
>
<!-- Global SVG definitions for arrows -->
<svg style="position: absolute; top: 0; left: 0; width: 0; height: 0">
<defs>
<marker
id="arrow"
viewBox="0 0 10 10"
refX="9"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#555" />
</marker>
<marker
id="arrow-selected"
viewBox="0 0 10 10"
refX="9"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#ff6b6b" />
</marker>
<marker
id="arrow-animated"
viewBox="0 0 10 10"
refX="9"
refY="3"
markerWidth="6"
markerHeight="6"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="#3b82f6" />
</marker>
</defs>
</svg>
<Background pattern-color="#aaa" gap="20" />
<Controls />
<MiniMap />
<!-- Custom edge template -->
<template #edge-custom="edgeProps">
<InteractiveArrowEdge v-bind="edgeProps" />
</template>
<template #edge-label="{ label }">
<div class="edge-label">{{ label }}</div>
</template>
<Panel position="top-right" class="node-controls">
<div class="p-2 bg-white rounded shadow-sm text-sm">
<div class="mb-1 font-medium">Controls:</div>
<div> Delete: Remove selected</div>
<div> Shift: Select nodes</div>
<div> Drag between nodes to connect</div>
<div> Double-click to remove</div>
<div class="mt-2 mb-1 font-medium">Edge Features:</div>
<div> Arrows show flow direction</div>
<div> Select edge for controls</div>
<div> Blue dot = drag to reposition</div>
<div> Reset button restores path</div>
</div>
</Panel>
</VueFlow>
</client-only>
</div>
</template>
<style>
/* Node styles from ProcessFlowNodes.js are imported globally in a plugin */
.process-flow-container {
width: 100%;
min-height: 500px;
border: 1px solid #e2e8f0;
border-radius: 0;
overflow: hidden;
position: relative;
flex: 1;
}
/* Make sure VueFlow takes full height */
:deep(.vue-flow) {
height: 100% !important;
}
/* Ensure the flow wrapper fills container */
:deep(.vue-flow__container) {
height: 100% !important;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.process-flow-container {
height: calc(100vh - 150px);
min-height: 400px;
}
}
@media (max-width: 480px) {
.process-flow-container {
height: calc(100vh - 120px);
min-height: 300px;
}
:deep(.vue-flow__controls) {
transform: scale(0.8);
transform-origin: bottom right;
}
:deep(.vue-flow__minimap) {
transform: scale(0.8);
transform-origin: top right;
}
}
.edge-label {
background-color: white;
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
border: 1.5px solid #3b82f6;
color: #1e40af;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
white-space: nowrap;
z-index: 10;
}
/* Vue Flow custom styles */
:deep(.vue-flow__edge-path) {
stroke: #555;
stroke-width: 2px;
transition: stroke-width 0.2s ease, stroke 0.2s ease;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.3));
}
:deep(.vue-flow__edge.animated .vue-flow__edge-path) {
stroke-dasharray: 5;
animation: flowEdgeAnimation 2s linear infinite;
}
:deep(.vue-flow__edge:hover .vue-flow__edge-path) {
stroke-width: 3px;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.2));
}
/* Custom edge styles */
:deep(.custom-edge) {
cursor: pointer;
}
:deep(.custom-edge.selected) {
filter: drop-shadow(0 0 4px rgba(255, 107, 107, 0.5));
}
/* Edge updater styles */
:deep(.vue-flow__edge-updater) {
stroke: #3b82f6;
fill: #3b82f6;
stroke-width: 2;
cursor: crosshair;
}
:deep(.vue-flow__edge-updater:hover) {
stroke: #2563eb;
fill: #2563eb;
}
/* Edge control point styles for better visibility */
:deep(.edge-control-point) {
cursor: move !important;
opacity: 0;
transition: opacity 0.2s ease, r 0.2s ease;
}
:deep(.vue-flow__edge.selected .edge-control-point) {
opacity: 1;
}
:deep(.edge-control-point:hover) {
r: 6 !important;
fill: #2563eb !important;
}
/* Add custom node icon styles */
:deep(.custom-node) {
border-radius: 6px;
padding: 12px;
background: white;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
min-width: 150px;
}
:deep(.custom-node-header) {
display: flex;
align-items: center;
margin-bottom: 8px;
}
:deep(.custom-node-icon) {
margin-right: 8px;
}
:deep(.custom-node-icon .material-icons) {
font-size: 20px;
}
:deep(.node-start .custom-node-icon .material-icons) {
color: #4CAF50;
}
:deep(.node-end .custom-node-icon .material-icons) {
color: #f44336;
}
:deep(.node-task .custom-node-icon .material-icons) {
color: #2196F3;
}
:deep(.node-form .custom-node-icon .material-icons) {
color: #9C27B0;
}
:deep(.node-gateway .custom-node-icon .material-icons) {
color: #FF9800;
font-size: 18px;
}
:deep(.node-script .custom-node-icon .material-icons) {
color: #607D8B;
}
:deep(.node-html .custom-node-icon .material-icons) {
color: #0ea5e9;
}
:deep(.custom-node-title) {
font-weight: 500;
flex-grow: 1;
}
:deep(.custom-node-content) {
font-size: 12px;
color: #666;
}
@keyframes flowEdgeAnimation {
from {
stroke-dashoffset: 10;
}
to {
stroke-dashoffset: 0;
}
}
.node-controls {
margin: 10px;
color: #666;
font-size: 12px;
background: white;
border-radius: 4px;
pointer-events: all;
}
:deep(.vue-flow__handle) {
width: 8px;
height: 8px;
background: #555;
border: 2px solid white;
}
:deep(.vue-flow__handle:hover) {
background: #ff6b6b;
}
:deep(.vue-flow__edge.selected .vue-flow__edge-path) {
stroke: #ff6b6b;
stroke-width: 3px;
}
:deep(.vue-flow__node.selected) {
box-shadow: 0 0 0 2px #ff6b6b;
}
/* Target both Vue Flow nodes and custom nodes - updated highlighting */
:deep(.vue-flow__node.highlighted-node),
:deep(.vue-flow__node[data-highlighted="true"]),
:deep(.vue-flow__node .custom-node.highlighted-node),
:deep(.vue-flow__node .custom-node[data-highlighted="true"]) {
animation: highlightPulse 2s ease-in-out infinite !important;
box-shadow: 0 0 0 3px #3b82f6, 0 0 20px rgba(59, 130, 246, 0.4) !important;
z-index: 999 !important;
transform: scale(1.05) !important;
transition: transform 0.3s ease-in-out !important;
}
/* Additional selector for direct custom node highlighting */
:deep(.custom-node.highlighted-node),
:deep(.custom-node[data-highlighted="true"]) {
animation: highlightPulse 2s ease-in-out infinite !important;
box-shadow: 0 0 0 3px #3b82f6, 0 0 20px rgba(59, 130, 246, 0.4) !important;
z-index: 999 !important;
transform: scale(1.05) !important;
transition: transform 0.3s ease-in-out !important;
}
@keyframes highlightPulse {
0%, 100% {
box-shadow: 0 0 0 3px #3b82f6, 0 0 20px rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 0 0 5px #60a5fa, 0 0 30px rgba(96, 165, 250, 0.6);
}
}
</style>