generated from corrad-software/corrad-af-2024
415 lines
9.9 KiB
Vue
415 lines
9.9 KiB
Vue
<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> |