428 lines
14 KiB
Vue
428 lines
14 KiB
Vue
<script setup>
|
||
import { ref, computed, watch, onUnmounted } from "vue";
|
||
import {
|
||
Drawer,
|
||
DrawerContent,
|
||
DrawerHeader,
|
||
DrawerTitle,
|
||
DrawerFooter,
|
||
DrawerClose,
|
||
} from "@/Components/ui/drawer";
|
||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||
import { Button } from "@/Components/ui/button";
|
||
import { Badge } from "@/Components/ui/badge";
|
||
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
|
||
import axios from "axios";
|
||
|
||
const props = defineProps({
|
||
show: { type: Boolean, default: false },
|
||
src: { type: String, default: "" },
|
||
title: { type: String, default: "Dokument" },
|
||
mimeType: { type: String, default: "" },
|
||
filename: { type: String, default: "" },
|
||
});
|
||
const emit = defineEmits(["close"]);
|
||
|
||
const textContent = ref("");
|
||
const loading = ref(false);
|
||
const previewGenerating = ref(false);
|
||
const previewError = ref("");
|
||
|
||
// Image viewer – zoom & pan state
|
||
const containerRef = ref(null);
|
||
const imageRef = ref(null);
|
||
const imageScale = ref(1);
|
||
const translateX = ref(0);
|
||
const translateY = ref(0);
|
||
const fitScale = ref(1);
|
||
const isDragging = ref(false);
|
||
const hasMoved = ref(false);
|
||
const dragStartX = ref(0);
|
||
const dragStartY = ref(0);
|
||
const dragStartTX = ref(0);
|
||
const dragStartTY = ref(0);
|
||
|
||
const MAX_SCALE = 8;
|
||
|
||
const imageCursorClass = computed(() => {
|
||
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
|
||
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
|
||
return "cursor-default";
|
||
});
|
||
|
||
const initImageView = () => {
|
||
const container = containerRef.value;
|
||
const img = imageRef.value;
|
||
if (!container || !img) return;
|
||
const cW = container.clientWidth;
|
||
const cH = container.clientHeight;
|
||
const iW = img.naturalWidth || cW;
|
||
const iH = img.naturalHeight || cH;
|
||
const fs = Math.min(1, cW / iW, cH / iH);
|
||
fitScale.value = fs;
|
||
imageScale.value = fs;
|
||
translateX.value = (cW - iW * fs) / 2;
|
||
translateY.value = (cH - iH * fs) / 2;
|
||
};
|
||
|
||
const resetImageView = () => initImageView();
|
||
|
||
const clampTranslate = (tx, ty, scale) => {
|
||
const container = containerRef.value;
|
||
const img = imageRef.value;
|
||
if (!container || !img) return { tx, ty };
|
||
const cW = container.clientWidth;
|
||
const cH = container.clientHeight;
|
||
const iW = img.naturalWidth * scale;
|
||
const iH = img.naturalHeight * scale;
|
||
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
|
||
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
|
||
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
|
||
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
|
||
return {
|
||
tx: Math.min(maxX, Math.max(minX, tx)),
|
||
ty: Math.min(maxY, Math.max(minY, ty)),
|
||
};
|
||
};
|
||
|
||
const zoomAt = (mx, my, factor) => {
|
||
const raw = imageScale.value * factor;
|
||
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
|
||
if (newScale === imageScale.value) return;
|
||
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
|
||
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
|
||
const clamped = clampTranslate(tx, ty, newScale);
|
||
translateX.value = clamped.tx;
|
||
translateY.value = clamped.ty;
|
||
imageScale.value = newScale;
|
||
};
|
||
|
||
const mousePos = (e) => {
|
||
const rect = containerRef.value.getBoundingClientRect();
|
||
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
|
||
};
|
||
|
||
const handleImageLoad = () => initImageView();
|
||
|
||
const handleWheel = (e) => {
|
||
e.preventDefault();
|
||
const { mx, my } = mousePos(e);
|
||
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
|
||
};
|
||
|
||
// Touch pinch-to-zoom
|
||
let lastTouchDist = null;
|
||
let lastTouchMidX = null;
|
||
let lastTouchMidY = null;
|
||
|
||
const getTouchDist = (touches) => {
|
||
const dx = touches[0].clientX - touches[1].clientX;
|
||
const dy = touches[0].clientY - touches[1].clientY;
|
||
return Math.hypot(dx, dy);
|
||
};
|
||
|
||
const handleTouchStart = (e) => {
|
||
if (e.touches.length === 2) {
|
||
lastTouchDist = getTouchDist(e.touches);
|
||
const rect = containerRef.value.getBoundingClientRect();
|
||
lastTouchMidX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||
lastTouchMidY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||
e.preventDefault();
|
||
} else if (e.touches.length === 1) {
|
||
isDragging.value = true;
|
||
hasMoved.value = false;
|
||
dragStartX.value = e.touches[0].clientX;
|
||
dragStartY.value = e.touches[0].clientY;
|
||
dragStartTX.value = translateX.value;
|
||
dragStartTY.value = translateY.value;
|
||
}
|
||
};
|
||
|
||
const handleTouchMove = (e) => {
|
||
if (e.touches.length === 2 && lastTouchDist !== null) {
|
||
e.preventDefault();
|
||
const dist = getTouchDist(e.touches);
|
||
const factor = dist / lastTouchDist;
|
||
const rect = containerRef.value.getBoundingClientRect();
|
||
const midX = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
|
||
const midY = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
|
||
zoomAt(midX, midY, factor);
|
||
lastTouchDist = dist;
|
||
lastTouchMidX = midX;
|
||
lastTouchMidY = midY;
|
||
} else if (e.touches.length === 1 && isDragging.value) {
|
||
const dx = e.touches[0].clientX - dragStartX.value;
|
||
const dy = e.touches[0].clientY - dragStartY.value;
|
||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
|
||
hasMoved.value = true;
|
||
}
|
||
if (hasMoved.value) {
|
||
const clamped = clampTranslate(
|
||
dragStartTX.value + dx,
|
||
dragStartTY.value + dy,
|
||
imageScale.value
|
||
);
|
||
translateX.value = clamped.tx;
|
||
translateY.value = clamped.ty;
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleTouchEnd = () => {
|
||
lastTouchDist = null;
|
||
isDragging.value = false;
|
||
setTimeout(() => { hasMoved.value = false; }, 0);
|
||
};
|
||
|
||
const onMouseMove = (e) => {
|
||
if (!isDragging.value) return;
|
||
const dx = e.clientX - dragStartX.value;
|
||
const dy = e.clientY - dragStartY.value;
|
||
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) hasMoved.value = true;
|
||
if (hasMoved.value) {
|
||
const clamped = clampTranslate(
|
||
dragStartTX.value + dx,
|
||
dragStartTY.value + dy,
|
||
imageScale.value
|
||
);
|
||
translateX.value = clamped.tx;
|
||
translateY.value = clamped.ty;
|
||
}
|
||
};
|
||
|
||
const onMouseUp = () => {
|
||
isDragging.value = false;
|
||
setTimeout(() => { hasMoved.value = false; }, 0);
|
||
window.removeEventListener("mousemove", onMouseMove);
|
||
window.removeEventListener("mouseup", onMouseUp);
|
||
};
|
||
|
||
const handleMouseDown = (e) => {
|
||
if (e.button !== 0) return;
|
||
isDragging.value = true;
|
||
hasMoved.value = false;
|
||
dragStartX.value = e.clientX;
|
||
dragStartY.value = e.clientY;
|
||
dragStartTX.value = translateX.value;
|
||
dragStartTY.value = translateY.value;
|
||
window.addEventListener("mousemove", onMouseMove);
|
||
window.addEventListener("mouseup", onMouseUp);
|
||
};
|
||
|
||
onUnmounted(() => {
|
||
window.removeEventListener("mousemove", onMouseMove);
|
||
window.removeEventListener("mouseup", onMouseUp);
|
||
});
|
||
|
||
const fileExtension = computed(() => {
|
||
if (props.filename) return props.filename.split(".").pop()?.toLowerCase() || "";
|
||
return "";
|
||
});
|
||
|
||
const viewerType = computed(() => {
|
||
const ext = fileExtension.value;
|
||
const mime = props.mimeType.toLowerCase();
|
||
if (ext === "pdf" || mime === "application/pdf") return "pdf";
|
||
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
|
||
return "docx";
|
||
if (["jpg", "jpeg", "png", "gif", "webp", "heic", "heif"].includes(ext) || mime.startsWith("image/"))
|
||
return "image";
|
||
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
|
||
return "unsupported";
|
||
});
|
||
|
||
const loadTextContent = async () => {
|
||
if (!props.src || viewerType.value !== "text") return;
|
||
loading.value = true;
|
||
try {
|
||
const response = await axios.get(props.src);
|
||
textContent.value = response.data;
|
||
} catch {
|
||
textContent.value = "Napaka pri nalaganju vsebine.";
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
};
|
||
|
||
const docxPreviewUrl = ref("");
|
||
const loadDocxPreview = async () => {
|
||
if (!props.src || viewerType.value !== "docx") return;
|
||
previewGenerating.value = true;
|
||
previewError.value = "";
|
||
docxPreviewUrl.value = "";
|
||
const maxRetries = 15;
|
||
const retryDelay = 2000;
|
||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||
try {
|
||
const response = await axios.head(props.src, { validateStatus: () => true });
|
||
if (response.status >= 200 && response.status < 300) {
|
||
docxPreviewUrl.value = props.src;
|
||
previewGenerating.value = false;
|
||
return;
|
||
} else if (response.status === 202) {
|
||
await new Promise((resolve) => setTimeout(resolve, retryDelay));
|
||
} else {
|
||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||
previewGenerating.value = false;
|
||
return;
|
||
}
|
||
} catch {
|
||
previewError.value = "Napaka pri nalaganju predogleda.";
|
||
previewGenerating.value = false;
|
||
return;
|
||
}
|
||
}
|
||
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
|
||
previewGenerating.value = false;
|
||
};
|
||
|
||
watch(
|
||
() => [props.show, props.src],
|
||
([show]) => {
|
||
if (show && viewerType.value === "text") loadTextContent();
|
||
if (show && viewerType.value === "docx") loadDocxPreview();
|
||
if (!show) {
|
||
previewGenerating.value = false;
|
||
previewError.value = "";
|
||
docxPreviewUrl.value = "";
|
||
imageScale.value = 1;
|
||
translateX.value = 0;
|
||
translateY.value = 0;
|
||
fitScale.value = 1;
|
||
}
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
</script>
|
||
|
||
<template>
|
||
<Drawer :open="show" @update:open="(val) => !val && emit('close')">
|
||
<DrawerContent class="flex flex-col h-[95vh]">
|
||
<DrawerHeader class="border-b px-4 py-3 shrink-0">
|
||
<DrawerTitle class="truncate pr-4">{{ title }}</DrawerTitle>
|
||
<div class="mt-1">
|
||
<Badge>{{ fileExtension }}</Badge>
|
||
</div>
|
||
</DrawerHeader>
|
||
|
||
<!-- Viewer area: flex-1 + min-h-0 works because parent has fixed h-[95vh] -->
|
||
<div class="flex-1 min-h-0 overflow-hidden">
|
||
<!-- PDF Viewer -->
|
||
<template v-if="viewerType === 'pdf' && src">
|
||
<iframe :src="src" class="w-full h-full" type="application/pdf" />
|
||
</template>
|
||
|
||
<!-- DOCX Viewer (converted to PDF by backend) -->
|
||
<template v-else-if="viewerType === 'docx'">
|
||
<div
|
||
v-if="previewGenerating"
|
||
class="flex flex-col items-center justify-center h-full gap-4"
|
||
>
|
||
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
|
||
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
|
||
</div>
|
||
<div
|
||
v-else-if="previewError"
|
||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||
>
|
||
<span>{{ previewError }}</span>
|
||
<Button as="a" :href="src" target="_blank" variant="outline">
|
||
Prenesi datoteko
|
||
</Button>
|
||
</div>
|
||
<iframe
|
||
v-else-if="docxPreviewUrl"
|
||
:src="docxPreviewUrl"
|
||
class="w-full h-full"
|
||
type="application/pdf"
|
||
/>
|
||
</template>
|
||
|
||
<!-- Image Viewer with touch pinch-to-zoom -->
|
||
<template v-else-if="viewerType === 'image' && src">
|
||
<div
|
||
ref="containerRef"
|
||
class="relative h-full overflow-hidden select-none"
|
||
:class="imageCursorClass"
|
||
@mousedown="handleMouseDown"
|
||
@wheel.prevent="handleWheel"
|
||
@touchstart.prevent="handleTouchStart"
|
||
@touchmove.prevent="handleTouchMove"
|
||
@touchend="handleTouchEnd"
|
||
>
|
||
<img
|
||
ref="imageRef"
|
||
:src="src"
|
||
:alt="title"
|
||
draggable="false"
|
||
class="absolute top-0 left-0 max-w-none"
|
||
:style="{
|
||
transformOrigin: '0 0',
|
||
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
|
||
transition: isDragging ? 'none' : 'transform 0.12s ease',
|
||
}"
|
||
@load="handleImageLoad"
|
||
/>
|
||
<div
|
||
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
|
||
>
|
||
{{ Math.round(imageScale * 100) }}%
|
||
</div>
|
||
<Button
|
||
v-if="imageScale > fitScale + 0.01"
|
||
size="icon-sm"
|
||
variant="secondary"
|
||
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
|
||
title="Ponastavi pogled"
|
||
@click.stop="resetImageView"
|
||
>
|
||
<RotateCcwIcon class="h-3 w-3" />
|
||
</Button>
|
||
<div
|
||
v-if="imageScale <= fitScale + 0.01"
|
||
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
|
||
>
|
||
Ščipni za povečavo · Povleči za premik
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<!-- Text/CSV/XML Viewer -->
|
||
<template v-else-if="viewerType === 'text'">
|
||
<div v-if="loading" class="flex items-center justify-center h-full">
|
||
<div class="animate-pulse text-gray-500">Nalaganje...</div>
|
||
</div>
|
||
<ScrollArea v-else class="h-full">
|
||
<pre
|
||
class="p-4 bg-gray-50 dark:bg-gray-900 text-sm whitespace-pre-wrap wrap-break-word"
|
||
>{{ textContent }}</pre
|
||
>
|
||
</ScrollArea>
|
||
</template>
|
||
|
||
<!-- Unsupported -->
|
||
<template v-else-if="viewerType === 'unsupported'">
|
||
<div
|
||
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
|
||
>
|
||
<span>Predogled ni na voljo za to vrsto datoteke.</span>
|
||
<Button as="a" :href="src" target="_blank" variant="outline">
|
||
Prenesi datoteko
|
||
</Button>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-else class="flex items-center justify-center h-full text-sm text-gray-500">
|
||
Ni dokumenta za prikaz.
|
||
</div>
|
||
</div>
|
||
|
||
<DrawerFooter class="border-t shrink-0 px-4 py-3">
|
||
<DrawerClose as-child>
|
||
<Button variant="outline" class="w-full" @click="emit('close')">Zapri</Button>
|
||
</DrawerClose>
|
||
</DrawerFooter>
|
||
</DrawerContent>
|
||
</Drawer>
|
||
</template>
|