From 5c4b402341989aaad5e54073ef10f9ff63207b37 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Thu, 3 Oct 2019 16:12:49 +0300 Subject: [PATCH] Fancy image interactions in the image viewer Desktop: - Mousewheel to zoom in/out (hold ctrl to move up/down) - If zoomed, drag around with mouse to move Mobile: - Move around with one finger - Change zoom with two fingers --- client/components/ImageViewer.vue | 321 ++++++++++++++++++++++++++++-- client/css/style.css | 37 ++-- 2 files changed, 325 insertions(+), 33 deletions(-) diff --git a/client/components/ImageViewer.vue b/client/components/ImageViewer.vue index bce41f79..f572d0b6 100644 --- a/client/components/ImageViewer.vue +++ b/client/components/ImageViewer.vue @@ -1,23 +1,25 @@ @@ -28,6 +30,15 @@ export default { data() { return { link: null, + position: { + x: 0, + y: 0, + }, + transform: { + x: 0, + y: 0, + scale: 0, + }, }; }, computed: { @@ -42,6 +53,11 @@ export default { watch: { link() { // TODO: history.pushState + if (this.link === null) { + return; + } + + this.$root.$on("resize", this.correctPosition); }, }, mounted() { @@ -53,8 +69,281 @@ export default { }, methods: { closeViewer() { + 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 = -image.naturalWidth / 2; + this.position.y = -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); + + // TODO: There's bugs with multi finger interactions, needs more testing + 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 + ); + + if (newScale > 1) { + this.transform.x = fixedPosition.x + deltaX; + this.transform.y = fixedPosition.y + deltaY; + } else if (Math.abs(deltaX) > Math.abs(deltaY)) { + this.transform.x = fixedPosition.x + deltaX; + } else { + this.transform.y = fixedPosition.y + deltaY; + } + + this.transform.scale = newScale; + }; + + 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; + } + }; + + 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(); + }, }, }; diff --git a/client/css/style.css b/client/css/style.css index 5544bc6b..fce2f5ca 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -278,6 +278,7 @@ kbd { #help .report-issue-link::before, #image-viewer .previous-image-btn::before, #image-viewer .next-image-btn::before, +#image-viewer .open-btn::before, #sidebar .not-secure-icon::before, #sidebar .not-connected-icon::before, #sidebar .parted-channel-icon::before, @@ -477,6 +478,10 @@ kbd { content: "\f105"; /* http://fontawesome.io/icon/angle-right/ */ } +#image-viewer .open-btn::before { + content: "\f35d"; /* https://fontawesome.com/icons/external-link-alt?style=solid */ +} + /* End icons */ #viewport { @@ -2616,6 +2621,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ #upload-overlay, #image-viewer, +#image-viewer .open-btn, #image-viewer .close-btn { /* Vertically and horizontally center stuff */ display: flex; @@ -2636,6 +2642,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ opacity: 0; transition: opacity 0.2s, visibility 0.2s; z-index: 999; + user-select: none; } #image-viewer.opened { @@ -2649,6 +2656,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } #image-viewer .close-btn, +#image-viewer .open-btn, #image-viewer .previous-image-btn, #image-viewer .next-image-btn { position: fixed; @@ -2670,6 +2678,14 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ content: "×"; } +#image-viewer .open-btn { + right: 0; + bottom: 0; + top: auto; + height: 2em; + z-index: 1002; +} + #image-viewer .previous-image-btn, #image-viewer .next-image-btn { bottom: 0; @@ -2690,19 +2706,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ opacity: 1; } -#image-viewer .image-link { - margin: 10px; -} - -#image-viewer .image-link:hover { - opacity: 1; -} - -#image-viewer .image-link img { - max-width: 100%; - - /* Top/Bottom margins + button height + image/button margin */ - max-height: calc(100vh - 2 * 10px - 37px - 10px); +#image-viewer > img { + cursor: grab; + position: absolute; + transform-origin: 50% 50%; /* Checkered background for transparent images */ background-position: 0 0, 10px 10px; @@ -2712,10 +2719,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ linear-gradient(45deg, #eee 25%, #fff 25%, #fff 75%, #eee 75%, #eee 100%); } -#image-viewer .open-btn { - margin: 0 auto 10px; -} - /* Correctly handle multiple successive whitespace characters. For example: user has quit ( ===> L O L <=== ) */