Last active
June 5, 2023 09:27
-
-
Save ryantownsend/e543bdc6f0387600730a341cda3543d8 to your computer and use it in GitHub Desktop.
A pinch-to-zoom custom element for HTML
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
| // 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