<template> <div id="image-viewer" ref="viewer" :class="{opened: link !== null}" @wheel="onMouseWheel" @touchstart.passive="onTouchStart" @click="onClick" > <template v-if="link !== null"> <button class="close-btn" aria-label="Close"></button> <a class="open-btn" :href="link.link" target="_blank" rel="noopener"></a> <img ref="image" :src="link.thumb" alt="" :style="computeImageStyles" @load="onImageLoad" @mousedown="onImageMouseDown" @touchstart.passive="onImageTouchStart" /> </template> </div> </template> <script> export default { name: "ImageViewer", data() { return { link: null, position: { x: 0, y: 0, }, transform: { x: 0, y: 0, scale: 0, }, }; }, computed: { computeImageStyles() { // Sub pixels may cause the image to blur in certain browsers // round it down to prevent that const transformX = Math.floor(this.transform.x); const transformY = Math.floor(this.transform.y); return { left: `${this.position.x}px`, top: `${this.position.y}px`, transform: `translate3d(${transformX}px, ${transformY}px, 0) scale3d(${this.transform.scale}, ${this.transform.scale}, 1)`, }; }, }, watch: { link() { // TODO: history.pushState if (this.link === null) { return; } this.$root.$on("resize", this.correctPosition); }, }, mounted() { this.$root.$on("escapekey", this.closeViewer); }, destroyed() { this.$root.$off("escapekey", this.closeViewer); }, methods: { closeViewer() { if (this.link === null) { return; } this.$root.$off("resize", this.correctPosition); this.link = null; }, onImageLoad() { this.prepareImage(); }, prepareImage() { const viewer = this.$refs.viewer; const image = this.$refs.image; const width = viewer.offsetWidth; const height = viewer.offsetHeight; const scale = Math.min(1, width / image.width, height / image.height); this.position.x = Math.floor(-image.naturalWidth / 2); this.position.y = Math.floor(-image.naturalHeight / 2); this.transform.scale = Math.max(scale, 0.1); this.transform.x = width / 2; this.transform.y = height / 2; }, calculateZoomShift(newScale, x, y, oldScale) { const imageWidth = this.$refs.image.width; const centerX = this.$refs.viewer.offsetWidth / 2; const centerY = this.$refs.viewer.offsetHeight / 2; return { x: centerX - ((centerX - (y - (imageWidth * x) / 2)) / x) * newScale + (imageWidth * newScale) / 2, y: centerY - ((centerY - (oldScale - (imageWidth * x) / 2)) / x) * newScale + (imageWidth * newScale) / 2, }; }, correctPosition() { const image = this.$refs.image; const widthScaled = image.width * this.transform.scale; const heightScaled = image.height * this.transform.scale; const containerWidth = this.$refs.viewer.offsetWidth; const containerHeight = this.$refs.viewer.offsetHeight; if (widthScaled < containerWidth) { this.transform.x = containerWidth / 2; } else if (this.transform.x - widthScaled / 2 > 0) { this.transform.x = widthScaled / 2; } else if (this.transform.x + widthScaled / 2 < containerWidth) { this.transform.x = containerWidth - widthScaled / 2; } if (heightScaled < containerHeight) { this.transform.y = containerHeight / 2; } else if (this.transform.y - heightScaled / 2 > 0) { this.transform.y = heightScaled / 2; } else if (this.transform.y + heightScaled / 2 < containerHeight) { this.transform.y = containerHeight - heightScaled / 2; } }, // Reduce multiple touch points into a single x/y/scale reduceTouches(touches) { let totalX = 0; let totalY = 0; let totalScale = 0; for (let i = 0; i < touches.length; i++) { const x = touches[i].clientX; const y = touches[i].clientY; totalX += x; totalY += y; for (let i2 = 0; i2 < touches.length; i2++) { if (i !== i2) { const x2 = touches[i2].clientX; const y2 = touches[i2].clientY; totalScale += Math.sqrt((x - x2) * (x - x2) + (y - y2) * (y - y2)); } } } if (totalScale === 0) { totalScale = 1; } return { x: totalX / touches.length, y: totalY / touches.length, scale: totalScale / touches.length, }; }, onTouchStart(e) { // prevent sidebar touchstart event, we don't want to interact with sidebar while in image viewer e.stopImmediatePropagation(); }, // Touch image manipulation: // 1. Move around by dragging it with one finger // 2. Change image scale by using two fingers onImageTouchStart(e) { const image = this.$refs.image; let touch = this.reduceTouches(e.touches); let currentTouches = e.touches; let touchEndFingers = 0; const currentTransform = { x: touch.x, y: touch.y, scale: touch.scale, }; const startTransform = { x: this.transform.x, y: this.transform.y, scale: this.transform.scale, }; const touchMove = (moveEvent) => { touch = this.reduceTouches(moveEvent.touches); if (currentTouches.length !== moveEvent.touches.length) { currentTransform.x = touch.x; currentTransform.y = touch.y; currentTransform.scale = touch.scale; startTransform.x = this.transform.x; startTransform.y = this.transform.y; startTransform.scale = this.transform.scale; } const deltaX = touch.x - currentTransform.x; const deltaY = touch.y - currentTransform.y; const deltaScale = touch.scale / currentTransform.scale; currentTouches = moveEvent.touches; touchEndFingers = 0; const newScale = Math.min(3, Math.max(0.1, startTransform.scale * deltaScale)); const fixedPosition = this.calculateZoomShift( newScale, startTransform.scale, startTransform.x, startTransform.y ); this.transform.x = fixedPosition.x + deltaX; this.transform.y = fixedPosition.y + deltaY; this.transform.scale = newScale; this.correctPosition(); }; const touchEnd = (endEvent) => { const changedTouches = endEvent.changedTouches.length; if (currentTouches.length > changedTouches + touchEndFingers) { touchEndFingers += changedTouches; return; } // todo: this is swipe to close, but it's not working very well due to unfinished delta calculation /* if ( this.transform.scale <= 1 && endEvent.changedTouches[0].clientY - startTransform.y <= -70 ) { return this.closeViewer(); }*/ this.correctPosition(); image.removeEventListener("touchmove", touchMove, {passive: true}); image.removeEventListener("touchend", touchEnd, {passive: true}); }; image.addEventListener("touchmove", touchMove, {passive: true}); image.addEventListener("touchend", touchEnd, {passive: true}); }, // Image mouse manipulation: // 1. Mouse wheel scrolling will zoom in and out // 2. If image is zoomed in, simply dragging it will move it around onImageMouseDown(e) { // todo: ignore if in touch event currently? // only left mouse if (e.which !== 1) { return; } e.stopPropagation(); e.preventDefault(); const viewer = this.$refs.viewer; const image = this.$refs.image; const startX = e.clientX; const startY = e.clientY; const startTransformX = this.transform.x; const startTransformY = this.transform.y; const widthScaled = image.width * this.transform.scale; const heightScaled = image.height * this.transform.scale; const containerWidth = viewer.offsetWidth; const containerHeight = viewer.offsetHeight; const centerX = this.transform.x - widthScaled / 2; const centerY = this.transform.y - heightScaled / 2; let movedDistance = 0; const mouseMove = (moveEvent) => { moveEvent.stopPropagation(); moveEvent.preventDefault(); const newX = moveEvent.clientX - startX; const newY = moveEvent.clientY - startY; movedDistance = Math.max(movedDistance, Math.abs(newX), Math.abs(newY)); if (centerX < 0 || widthScaled + centerX > containerWidth) { this.transform.x = startTransformX + newX; } if (centerY < 0 || heightScaled + centerY > containerHeight) { this.transform.y = startTransformY + newY; } this.correctPosition(); }; const mouseUp = (upEvent) => { this.correctPosition(); if (movedDistance < 2 && upEvent.button === 0) { this.closeViewer(); } image.removeEventListener("mousemove", mouseMove); image.removeEventListener("mouseup", mouseUp); }; image.addEventListener("mousemove", mouseMove); image.addEventListener("mouseup", mouseUp); }, // If image is zoomed in, holding ctrl while scrolling will move the image up and down onMouseWheel(e) { // if image viewer is closing (css animation), you can still trigger mousewheel // TODO: Figure out a better fix for this if (this.link === null) { return; } e.preventDefault(); // TODO: Can this be passive? if (e.ctrlKey) { this.transform.y += e.deltaY; } else { const delta = e.deltaY > 0 ? 0.1 : -0.1; const newScale = Math.min(3, Math.max(0.1, this.transform.scale + delta)); const fixedPosition = this.calculateZoomShift( newScale, this.transform.scale, this.transform.x, this.transform.y ); this.transform.scale = newScale; this.transform.x = fixedPosition.x; this.transform.y = fixedPosition.y; } this.correctPosition(); }, onClick(e) { // If click triggers on the image, ignore it if (e.target === this.$refs.image) { return; } this.closeViewer(); }, }, }; </script>