Skip to content

Instantly share code, notes, and snippets.

@ryantownsend
Last active June 5, 2023 09:27
Show Gist options
  • Select an option

  • Save ryantownsend/e543bdc6f0387600730a341cda3543d8 to your computer and use it in GitHub Desktop.

Select an option

Save ryantownsend/e543bdc6f0387600730a341cda3543d8 to your computer and use it in GitHub Desktop.
A pinch-to-zoom custom element for HTML
// author: Ryan Townsend (lessonsofacto.com)
//
// usage:
// ```html
// <pinch-to-zoom style="display:block">
// <img srcset="..." style="display:block;width:100%;aspect-ratio:1/1" />
// </pinch-to-zoom>
// <script src='/scripts/pinch-to-zoom.js' defer></script>
// ```
class PinchToZoom extends HTMLElement {
static calculateDistance(touches) {
return Math.hypot(touches.item(0).screenX - touches.item(1).screenX, touches.item(0).screenY - touches.item(1).screenY)
}
connectedCallback() {
this.addEventListener("touchstart", this.handleTouchStart.bind(this), { passive: false })
this.addEventListener("touchmove", this.handleTouchMove.bind(this), { passive: false })
this.addEventListener("touchend", this.handleTouchEnd.bind(this))
// tell the browser to expect pinch zoom
this.style.touchAction = "pinch-zoom"
// prevent user selection
this.style.userSelect = "none"
// prevent pointer events on child
this.querySelector(":scope > *").style.pointerEvents = "none"
}
disconnectedCallback() {
this.removeEventListener("touchstart", this.handleTouchStart.bind(this))
this.removeEventListener("touchmove", this.handleTouchMove.bind(this))
this.removeEventListener("touchend", this.handleTouchStart.bind(this))
}
handleTouchStart(event) {
// store the initial sizes of the element
this.initialWidth = this.offsetWidth
this.initialHeight = this.offsetHeight
// allow panning only when zoomed in
if (event.touches.length == 1 && this.currentScaleFactor > 1) {
this.handlePanStart(event)
} else if (event.touches.length == 2) {
this.handleZoomStart(event)
}
}
handleTouchMove(event) {
// allow panning only when zoomed in
if (event.touches.length == 1 && this.state == "panning") {
this.handlePanMove(event)
} else if (event.touches.length == 2) {
this.handleZoomMove(event)
}
}
handleTouchEnd() {
// if we're zoomed, tell the browser to expect zoom/pan, otherwise just zoom
this.style.touchAction = this.currentScaleFactor > 1 ? "manipulation" : "pinch-zoom"
// reset our state
this.state = null
}
handlePanStart(event) {
this.state = "panning"
// store where the singular touch started panning around
this.panStartX = event.touches.item(0).screenX
this.panStartY = event.touches.item(0).screenY
this.panStartTranslate = this.currentTranslate
// ignore the native event
event.preventDefault()
}
handleZoomStart(event) {
this.state = "zooming"
// store the scale factor we're starting zooming at
this.zoomStartScaleFactor = this.currentScaleFactor
// store the initial distance between the two touches
this.zoomStartDistance = this.constructor.calculateDistance(event.touches)
// ignore the native event
event.preventDefault()
}
handleZoomMove(event) {
// calculate the distance between the two touches
const zoomCurrentDistance = this.constructor.calculateDistance(event.touches)
// calculate how much we've zoomed in by relative to the starting distance between touches
const multiplier = zoomCurrentDistance / this.zoomStartDistance
// calculate the new scale factor from the factor when we started zooming, capped between 1x and 4x
this.currentScaleFactor = Math.min(Math.max(this.zoomStartScaleFactor * multiplier, 1), 4)
// enqueue a refresh
this.enqueueStyleUpdate()
// ignore the native event
event.preventDefault()
}
handlePanMove(event) {
// ignore events when not panning
if (!this.state || this.state != "panning") return
// update the translation based on the difference of the original touch to now
this.currentTranslate = {
x: this.panStartTranslate.x + event.touches.item(0).screenX - this.panStartX,
y: this.panStartTranslate.y + event.touches.item(0).screenY - this.panStartY
}
// enqueue a refresh
this.enqueueStyleUpdate()
// ignore the native event
event.preventDefault()
}
enqueueStyleUpdate() {
// if we already have an enqueued animation frame request, cancel it
if (this.animationRequest) window.cancelAnimationFrame(this.animationRequest)
// initiate a new request
this.animationRequest = window.requestAnimationFrame(this.updateStyles.bind(this))
}
updateStyles() {
this.style.scale = this.currentScaleFactor
this.style.translate = `${this.currentTranslate.x}px ${this.currentTranslate.y}px`
}
get currentScaleFactor() {
return (this.rawCurrentScaleFactor ??= 1)
}
set currentScaleFactor(value) {
this.rawCurrentScaleFactor = value
// reapply caps on translate as may have changed with zooming
this.currentTranslate = this.currentTranslate
}
set currentTranslate({ x, y }) {
this.rawCurrentTranslateX = Math.min(Math.max(x, this.minPanX), this.maxPanX)
this.rawCurrentTranslateY = Math.min(Math.max(y, this.minPanY), this.maxPanY)
}
get currentTranslate() {
return { x: this.rawCurrentTranslateX || 0, y: this.rawCurrentTranslateY || 0 }
}
get minPanX() {
return 0 - Math.floor(this.initialWidth * ((this.currentScaleFactor - 1) / 2))
}
get maxPanX() {
return Math.floor(this.initialWidth * ((this.currentScaleFactor - 1) / 2))
}
get minPanY() {
return 0 - Math.floor(this.initialHeight * ((this.currentScaleFactor - 1) / 2))
}
get maxPanY() {
return Math.floor(this.initialHeight * ((this.currentScaleFactor - 1) / 2))
}
}
// only enable on touch-capable devices
if (window.matchMedia("(any-pointer: coarse)").matches) {
customElements.define("pinch-to-zoom", PinchToZoom)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment