EDMS/components/dms/preview/DocumentPreviewDialog.vue
2025-06-05 14:57:08 +08:00

941 lines
26 KiB
Vue

<template>
<div
v-if="visible"
class="fixed inset-0 z-50 bg-black bg-opacity-90 flex items-center justify-center"
@keydown.esc="closePreview"
tabindex="-1"
role="dialog"
aria-modal="true"
:aria-label="`Preview of ${document?.name}`"
>
<!-- Loading State -->
<div v-if="isLoading" class="flex flex-col items-center text-white">
<Icon name="mdi:loading" class="w-12 h-12 animate-spin mb-4" />
<p class="text-lg">Loading document...</p>
<div class="w-64 bg-gray-700 rounded-full h-2 mt-4">
<div
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
:style="{ width: `${loadingProgress}%` }"
></div>
</div>
</div>
<!-- Error State -->
<div v-else-if="hasError" class="text-center text-white max-w-md">
<Icon name="mdi:alert-circle" class="w-16 h-16 mx-auto mb-4 text-red-400" />
<h3 class="text-xl font-semibold mb-2">Unable to Preview Document</h3>
<p class="text-gray-300 mb-6">{{ errorMessage }}</p>
<div class="flex gap-3 justify-center">
<button
@click="downloadDocument"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2"
>
<Icon name="mdi:download" class="w-4 h-4" />
Download Instead
</button>
<button
@click="closePreview"
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 rounded-lg"
>
Close
</button>
</div>
</div>
<!-- Main Preview Content -->
<div v-else class="w-full h-full flex flex-col relative">
<!-- Header Bar -->
<div class="bg-gray-900 text-white p-4 flex items-center justify-between border-b border-gray-700">
<div class="flex items-center space-x-4">
<Icon
:name="getFileTypeIcon(document.name)"
:class="['w-6 h-6', getFileTypeColor(document.name)]"
/>
<div>
<h2 class="text-lg font-semibold truncate max-w-md">{{ document.name }}</h2>
<p class="text-sm text-gray-400">
{{ formatFileSize(document.size) }} Modified {{ formatDate(document.lastModified) }}
</p>
</div>
</div>
<div class="flex items-center space-x-2">
<!-- Mode Toggle -->
<div class="inline-flex rounded-lg border border-gray-600 p-1 bg-gray-800">
<button
@click="previewMode = 'view'"
:class="[
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
previewMode === 'view'
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:text-white hover:bg-gray-700'
]"
>
<Icon name="mdi:eye" class="w-4 h-4 mr-1" />
View
</button>
<button
v-if="canEdit"
@click="previewMode = 'edit'"
:class="[
'px-3 py-1.5 text-sm font-medium rounded-md transition-all',
previewMode === 'edit'
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:text-white hover:bg-gray-700'
]"
>
<Icon name="mdi:pencil" class="w-4 h-4 mr-1" />
Edit
</button>
</div>
<!-- Close Button -->
<button
@click="closePreview"
class="p-2 hover:bg-gray-800 rounded-lg transition-colors"
aria-label="Close preview"
>
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 relative overflow-hidden">
<!-- PDF Viewer -->
<PDFViewer
v-if="fileType === 'pdf'"
:document="document"
:mode="previewMode"
:annotations="annotations"
@annotation-added="handleAnnotationAdded"
@annotation-updated="handleAnnotationUpdated"
@annotation-deleted="handleAnnotationDeleted"
@page-changed="handlePageChanged"
class="w-full h-full"
/>
<!-- Document Viewer (DOCX, DOC) -->
<DocumentViewer
v-else-if="['docx', 'doc'].includes(fileType)"
:document="document"
:mode="previewMode"
:content="documentContent"
@content-changed="handleContentChanged"
@save-requested="handleSaveDocument"
class="w-full h-full"
/>
<!-- Spreadsheet Viewer (XLSX, XLS) -->
<SpreadsheetViewer
v-else-if="['xlsx', 'xls'].includes(fileType)"
:document="document"
:mode="previewMode"
:data="spreadsheetData"
@data-changed="handleDataChanged"
@save-requested="handleSaveDocument"
class="w-full h-full"
/>
<!-- Image Viewer -->
<ImageViewer
v-else-if="['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(fileType)"
:document="document"
:zoom="imageZoom"
@zoom-changed="imageZoom = $event"
class="w-full h-full"
/>
<!-- Text Viewer -->
<TextViewer
v-else-if="['txt', 'md', 'json', 'xml', 'csv'].includes(fileType)"
:document="document"
:mode="previewMode"
:content="textContent"
@content-changed="handleContentChanged"
@save-requested="handleSaveDocument"
class="w-full h-full"
/>
<!-- Unsupported File Type -->
<div v-else class="flex items-center justify-center h-full text-white">
<div class="text-center">
<Icon name="mdi:file-question" class="w-24 h-24 mx-auto mb-4 text-gray-400" />
<h3 class="text-xl font-semibold mb-2">Preview Not Available</h3>
<p class="text-gray-400 mb-6">
This file type ({{ fileType?.toUpperCase() }}) cannot be previewed.
</p>
<button
@click="downloadDocument"
class="px-6 py-3 bg-blue-600 hover:bg-blue-700 rounded-lg flex items-center gap-2 mx-auto"
>
<Icon name="mdi:download" class="w-5 h-5" />
Download File
</button>
</div>
</div>
<!-- Floating Toolbar -->
<div
v-if="!isLoading && !hasError && fileType"
class="absolute bottom-6 left-1/2 transform -translate-x-1/2 bg-gray-800 rounded-full px-4 py-2 shadow-lg border border-gray-600 flex items-center space-x-1"
>
<!-- Zoom Controls (for applicable file types) -->
<template v-if="['pdf', 'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(fileType)">
<button
@click="zoomOut"
:disabled="currentZoom <= 25"
class="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
title="Zoom Out"
>
<Icon name="mdi:magnify-minus" class="w-4 h-4 text-white" />
</button>
<div class="px-3 py-1 text-sm text-white min-w-16 text-center">
{{ currentZoom }}%
</div>
<button
@click="zoomIn"
:disabled="currentZoom >= 300"
class="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
title="Zoom In"
>
<Icon name="mdi:magnify-plus" class="w-4 h-4 text-white" />
</button>
<div class="w-px h-6 bg-gray-600 mx-2"></div>
</template>
<!-- Page Navigation (for PDFs) -->
<template v-if="fileType === 'pdf' && totalPages > 1">
<button
@click="previousPage"
:disabled="currentPage <= 1"
class="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
title="Previous Page"
>
<Icon name="mdi:chevron-left" class="w-4 h-4 text-white" />
</button>
<div class="px-3 py-1 text-sm text-white">
{{ currentPage }} / {{ totalPages }}
</div>
<button
@click="nextPage"
:disabled="currentPage >= totalPages"
class="p-2 hover:bg-gray-700 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
title="Next Page"
>
<Icon name="mdi:chevron-right" class="w-4 h-4 text-white" />
</button>
<div class="w-px h-6 bg-gray-600 mx-2"></div>
</template>
<!-- Annotation Tools (for PDFs) -->
<template v-if="fileType === 'pdf'">
<button
@click="toggleAnnotationMode('highlight')"
:class="[
'p-2 rounded-lg transition-colors',
annotationMode === 'highlight'
? 'bg-yellow-600 text-white'
: 'hover:bg-gray-700 text-white'
]"
title="Highlight Text"
>
<Icon name="mdi:marker" class="w-4 h-4" />
</button>
<button
@click="toggleAnnotationMode('comment')"
:class="[
'p-2 rounded-lg transition-colors',
annotationMode === 'comment'
? 'bg-blue-600 text-white'
: 'hover:bg-gray-700 text-white'
]"
title="Add Comment"
>
<Icon name="mdi:comment" class="w-4 h-4" />
</button>
<button
@click="toggleAnnotationMode('draw')"
:class="[
'p-2 rounded-lg transition-colors',
annotationMode === 'draw'
? 'bg-green-600 text-white'
: 'hover:bg-gray-700 text-white'
]"
title="Draw"
>
<Icon name="mdi:draw" class="w-4 h-4" />
</button>
<div class="w-px h-6 bg-gray-600 mx-2"></div>
</template>
<!-- Main Actions -->
<button
@click="toggleFullscreen"
class="p-2 hover:bg-gray-700 rounded-lg"
:title="isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'"
>
<Icon
:name="isFullscreen ? 'mdi:fullscreen-exit' : 'mdi:fullscreen'"
class="w-4 h-4 text-white"
/>
</button>
<button
@click="printDocument"
class="p-2 hover:bg-gray-700 rounded-lg"
title="Print"
>
<Icon name="mdi:printer" class="w-4 h-4 text-white" />
</button>
<button
@click="downloadDocument"
class="p-2 hover:bg-gray-700 rounded-lg"
title="Download"
>
<Icon name="mdi:download" class="w-4 h-4 text-white" />
</button>
<button
v-if="canShare"
@click="shareDocument"
class="p-2 hover:bg-gray-700 rounded-lg"
title="Share"
>
<Icon name="mdi:share" class="w-4 h-4 text-white" />
</button>
<button
@click="showDocumentInfo = true"
class="p-2 hover:bg-gray-700 rounded-lg"
title="Document Info"
>
<Icon name="mdi:information" class="w-4 h-4 text-white" />
</button>
</div>
<!-- Save Indicator -->
<div
v-if="isSaving"
class="absolute top-4 right-4 bg-blue-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center space-x-2"
>
<Icon name="mdi:loading" class="w-4 h-4 animate-spin" />
<span>Saving...</span>
</div>
<!-- Auto-save Indicator -->
<div
v-if="showAutoSaveIndicator"
class="absolute top-4 right-4 bg-green-600 text-white px-4 py-2 rounded-lg shadow-lg flex items-center space-x-2 transition-opacity duration-500"
:class="{ 'opacity-0': !showAutoSaveIndicator }"
>
<Icon name="mdi:check" class="w-4 h-4" />
<span>Auto-saved</span>
</div>
</div>
</div>
<!-- Document Info Modal -->
<DocumentInfoModal
v-model:visible="showDocumentInfo"
:document="document"
:annotations="annotations"
@tag-added="handleTagAdded"
@tag-removed="handleTagRemoved"
/>
<!-- Share Modal -->
<ShareDocumentModal
v-model:visible="showShareModal"
:document="document"
@shared="handleDocumentShared"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
// Component imports
import PDFViewer from './PDFViewer.vue';
import DocumentViewer from './DocumentViewer.vue';
import SpreadsheetViewer from './SpreadsheetViewer.vue';
import ImageViewer from './ImageViewer.vue';
import TextViewer from './TextViewer.vue';
import DocumentInfoModal from './DocumentInfoModal.vue';
import ShareDocumentModal from './ShareDocumentModal.vue';
// Props
const props = defineProps({
visible: {
type: Boolean,
default: false
},
document: {
type: Object,
default: null
},
initialMode: {
type: String,
default: 'view' // 'view' | 'edit'
}
});
// Emits
const emit = defineEmits([
'close',
'document-updated',
'annotation-added',
'annotation-updated',
'annotation-deleted',
'document-shared',
'tag-added',
'tag-removed'
]);
// State
const isLoading = ref(false);
const hasError = ref(false);
const errorMessage = ref('');
const loadingProgress = ref(0);
const previewMode = ref(props.initialMode);
const currentPage = ref(1);
const totalPages = ref(1);
const currentZoom = ref(100);
const imageZoom = ref(100);
const isFullscreen = ref(false);
const annotationMode = ref(null); // 'highlight' | 'comment' | 'draw' | null
const isSaving = ref(false);
const showAutoSaveIndicator = ref(false);
const showDocumentInfo = ref(false);
const showShareModal = ref(false);
// Content state
const documentContent = ref('');
const spreadsheetData = ref([]);
const textContent = ref('');
const annotations = ref([]);
// Computed
const fileType = computed(() => {
if (!props.document?.name) return null;
return props.document.name.split('.').pop()?.toLowerCase();
});
const canEdit = computed(() => {
// Check user permissions and file type
const editableTypes = ['docx', 'doc', 'xlsx', 'xls', 'txt', 'md', 'csv'];
return editableTypes.includes(fileType.value) && hasEditPermission();
});
const canShare = computed(() => {
return hasSharePermission();
});
// Methods
const closePreview = () => {
if (previewMode.value === 'edit' && hasUnsavedChanges()) {
if (confirm('You have unsaved changes. Are you sure you want to close?')) {
emit('close');
}
} else {
emit('close');
}
};
const loadDocument = async () => {
if (!props.document) return;
isLoading.value = true;
hasError.value = false;
loadingProgress.value = 0;
try {
// Simulate loading progress
const progressInterval = setInterval(() => {
loadingProgress.value = Math.min(loadingProgress.value + 10, 90);
}, 100);
// Load document content based on type
switch (fileType.value) {
case 'pdf':
await loadPDFDocument();
break;
case 'docx':
case 'doc':
await loadWordDocument();
break;
case 'xlsx':
case 'xls':
await loadExcelDocument();
break;
case 'txt':
case 'md':
case 'csv':
await loadTextDocument();
break;
default:
// For unsupported types, just show the preview with download option
break;
}
clearInterval(progressInterval);
loadingProgress.value = 100;
// Small delay to show 100% before hiding
setTimeout(() => {
isLoading.value = false;
}, 200);
} catch (error) {
console.error('Failed to load document:', error);
hasError.value = true;
errorMessage.value = error.message || 'Failed to load document';
isLoading.value = false;
}
};
const loadPDFDocument = async () => {
// Mock PDF loading - in real implementation, use PDF.js
await new Promise(resolve => setTimeout(resolve, 1000));
totalPages.value = Math.floor(Math.random() * 20) + 1;
currentPage.value = 1;
// Load existing annotations
annotations.value = await fetchDocumentAnnotations(props.document.id);
};
const loadWordDocument = async () => {
// Mock Word document loading - in real implementation, use Office.js or similar
await new Promise(resolve => setTimeout(resolve, 800));
documentContent.value = `<h1>${props.document.name}</h1><p>Sample document content...</p>`;
};
const loadExcelDocument = async () => {
// Mock Excel loading - in real implementation, use appropriate library
await new Promise(resolve => setTimeout(resolve, 600));
spreadsheetData.value = [
['Name', 'Age', 'City'],
['John Doe', 30, 'New York'],
['Jane Smith', 25, 'London']
];
};
const loadTextDocument = async () => {
// Mock text loading
await new Promise(resolve => setTimeout(resolve, 400));
textContent.value = `This is the content of ${props.document.name}`;
};
const fetchDocumentAnnotations = async (documentId) => {
// Mock annotation fetching
return [
{
id: '1',
type: 'highlight',
page: 1,
text: 'Important section',
color: 'yellow',
author: 'Current User',
timestamp: new Date().toISOString()
}
];
};
// File type detection
const getFileTypeIcon = (fileName) => {
if (!fileName) return 'mdi:file-document';
const extension = fileName.split('.').pop()?.toLowerCase();
const iconMap = {
pdf: 'mdi:file-pdf-box',
doc: 'mdi:file-word-box',
docx: 'mdi:file-word-box',
xls: 'mdi:file-excel-box',
xlsx: 'mdi:file-excel-box',
ppt: 'mdi:file-powerpoint-box',
pptx: 'mdi:file-powerpoint-box',
txt: 'mdi:file-document-outline',
md: 'mdi:language-markdown',
jpg: 'mdi:file-image',
jpeg: 'mdi:file-image',
png: 'mdi:file-image',
gif: 'mdi:file-image',
default: 'mdi:file-document'
};
return iconMap[extension] || iconMap.default;
};
const getFileTypeColor = (fileName) => {
if (!fileName) return 'text-gray-400';
const extension = fileName.split('.').pop()?.toLowerCase();
const colorMap = {
pdf: 'text-red-400',
doc: 'text-blue-400',
docx: 'text-blue-400',
xls: 'text-green-400',
xlsx: 'text-green-400',
ppt: 'text-orange-400',
pptx: 'text-orange-400',
txt: 'text-gray-400',
md: 'text-purple-400',
jpg: 'text-purple-400',
jpeg: 'text-purple-400',
png: 'text-purple-400',
gif: 'text-purple-400',
default: 'text-gray-400'
};
return colorMap[extension] || colorMap.default;
};
// Utility functions
const formatFileSize = (size) => {
if (typeof size === 'string') return size;
if (!size) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let index = 0;
while (size >= 1024 && index < units.length - 1) {
size /= 1024;
index++;
}
return `${size.toFixed(1)} ${units[index]}`;
};
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
// Zoom controls
const zoomIn = () => {
currentZoom.value = Math.min(currentZoom.value + 25, 300);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(fileType.value)) {
imageZoom.value = currentZoom.value;
}
};
const zoomOut = () => {
currentZoom.value = Math.max(currentZoom.value - 25, 25);
if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(fileType.value)) {
imageZoom.value = currentZoom.value;
}
};
// Page navigation
const nextPage = () => {
if (currentPage.value < totalPages.value) {
currentPage.value++;
}
};
const previousPage = () => {
if (currentPage.value > 1) {
currentPage.value--;
}
};
// Annotation tools
const toggleAnnotationMode = (mode) => {
annotationMode.value = annotationMode.value === mode ? null : mode;
};
// Fullscreen
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
isFullscreen.value = true;
} else {
document.exitFullscreen();
isFullscreen.value = false;
}
};
// Actions
const printDocument = () => {
window.print();
};
const downloadDocument = () => {
// Create download link
const link = document.createElement('a');
link.href = `/api/documents/${props.document.id}/download`;
link.download = props.document.name;
link.click();
};
const shareDocument = () => {
showShareModal.value = true;
};
// Permission checks
const hasEditPermission = () => {
// Mock permission check
return props.document?.permissions?.includes('edit') ?? true;
};
const hasSharePermission = () => {
// Mock permission check
return props.document?.permissions?.includes('share') ?? true;
};
const hasUnsavedChanges = () => {
// Check if there are unsaved changes
return false; // Implement based on content state
};
// Auto-save functionality
let autoSaveTimeout = null;
const autoSave = () => {
if (previewMode.value === 'edit') {
clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(async () => {
await saveDocument(true);
showAutoSaveIndicator.value = true;
setTimeout(() => {
showAutoSaveIndicator.value = false;
}, 2000);
}, 2000);
}
};
const saveDocument = async (isAutoSave = false) => {
if (!isAutoSave) {
isSaving.value = true;
}
try {
// Save document content based on type
const payload = {
id: props.document.id,
content: getDocumentContent(),
annotations: annotations.value
};
// Mock API call
await new Promise(resolve => setTimeout(resolve, 1000));
emit('document-updated', payload);
} catch (error) {
console.error('Failed to save document:', error);
// Show error notification
} finally {
if (!isAutoSave) {
isSaving.value = false;
}
}
};
const getDocumentContent = () => {
switch (fileType.value) {
case 'docx':
case 'doc':
return documentContent.value;
case 'xlsx':
case 'xls':
return spreadsheetData.value;
case 'txt':
case 'md':
case 'csv':
return textContent.value;
default:
return null;
}
};
// Event handlers
const handlePageChanged = (page) => {
currentPage.value = page;
};
const handleContentChanged = (content) => {
if (fileType.value === 'docx' || fileType.value === 'doc') {
documentContent.value = content;
} else {
textContent.value = content;
}
autoSave();
};
const handleDataChanged = (data) => {
spreadsheetData.value = data;
autoSave();
};
const handleSaveDocument = () => {
saveDocument();
};
const handleAnnotationAdded = (annotation) => {
annotations.value.push(annotation);
emit('annotation-added', annotation);
autoSave();
};
const handleAnnotationUpdated = (annotation) => {
const index = annotations.value.findIndex(a => a.id === annotation.id);
if (index !== -1) {
annotations.value[index] = annotation;
emit('annotation-updated', annotation);
autoSave();
}
};
const handleAnnotationDeleted = (annotationId) => {
annotations.value = annotations.value.filter(a => a.id !== annotationId);
emit('annotation-deleted', annotationId);
autoSave();
};
const handleDocumentShared = (shareData) => {
emit('document-shared', shareData);
showShareModal.value = false;
};
const handleTagAdded = (tag) => {
emit('tag-added', tag);
};
const handleTagRemoved = (tag) => {
emit('tag-removed', tag);
};
// Keyboard shortcuts
const handleKeydown = (event) => {
if (event.key === 'Escape') {
closePreview();
} else if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 's':
event.preventDefault();
if (previewMode.value === 'edit') {
saveDocument();
}
break;
case 'p':
event.preventDefault();
printDocument();
break;
case 'd':
event.preventDefault();
downloadDocument();
break;
case '+':
case '=':
event.preventDefault();
zoomIn();
break;
case '-':
event.preventDefault();
zoomOut();
break;
}
} else if (fileType.value === 'pdf') {
switch (event.key) {
case 'ArrowLeft':
event.preventDefault();
previousPage();
break;
case 'ArrowRight':
event.preventDefault();
nextPage();
break;
}
}
};
// Watchers
watch(() => props.visible, (newValue) => {
if (newValue && props.document) {
loadDocument();
currentZoom.value = 100;
imageZoom.value = 100;
currentPage.value = 1;
previewMode.value = props.initialMode;
annotationMode.value = null;
}
});
watch(() => props.document, (newDoc) => {
if (newDoc && props.visible) {
loadDocument();
}
});
// Lifecycle
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
// Listen for fullscreen changes
document.addEventListener('fullscreenchange', () => {
isFullscreen.value = !!document.fullscreenElement;
});
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
clearTimeout(autoSaveTimeout);
});
</script>
<style scoped>
/* Custom scrollbars for preview content */
:deep(.preview-content) {
scrollbar-width: thin;
scrollbar-color: #4B5563 #1F2937;
}
:deep(.preview-content::-webkit-scrollbar) {
width: 8px;
height: 8px;
}
:deep(.preview-content::-webkit-scrollbar-track) {
background: #1F2937;
}
:deep(.preview-content::-webkit-scrollbar-thumb) {
background: #4B5563;
border-radius: 4px;
}
:deep(.preview-content::-webkit-scrollbar-thumb:hover) {
background: #6B7280;
}
/* Prevent text selection on toolbar */
.floating-toolbar {
user-select: none;
}
/* Smooth transitions */
.transition-opacity {
transition-property: opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 500ms;
}
/* Loading animation */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
</style>