Files
media_viewer/src/components/ImageViewer.vue
T
2026-06-17 23:14:59 -04:00

167 lines
4.2 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { MediaLeafProps } from '@/types'
const props = defineProps<MediaLeafProps>()
// ── Pan / zoom state ────────────────────────────────────────────────────────
const scale = ref(1)
const translateX = ref(0)
const translateY = ref(0)
const isDragging = ref(false)
const MIN_SCALE = 0.1
const MAX_SCALE = 10
const ZOOM_FACTOR = 0.12
let dragStartX = 0
let dragStartY = 0
let dragStartTX = 0
let dragStartTY = 0
const containerRef = ref<HTMLElement | null>(null)
// ── Zoom ───────────────────────────────────────────────────────────────────
function clamp(val: number, min: number, max: number) {
return Math.min(max, Math.max(min, val))
}
function handleWheel(e: WheelEvent) {
e.preventDefault()
const delta = e.deltaY > 0 ? -(ZOOM_FACTOR) : ZOOM_FACTOR
scale.value = clamp(scale.value * (1 + delta), MIN_SCALE, MAX_SCALE)
}
// ── Drag (pan) ─────────────────────────────────────────────────────────────
function handleMousedown(e: MouseEvent) {
if (e.button !== 0) return
isDragging.value = true
dragStartX = e.clientX
dragStartY = e.clientY
dragStartTX = translateX.value
dragStartTY = translateY.value
}
function handleMousemove(e: MouseEvent) {
if (!isDragging.value) return
translateX.value = dragStartTX + (e.clientX - dragStartX)
translateY.value = dragStartTY + (e.clientY - dragStartY)
}
function handleMouseup() {
isDragging.value = false
}
function handleDblclick() {
// Double-click to reset
scale.value = 1
translateX.value = 0
translateY.value = 0
}
// Pan/zoom is for the full viewer only; the hover popover shows a static fit.
const interactive = computed(() => props.presentation !== 'popover')
onMounted(() => {
if (!interactive.value) return
const el = containerRef.value
if (el) {
el.addEventListener('wheel', handleWheel, { passive: false })
}
window.addEventListener('mousemove', handleMousemove)
window.addEventListener('mouseup', handleMouseup)
})
onUnmounted(() => {
if (!interactive.value) return
const el = containerRef.value
if (el) {
el.removeEventListener('wheel', handleWheel)
}
window.removeEventListener('mousemove', handleMousemove)
window.removeEventListener('mouseup', handleMouseup)
})
</script>
<template>
<div
ref="containerRef"
class="image-viewer"
:class="{ 'image-viewer--dragging': isDragging, 'image-viewer--static': !interactive }"
@mousedown="interactive && handleMousedown($event)"
@dblclick="interactive && handleDblclick()"
>
<img
:src="url"
:alt="title ?? item?.title ?? 'Preview image'"
class="image-viewer-img"
:style="{
transform: `translate(${translateX}px, ${translateY}px) scale(${scale})`,
transformOrigin: 'center center',
}"
draggable="false"
/>
<!-- Zoom hint -->
<div v-if="interactive" class="image-viewer-hint text-caption text-medium-emphasis">
Scroll to zoom · Drag to pan · Double-click to reset
</div>
</div>
</template>
<style scoped>
.image-viewer {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
cursor: grab;
user-select: none;
}
.image-viewer--dragging {
cursor: grabbing;
}
.image-viewer--static {
cursor: default;
}
.image-viewer-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
transition: transform 0.05s linear;
pointer-events: none;
}
.image-viewer--dragging .image-viewer-img {
transition: none;
}
.image-viewer-hint {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.35);
color: #fff;
padding: 2px 10px;
border-radius: 12px;
pointer-events: none;
white-space: nowrap;
opacity: 0;
transition: opacity 0.3s;
}
.image-viewer:hover .image-viewer-hint {
opacity: 1;
}
</style>