- 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.
1475 lines
46 KiB
Vue
1475 lines
46 KiB
Vue
<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> |