diff --git a/components/process-flow/ExportCanvasModal.vue b/components/process-flow/ExportCanvasModal.vue
new file mode 100644
index 0000000..f0cca2f
--- /dev/null
+++ b/components/process-flow/ExportCanvasModal.vue
@@ -0,0 +1,441 @@
+
+
+
+
+
+
+
Export Canvas
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ exportProgress }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/components/process-flow/ProcessFlowCanvas.vue b/components/process-flow/ProcessFlowCanvas.vue
index 3aeb1fd..0a89178 100644
--- a/components/process-flow/ProcessFlowCanvas.vue
+++ b/components/process-flow/ProcessFlowCanvas.vue
@@ -106,6 +106,7 @@ const {
onEdgesChange,
onConnect,
fitView,
+ fitBounds,
project,
removeNodes,
removeEdges,
@@ -890,6 +891,303 @@ const onDragOver = (event) => {
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,
@@ -901,6 +1199,9 @@ defineExpose({
// 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 {
diff --git a/package.json b/package.json
index b5d3914..824cc57 100644
--- a/package.json
+++ b/package.json
@@ -60,7 +60,9 @@
"cross-env": "^7.0.3",
"crypto-js": "^4.1.1",
"floating-vue": "^2.0.0-beta.24",
+ "html2canvas": "^1.4.1",
"jsonwebtoken": "^8.5.1",
+ "jspdf": "^3.0.1",
"luxon": "^3.1.0",
"maska": "^1.5.0",
"pinia": "^2.1.6",
diff --git a/pages/process-builder/index.vue b/pages/process-builder/index.vue
index 8f0a469..976483e 100644
--- a/pages/process-builder/index.vue
+++ b/pages/process-builder/index.vue
@@ -23,6 +23,7 @@ import SubprocessNodeConfigurationModal from '~/components/process-flow/Subproce
import ProcessTemplatesModal from '~/components/ProcessTemplatesModal.vue';
import ProcessSettingsModal from '~/components/process-flow/ProcessSettingsModal.vue';
import ProcessHistoryModal from '~/components/ProcessHistoryModal.vue';
+import ExportCanvasModal from '~/components/process-flow/ExportCanvasModal.vue';
// Define page meta
definePageMeta({
@@ -94,6 +95,7 @@ const showTemplatesModal = ref(false);
const showProcessSettings = ref(false);
const showDropdown = ref(false);
const showProcessHistoryModal = ref(false);
+const showExportModal = ref(false);
// Add mobile responsive state
const showLeftPanel = ref(true);
@@ -1830,6 +1832,29 @@ const handleProcessRestored = (restoredProcess) => {
toast.success('Process has been restored successfully');
};
+// Handle canvas export
+const handleExportCanvas = async (options) => {
+ try {
+ if (!processFlowCanvas.value) {
+ throw new Error('Canvas not available for export');
+ }
+
+ console.log('Starting export with options:', options);
+
+ if (options.format === 'png') {
+ await processFlowCanvas.value.exportToPNG(options);
+ toast.success('Canvas exported as PNG successfully');
+ } else if (options.format === 'pdf') {
+ await processFlowCanvas.value.exportToPDF(options);
+ toast.success('Canvas exported as PDF successfully');
+ }
+ } catch (error) {
+ console.error('Export error:', error);
+ toast.error(`Export failed: ${error.message}`);
+ throw error;
+ }
+};
+
// Navigate to variables page
const navigateToVariables = () => {
confirmNavigation('/variables');
@@ -2324,6 +2349,14 @@ const sendToBack = () => {
History
+
+
+
+
+
+ Export
+
+
@@ -2343,6 +2376,10 @@ const sendToBack = () => {
History
+