Skip to content

Instantly share code, notes, and snippets.

@geschke
Created August 11, 2025 14:51
Show Gist options
  • Select an option

  • Save geschke/e57681d1ca8b2cc924146dcee932be9a to your computer and use it in GitHub Desktop.

Select an option

Save geschke/e57681d1ca8b2cc924146dcee932be9a to your computer and use it in GitHub Desktop.
A small Vue.js image viewer component
<template>
<div class="ivm-wrapper">
<div class="ivm-container" ref="containerEl" @wheel.prevent="onWheel" @mousedown="onMouseDown">
<img @load="onImageLoad" :src="src" :alt="alt" :style="imgStyle" draggable="false" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { CSSProperties } from 'vue';
const props = defineProps<{
src: string
alt?: string
}>()
const scale = ref(1);
const rotation = ref(0); // Current rotation (in degrees)
const containerEl = ref<HTMLDivElement | null>(null);
const isDragging = ref(false);
const startX = ref(0);
const startY = ref(0);
const scrollLeft = ref(0);
const scrollTop = ref(0);
const naturalWidth = ref(0);
const naturalHeight = ref(0);
const previousScale = ref<number | null>(null); // Used to store scale before switching to original size
const enableTransition = ref(true); // Controls smooth animation of rotation
let bouncePending = false; // Prevents concurrent transitions
// CSS styles dynamically applied to <img>
const imgStyle = computed<CSSProperties>(() => ({
transform: `scale(${scale.value}) rotate(${rotation.value}deg)`,
transition: enableTransition.value ? 'transform 0.2s ease' : 'none',
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
userSelect: 'none',
pointerEvents: 'none',
}))
// Indicates whether we are currently in "original size" mode
const isOriginalSizeActive = computed(() => previousScale.value !== null)
// Expose methods and state to parent component
defineExpose({
scale,
rotation,
setScale: (s: number) => {
scale.value = s
},
rotateLeft,
rotateRight,
toggleOriginalSize,
isOriginalSizeActive
})
/** Rotate image 90° clockwise */
function rotateRight() {
if (bouncePending) return;
rotation.value += 90;
if (rotation.value > 100000) {
bouncePending = true;
// Let the transition play (200ms), then bounce back instantly
setTimeout(() => {
enableTransition.value = false;
rotation.value = ((rotation.value % 360) + 360) % 360; // prevent negative values
requestAnimationFrame(() => {
// Let the DOM catch up
enableTransition.value = true;
bouncePending = false;
})
}, 210);
}
}
/** Rotate image 90° counter-clockwise */
function rotateLeft() {
if (bouncePending) return;
rotation.value -= 90;
if (rotation.value < -100000) {
bouncePending = true;
// Let the transition play (200ms), then bounce back instantly
setTimeout(() => {
enableTransition.value = false;
rotation.value = ((rotation.value % 360) + 360) % 360; // prevent negative values
requestAnimationFrame(() => {
// Let the DOM catch up
enableTransition.value = true;
bouncePending = false;
})
}, 210);
}
}
/** Zoom using the mouse wheel */
function onWheel(e: WheelEvent) {
const delta = e.deltaY > 0 ? -0.1 : 0.1;
const next = scale.value + delta;
const clamped = Math.min(Math.max(next, 0.25), 4); // minimum 25%, maximum 400%
scale.value = roundScale(clamped);
}
/** Round scale value to two decimal places */
function roundScale(value: number) {
return Math.round(value * 100) / 100
}
/** Start dragging the image container */
function onMouseDown(e: MouseEvent) {
if (!containerEl.value) return;
isDragging.value = true;
containerEl.value.style.cursor = 'grabbing';
startX.value = e.pageX - containerEl.value.offsetLeft;
startY.value = e.pageY - containerEl.value.offsetTop;
scrollLeft.value = containerEl.value.scrollLeft;
scrollTop.value = containerEl.value.scrollTop;
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
}
/** Continue dragging */
function onMouseMove(e: MouseEvent) {
if (!isDragging.value || !containerEl.value) return;
e.preventDefault();
const x = e.pageX - containerEl.value.offsetLeft;
const y = e.pageY - containerEl.value.offsetTop;
const walkX = x - startX.value;
const walkY = y - startY.value;
containerEl.value.scrollLeft = scrollLeft.value - walkX;
containerEl.value.scrollTop = scrollTop.value - walkY;
}
/** Stop dragging */
function onMouseUp() {
if (!containerEl.value) return;
isDragging.value = false;
containerEl.value.style.cursor = 'grab';
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
}
/** Store natural image size on load */
function onImageLoad(e: Event) {
const img = e.currentTarget as HTMLImageElement;
naturalWidth.value = img.naturalWidth;
naturalHeight.value = img.naturalHeight;
}
/** Toggle between current scale and original image size */
function toggleOriginalSize() {
const el = containerEl.value;
if (!el || naturalWidth.value === 0) return;
if (previousScale.value !== null) {
scale.value = previousScale.value;
previousScale.value = null;
return;
}
// Store current scale and zoom to original size
previousScale.value = scale.value;
const containerWidth = el.clientWidth;
const scaleFactor = naturalWidth.value / containerWidth;
scale.value = roundScale(scaleFactor);
}
</script>
<style scoped>
.ivm-wrapper {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.ivm-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
cursor: grab;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment