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

415 lines
9.9 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 class="image-viewer w-full h-full bg-gray-100 dark:bg-gray-900 relative overflow-hidden">
<!-- Image Container -->
<div
ref="imageContainer"
class="w-full h-full flex items-center justify-center overflow-auto"
@wheel="handleWheel"
@mousedown="handleMouseDown"
@mousemove="handleMouseMove"
@mouseup="handleMouseUp"
@mouseleave="handleMouseUp"
>
<div
class="relative"
:style="{
transform: `scale(${currentZoom / 100}) translate(${panX}px, ${panY}px)`,
transformOrigin: 'center center',
transition: isAnimating ? 'transform 0.3s ease-out' : 'none'
}"
>
<img
ref="imageElement"
:src="imageUrl"
:alt="document.name"
class="max-w-none block select-none"
@load="handleImageLoad"
@error="handleImageError"
@dragstart.prevent
/>
</div>
</div>
<!-- Loading State -->
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 dark:bg-gray-900 dark:bg-opacity-90"
>
<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 dark:text-gray-400">Loading image...</p>
</div>
</div>
<!-- Error State -->
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-white bg-opacity-90 dark:bg-gray-900 dark:bg-opacity-90"
>
<div class="text-center">
<Icon name="mdi:image-broken" class="w-16 h-16 text-red-400 mx-auto mb-2" />
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Failed to Load Image</h3>
<p class="text-gray-600 dark:text-gray-400">The image could not be displayed.</p>
</div>
</div>
<!-- Image Info Overlay -->
<div
v-if="showImageInfo && !isLoading && !hasError"
class="absolute top-4 left-4 bg-black bg-opacity-70 text-white px-3 py-2 rounded-lg text-sm"
>
<div>{{ document.name }}</div>
<div v-if="imageMetadata.dimensions">{{ imageMetadata.dimensions }}</div>
<div v-if="imageMetadata.fileSize">{{ imageMetadata.fileSize }}</div>
</div>
<!-- Zoom Controls -->
<div
v-if="!isLoading && !hasError"
class="absolute bottom-4 left-1/2 transform -translate-x-1/2 bg-black bg-opacity-70 rounded-full px-4 py-2 flex items-center space-x-2"
>
<button
@click="zoomOut"
:disabled="currentZoom <= 25"
class="p-1 text-white hover:bg-white hover:bg-opacity-20 rounded disabled:opacity-50"
title="Zoom Out"
>
<Icon name="mdi:magnify-minus" class="w-4 h-4" />
</button>
<span class="text-white text-sm min-w-12 text-center">
{{ Math.round(currentZoom) }}%
</span>
<button
@click="zoomIn"
:disabled="currentZoom >= 500"
class="p-1 text-white hover:bg-white hover:bg-opacity-20 rounded disabled:opacity-50"
title="Zoom In"
>
<Icon name="mdi:magnify-plus" class="w-4 h-4" />
</button>
<div class="w-px h-4 bg-white bg-opacity-30 mx-1"></div>
<button
@click="fitToScreen"
class="p-1 text-white hover:bg-white hover:bg-opacity-20 rounded"
title="Fit to Screen"
>
<Icon name="mdi:fit-to-screen" class="w-4 h-4" />
</button>
<button
@click="actualSize"
class="p-1 text-white hover:bg-white hover:bg-opacity-20 rounded"
title="Actual Size"
>
<Icon name="mdi:magnify" class="w-4 h-4" />
</button>
<button
@click="toggleImageInfo"
class="p-1 text-white hover:bg-white hover:bg-opacity-20 rounded"
title="Toggle Info"
>
<Icon name="mdi:information" class="w-4 h-4" />
</button>
</div>
<!-- Pan Hint -->
<div
v-if="currentZoom > 100 && !isLoading && !hasError"
class="absolute top-4 right-4 bg-black bg-opacity-70 text-white px-3 py-1 rounded text-xs"
>
Drag to pan
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue';
const props = defineProps({
document: {
type: Object,
required: true
},
zoom: {
type: Number,
default: 100
}
});
const emit = defineEmits([
'zoom-changed'
]);
// State
const isLoading = ref(true);
const hasError = ref(false);
const currentZoom = ref(props.zoom);
const showImageInfo = ref(false);
const isAnimating = ref(false);
// Pan state
const panX = ref(0);
const panY = ref(0);
const isPanning = ref(false);
const lastPanX = ref(0);
const lastPanY = ref(0);
// Image metadata
const imageMetadata = ref({
dimensions: null,
fileSize: null,
naturalWidth: 0,
naturalHeight: 0
});
// Refs
const imageContainer = ref(null);
const imageElement = ref(null);
// Computed
const imageUrl = computed(() => {
// In a real implementation, this would be the actual image URL
return `/api/documents/${props.document.id}/preview`;
});
// Methods
const handleImageLoad = () => {
isLoading.value = false;
hasError.value = false;
if (imageElement.value) {
const img = imageElement.value;
imageMetadata.value = {
dimensions: `${img.naturalWidth} × ${img.naturalHeight}`,
fileSize: props.document.size,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight
};
// Auto-fit to screen on first load
nextTick(() => {
fitToScreen();
});
}
};
const handleImageError = () => {
isLoading.value = false;
hasError.value = true;
};
const zoomIn = () => {
const newZoom = Math.min(currentZoom.value * 1.25, 500);
setZoom(newZoom);
};
const zoomOut = () => {
const newZoom = Math.max(currentZoom.value / 1.25, 25);
setZoom(newZoom);
};
const setZoom = (zoom, animate = true) => {
if (animate) {
isAnimating.value = true;
setTimeout(() => {
isAnimating.value = false;
}, 300);
}
currentZoom.value = zoom;
emit('zoom-changed', zoom);
// Reset pan when zooming
if (zoom <= 100) {
panX.value = 0;
panY.value = 0;
}
};
const fitToScreen = () => {
if (!imageElement.value || !imageContainer.value) return;
const container = imageContainer.value;
const img = imageElement.value;
const containerWidth = container.clientWidth;
const containerHeight = container.clientHeight;
const imageWidth = img.naturalWidth;
const imageHeight = img.naturalHeight;
const scaleX = containerWidth / imageWidth;
const scaleY = containerHeight / imageHeight;
const scale = Math.min(scaleX, scaleY, 1) * 0.9; // 90% to leave some margin
setZoom(scale * 100);
panX.value = 0;
panY.value = 0;
};
const actualSize = () => {
setZoom(100);
panX.value = 0;
panY.value = 0;
};
const toggleImageInfo = () => {
showImageInfo.value = !showImageInfo.value;
};
// Mouse handling for pan
const handleMouseDown = (event) => {
if (currentZoom.value > 100) {
isPanning.value = true;
lastPanX.value = event.clientX;
lastPanY.value = event.clientY;
event.preventDefault();
}
};
const handleMouseMove = (event) => {
if (isPanning.value && currentZoom.value > 100) {
const deltaX = event.clientX - lastPanX.value;
const deltaY = event.clientY - lastPanY.value;
panX.value += deltaX;
panY.value += deltaY;
lastPanX.value = event.clientX;
lastPanY.value = event.clientY;
event.preventDefault();
}
};
const handleMouseUp = () => {
isPanning.value = false;
};
// Wheel handling for zoom
const handleWheel = (event) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const delta = event.deltaY > 0 ? -10 : 10;
const newZoom = Math.max(25, Math.min(500, currentZoom.value + delta));
setZoom(newZoom, false);
}
};
// Keyboard shortcuts
const handleKeydown = (event) => {
switch (event.key) {
case '+':
case '=':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
zoomIn();
}
break;
case '-':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
zoomOut();
}
break;
case '0':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
fitToScreen();
}
break;
case '1':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
actualSize();
}
break;
case 'i':
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
toggleImageInfo();
}
break;
}
};
// Watchers
watch(() => props.zoom, (newZoom) => {
if (newZoom !== currentZoom.value) {
setZoom(newZoom, false);
}
});
watch(() => props.document, () => {
// Reset state when document changes
isLoading.value = true;
hasError.value = false;
currentZoom.value = 100;
panX.value = 0;
panY.value = 0;
showImageInfo.value = false;
});
// Lifecycle
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
});
</script>
<style scoped>
.image-viewer {
cursor: grab;
}
.image-viewer.panning {
cursor: grabbing;
}
/* Prevent text selection during panning */
.image-viewer * {
user-select: none;
}
/* Custom scrollbars */
.image-viewer::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.image-viewer::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
}
.image-viewer::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.3);
border-radius: 4px;
}
.image-viewer::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.5);
}
/* Animation for smooth zooming */
.zoom-transition {
transition: transform 0.3s ease-out;
}
/* Loading animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
</style>