Skip to content

Commit

Permalink
feat(images): Pinch to zoom - initial implementation
Browse files Browse the repository at this point in the history
Signed-off-by: Dariusz Olszewski <starypatyk@users.noreply.github.com>
  • Loading branch information
starypatyk committed Mar 10, 2024
1 parent 003f31f commit e96662c
Showing 1 changed file with 122 additions and 56 deletions.
178 changes: 122 additions & 56 deletions src/components/Images.vue
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,25 @@
:class="{
dragging,
loaded,
zoomed: zoomRatio !== 1
zoomed: zoomRatio > 1
}"
:src="data"
:style="imgStyle"
@error.capture.prevent.stop.once="onFail"
@load="updateImgSize"
@wheel="updateZoom"
@wheel.stop.prevent="updateZoom"
@dblclick.prevent="onDblclick"
@pointerdown.prevent="dragStart">
@pointerdown.prevent="dragStart"
@pointerup.prevent="dragEnd"
@pointermove.prevent="dragHandler">

<template v-if="livePhoto">
<video v-show="livePhotoCanBePlayed"
ref="video"
:class="{
dragging,
loaded,
zoomed: zoomRatio !== 1
zoomed: zoomRatio > 1
}"
:style="imgStyle"
:playsinline="true"
Expand All @@ -60,10 +62,12 @@
preload="metadata"
@canplaythrough="doneLoadingLivePhoto"
@loadedmetadata="updateImgSize"
@wheel="updateZoom"
@wheel.stop.prevent="updateZoom"
@error.capture.prevent.stop.once="onFail"
@dblclick.prevent="onDblclick"
@pointerdown.prevent="dragStart"
@pointerup.prevent="dragEnd"
@pointermove.prevent="dragHandler"
@ended="stopLivePhoto" />
<button v-if="width !== 0"
class="live-photo_play_button"
Expand Down Expand Up @@ -128,6 +132,10 @@ export default {
zoomRatio: 1,
fallback: false,
livePhotoCanBePlayed: false,
zooming: false,
pinchDistance: 0,
pinchStartZoomRatio: 1,
pointerCache: [],
}
},
Expand Down Expand Up @@ -204,9 +212,11 @@ export default {
if (val === true && old === false) {
this.resetZoom()
// end the dragging if your pointer (mouse or touch) go out of the content
// Not sure why ???
window.addEventListener('pointerout', this.dragEnd)
// the item is not displayed
} else if (val === false) {
// Not sure why ???
window.removeEventListener('pointerout', this.dragEnd)
}
},
Expand Down Expand Up @@ -236,6 +246,45 @@ export default {
return `data:${this.mime};base64,${btoa(file.data)}`
},
// Helper methods for zoom/pan operations
updateShift(newShiftX, newShiftY, newZoomRatio) {
const maxShiftX = this.width * newZoomRatio - this.width
const maxShiftY = this.height * newZoomRatio - this.height
this.shiftX = Math.min(Math.max(newShiftX, -maxShiftX / 2), maxShiftX / 2)
this.shiftY = Math.min(Math.max(newShiftY, -maxShiftY / 2), maxShiftY / 2)
},
// Change zoom ratio of the image to newZoomRatio.
// Try to make sure that image position at stableX, stableY
// in client coordinates stays in the same place on the screen.
updateZoomAndShift(stableX, stableY, newZoomRatio) {
// scrolling position relative to the image
const element = this.$refs.image ?? this.$refs.video
const scrollX = stableX - element.getBoundingClientRect().x - (this.width * this.zoomRatio / 2)
const scrollY = stableY - element.getBoundingClientRect().y - (this.height * this.zoomRatio / 2)
const scrollPercX = scrollX / (this.width * this.zoomRatio)
const scrollPercY = scrollY / (this.height * this.zoomRatio)
// calc how much the img grow from its current size
// and adjust the margin accordingly
const growX = this.width * newZoomRatio - this.width * this.zoomRatio
const growY = this.height * newZoomRatio - this.height * this.zoomRatio
// compensate for existing margins
const newShiftX = this.shiftX - scrollPercX * growX
const newShiftY = this.shiftY - scrollPercY * growY
this.updateShift(newShiftX, newShiftY, newZoomRatio)
this.zoomRatio = newZoomRatio
},
distanceBetweenTouches() {
const t0 = this.pointerCache[0]
const t1 = this.pointerCache[1]
const diffX = (t1.x - t0.x)
const diffY = (t1.y - t0.y)
return Math.sqrt(diffX * diffX + diffY * diffY)
},
/**
* Handle zooming
*
Expand All @@ -247,17 +296,7 @@ export default {
return
}
event.stopPropagation()
event.preventDefault()
// scrolling position relative to the image
const element = this.$refs.image ?? this.$refs.video
const scrollX = event.clientX - element.x - (this.width * this.zoomRatio / 2)
const scrollY = event.clientY - element.y - (this.height * this.zoomRatio / 2)
const scrollPercX = scrollX / (this.width * this.zoomRatio)
const scrollPercY = scrollY / (this.height * this.zoomRatio)
const isZoomIn = event.deltaY < 0
const newZoomRatio = isZoomIn
? Math.min(this.zoomRatio * 1.1, 5) // prevent too big zoom
: Math.max(this.zoomRatio / 1.1, 1) // prevent too small zoom
Expand All @@ -267,16 +306,8 @@ export default {
return this.resetZoom()
}
// calc how much the img grow from its current size
// and adjust the margin accordingly
const growX = this.width * newZoomRatio - this.width * this.zoomRatio
const growY = this.height * newZoomRatio - this.height * this.zoomRatio
// compensate for existing margins
this.disableSwipe()
this.shiftX = this.shiftX - scrollPercX * growX
this.shiftY = this.shiftY - scrollPercY * growY
this.zoomRatio = newZoomRatio
this.updateZoomAndShift(event.clientX, event.clientY, newZoomRatio)
},
resetZoom() {
Expand All @@ -286,52 +317,91 @@ export default {
this.shiftY = 0
},
// Pinch-zoom implementation based on:
// https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events/Pinch_zoom_gestures
/**
* Dragging handlers
*
* @param {DragEvent} event the event
*/
dragStart(event) {
const { pageX, pageY } = event
// New pointer - mouse down or additional touch --> store client coordinates in the pointer cache
this.pointerCache.push({ pointerId: event.pointerId, x: event.clientX, y: event.clientY })
// Single touch or mouse down --> start dragging
if (this.pointerCache.length === 1) {
this.dragX = event.clientX
this.dragY = event.clientY
this.dragging = true
}
this.dragX = pageX
this.dragY = pageY
this.dragging = true
const element = this.$refs.image ?? this.$refs.video
element.onpointerup = this.dragEnd
element.onpointermove = this.dragHandler
// Two touches --> start (pinch) zooming
if (this.pointerCache.length === 2) {
// Calculate base (reference) distance between touches
this.pinchDistance = this.distanceBetweenTouches()
this.pinchStartZoomRatio = this.zoomRatio
this.zooming = true
this.disableSwipe()
}
},
/**
* @param {DragEvent} event the event
*/
dragEnd(event) {
event.preventDefault()
// Remove pointer from the pointer cache
const index = this.pointerCache.findIndex(
(cachedEv) => cachedEv.pointerId === event.pointerId,
)
this.pointerCache.splice(index, 1)
this.dragging = false
const element = this.$refs.image ?? this.$refs.video
if (element) {
element.onpointerup = null
element.onpointermove = null
}
this.zooming = false
},
/**
* @param {DragEvent} event the event
*/
dragHandler(event) {
event.preventDefault()
const { pageX, pageY } = event
if (this.dragging && this.zoomRatio > 1 && pageX > 0 && pageY > 0) {
const moveX = this.shiftX + (pageX - this.dragX)
const moveY = this.shiftY + (pageY - this.dragY)
const growX = this.zoomWidth - this.width
const growY = this.zoomHeight - this.height
this.shiftX = Math.min(Math.max(moveX, -growX / 2), growX / 2)
this.shiftY = Math.min(Math.max(moveY, -growY / 2), growY / 2)
this.dragX = pageX
this.dragY = pageY
if (this.pointerCache.length > 0) {
// Update pointer position in the pointer cache
const index = this.pointerCache.findIndex(
(cachedEv) => cachedEv.pointerId === event.pointerId,
)
if (index >= 0) {
this.pointerCache[index].x = event.clientX
this.pointerCache[index].y = event.clientY
}
}
// Single touch or mouse down --> dragging
if (this.pointerCache.length === 1 && this.dragging && !this.zooming && this.zoomRatio > 1) {
const {clientX, clientY} = event
const newShiftX = this.shiftX + (clientX - this.dragX)
const newShiftY = this.shiftY + (clientY - this.dragY)
this.updateShift(newShiftX, newShiftY, this.zoomRatio)
this.dragX = clientX
this.dragY = clientY
}
// Two touches --> (pinch) zooming
if (this.pointerCache.length === 2 && this.zooming) {
// Calculate current distance between touches
const newDistance = this.distanceBetweenTouches()
// Calculate new zoom ratio - keep it between 1 and 5
const newZoomRatio = Math.min(Math.max(this.pinchStartZoomRatio * (newDistance / this.pinchDistance), 1), 5)
// Calculate "stable" point - in the middle between touches
const t0 = this.pointerCache[0]
const t1 = this.pointerCache[1]
const stableX = (t0.x + t1.x) / 2
const stableY = (t0.y + t1.y) / 2
this.updateZoomAndShift(stableX, stableY, newZoomRatio)
}
},
onDblclick() {
if (!this.canZoom) {
Expand Down Expand Up @@ -392,14 +462,13 @@ $checkered-color: #efefef;
}
img, video {
max-width: 100%;
max-height: 100%;
align-self: center;
justify-self: center;
// black while loading
background-color: #000;
// disable animations during zooming/resize
transition: none !important;
touch-action: none;
// show checkered bg on hover if not currently zooming (but ok if zoomed)
&:hover {
background-image: linear-gradient(45deg, #{$checkered-color} 25%, transparent 25%),
Expand All @@ -414,9 +483,6 @@ img, video {
background-color: #fff;
}
&.zoomed {
position: absolute;
max-height: none;
max-width: none;
z-index: 10010;
cursor: move;
}
Expand Down

0 comments on commit e96662c

Please sign in to comment.