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

836 lines
22 KiB
Vue

<template>
<div class="pdf-viewer w-full h-full bg-gray-100 dark:bg-gray-900 relative overflow-hidden">
<!-- PDF Canvas Container -->
<div
ref="canvasContainer"
class="pdf-canvas-container w-full h-full overflow-auto scroll-smooth"
@wheel="handleWheel"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
>
<div class="flex flex-col items-center py-8 space-y-4">
<!-- Page Canvas -->
<div
v-for="pageNum in totalPages"
:key="pageNum"
:ref="el => setPageRef(el, pageNum)"
class="page-container bg-white shadow-lg relative"
:style="{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top center'
}"
>
<canvas
:id="`pdf-page-${pageNum}`"
class="pdf-page border border-gray-300"
@contextmenu.prevent
></canvas>
<!-- Annotation Layer -->
<div
class="annotation-layer absolute inset-0 pointer-events-none"
:style="{ transform: `scale(${zoom / 100})` }"
>
<!-- Highlights -->
<div
v-for="highlight in getPageAnnotations(pageNum, 'highlight')"
:key="highlight.id"
class="absolute pointer-events-auto cursor-pointer"
:style="{
left: highlight.x + 'px',
top: highlight.y + 'px',
width: highlight.width + 'px',
height: highlight.height + 'px',
backgroundColor: highlight.color + '40',
border: `2px solid ${highlight.color}`
}"
@click="editAnnotation(highlight)"
@contextmenu.prevent="deleteAnnotation(highlight.id)"
:title="`${highlight.text} - ${highlight.author}`"
></div>
<!-- Comments -->
<div
v-for="comment in getPageAnnotations(pageNum, 'comment')"
:key="comment.id"
class="absolute pointer-events-auto"
:style="{
left: comment.x + 'px',
top: comment.y + 'px'
}"
>
<button
@click="showCommentDialog(comment)"
class="w-6 h-6 bg-blue-500 text-white rounded-full flex items-center justify-center text-xs font-bold shadow-lg hover:bg-blue-600 transition-colors"
:title="comment.text"
>
<Icon name="mdi:comment" class="w-3 h-3" />
</button>
</div>
<!-- Drawings -->
<svg
v-for="drawing in getPageAnnotations(pageNum, 'draw')"
:key="drawing.id"
class="absolute pointer-events-auto"
:style="{
left: '0px',
top: '0px',
width: '100%',
height: '100%'
}"
@contextmenu.prevent="deleteAnnotation(drawing.id)"
>
<path
:d="drawing.path"
:stroke="drawing.color"
:stroke-width="drawing.strokeWidth || 2"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
<!-- Page Number -->
<div class="absolute bottom-2 right-2 bg-black bg-opacity-50 text-white px-2 py-1 rounded text-xs">
{{ pageNum }}
</div>
</div>
</div>
</div>
<!-- Drawing Canvas Overlay (for annotation mode) -->
<canvas
v-if="annotationMode === 'draw'"
ref="drawingCanvas"
class="absolute inset-0 cursor-crosshair pointer-events-auto"
@mousedown="startDrawing"
@mousemove="draw"
@mouseup="endDrawing"
@mouseleave="endDrawing"
></canvas>
<!-- Loading Overlay -->
<div
v-if="isLoading"
class="absolute inset-0 bg-white bg-opacity-90 flex items-center justify-center z-10"
>
<div class="text-center">
<Icon name="mdi:loading" class="w-8 h-8 animate-spin text-blue-600 mx-auto mb-2" />
<p class="text-gray-600">Loading PDF...</p>
<div class="w-48 bg-gray-200 rounded-full h-2 mt-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="{ width: `${loadingProgress}%` }"
></div>
</div>
<p class="text-xs text-gray-500 mt-1">Page {{ currentLoadingPage }} of {{ totalPages }}</p>
</div>
</div>
<!-- Comment Dialog -->
<div
v-if="showingComment"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
@click="closeCommentDialog"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
@click.stop
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Comment</h3>
<button
@click="closeCommentDialog"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<Icon name="mdi:close" class="w-5 h-5" />
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Comment Text
</label>
<textarea
v-model="commentText"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md
bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100
focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
rows="3"
placeholder="Enter your comment..."
></textarea>
</div>
<div class="flex justify-end space-x-2">
<button
@click="closeCommentDialog"
class="px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
Cancel
</button>
<button
@click="saveComment"
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{{ editingComment ? 'Update' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
<!-- Page Navigation Thumbnail Strip -->
<div
v-if="totalPages > 1 && showThumbnails"
class="absolute left-4 top-1/2 transform -translate-y-1/2 bg-white dark:bg-gray-800 rounded-lg shadow-lg p-2 max-h-80 overflow-y-auto"
>
<div class="space-y-2">
<div
v-for="pageNum in totalPages"
:key="`thumb-${pageNum}`"
@click="scrollToPage(pageNum)"
:class="[
'thumbnail-page cursor-pointer border-2 transition-all',
currentVisiblePage === pageNum
? 'border-blue-500 shadow-md'
: 'border-gray-300 dark:border-gray-600 hover:border-blue-300'
]"
>
<canvas
:id="`pdf-thumbnail-${pageNum}`"
class="thumbnail-canvas"
:width="60"
:height="80"
></canvas>
<div class="text-xs text-center py-1">{{ pageNum }}</div>
</div>
</div>
</div>
<!-- Zoom Level Indicator -->
<div
v-if="showZoomIndicator"
class="absolute top-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-70 text-white px-3 py-1 rounded-full text-sm"
>
{{ Math.round(zoom) }}%
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
// PDF.js imports (in real implementation)
// import * as pdfjsLib from 'pdfjs-dist';
const props = defineProps({
document: {
type: Object,
required: true
},
mode: {
type: String,
default: 'view' // 'view' | 'edit'
},
annotations: {
type: Array,
default: () => []
},
zoom: {
type: Number,
default: 100
},
annotationMode: {
type: String,
default: null // 'highlight' | 'comment' | 'draw' | null
}
});
const emit = defineEmits([
'annotation-added',
'annotation-updated',
'annotation-deleted',
'page-changed',
'zoom-changed'
]);
// State
const isLoading = ref(true);
const loadingProgress = ref(0);
const currentLoadingPage = ref(0);
const totalPages = ref(1);
const currentVisiblePage = ref(1);
const zoom = ref(props.zoom);
const showThumbnails = ref(false);
const showZoomIndicator = ref(false);
// PDF.js state
const pdfDocument = ref(null);
const pageRefs = ref(new Map());
const renderedPages = ref(new Set());
// Annotation state
const isAnnotating = ref(false);
const currentAnnotation = ref(null);
const showingComment = ref(false);
const commentText = ref('');
const editingComment = ref(null);
// Drawing state
const isDrawing = ref(false);
const drawingPath = ref('');
const drawingStartPoint = ref(null);
// Container refs
const canvasContainer = ref(null);
const drawingCanvas = ref(null);
// Computed
const annotationMode = computed(() => props.annotationMode);
// Methods
const setPageRef = (el, pageNum) => {
if (el) {
pageRefs.value.set(pageNum, el);
}
};
const loadPDF = async () => {
isLoading.value = true;
loadingProgress.value = 0;
try {
// Mock PDF loading - in real implementation, use PDF.js
// const loadingTask = pdfjsLib.getDocument(props.document.url);
// pdfDocument.value = await loadingTask.promise;
// Mock implementation
await simulatePDFLoading();
// Render initial pages
await renderVisiblePages();
await generateThumbnails();
} catch (error) {
console.error('Failed to load PDF:', error);
} finally {
isLoading.value = false;
}
};
const simulatePDFLoading = async () => {
// Simulate PDF loading with progress
totalPages.value = Math.floor(Math.random() * 10) + 5; // 5-15 pages
for (let i = 1; i <= totalPages.value; i++) {
currentLoadingPage.value = i;
loadingProgress.value = (i / totalPages.value) * 100;
await new Promise(resolve => setTimeout(resolve, 200));
}
};
const renderVisiblePages = async () => {
// Get visible pages based on scroll position
const container = canvasContainer.value;
if (!container) return;
const containerRect = container.getBoundingClientRect();
const containerTop = container.scrollTop;
const containerBottom = containerTop + containerRect.height;
// Find visible pages
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
const pageElement = pageRefs.value.get(pageNum);
if (!pageElement) continue;
const pageRect = pageElement.getBoundingClientRect();
const pageTop = pageElement.offsetTop;
const pageBottom = pageTop + pageRect.height;
// Check if page is visible with some buffer
const buffer = 200;
if (pageBottom >= containerTop - buffer && pageTop <= containerBottom + buffer) {
if (!renderedPages.value.has(pageNum)) {
await renderPage(pageNum);
renderedPages.value.add(pageNum);
}
}
}
// Update current visible page
updateCurrentVisiblePage();
};
const renderPage = async (pageNum) => {
const canvas = document.getElementById(`pdf-page-${pageNum}`);
if (!canvas) return;
const ctx = canvas.getContext('2d');
// Mock rendering - in real implementation, use PDF.js page.render()
canvas.width = 600;
canvas.height = 800;
// Fill with white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add some mock content
ctx.fillStyle = '#333333';
ctx.font = '16px Arial';
ctx.fillText(`Page ${pageNum} Content`, 50, 50);
// Add some mock text lines
for (let i = 0; i < 20; i++) {
ctx.fillText(`This is line ${i + 1} of page ${pageNum}`, 50, 100 + i * 25);
}
};
const generateThumbnails = async () => {
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
const canvas = document.getElementById(`pdf-thumbnail-${pageNum}`);
if (!canvas) continue;
const ctx = canvas.getContext('2d');
// Mock thumbnail generation
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, 60, 80);
ctx.fillStyle = '#cccccc';
ctx.fillRect(5, 5, 50, 70);
ctx.fillStyle = '#333333';
ctx.font = '8px Arial';
ctx.fillText(`P${pageNum}`, 25, 40);
}
};
const updateCurrentVisiblePage = () => {
const container = canvasContainer.value;
if (!container) return;
const containerTop = container.scrollTop;
const containerCenter = containerTop + container.clientHeight / 2;
let closestPage = 1;
let closestDistance = Infinity;
for (let pageNum = 1; pageNum <= totalPages.value; pageNum++) {
const pageElement = pageRefs.value.get(pageNum);
if (!pageElement) continue;
const pageTop = pageElement.offsetTop;
const pageCenter = pageTop + pageElement.clientHeight / 2;
const distance = Math.abs(pageCenter - containerCenter);
if (distance < closestDistance) {
closestDistance = distance;
closestPage = pageNum;
}
}
if (closestPage !== currentVisiblePage.value) {
currentVisiblePage.value = closestPage;
emit('page-changed', closestPage);
}
};
const scrollToPage = (pageNum) => {
const pageElement = pageRefs.value.get(pageNum);
if (!pageElement || !canvasContainer.value) return;
const container = canvasContainer.value;
const pageTop = pageElement.offsetTop;
const containerHeight = container.clientHeight;
const pageHeight = pageElement.clientHeight;
// Center the page in the viewport
const scrollTop = pageTop - (containerHeight - pageHeight) / 2;
container.scrollTo({
top: Math.max(0, scrollTop),
behavior: 'smooth'
});
};
// Annotation methods
const getPageAnnotations = (pageNum, type) => {
return props.annotations.filter(annotation =>
annotation.page === pageNum && annotation.type === type
);
};
const handleMouseDown = (event) => {
if (!annotationMode.value) return;
const rect = event.target.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (annotationMode.value === 'highlight') {
startHighlight(x, y, event.target);
} else if (annotationMode.value === 'comment') {
addComment(x, y, event.target);
}
};
const handleMouseMove = (event) => {
if (!isAnnotating.value || annotationMode.value !== 'highlight') return;
const rect = event.target.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
updateHighlight(x, y);
};
const handleMouseUp = () => {
if (isAnnotating.value && annotationMode.value === 'highlight') {
finishHighlight();
}
};
const startHighlight = (x, y, canvas) => {
const pageNum = getPageNumberFromCanvas(canvas);
if (!pageNum) return;
isAnnotating.value = true;
currentAnnotation.value = {
type: 'highlight',
page: pageNum,
x: x,
y: y,
width: 0,
height: 20,
color: '#ffff00',
text: 'Highlighted text',
author: 'Current User',
timestamp: new Date().toISOString()
};
};
const updateHighlight = (x, y) => {
if (!currentAnnotation.value) return;
currentAnnotation.value.width = x - currentAnnotation.value.x;
currentAnnotation.value.height = Math.max(20, y - currentAnnotation.value.y);
};
const finishHighlight = () => {
if (!currentAnnotation.value) return;
const annotation = {
...currentAnnotation.value,
id: Date.now().toString()
};
emit('annotation-added', annotation);
isAnnotating.value = false;
currentAnnotation.value = null;
};
const addComment = (x, y, canvas) => {
const pageNum = getPageNumberFromCanvas(canvas);
if (!pageNum) return;
currentAnnotation.value = {
type: 'comment',
page: pageNum,
x: x,
y: y,
text: '',
author: 'Current User',
timestamp: new Date().toISOString()
};
commentText.value = '';
editingComment.value = null;
showingComment.value = true;
};
const showCommentDialog = (comment) => {
editingComment.value = comment;
commentText.value = comment.text;
showingComment.value = true;
};
const closeCommentDialog = () => {
showingComment.value = false;
commentText.value = '';
editingComment.value = null;
currentAnnotation.value = null;
};
const saveComment = () => {
if (editingComment.value) {
// Update existing comment
const updatedComment = {
...editingComment.value,
text: commentText.value,
timestamp: new Date().toISOString()
};
emit('annotation-updated', updatedComment);
} else if (currentAnnotation.value) {
// Create new comment
const annotation = {
...currentAnnotation.value,
id: Date.now().toString(),
text: commentText.value
};
emit('annotation-added', annotation);
}
closeCommentDialog();
};
const editAnnotation = (annotation) => {
if (annotation.type === 'comment') {
showCommentDialog(annotation);
}
};
const deleteAnnotation = (annotationId) => {
if (confirm('Delete this annotation?')) {
emit('annotation-deleted', annotationId);
}
};
// Drawing methods
const startDrawing = (event) => {
if (annotationMode.value !== 'draw') return;
isDrawing.value = true;
const rect = drawingCanvas.value.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
drawingStartPoint.value = { x, y };
drawingPath.value = `M ${x} ${y}`;
};
const draw = (event) => {
if (!isDrawing.value || annotationMode.value !== 'draw') return;
const rect = drawingCanvas.value.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
drawingPath.value += ` L ${x} ${y}`;
// Draw on canvas for immediate feedback
const ctx = drawingCanvas.value.getContext('2d');
ctx.lineTo(x, y);
ctx.stroke();
};
const endDrawing = () => {
if (!isDrawing.value) return;
isDrawing.value = false;
// Create annotation from drawing
const annotation = {
id: Date.now().toString(),
type: 'draw',
page: currentVisiblePage.value,
path: drawingPath.value,
color: '#ff0000',
strokeWidth: 2,
author: 'Current User',
timestamp: new Date().toISOString()
};
emit('annotation-added', annotation);
// Clear drawing canvas
const ctx = drawingCanvas.value.getContext('2d');
ctx.clearRect(0, 0, drawingCanvas.value.width, drawingCanvas.value.height);
drawingPath.value = '';
drawingStartPoint.value = null;
};
// Utility methods
const getPageNumberFromCanvas = (canvas) => {
const id = canvas.id;
const match = id.match(/pdf-page-(\d+)/);
return match ? parseInt(match[1]) : null;
};
const handleWheel = (event) => {
if (event.ctrlKey || event.metaKey) {
// Zoom with Ctrl + wheel
event.preventDefault();
const delta = event.deltaY > 0 ? -10 : 10;
const newZoom = Math.max(25, Math.min(300, zoom.value + delta));
zoom.value = newZoom;
emit('zoom-changed', newZoom);
// Show zoom indicator
showZoomIndicator.value = true;
setTimeout(() => {
showZoomIndicator.value = false;
}, 1000);
} else {
// Normal scroll - update visible pages
nextTick(() => {
renderVisiblePages();
});
}
};
// Watchers
watch(() => props.zoom, (newZoom) => {
zoom.value = newZoom;
});
watch(() => props.document, () => {
if (props.document) {
loadPDF();
}
});
watch(annotationMode, (newMode) => {
if (newMode === 'draw' && drawingCanvas.value) {
// Setup drawing canvas
const container = canvasContainer.value;
const canvas = drawingCanvas.value;
canvas.width = container.scrollWidth;
canvas.height = container.scrollHeight;
const ctx = canvas.getContext('2d');
ctx.strokeStyle = '#ff0000';
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
});
// Lifecycle
onMounted(() => {
if (props.document) {
loadPDF();
}
// Setup scroll listener for lazy loading
if (canvasContainer.value) {
canvasContainer.value.addEventListener('scroll', () => {
nextTick(() => {
renderVisiblePages();
});
}, { passive: true });
}
});
onUnmounted(() => {
// Cleanup
if (canvasContainer.value) {
canvasContainer.value.removeEventListener('scroll', renderVisiblePages);
}
});
</script>
<style scoped>
.pdf-viewer {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
.pdf-canvas-container {
scroll-behavior: smooth;
}
.pdf-canvas-container::-webkit-scrollbar {
width: 12px;
height: 12px;
}
.pdf-canvas-container::-webkit-scrollbar-track {
background: #f1f1f1;
}
.pdf-canvas-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 6px;
}
.pdf-canvas-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.page-container {
transition: transform 0.3s ease;
margin-bottom: 20px;
}
.pdf-page {
display: block;
max-width: 100%;
height: auto;
}
.annotation-layer {
pointer-events: none;
}
.annotation-layer > * {
pointer-events: auto;
}
.thumbnail-page {
width: 60px;
border-radius: 4px;
overflow: hidden;
}
.thumbnail-canvas {
display: block;
width: 100%;
height: auto;
}
/* Selection styles for text selection in annotation mode */
.pdf-viewer ::selection {
background: rgba(255, 255, 0, 0.3);
}
.pdf-viewer ::-moz-selection {
background: rgba(255, 255, 0, 0.3);
}
/* Annotation cursor styles */
.annotation-mode-highlight {
cursor: text;
}
.annotation-mode-comment {
cursor: crosshair;
}
.annotation-mode-draw {
cursor: crosshair;
}
/* Smooth animations */
.page-container,
.thumbnail-page {
transition: all 0.2s ease;
}
.annotation-layer > div {
transition: opacity 0.2s ease;
}
/* Loading animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>