Created
August 11, 2025 14:51
-
-
Save geschke/e57681d1ca8b2cc924146dcee932be9a to your computer and use it in GitHub Desktop.
A small Vue.js image viewer component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <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