feat: add image viewer with zoom and pan
Full-featured image viewer with pinch-zoom, scroll-zoom, drag-pan, double-click reset, and keyboard shortcuts. Animated open/close transitions match the origin element position. Integrates into attachment display with click-to-open behavior.
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import ImageViewer from '~/components/ImageViewer.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
file: {
|
||||
id: string;
|
||||
@@ -7,7 +9,7 @@ const props = defineProps<{
|
||||
status?: 'uploading' | 'uploaded' | 'error';
|
||||
url: string;
|
||||
progress?: number;
|
||||
}
|
||||
};
|
||||
}>();
|
||||
|
||||
const isImage = computed(() => props.file.mimeType.startsWith('image/'));
|
||||
@@ -15,6 +17,10 @@ const isVideo = computed(() => props.file.mimeType.startsWith('video/'));
|
||||
const isAudio = computed(() => props.file.mimeType.startsWith('audio/'));
|
||||
const isPdf = computed(() => props.file.mimeType === 'application/pdf');
|
||||
|
||||
const imageViewerOpen = ref(false);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const imageViewerOriginRect = ref<DOMRect | null>(null);
|
||||
|
||||
const fileIcon = computed(() => {
|
||||
if (isImage.value) return 'i-mynaui-image';
|
||||
if (isVideo.value) return 'i-mynaui-video';
|
||||
@@ -29,11 +35,23 @@ const fileExtension = computed(() => {
|
||||
const parts = props.file.name.split('.');
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : 'FILE';
|
||||
});
|
||||
|
||||
function openImageViewer() {
|
||||
imageViewerOriginRect.value = imageRef.value?.getBoundingClientRect() ?? null;
|
||||
imageViewerOpen.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-full w-fit flex">
|
||||
<img v-if="isImage" :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
|
||||
<div v-if="isImage">
|
||||
<img ref="imageRef" :src="props.file.url"
|
||||
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover cursor-zoom-in hover:opacity-90 transition-opacity"
|
||||
@click="openImageViewer" />
|
||||
<ImageViewer v-if="imageViewerOpen" :src="props.file.url" :alt="props.file.name"
|
||||
:origin-rect="imageViewerOriginRect" :origin-element="imageRef"
|
||||
@close="imageViewerOpen = false" />
|
||||
</div>
|
||||
<video v-else-if="isVideo" controls :src="props.file.url"
|
||||
class="rounded-lg h-full w-full max-h-36 max-w-64 object-cover" />
|
||||
<audio v-else-if="isAudio" controls :src="props.file.url" class="rounded-lg h-full w-full max-h-36 max-w-64" />
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
<script setup lang="ts">
|
||||
import { assert } from '~~/utils/assert';
|
||||
|
||||
type ViewerRect = {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type Phase = 'entering' | 'open' | 'leaving';
|
||||
|
||||
const props = defineProps<{
|
||||
src: string;
|
||||
alt?: string;
|
||||
initialScale?: number;
|
||||
originRect?: ViewerRect | null;
|
||||
originElement?: HTMLImageElement | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const MIN_SCALE = 0.5;
|
||||
const MAX_SCALE = 12;
|
||||
const ZOOM_STEP = 0.15;
|
||||
|
||||
const rootRef = ref<HTMLDivElement | null>(null);
|
||||
const containerRef = ref<HTMLDivElement | null>(null);
|
||||
const imageRef = ref<HTMLImageElement | null>(null);
|
||||
const transitionImageRef = ref<HTMLImageElement | null>(null);
|
||||
|
||||
const phase = ref<Phase>(props.originRect ? 'entering' : 'open');
|
||||
|
||||
const transitionImageStyle = ref<Record<string, string>>({
|
||||
position: 'fixed',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
const scale = ref(props.initialScale ?? 1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragStart = ref({ x: 0, y: 0 });
|
||||
const dragTranslateStart = ref({ x: 0, y: 0 });
|
||||
const pointerDownTarget = ref<HTMLElement | null>(null);
|
||||
const hasMoved = ref(false);
|
||||
|
||||
const imageStyle = computed(() => {
|
||||
const { x, y } = clampTranslate(translateX.value, translateY.value);
|
||||
return {
|
||||
transform: `translate(${x}px, ${y}px) scale(${scale.value})`,
|
||||
cursor: isDragging.value ? 'grabbing' : scale.value > 1 ? 'grab' : 'zoom-in',
|
||||
};
|
||||
});
|
||||
|
||||
// --- Geometry helpers ---
|
||||
|
||||
function getTranslateBounds() {
|
||||
const image = imageRef.value;
|
||||
if (!image) return { maxX: Infinity, maxY: Infinity };
|
||||
|
||||
const maxX = (image.offsetWidth * scale.value) / 2;
|
||||
const maxY = (image.offsetHeight * scale.value) / 2;
|
||||
return { maxX, maxY };
|
||||
}
|
||||
|
||||
function clampTranslate(x: number, y: number) {
|
||||
const { maxX, maxY } = getTranslateBounds();
|
||||
return {
|
||||
x: Math.min(maxX, Math.max(-maxX, x)),
|
||||
y: Math.min(maxY, Math.max(-maxY, y)),
|
||||
};
|
||||
}
|
||||
|
||||
function zoomAroundPoint(newScale: number, x: number, y: number) {
|
||||
if (phase.value !== 'open') return;
|
||||
|
||||
const clamped = Math.min(MAX_SCALE, Math.max(MIN_SCALE, newScale));
|
||||
if (clamped === scale.value) return;
|
||||
|
||||
const rect = containerRef.value!.getBoundingClientRect();
|
||||
const mx = x - rect.left - rect.width / 2;
|
||||
const my = y - rect.top - rect.height / 2;
|
||||
const old = scale.value;
|
||||
const diff = clamped - old;
|
||||
|
||||
translateX.value -= ((mx - translateX.value) / old) * diff;
|
||||
translateY.value -= ((my - translateY.value) / old) * diff;
|
||||
scale.value = clamped;
|
||||
}
|
||||
|
||||
function applyPan(rawX: number, rawY: number) {
|
||||
const clamped = clampTranslate(rawX, rawY);
|
||||
translateX.value = clamped.x;
|
||||
translateY.value = clamped.y;
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
if (phase.value !== 'open') return;
|
||||
scale.value = 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
}
|
||||
|
||||
// --- Transition animations ---
|
||||
|
||||
function getValidRect(rect?: ViewerRect | DOMRect | null) {
|
||||
if (!rect || rect.width <= 0 || rect.height <= 0) return null;
|
||||
return { top: rect.top, left: rect.left, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
function getOriginRect() {
|
||||
const live = props.originElement?.getBoundingClientRect();
|
||||
return getValidRect(live) ?? getValidRect(props.originRect);
|
||||
}
|
||||
|
||||
async function runTransition(
|
||||
from: ViewerRect | DOMRect,
|
||||
to: ViewerRect | DOMRect,
|
||||
opts: { duration: number; easing: string; backdropFrom: number; backdropTo: number; borderRadiusFrom: string; borderRadiusTo: string },
|
||||
) {
|
||||
const transitionImage = transitionImageRef.value;
|
||||
if (!transitionImage) return;
|
||||
|
||||
const imageAnim = transitionImage.animate([
|
||||
{ top: `${from.top}px`, left: `${from.left}px`, width: `${from.width}px`, height: `${from.height}px`, borderRadius: opts.borderRadiusFrom },
|
||||
{ top: `${to.top}px`, left: `${to.left}px`, width: `${to.width}px`, height: `${to.height}px`, borderRadius: opts.borderRadiusTo },
|
||||
], { duration: opts.duration, easing: opts.easing, fill: 'forwards' });
|
||||
|
||||
const backdropAnim = rootRef.value?.animate(
|
||||
[{ opacity: opts.backdropFrom }, { opacity: opts.backdropTo }],
|
||||
{ duration: opts.duration - 20, easing: opts.backdropFrom === 0 ? 'ease-out' : 'ease-in', fill: 'forwards' },
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
imageAnim.finished.catch(() => undefined),
|
||||
backdropAnim?.finished.catch(() => undefined),
|
||||
]);
|
||||
}
|
||||
|
||||
async function runOpenAnimation() {
|
||||
const start = getValidRect(props.originRect);
|
||||
if (!start || !imageRef.value) {
|
||||
phase.value = 'open';
|
||||
return;
|
||||
}
|
||||
|
||||
transitionImageStyle.value = {
|
||||
...transitionImageStyle.value,
|
||||
top: `${start.top}px`, left: `${start.left}px`,
|
||||
width: `${start.width}px`, height: `${start.height}px`,
|
||||
borderRadius: '0.5rem',
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
const end = getValidRect(imageRef.value.getBoundingClientRect());
|
||||
if (!end) {
|
||||
phase.value = 'open';
|
||||
return;
|
||||
}
|
||||
|
||||
await runTransition(start, end, {
|
||||
duration: 240,
|
||||
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
backdropFrom: 0, backdropTo: 1,
|
||||
borderRadiusFrom: '0.5rem', borderRadiusTo: '0rem',
|
||||
});
|
||||
|
||||
phase.value = 'open';
|
||||
}
|
||||
|
||||
async function requestClose() {
|
||||
if (phase.value !== 'open') return;
|
||||
|
||||
isDragging.value = false;
|
||||
hasMoved.value = false;
|
||||
|
||||
const start = imageRef.value ? getValidRect(imageRef.value.getBoundingClientRect()) : null;
|
||||
const end = getOriginRect();
|
||||
if (!start || !end) {
|
||||
emit('close');
|
||||
return;
|
||||
}
|
||||
|
||||
phase.value = 'leaving';
|
||||
|
||||
transitionImageStyle.value = {
|
||||
...transitionImageStyle.value,
|
||||
top: `${start.top}px`, left: `${start.left}px`,
|
||||
width: `${start.width}px`, height: `${start.height}px`,
|
||||
borderRadius: '0rem',
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
await runTransition(start, end, {
|
||||
duration: 210,
|
||||
easing: 'cubic-bezier(0.4, 0, 1, 1)',
|
||||
backdropFrom: 1, backdropTo: 0,
|
||||
borderRadiusFrom: '0rem', borderRadiusTo: '0.5rem',
|
||||
});
|
||||
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// --- Interaction handlers ---
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
if (phase.value !== 'open') return;
|
||||
const factor = e.deltaY > 0 ? (1 - ZOOM_STEP) : (1 + ZOOM_STEP);
|
||||
zoomAroundPoint(scale.value * factor, e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function startDrag(x: number, y: number) {
|
||||
isDragging.value = true;
|
||||
dragStart.value = { x, y };
|
||||
dragTranslateStart.value = { x: translateX.value, y: translateY.value };
|
||||
hasMoved.value = false;
|
||||
}
|
||||
|
||||
function moveDrag(x: number, y: number) {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const dx = x - dragStart.value.x;
|
||||
const dy = y - dragStart.value.y;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) hasMoved.value = true;
|
||||
|
||||
applyPan(dragTranslateStart.value.x + dx, dragTranslateStart.value.y + dy);
|
||||
}
|
||||
|
||||
function endDrag(upX: number, upY: number, upTarget: HTMLElement | null) {
|
||||
if (phase.value !== 'open') return;
|
||||
isDragging.value = false;
|
||||
|
||||
if (hasMoved.value) {
|
||||
hasMoved.value = false;
|
||||
pointerDownTarget.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const startedOnImage = pointerDownTarget.value === imageRef.value;
|
||||
const endedOnImage = upTarget === imageRef.value;
|
||||
pointerDownTarget.value = null;
|
||||
|
||||
if (!startedOnImage && !endedOnImage) {
|
||||
void requestClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (startedOnImage && endedOnImage) {
|
||||
const dx = Math.abs(dragStart.value.x - upX);
|
||||
const dy = Math.abs(dragStart.value.y - upY);
|
||||
if (dx < 10 && dy < 10) {
|
||||
if (scale.value <= 1) {
|
||||
scale.value = scale.value === 1 ? 2 : 1;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (phase.value !== 'open') return;
|
||||
pointerDownTarget.value = e.target as HTMLElement;
|
||||
startDrag(e.clientX, e.clientY);
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handlePointerMove(e: PointerEvent) {
|
||||
if (phase.value !== 'open') return;
|
||||
moveDrag(e.clientX, e.clientY);
|
||||
}
|
||||
|
||||
function handlePointerUp(e: PointerEvent) {
|
||||
endDrag(e.clientX, e.clientY, e.target as HTMLElement);
|
||||
}
|
||||
|
||||
function handlePointerLeave() {
|
||||
if (isDragging.value) {
|
||||
isDragging.value = false;
|
||||
hasMoved.value = false;
|
||||
pointerDownTarget.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDoubleClick() {
|
||||
if (phase.value !== 'open') return;
|
||||
resetView();
|
||||
}
|
||||
|
||||
// --- Touch (pinch zoom) ---
|
||||
|
||||
let lastTouchDistance = 0;
|
||||
let lastTouchCenter = { x: 0, y: 0 };
|
||||
let touchStartTime: number | null = null;
|
||||
let touchTarget: HTMLElement | null = null;
|
||||
|
||||
function getTouchDistance(touches: TouchList) {
|
||||
assert(touches.length >= 2);
|
||||
const dx = touches[0]!.clientX - touches[1]!.clientX;
|
||||
const dy = touches[0]!.clientY - touches[1]!.clientY;
|
||||
return Math.sqrt(dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
function getTouchCenter(touches: TouchList) {
|
||||
assert(touches.length >= 2);
|
||||
return {
|
||||
x: (touches[0]!.clientX + touches[1]!.clientX) / 2,
|
||||
y: (touches[0]!.clientY + touches[1]!.clientY) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
if (phase.value !== 'open') return;
|
||||
touchTarget = e.target as HTMLElement;
|
||||
touchStartTime = Date.now();
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
touchStartTime = null;
|
||||
lastTouchDistance = getTouchDistance(e.touches);
|
||||
lastTouchCenter = getTouchCenter(e.touches);
|
||||
} else if (e.touches.length === 1 && scale.value > 1) {
|
||||
startDrag(e.touches[0]!.clientX, e.touches[0]!.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (phase.value !== 'open') return;
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 2) {
|
||||
const distance = getTouchDistance(e.touches);
|
||||
const center = getTouchCenter(e.touches);
|
||||
const factor = distance / lastTouchDistance;
|
||||
|
||||
zoomAroundPoint(scale.value * factor, center.x, center.y);
|
||||
|
||||
const pan = clampTranslate(
|
||||
translateX.value + center.x - lastTouchCenter.x,
|
||||
translateY.value + center.y - lastTouchCenter.y,
|
||||
);
|
||||
translateX.value = pan.x;
|
||||
translateY.value = pan.y;
|
||||
|
||||
lastTouchDistance = distance;
|
||||
lastTouchCenter = center;
|
||||
} else if (e.touches.length === 1 && isDragging.value) {
|
||||
moveDrag(e.touches[0]!.clientX, e.touches[0]!.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (phase.value !== 'open') return;
|
||||
|
||||
if (touchStartTime !== null && Date.now() - touchStartTime < 200) {
|
||||
if (touchTarget !== imageRef.value && e.target !== imageRef.value) {
|
||||
void requestClose();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
isDragging.value = false;
|
||||
lastTouchDistance = 0;
|
||||
}
|
||||
|
||||
// --- Keyboard ---
|
||||
|
||||
function zoomIn() { zoomAroundPoint(scale.value * (1 + ZOOM_STEP), window.innerWidth / 2, window.innerHeight / 2); }
|
||||
function zoomOut() { zoomAroundPoint(scale.value * (1 - ZOOM_STEP), window.innerWidth / 2, window.innerHeight / 2); }
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') { void requestClose(); e.preventDefault(); }
|
||||
else if (e.key === '+' || e.key === '=') { zoomIn(); e.preventDefault(); }
|
||||
else if (e.key === '-') { zoomOut(); e.preventDefault(); }
|
||||
else if (e.key === '0') { resetView(); e.preventDefault(); }
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
void runOpenAnimation();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div ref="rootRef" class="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-sm"
|
||||
:style="{ opacity: phase === 'entering' ? 0 : 1 }">
|
||||
<div ref="containerRef" class="relative w-full h-full overflow-hidden flex items-center justify-center"
|
||||
@wheel.prevent="handleWheel" @pointerdown="handlePointerDown" @pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp" @pointerleave="handlePointerLeave" @dblclick="handleDoubleClick"
|
||||
@touchstart.prevent="handleTouchStart" @touchmove.prevent="handleTouchMove" @touchend="handleTouchEnd">
|
||||
<img ref="imageRef" :src="src" :alt="alt ?? 'Image'" :style="imageStyle"
|
||||
class="max-w-full max-h-full select-none"
|
||||
:class="phase === 'open' ? 'opacity-100' : 'opacity-0 pointer-events-none'" draggable="false" />
|
||||
|
||||
<img v-if="phase === 'entering' || phase === 'leaving'" ref="transitionImageRef" :src="src"
|
||||
:alt="alt ?? 'Image'" class="select-none" :style="transitionImageStyle" draggable="false" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute top-4 left-1/2 -translate-x-1/2 flex items-center gap-2 bg-black/60 backdrop-blur-md rounded-full px-3 py-1.5 text-white text-sm">
|
||||
<button @click="zoomOut"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
|
||||
title="Zoom out (-)">
|
||||
<span class="i-mynaui-minus-solid text-4"></span>
|
||||
</button>
|
||||
<span class="w-14 text-center tabular-nums">{{ Math.round(scale * 100) }}%</span>
|
||||
<button @click="zoomIn"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
|
||||
title="Zoom in (+)">
|
||||
<span class="i-mynaui-plus-solid text-4"></span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-white/20 mx-1"></div>
|
||||
<button @click="resetView"
|
||||
class="w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/20 transition-colors"
|
||||
title="Reset (0)">
|
||||
<span class="i-tabler-zoom-reset text-4"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button @click="requestClose"
|
||||
class="absolute top-4 right-4 w-10 h-10 flex items-center justify-center rounded-full bg-black/60 backdrop-blur-md hover:bg-white/20 transition-colors text-white"
|
||||
title="Close (Esc)">
|
||||
<span class="i-mynaui-x-solid text-5"></span>
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user