- Introduced a new 'Page Wrapper' node type to allow multiple components (forms, HTML, tables) to be rendered as a single page, enhancing the flexibility of the process builder. - Implemented child node management within the Page Wrapper, including adding, updating, and removing child nodes through the process store. - Enhanced the ProcessFlowCanvas to support the new Page Wrapper node, including event handling for child node interactions and configuration. - Updated the workflow page to handle submissions and interactions for Page Wrapper nodes, ensuring seamless integration with existing process flows. - Improved documentation in CLAUDE.md to provide guidance on the new Page Wrapper architecture and its components. - Refactored related components and store logic to accommodate the new functionality, ensuring a cohesive user experience across the application.
2612 lines
77 KiB
Vue
2612 lines
77 KiB
Vue
<script setup>
|
||
import {
|
||
ref,
|
||
onMounted,
|
||
computed,
|
||
shallowRef,
|
||
watch,
|
||
defineExpose,
|
||
nextTick,
|
||
markRaw,
|
||
provide,
|
||
} 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 { useNodeValidation } from '~/composables/useNodeValidation';
|
||
import { useProcessBuilderStore } from '~/stores/processBuilder';
|
||
// 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 PageWrapperNode from "~/components/process-flow/custom/PageWrapperNode.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),
|
||
"page-wrapper": markRaw(PageWrapperNode),
|
||
};
|
||
|
||
// 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",
|
||
"child-click",
|
||
"header-click",
|
||
"child-add",
|
||
"child-drop",
|
||
]);
|
||
|
||
// Get the flow instance
|
||
const { flowInstance } = useVueFlow();
|
||
|
||
// Initialize validation system
|
||
const {
|
||
validateProcess,
|
||
validationResults,
|
||
isValidating,
|
||
overallValidationStatus,
|
||
validationSummary,
|
||
clearValidation
|
||
} = useNodeValidation();
|
||
|
||
// Initialize process store for validation updates
|
||
const processStore = useProcessBuilderStore();
|
||
|
||
// Initialize Vue Flow
|
||
const {
|
||
nodes,
|
||
edges,
|
||
addNodes,
|
||
addEdges,
|
||
onNodesChange,
|
||
onEdgesChange,
|
||
onConnect,
|
||
fitView,
|
||
fitBounds,
|
||
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;
|
||
};
|
||
|
||
// Validation panel state
|
||
const showValidationPanel = ref(true);
|
||
|
||
// Toggle validation panel
|
||
const toggleValidationPanel = () => {
|
||
showValidationPanel.value = !showValidationPanel.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");
|
||
};
|
||
|
||
// Handle child node events from PageWrapper
|
||
const onChildNodeClick = (eventData) => {
|
||
console.log('Canvas received child-click:', eventData);
|
||
emit("child-click", eventData);
|
||
};
|
||
|
||
const onPageWrapperHeaderClick = (nodeId) => {
|
||
console.log('Canvas received header-click:', nodeId);
|
||
emit("header-click", nodeId);
|
||
};
|
||
|
||
const onChildAdd = (parentId) => {
|
||
console.log('Canvas received child-add:', parentId);
|
||
emit("child-add", parentId);
|
||
};
|
||
|
||
const onChildDrop = (eventData) => {
|
||
console.log('Canvas received child-drop:', eventData);
|
||
emit("child-drop", eventData);
|
||
};
|
||
|
||
// Provide child node event handlers to PageWrapper components
|
||
provide('childNodeEventHandlers', {
|
||
onChildNodeClick,
|
||
onPageWrapperHeaderClick,
|
||
onChildAdd,
|
||
onChildDrop,
|
||
});
|
||
|
||
// 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);
|
||
|
||
// FIXED: Only fit view initially if there are no existing nodes OR if nodes have no 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)
|
||
);
|
||
|
||
// Only auto-fit if we have no nodes, or nodes have default/zero positions
|
||
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 - STRICTLY PRESERVE CANVAS POSITIONS
|
||
newNodes.forEach((newNode) => {
|
||
const existingNode = nodes.value.find((n) => n.id === newNode.id);
|
||
if (existingNode) {
|
||
// FIXED: NEVER update positions for existing nodes during sync
|
||
// The canvas is the source of truth for positions once a node exists
|
||
// Only update data and label, preserve the current canvas position
|
||
Object.assign(existingNode, {
|
||
label: newNode.label,
|
||
data: { ...newNode.data },
|
||
// ALWAYS keep the current canvas position - never overwrite from store
|
||
position: existingNode.position,
|
||
});
|
||
|
||
// Always call updateNodeInternals to force re-render
|
||
// This ensures property changes are reflected immediately
|
||
updateNodeInternals([newNode.id]);
|
||
}
|
||
});
|
||
|
||
// FIXED: Don't auto-fit view when adding individual nodes to prevent repositioning
|
||
// Only fit view if this is a bulk operation (like loading a process) with many nodes
|
||
if (nodesToAdd.length > 3) {
|
||
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);
|
||
});
|
||
|
||
// Watch for changes to nodes and edges to trigger validation
|
||
watch(
|
||
() => [nodes.value, edges.value],
|
||
([currentNodes, currentEdges]) => {
|
||
if (currentNodes && currentNodes.length > 0) {
|
||
// Debounce validation to avoid excessive re-computation
|
||
clearTimeout(validationTimeout.value);
|
||
validationTimeout.value = setTimeout(() => {
|
||
const issues = validateProcess(currentNodes, currentEdges || []);
|
||
|
||
// Update the process store with validation results
|
||
processStore.updateValidationResults(
|
||
validationResults.value,
|
||
validationSummary.value,
|
||
overallValidationStatus.value
|
||
);
|
||
}, 300);
|
||
} else {
|
||
// Clear validation if no nodes
|
||
clearValidation();
|
||
processStore.clearValidationResults();
|
||
}
|
||
},
|
||
{ deep: true }
|
||
);
|
||
|
||
// Timeout for debouncing validation
|
||
const validationTimeout = ref(null);
|
||
|
||
// 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;
|
||
}
|
||
|
||
// FIXED: Only emit to parent, don't add directly to canvas
|
||
// The parent will add to store, which will trigger canvas update through watchers
|
||
// This prevents double node creation (canvas + store)
|
||
emit(
|
||
"nodesChange",
|
||
[{ type: "add", id: newNode.id, item: newNode }],
|
||
nodes.value
|
||
);
|
||
|
||
console.log('📦 Canvas: Drop handled, emitted to parent:', newNode.id);
|
||
} catch (error) {
|
||
console.error("Error handling drop:", error);
|
||
}
|
||
};
|
||
|
||
// Handle drag over
|
||
const onDragOver = (event) => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.dataTransfer.dropEffect = "copy";
|
||
};
|
||
|
||
// Export functions for canvas export
|
||
async function exportToPNG(options = {}) {
|
||
try {
|
||
const { default: html2canvas } = await import('html2canvas');
|
||
|
||
// Find Vue Flow elements
|
||
const vueFlowContainer = document.querySelector('.vue-flow');
|
||
const vueFlowViewport = document.querySelector('.vue-flow__viewport');
|
||
|
||
if (!vueFlowContainer || !vueFlowViewport) {
|
||
throw new Error('Vue Flow elements not found');
|
||
}
|
||
|
||
console.log('Vue Flow elements found:', { container: vueFlowContainer, viewport: vueFlowViewport });
|
||
|
||
// Wait for any animations or transitions to complete
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Get current nodes from Vue Flow state
|
||
const currentNodes = nodes.value || [];
|
||
|
||
console.log('Current nodes for export:', currentNodes.length);
|
||
|
||
if (currentNodes.length === 0) {
|
||
throw new Error('No nodes found to export');
|
||
}
|
||
|
||
// Store original viewport
|
||
const originalViewport = getViewport();
|
||
|
||
// Use Vue Flow's fitView to ensure all nodes are visible and centered
|
||
fitView({
|
||
padding: 0.2,
|
||
includeHiddenNodes: false,
|
||
minZoom: 0.5,
|
||
maxZoom: 2
|
||
});
|
||
|
||
// Wait for the view to settle
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Use html2canvas to capture the entire Vue Flow container
|
||
const html2canvasOptions = {
|
||
backgroundColor: options.includeBackground ? '#f8fafc' : 'white',
|
||
scale: options.quality || 2,
|
||
useCORS: true,
|
||
allowTaint: false,
|
||
foreignObjectRendering: true,
|
||
imageTimeout: 20000,
|
||
logging: false,
|
||
removeContainer: false,
|
||
// Capture the entire container instead of just viewport
|
||
width: vueFlowContainer.offsetWidth,
|
||
height: vueFlowContainer.offsetHeight,
|
||
x: 0,
|
||
y: 0,
|
||
scrollX: 0,
|
||
scrollY: 0,
|
||
windowWidth: vueFlowContainer.offsetWidth,
|
||
windowHeight: vueFlowContainer.offsetHeight,
|
||
onclone: function(clonedDoc) {
|
||
const clonedContainer = clonedDoc.querySelector('.vue-flow');
|
||
const clonedViewport = clonedDoc.querySelector('.vue-flow__viewport');
|
||
|
||
if (clonedContainer && clonedViewport) {
|
||
// Ensure container styles don't interfere with capture
|
||
clonedContainer.style.overflow = 'visible';
|
||
clonedContainer.style.position = 'relative';
|
||
clonedViewport.style.overflow = 'visible';
|
||
|
||
// Make sure all content is visible in the cloned document
|
||
const allElements = clonedContainer.querySelectorAll('*');
|
||
allElements.forEach(el => {
|
||
// Force visibility of all elements
|
||
if (el.style.display === 'none') {
|
||
el.style.display = 'block';
|
||
}
|
||
if (el.style.visibility === 'hidden') {
|
||
el.style.visibility = 'visible';
|
||
}
|
||
if (el.style.opacity === '0' || el.style.opacity === '') {
|
||
el.style.opacity = '1';
|
||
}
|
||
// Remove any transforms that might hide content
|
||
if (el.style.transform && el.style.transform.includes('scale(0)')) {
|
||
el.style.transform = el.style.transform.replace(/scale\([0-9.]*\)/, 'scale(1)');
|
||
}
|
||
});
|
||
|
||
console.log('Cloned document prepared, elements made visible');
|
||
}
|
||
},
|
||
ignoreElements: function(element) {
|
||
// Only ignore UI controls, not the actual process content
|
||
return element.classList.contains('vue-flow__controls') ||
|
||
element.classList.contains('vue-flow__minimap') ||
|
||
(element.classList.contains('vue-flow__background') && !options.includeBackground);
|
||
}
|
||
};
|
||
|
||
console.log('html2canvas options:', html2canvasOptions);
|
||
|
||
// Capture the Vue Flow container
|
||
const canvas = await html2canvas(vueFlowContainer, html2canvasOptions);
|
||
|
||
console.log('Canvas captured successfully:', canvas.width, 'x', canvas.height);
|
||
|
||
// Restore original viewport
|
||
setViewport(originalViewport);
|
||
|
||
// Convert to blob and download
|
||
return new Promise((resolve, reject) => {
|
||
canvas.toBlob((blob) => {
|
||
if (!blob) {
|
||
reject(new Error('Failed to create image blob'));
|
||
return;
|
||
}
|
||
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = options.fileName || 'process-flow.png';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
document.body.removeChild(link);
|
||
URL.revokeObjectURL(url);
|
||
resolve();
|
||
}, 'image/png', 0.95);
|
||
});
|
||
} catch (error) {
|
||
console.error('Error exporting to PNG:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function exportToPDF(options = {}) {
|
||
try {
|
||
// Use the same improved capture logic as PNG export
|
||
const { default: html2canvas } = await import('html2canvas');
|
||
|
||
const vueFlowContainer = document.querySelector('.vue-flow');
|
||
const vueFlowViewport = document.querySelector('.vue-flow__viewport');
|
||
|
||
if (!vueFlowContainer || !vueFlowViewport) {
|
||
throw new Error('Vue Flow elements not found');
|
||
}
|
||
|
||
console.log('Vue Flow elements found for PDF:', { container: vueFlowContainer, viewport: vueFlowViewport });
|
||
|
||
// Wait for any animations or transitions to complete
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Get current nodes from Vue Flow state
|
||
const currentNodes = nodes.value || [];
|
||
|
||
console.log('Current nodes for PDF export:', currentNodes.length);
|
||
|
||
if (currentNodes.length === 0) {
|
||
throw new Error('No nodes found to export');
|
||
}
|
||
|
||
// Store original viewport
|
||
const originalViewport = getViewport();
|
||
|
||
// Use Vue Flow's fitView to ensure all nodes are visible and centered
|
||
fitView({
|
||
padding: 0.2,
|
||
includeHiddenNodes: false,
|
||
minZoom: 0.5,
|
||
maxZoom: 2
|
||
});
|
||
|
||
// Wait for the view to settle
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
|
||
// Use html2canvas to capture the entire viewport
|
||
const html2canvasOptions = {
|
||
backgroundColor: options.includeBackground ? '#f8fafc' : '#ffffff',
|
||
scale: options.quality || 2,
|
||
useCORS: true,
|
||
allowTaint: false,
|
||
foreignObjectRendering: true,
|
||
imageTimeout: 20000,
|
||
logging: false,
|
||
removeContainer: false,
|
||
// Capture the entire viewport
|
||
width: vueFlowViewport.offsetWidth,
|
||
height: vueFlowViewport.offsetHeight,
|
||
x: 0,
|
||
y: 0,
|
||
scrollX: 0,
|
||
scrollY: 0,
|
||
windowWidth: vueFlowViewport.offsetWidth,
|
||
windowHeight: vueFlowViewport.offsetHeight,
|
||
onclone: function(clonedDoc) {
|
||
const clonedContainer = clonedDoc.querySelector('.vue-flow');
|
||
const clonedViewport = clonedDoc.querySelector('.vue-flow__viewport');
|
||
|
||
if (clonedContainer && clonedViewport) {
|
||
// Ensure container styles don't interfere with capture
|
||
clonedContainer.style.overflow = 'visible';
|
||
clonedContainer.style.position = 'relative';
|
||
clonedViewport.style.overflow = 'visible';
|
||
|
||
// Make sure all content is visible in the cloned document
|
||
const allElements = clonedContainer.querySelectorAll('*');
|
||
allElements.forEach(el => {
|
||
// Force visibility of all elements
|
||
if (el.style.display === 'none') {
|
||
el.style.display = 'block';
|
||
}
|
||
if (el.style.visibility === 'hidden') {
|
||
el.style.visibility = 'visible';
|
||
}
|
||
if (el.style.opacity === '0' || el.style.opacity === '') {
|
||
el.style.opacity = '1';
|
||
}
|
||
// Remove any transforms that might hide content
|
||
if (el.style.transform && el.style.transform.includes('scale(0)')) {
|
||
el.style.transform = el.style.transform.replace(/scale\([0-9.]*\)/, 'scale(1)');
|
||
}
|
||
});
|
||
}
|
||
},
|
||
ignoreElements: function(element) {
|
||
// Only ignore UI controls, not the actual process content
|
||
return element.classList.contains('vue-flow__controls') ||
|
||
element.classList.contains('vue-flow__minimap') ||
|
||
(element.classList.contains('vue-flow__background') && !options.includeBackground);
|
||
}
|
||
};
|
||
|
||
// Capture the Vue Flow container
|
||
const canvas = await html2canvas(vueFlowContainer, html2canvasOptions);
|
||
|
||
// Restore original viewport
|
||
setViewport(originalViewport);
|
||
|
||
// Now create PDF
|
||
const { jsPDF } = await import('jspdf');
|
||
const imgData = canvas.toDataURL('image/png');
|
||
|
||
console.log('PDF Canvas captured:', canvas.width, 'x', canvas.height);
|
||
|
||
// Calculate dimensions
|
||
const imgWidth = canvas.width;
|
||
const imgHeight = canvas.height;
|
||
|
||
// PDF page configurations
|
||
const pageConfigs = {
|
||
a4: { width: 210, height: 297 },
|
||
a3: { width: 297, height: 420 },
|
||
letter: { width: 216, height: 279 },
|
||
legal: { width: 216, height: 356 }
|
||
};
|
||
|
||
let pageConfig = pageConfigs[options.pdf?.pageSize] || pageConfigs.a4;
|
||
const orientation = options.pdf?.orientation || 'landscape';
|
||
|
||
if (orientation === 'landscape') {
|
||
[pageConfig.width, pageConfig.height] = [pageConfig.height, pageConfig.width];
|
||
}
|
||
|
||
// Create PDF
|
||
const pdf = new jsPDF({
|
||
orientation: orientation,
|
||
unit: 'mm',
|
||
format: options.pdf?.pageSize === 'auto' ? [imgWidth * 0.26458, imgHeight * 0.26458] : [pageConfig.width, pageConfig.height]
|
||
});
|
||
|
||
if (options.pdf?.pageSize === 'auto') {
|
||
// Auto-fit: scale image to fit exactly
|
||
pdf.addImage(imgData, 'PNG', 0, 0, imgWidth * 0.26458, imgHeight * 0.26458);
|
||
} else {
|
||
// Fixed page size: scale to fit within margins
|
||
const margin = 10;
|
||
const maxWidth = pageConfig.width - (margin * 2);
|
||
const maxHeight = pageConfig.height - (margin * 2);
|
||
|
||
const scaleFactor = Math.min(maxWidth / (imgWidth * 0.26458), maxHeight / (imgHeight * 0.26458));
|
||
const scaledWidth = (imgWidth * 0.26458) * scaleFactor;
|
||
const scaledHeight = (imgHeight * 0.26458) * scaleFactor;
|
||
|
||
const x = (pageConfig.width - scaledWidth) / 2;
|
||
const y = (pageConfig.height - scaledHeight) / 2;
|
||
|
||
pdf.addImage(imgData, 'PNG', x, y, scaledWidth, scaledHeight);
|
||
}
|
||
|
||
// Save the PDF
|
||
pdf.save(options.fileName || 'process-flow.pdf');
|
||
} catch (error) {
|
||
console.error('Error exporting to PDF:', error);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Define methods to expose to parent components
|
||
defineExpose({
|
||
updateNode,
|
||
addNode,
|
||
removeNode,
|
||
fitView,
|
||
syncCanvas,
|
||
centerOnNode,
|
||
// Add Vue Flow save/restore methods
|
||
toObject,
|
||
fromObject,
|
||
// Add export methods
|
||
exportToPNG,
|
||
exportToPDF,
|
||
// 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;
|
||
},
|
||
// Provide direct access to nodes and edges
|
||
get nodes() {
|
||
return nodes.value;
|
||
},
|
||
get edges() {
|
||
return edges.value;
|
||
},
|
||
// Add method to get selected elements
|
||
getSelectedElements: () => {
|
||
try {
|
||
// First try to get selection from Vue Flow instance
|
||
if (flowInstance) {
|
||
const selection = flowInstance.getSelection();
|
||
if (selection && (selection.nodes?.length > 0 || selection.edges?.length > 0)) {
|
||
return {
|
||
nodes: selection.nodes || [],
|
||
edges: selection.edges || []
|
||
};
|
||
}
|
||
}
|
||
|
||
// Fallback: check for selected property on nodes and edges
|
||
const selectedNodes = nodes.value.filter(node => node.selected === true);
|
||
const selectedEdges = edges.value.filter(edge => edge.selected === true);
|
||
|
||
// If still no selection, try DOM-based detection
|
||
if (selectedNodes.length === 0 && selectedEdges.length === 0) {
|
||
// Look for selected nodes in the DOM
|
||
const selectedNodeElements = document.querySelectorAll('.vue-flow__node.selected');
|
||
const selectedEdgeElements = document.querySelectorAll('.vue-flow__edge.selected');
|
||
|
||
// Convert DOM elements to node/edge data
|
||
const domSelectedNodes = [];
|
||
const domSelectedEdges = [];
|
||
|
||
selectedNodeElements.forEach(element => {
|
||
const nodeId = element.getAttribute('data-id');
|
||
if (nodeId) {
|
||
const node = nodes.value.find(n => n.id === nodeId);
|
||
if (node) {
|
||
domSelectedNodes.push(node);
|
||
}
|
||
}
|
||
});
|
||
|
||
selectedEdgeElements.forEach(element => {
|
||
const edgeId = element.getAttribute('data-id');
|
||
if (edgeId) {
|
||
const edge = edges.value.find(e => e.id === edgeId);
|
||
if (edge) {
|
||
domSelectedEdges.push(edge);
|
||
}
|
||
}
|
||
});
|
||
|
||
if (domSelectedNodes.length > 0 || domSelectedEdges.length > 0) {
|
||
return { nodes: domSelectedNodes, edges: domSelectedEdges };
|
||
}
|
||
}
|
||
|
||
return { nodes: selectedNodes, edges: selectedEdges };
|
||
} catch (error) {
|
||
console.warn('Error getting selected elements:', error);
|
||
return { nodes: [], edges: [] };
|
||
}
|
||
}
|
||
});
|
||
|
||
// 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 - STRICTLY PRESERVE CANVAS POSITIONS
|
||
newNodes.forEach((newNode) => {
|
||
const existingNode = nodes.value.find((n) => n.id === newNode.id);
|
||
if (existingNode) {
|
||
// FIXED: NEVER update positions for existing nodes during manual sync
|
||
// The canvas is the source of truth for positions once a node exists
|
||
// Only update data and label, preserve the current canvas position
|
||
Object.assign(existingNode, {
|
||
label: newNode.label,
|
||
data: { ...newNode.data },
|
||
// ALWAYS keep the current canvas position - never overwrite from store
|
||
position: existingNode.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>
|
||
|
||
<!-- Collapsible Validation Panel -->
|
||
<Panel position="top-left" class="validation-panel">
|
||
<div class="validation-panel-content">
|
||
<button
|
||
@click="toggleValidationPanel"
|
||
class="validation-toggle-btn"
|
||
:class="{ 'expanded': showValidationPanel }"
|
||
>
|
||
<Icon
|
||
:name="processStore.overallValidationStatus === 'error' ? 'material-symbols:error' :
|
||
processStore.overallValidationStatus === 'warning' ? 'material-symbols:warning' :
|
||
'material-symbols:check-circle'"
|
||
class="w-4 h-4"
|
||
/>
|
||
<span v-if="showValidationPanel">Hide Validation</span>
|
||
<span v-else>
|
||
Validation
|
||
<span v-if="processStore.validationSummary.totalIssues > 0" class="inline-badge">
|
||
{{ processStore.validationSummary.totalIssues }}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
|
||
<div v-if="showValidationPanel" class="validation-content">
|
||
<div class="validation-header">
|
||
<div class="validation-status" :class="processStore.overallValidationStatus">
|
||
<span class="status-text">
|
||
{{ processStore.overallValidationStatus === 'error' ? 'Errors Found' :
|
||
processStore.overallValidationStatus === 'warning' ? 'Warnings Found' : 'All Valid' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="processStore.validationSummary.totalIssues > 0" class="validation-summary">
|
||
<div class="summary-stats">
|
||
<span v-if="processStore.validationSummary.errors > 0" class="stat-item error">
|
||
{{ processStore.validationSummary.errors }} error{{ processStore.validationSummary.errors > 1 ? 's' : '' }}
|
||
</span>
|
||
<span v-if="processStore.validationSummary.warnings > 0" class="stat-item warning">
|
||
{{ processStore.validationSummary.warnings }} warning{{ processStore.validationSummary.warnings > 1 ? 's' : '' }}
|
||
</span>
|
||
<span v-if="processStore.validationSummary.infos > 0" class="stat-item info">
|
||
{{ processStore.validationSummary.infos }} info{{ processStore.validationSummary.infos > 1 ? 's' : '' }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="nodes.length > 0" class="validation-success">
|
||
<Icon name="material-symbols:check-circle" class="w-4 h-4 text-green-500" />
|
||
<span class="text-green-600 text-sm">Process validation passed</span>
|
||
</div>
|
||
|
||
<div v-else class="validation-empty">
|
||
<span class="text-gray-500 text-sm">Add nodes to validate process</span>
|
||
</div>
|
||
|
||
<div v-if="isValidating" class="validation-loading">
|
||
<Icon name="material-symbols:refresh" class="w-4 h-4 animate-spin" />
|
||
<span class="text-sm text-gray-600">Validating...</span>
|
||
</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;
|
||
}
|
||
|
||
/* Validation Panel Styles */
|
||
.validation-panel {
|
||
pointer-events: all;
|
||
}
|
||
|
||
.validation-panel-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;
|
||
}
|
||
|
||
.validation-toggle-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 12px;
|
||
background: #059669;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
}
|
||
|
||
.validation-toggle-btn:hover {
|
||
background: #047857;
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.validation-toggle-btn.expanded {
|
||
background: #dc2626;
|
||
}
|
||
|
||
.validation-toggle-btn.expanded:hover {
|
||
background: #b91c1c;
|
||
}
|
||
|
||
.inline-badge {
|
||
background: rgba(255, 255, 255, 0.3);
|
||
padding: 2px 6px;
|
||
border-radius: 10px;
|
||
font-size: 10px;
|
||
margin-left: 4px;
|
||
}
|
||
|
||
.validation-content {
|
||
padding: 12px;
|
||
background: white;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.validation-header {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.validation-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
padding: 4px 8px;
|
||
border-radius: 4px;
|
||
background: #f9fafb;
|
||
border: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.validation-status.error {
|
||
color: #dc2626;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.validation-status.warning {
|
||
color: #d97706;
|
||
background: #fffbeb;
|
||
}
|
||
|
||
.validation-status.valid {
|
||
color: #059669;
|
||
background: #f0fdf4;
|
||
}
|
||
|
||
.validation-summary {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.summary-stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.stat-item {
|
||
font-size: 10px;
|
||
padding: 2px 6px;
|
||
border-radius: 3px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.stat-item.error {
|
||
color: #dc2626;
|
||
background: #fef2f2;
|
||
}
|
||
|
||
.stat-item.warning {
|
||
color: #d97706;
|
||
background: #fffbeb;
|
||
}
|
||
|
||
.stat-item.info {
|
||
color: #2563eb;
|
||
background: #eff6ff;
|
||
}
|
||
|
||
.validation-success {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.validation-empty {
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.validation-loading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.status-text {
|
||
font-size: 10px;
|
||
}
|
||
</style>
|