generated from Nodarx/template
@@ -0,0 +1,166 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user