8c2cefea49
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.
439 lines
14 KiB
Vue
439 lines
14 KiB
Vue
<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>
|