Files
Teren-app/resources/js/Components/DocumentsTable/DocumentViewerDrawer.vue
T
2026-06-21 19:49:04 +02:00

428 lines
14 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.
<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>