corrad-bp/components/process-flow/ProcessFlowCanvas.vue
Md Afiq Iskandar 80038e00a3 Enhance ProcessFlowCanvas with Selection Features and Help Guide
- Added support for node selection changes and multiple node selection events, improving user interaction within the process flow.
- Introduced a collapsible help guide to assist users with selection and action instructions, enhancing usability.
- Updated styles for selection boxes and multi-selected nodes to improve visual feedback during interactions.
- Refactored event handling for node and edge clicks to streamline the selection process and improve error logging.
2025-07-23 07:23:07 +08:00

1943 lines
56 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 InteractiveArrowEdge from "./InteractiveArrowEdge.vue";
// Import all file-based custom node components
import StartNode from "~/components/process-flow/custom/StartNode.vue";
import EndNode from "~/components/process-flow/custom/EndNode.vue";
import FormNode from "~/components/process-flow/custom/FormNode.vue";
import ApiNode from "~/components/process-flow/custom/ApiNode.vue";
import GatewayNode from "~/components/process-flow/custom/GatewayNode.vue";
import ScriptNode from "~/components/process-flow/custom/ScriptNode.vue";
import BusinessRuleNode from "~/components/process-flow/custom/BusinessRuleNode.vue";
import NotificationNode from "~/components/process-flow/custom/NotificationNode.vue";
import HtmlNode from "~/components/process-flow/custom/HtmlNode.vue";
import SubprocessNode from "~/components/process-flow/custom/SubprocessNode.vue";
import HexagonShape from "~/components/process-flow/custom/HexagonShape.vue";
import TrapezoidShape from "~/components/process-flow/custom/TrapezoidShape.vue";
import RectangleShape from "~/components/process-flow/custom/RectangleShape.vue";
import SwimlaneHorizontal from "~/components/process-flow/custom/SwimlaneHorizontal.vue";
import SwimlaneVertical from "~/components/process-flow/custom/SwimlaneVertical.vue";
import TextAnnotation from "~/components/process-flow/custom/TextAnnotation.vue";
import ProcessGroup from "~/components/process-flow/custom/ProcessGroup.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";
// Create nodeTypes object with markRaw to prevent reactivity issues
const customNodeTypes = {
start: markRaw(StartNode),
end: markRaw(EndNode),
form: markRaw(FormNode),
api: markRaw(ApiNode),
gateway: markRaw(GatewayNode),
script: markRaw(ScriptNode),
"business-rule": markRaw(BusinessRuleNode),
notification: markRaw(NotificationNode),
html: markRaw(HtmlNode),
subprocess: markRaw(SubprocessNode),
// Shape nodes
"hexagon-shape": markRaw(HexagonShape),
"trapezoid-shape": markRaw(TrapezoidShape),
"rectangle-shape": markRaw(RectangleShape),
"swimlane-horizontal": markRaw(SwimlaneHorizontal),
"swimlane-vertical": markRaw(SwimlaneVertical),
"text-annotation": markRaw(TextAnnotation),
"process-group": markRaw(ProcessGroup),
};
// 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",
"selectionChange",
"nodesSelection",
]);
// 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",
connectionMode: "strict",
edgeUpdaterRadius: 12,
edgesUpdatable: true,
defaultMarkerColor: "#555",
isValidConnection: (connection) => {
return true;
},
selectNodesOnDrag: true,
panOnDrag: [1, 2],
panOnScroll: false,
zoomOnScroll: true,
});
// Define custom edge types - use markRaw to prevent reactivity issues
const edgeTypes = {
custom: shallowRef(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: true,
panOnDrag: [1, 2],
panOnScroll: false,
zoomOnScroll: true,
zoomOnPinch: true,
zoomOnDoubleClick: false,
connectOnClick: false,
selectionMode: "partial",
selectionKeyCode: true,
multiSelectionKeyCode: true,
selectionOnDrag: true,
selectionBoxMode: "Default",
preventScrolling: true,
});
// Use shallowRef for selected node to avoid unnecessary reactivity
const selectedNode = shallowRef(null);
// Help guide state
const showHelpGuide = ref(false);
// Toggle help guide
const toggleHelpGuide = () => {
showHelpGuide.value = !showHelpGuide.value;
};
// 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 click:", error);
}
};
// Handle selection change
const onSelectionChange = ({ nodes, edges }) => {
console.log('Selection changed:', { nodes: nodes.length, edges: edges.length });
// You can add custom logic here for handling multiple selections
};
// Handle multiple node selection
const onNodesSelection = ({ nodes }) => {
console.log('Multiple nodes selected:', nodes.map(n => n.id));
// Emit event for multiple node selection
emit("nodeSelected", nodes);
};
// 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) {
console.log("Mounted: initialNodes", props.initialNodes);
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);
// Only fit view initially if there are no existing nodes with positions
// This prevents repositioning existing nodes when the canvas mounts
const hasExistingNodes = props.initialNodes.length > 0;
const hasPositions = props.initialNodes.some(
(node) => node.position && (node.position.x !== 0 || node.position.y !== 0)
);
if (!hasExistingNodes || !hasPositions) {
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) => {
console.log("Watcher: initialNodes changed", newNodes);
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,
// Add direct access to Vue Flow methods for production fallbacks
setNodes: (newNodes) => {
try {
if (Array.isArray(newNodes)) {
nodes.value = newNodes;
}
} catch (error) {
console.error("Error in setNodes:", error);
}
},
setEdges: (newEdges) => {
try {
if (Array.isArray(newEdges)) {
edges.value = newEdges;
}
} catch (error) {
console.error("Error in setEdges:", error);
}
},
// Provide access to the flow instance
get flowInstance() {
return flowInstance;
},
});
// 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
// Be more conservative - only remove if we're sure it's not a temporary state
const nodesToRemove = nodes.value.filter(
(node) => !newNodeIds.has(node.id)
);
// Only remove nodes if the new list is not empty (prevents accidental clearing)
if (nodesToRemove.length > 0 && newNodes.length > 0) {
console.log(
"🗑️ Removing nodes:",
nodesToRemove.map((n) => n.id)
);
removeNodes(nodesToRemove);
} else if (nodesToRemove.length > 0 && newNodes.length === 0) {
console.log(
"⚠️ Not removing nodes - new list is empty, might be temporary state"
);
}
// 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 {
// Check if we're restoring the same data that's already there
const currentNodes = nodes.value.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
}));
const newNodes =
flowObject.nodes?.map((n) => ({
id: n.id,
type: n.type,
position: n.position,
})) || [];
const nodesAreSame =
currentNodes.length === newNodes.length &&
currentNodes.every(
(node, index) =>
node.id === newNodes[index]?.id &&
node.type === newNodes[index]?.type
);
// Only clear if we're actually restoring different data
if (!nodesAreSame) {
// 1. First, clear existing state
if (nodes.value.length > 0) {
console.log("🔄 Clearing existing nodes for restoration");
removeNodes([...nodes.value]);
}
if (edges.value.length > 0) {
console.log("🔄 Clearing existing edges for restoration");
removeEdges([...edges.value]);
}
} else {
console.log("✅ Nodes are the same, skipping clear operation");
}
// Wait for clearing to complete
await nextTick();
// 2. Restore nodes first (only if we cleared or if no nodes exist)
if (
flowObject.nodes &&
Array.isArray(flowObject.nodes) &&
flowObject.nodes.length > 0 &&
(!nodesAreSame || nodes.value.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
}));
console.log("🔄 Restoring", nodesToRestore.length, "nodes");
addNodes(nodesToRestore);
await nextTick();
// Wait a bit more for nodes to be fully initialized
await new Promise((resolve) => setTimeout(resolve, 100));
} else if (nodesAreSame) {
console.log("✅ Nodes already exist, skipping node restoration");
}
// 3. Restore edges after nodes are ready (only if we cleared or if no edges exist)
if (
flowObject.edges &&
Array.isArray(flowObject.edges) &&
flowObject.edges.length > 0 &&
nodes.value.length > 0 &&
(!nodesAreSame || edges.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
}));
console.log("🔄 Restoring", cleanEdges.length, "edges");
addEdges(cleanEdges);
await nextTick();
}
} else if (nodesAreSame) {
console.log("✅ Edges already exist, skipping edge restoration");
}
// 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">
<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"
@selectionChange="onSelectionChange"
@nodesSelection="onNodesSelection"
>
<!-- 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>
<!-- Collapsible Help Guide -->
<Panel position="top-right" class="help-guide">
<div class="help-guide-content">
<button
@click="toggleHelpGuide"
class="help-toggle-btn"
:class="{ 'expanded': showHelpGuide }"
>
<Icon name="material-symbols:help-outline" class="w-4 h-4" />
<span v-if="showHelpGuide">Hide Help</span>
<span v-else>Show Help</span>
</button>
<div v-if="showHelpGuide" class="help-content">
<div class="help-section">
<h4 class="help-title">Selection</h4>
<ul class="help-list">
<li> Click: Select single node</li>
<li> Drag: Select multiple nodes</li>
<li> Ctrl+Click: Add to selection</li>
<li> Drag selected nodes together</li>
</ul>
</div>
<div class="help-section">
<h4 class="help-title">Actions</h4>
<ul class="help-list">
<li> Delete: Remove selected</li>
<li> Double-click: Remove node/edge</li>
<li> Drag between nodes: Connect</li>
</ul>
</div>
<div class="help-section">
<h4 class="help-title">Edges</h4>
<ul class="help-list">
<li> Select edge for controls</li>
<li> Blue dot: Reposition</li>
<li> Reset: Restore path</li>
</ul>
</div>
</div>
</div>
</Panel>
</VueFlow>
</div>
</template>
<style>
/* Node styles from nodeStyles.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 {
display: none;
}
: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);
}
}
/* Selection box styles */
:deep(.vue-flow__selection) {
background: rgba(59, 130, 246, 0.1);
border: 2px solid #3b82f6;
border-radius: 4px;
pointer-events: none;
}
:deep(.vue-flow__selection-rect) {
fill: rgba(59, 130, 246, 0.1);
stroke: #3b82f6;
stroke-width: 2;
stroke-dasharray: 5, 5;
}
/* Multi-selected nodes styling */
:deep(.vue-flow__node.selected) {
box-shadow: 0 0 0 2px #3b82f6;
z-index: 10;
}
:deep(.vue-flow__node.selected .custom-node) {
box-shadow: 0 0 0 2px #3b82f6, 0 2px 5px rgba(0, 0, 0, 0.1);
}
/* Selection box animation */
@keyframes selectionPulse {
0%, 100% {
opacity: 0.3;
}
50% {
opacity: 0.6;
}
}
:deep(.vue-flow__selection) {
animation: selectionPulse 2s ease-in-out infinite;
}
/* Help Guide Styles */
.help-guide {
pointer-events: all;
}
.help-guide-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
max-width: 280px;
transition: all 0.3s ease;
}
.help-toggle-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.help-toggle-btn:hover {
background: #2563eb;
transform: translateY(-1px);
}
.help-toggle-btn.expanded {
background: #dc2626;
}
.help-toggle-btn.expanded:hover {
background: #b91c1c;
}
.help-content {
padding: 12px;
background: white;
border-top: 1px solid #e5e7eb;
}
.help-section {
margin-bottom: 12px;
}
.help-section:last-child {
margin-bottom: 0;
}
.help-title {
font-size: 11px;
font-weight: 600;
color: #374151;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.help-list {
list-style: none;
padding: 0;
margin: 0;
}
.help-list li {
font-size: 11px;
color: #6b7280;
margin-bottom: 3px;
line-height: 1.3;
}
.help-list li:last-child {
margin-bottom: 0;
}
</style>