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:
Zoe
2026-04-27 12:05:58 -05:00
parent 9914c043cb
commit 8c2cefea49
2 changed files with 458 additions and 2 deletions
+20 -2
View File
@@ -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" />
+438
View File
@@ -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>