corrad-bp/components/process-flow/ExportCanvasModal.vue
Md Afiq Iskandar c4a143bb2d Add Export Functionality to Process Flow Canvas
- Introduced a new ExportCanvasModal component for exporting the process flow as PNG or PDF.
- Enhanced ProcessFlowCanvas with exportToPNG and exportToPDF methods, utilizing html2canvas and jsPDF for rendering.
- Updated the process builder page to integrate the export modal, allowing users to initiate exports easily.
- Added UI elements for selecting export format and quality, improving user experience during the export process.
- Implemented error handling and progress indicators for export operations, ensuring better feedback for users.
2025-07-23 08:01:29 +08:00

441 lines
12 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.

<template>
<div v-if="show" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" @click="closeModal">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4" @click.stop>
<!-- Modal Header -->
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Export Canvas</h3>
<button
@click="closeModal"
class="text-gray-400 hover:text-gray-600 transition-colors"
>
<span class="material-icons">close</span>
</button>
</div>
</div>
<!-- Modal Body -->
<div class="px-6 py-4 space-y-4">
<!-- Export Format -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Export Format
</label>
<div class="grid grid-cols-2 gap-3">
<button
@click="selectedFormat = 'png'"
:class="[
'flex items-center justify-center px-4 py-3 border rounded-lg transition-all',
selectedFormat === 'png'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 hover:border-gray-400'
]"
>
<span class="material-icons mr-2 text-sm">image</span>
PNG Image
</button>
<button
@click="selectedFormat = 'pdf'"
:class="[
'flex items-center justify-center px-4 py-3 border rounded-lg transition-all',
selectedFormat === 'pdf'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 hover:border-gray-400'
]"
>
<span class="material-icons mr-2 text-sm">picture_as_pdf</span>
PDF Document
</button>
</div>
</div>
<!-- Quality Settings -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Quality
</label>
<select
v-model="selectedQuality"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
>
<option value="1">Low (1x)</option>
<option value="2">Standard (2x)</option>
<option value="3">High (3x)</option>
<option value="4">Ultra (4x)</option>
</select>
</div>
<!-- Include Background -->
<div class="flex items-center">
<input
id="includeBackground"
v-model="includeBackground"
type="checkbox"
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
>
<label for="includeBackground" class="ml-2 block text-sm text-gray-700">
Include background pattern
</label>
</div>
<!-- File Name -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
File Name
</label>
<input
v-model="fileName"
type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
placeholder="Enter file name"
>
</div>
<!-- PDF Options (when PDF is selected) -->
<div v-if="selectedFormat === 'pdf'" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Page Size
</label>
<select
v-model="pdfPageSize"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
>
<option value="a4">A4 (210 × 297 mm)</option>
<option value="a3">A3 (297 × 420 mm)</option>
<option value="letter">Letter (8.5 × 11 in)</option>
<option value="legal">Legal (8.5 × 14 in)</option>
<option value="auto">Auto-fit</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">
Orientation
</label>
<div class="grid grid-cols-2 gap-3">
<button
@click="pdfOrientation = 'portrait'"
:class="[
'flex items-center justify-center px-3 py-2 border rounded-lg transition-all',
pdfOrientation === 'portrait'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 hover:border-gray-400'
]"
>
<span class="material-icons mr-1 text-sm">stay_current_portrait</span>
Portrait
</button>
<button
@click="pdfOrientation = 'landscape'"
:class="[
'flex items-center justify-center px-3 py-2 border rounded-lg transition-all',
pdfOrientation === 'landscape'
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 hover:border-gray-400'
]"
>
<span class="material-icons mr-1 text-sm">stay_current_landscape</span>
Landscape
</button>
</div>
</div>
</div>
<!-- Export Progress -->
<div v-if="isExporting" class="flex flex-col items-center justify-center py-4">
<div class="flex items-center space-x-2 mb-3">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-blue-600"></div>
<span class="text-sm text-gray-600">{{ exportProgress }}</span>
</div>
<button
@click="forceClose"
class="text-xs text-gray-500 hover:text-gray-700 underline"
>
Cancel Export
</button>
</div>
</div>
<!-- Modal Footer -->
<div class="px-6 py-4 border-t border-gray-200 flex justify-end space-x-3">
<button
@click="closeModal"
:disabled="isExporting"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors disabled:opacity-50"
>
Cancel
</button>
<button
@click="handleExport"
:disabled="isExporting || !fileName.trim()"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
>
<span class="material-icons mr-1 text-sm">download</span>
Export {{ selectedFormat.toUpperCase() }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
processName: {
type: String,
default: 'process'
}
})
// Emits
const emit = defineEmits(['close', 'export'])
// State
const selectedFormat = ref('png')
const selectedQuality = ref('2')
const includeBackground = ref(true)
const fileName = ref('')
const isExporting = ref(false)
const exportProgress = ref('')
// PDF specific options
const pdfPageSize = ref('a4')
const pdfOrientation = ref('landscape')
// Computed
const exportOptions = computed(() => ({
format: selectedFormat.value,
quality: parseInt(selectedQuality.value),
includeBackground: includeBackground.value,
fileName: fileName.value.trim(),
pdf: {
pageSize: pdfPageSize.value,
orientation: pdfOrientation.value
}
}))
// Watch for prop changes to set default filename
watch(() => props.processName, (newName) => {
if (newName && !fileName.value) {
fileName.value = newName.replace(/[^a-zA-Z0-9]/g, '_')
}
}, { immediate: true })
// Watch format changes to update filename extension
watch(selectedFormat, (newFormat) => {
if (fileName.value) {
const nameWithoutExt = fileName.value.replace(/\.(png|pdf)$/i, '')
fileName.value = `${nameWithoutExt}.${newFormat}`
}
})
// Methods
const closeModal = () => {
if (!isExporting.value) {
resetForm()
emit('close')
}
}
const resetForm = () => {
selectedFormat.value = 'png'
selectedQuality.value = '2'
includeBackground.value = true
fileName.value = ''
isExporting.value = false
exportProgress.value = ''
pdfPageSize.value = 'a4'
pdfOrientation.value = 'landscape'
}
// Force close modal (for error recovery)
const forceClose = () => {
resetForm()
emit('close')
}
const handleExport = async () => {
if (!fileName.value.trim() || isExporting.value) return
try {
isExporting.value = true
exportProgress.value = 'Preparing export...'
// Update progress during export
setTimeout(() => {
if (isExporting.value) {
exportProgress.value = 'Capturing canvas...'
}
}, 500)
setTimeout(() => {
if (isExporting.value) {
exportProgress.value = 'Processing image...'
}
}, 2000)
// Emit export event with options
await emit('export', exportOptions.value)
// Success state - ensure we reset the loading state
exportProgress.value = 'Export completed!'
isExporting.value = false
// Close modal after successful export
setTimeout(() => {
closeModal()
}, 1000)
} catch (error) {
console.error('Export error:', error)
exportProgress.value = 'Export failed - ' + (error.message || 'Unknown error')
isExporting.value = false
setTimeout(() => {
exportProgress.value = ''
}, 3000)
}
}
// Update export progress
const updateProgress = (message) => {
exportProgress.value = message
}
// Expose methods for parent component
defineExpose({
updateProgress,
forceClose,
resetForm
})
</script>
<style scoped>
/* Custom styling for better modal appearance */
.modal-enter-active, .modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from, .modal-leave-to {
opacity: 0;
}
/* Ensure proper z-index for dropdowns */
select {
position: relative;
z-index: 10;
}
/* Enhanced button styles */
button {
transition: all 0.2s ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12);
}
/* Format selection buttons */
.format-button {
transition: all 0.2s ease;
position: relative;
overflow: hidden;
}
.format-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.format-button:hover::before {
left: 100%;
}
/* Quality select styling */
select:focus {
ring-width: 2px;
ring-color: rgb(59 130 246);
ring-opacity: 0.5;
}
/* Checkbox styling */
input[type="checkbox"]:checked {
background-color: rgb(59 130 246);
border-color: rgb(59 130 246);
}
/* Loading spinner animation */
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* Progress indicator styling */
.export-progress {
background: linear-gradient(90deg, #3b82f6, #60a5fa, #3b82f6);
background-size: 200% 100%;
animation: progressShimmer 2s ease-in-out infinite;
}
@keyframes progressShimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
/* Modal backdrop blur effect */
.fixed.inset-0 {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
/* Enhanced modal shadow */
.bg-white.rounded-lg.shadow-xl {
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
/* File input styling */
input[type="text"]:focus {
ring-width: 2px;
ring-color: rgb(59 130 246);
ring-opacity: 0.5;
border-color: rgb(59 130 246);
}
/* Responsive adjustments */
@media (max-width: 640px) {
.bg-white.rounded-lg.shadow-xl {
margin: 1rem;
max-width: calc(100vw - 2rem);
}
.grid.grid-cols-2 {
grid-template-columns: 1fr;
gap: 0.75rem;
}
}
</style>