DEV Community

ndesmic
ndesmic

Posted on

How to make a pan and zoom control with Web Components

This one came up as I was trying to make a map display that the user can pan around. As far as I know there is no simple way to do this with just CSS even though mobile browsers basically do this by default so I decided to make a little control that lets me manipulate the content inside it with pan and zoom.

Boilerplate

export class WcPanBox extends HTMLElement {
    constructor() {
        super();
        this.bind(this);
    }
    bind(element) {
        element.attachEvents = element.attachEvents.bind(element);
        element.render = element.render.bind(element);
        element.cacheDom = element.cacheDom.bind(element);
        element.onWheel = element.onWheel.bind(element);
        element.onPointerDown = element.onPointerDown.bind(element);
        element.onPointerMove = element.onPointerMove.bind(element);
        element.onPointerUp = element.onPointerUp.bind(element);
    }
    render() {
        this.attachShadow({ mode: "open" });
        this.shadowRoot.innerHTML = `
            <style>
                #viewport { height: 100%; width: 100%; overflow: auto; cursor: grab; }
                #viewport.manipulating { cursor: grabbing; }
            </style>
            <div id="viewport">
                <slot></slot>
            </div>
        `;
    }
    connectedCallback() {
        this.render();
        this.cacheDom();
        this.attachEvents();
    }
    cacheDom() {
        this.dom = {
            viewport: this.shadowRoot.querySelector("#viewport")
        };
    }
    attachEvents() {
        this.dom.viewport.addEventListener("wheel", this.onWheel);
        this.dom.viewport.addEventListener("pointerdown", this.onPointerDown);
    }
    onWheel(e){

    }
    onPointerDown(e) {

    }
    onPointerMove(e) {

    }
    onPointerUp(e) {

    }
        attributeChangedCallback(name, oldValue, newValue) {
        this[name] = newValue;
    }
}

customElements.define("wc-pan-box", WcPanBox);
Enter fullscreen mode Exit fullscreen mode

Nothing interesting here, but I know a few of the things I'm going to need. Most of the functionality will be based off the #viewport div which will have the scroll bars and wraps the large content. Stylistically it'll use the grab and grabbing cursor so the user knows what to expect.

Panning

If you've seen my other articles about drag and drop it's the same idea.

onPointerDown(e) {
        e.preventDefault();
    this.dom.viewport.classList.add("manipulating");
    this.#lastPointer = [
        e.offsetX,
        e.offsetY
    ];
    this.#lastScroll = [
        this.dom.viewport.scrollLeft,
        this.dom.viewport.scrollTop
    ];
    this.dom.viewport.setPointerCapture(e.pointerId);
    this.dom.viewport.addEventListener("pointermove", this.onPointerMove);
    this.dom.viewport.addEventListener("pointerup", this.onPointerUp);
}
onPointerMove(e) {
    const currentPointer = [
        e.offsetX,
        e.offsetY
    ];
    const delta = [
        currentPointer[0] + this.#lastScroll[0] - this.#lastPointer[0],
        currentPointer[1] + this.#lastScroll[1] - this.#lastPointer[1]
    ];
    this.dom.viewport.scroll(this.#lastScroll[0] - delta[0], this.#lastScroll[1] - delta[1], { behavior: "instant" });
}
onPointerUp(e) {
    this.dom.viewport.classList.remove("manipulating");
    this.dom.viewport.removeEventListener("pointermove", this.onPointerMove);
    this.dom.viewport.removeEventListener("pointerup", this.onPointerUp);
    this.dom.viewport.releasePointerCapture(e.pointerId);
}
Enter fullscreen mode Exit fullscreen mode

We have 3 events for pointerdown, pointerup and pointermove. pointerdown will register the other 2 events and pointerup will unregister them. When we click the pointer (or touch with the stylus/finger) we want to save the starting location as everything is relative to it. This includes where the scroll position was when we touched down and the location of the touch. This should also preventDefault to avoid triggering unwanted actions while panning.

Once we've touched down now we can move. On each move we calculate the displacement from the current position (including the scroll offset) to the original position and then offset the original scroll position by that amount. We want the behavior to be instant so there's no animation to it while we move around.

Finally on pointer up we unregister the event listeners and reset back to the original down event.

2 things to notice are that we add a class to the viewport so we can add styles to the in-progress dragging (specifically this is to show the dragging cursor). We also use something called setPointerCapture on the viewport. This basically allows the events to keep firing on the element even if we mouse outside it. This is very handy and removes the need to set body event handlers to deal with it.

With this we have enough to drag-pan the image.

Zooming

Basic zooming isn't too bad because there's CSS that helps with this already. We will do our zooming with the mousewheel.

#zoom = 1;
static observedAttributes = ["zoom"];
onWheel(e){
    e.preventDefault();
    this.zoom += e.deltaY / 1000;
}
set zoom(val){
    this.#zoom = parseFloat(val);
        if(this.dom.viewport){
           this.dom.viewport.style.zoom = this.#zoom;
        }
}
get zoom(){
    return this.#zoom;
}
Enter fullscreen mode Exit fullscreen mode

First we prevent default because the default is to scroll and we've already handled that. Next is to set the zoom amount. There's no one way to do this. I've used a very linear delta / 1000 (delta for each notch on a scroll wheel is typically 100) and then add that to a cumulative zoom amount. The net result is scrolling up zooms out and scrolling down zooms in. This won't give the smoothest behavior but it's simple and works.

We'll also set up the ability to set this with an attribute with getters and setters over a private property. We'll parse anything that comes in as a float to ensure we're just dealing with numbers. #viewport might not exist early on so we a guard and a small edit to the DOM:

this.shadowRoot.innerHTML = `
    <style>
        #viewport { height: 100%; width: 100%; overflow: auto; cursor: grab; }
        #viewport.manipulating { cursor: grabbing; }
    </style>
    <div id="viewport" style="zoom: ${this.#zoom};">
        <slot></slot>
    </div>
`;
Enter fullscreen mode Exit fullscreen mode

We also want to be able to define the min and max zoom:

//add to observed attributes
set ["min-zoom"](val){
    this.#minZoom = val;
}
get ["min-zoom"](){
    return this.#minZoom;
}
set ["max-zoom"](val){
    this.#maxZoom = val;
}
get ["max-zoom"](){
    return this.#maxZoom;
}
Enter fullscreen mode Exit fullscreen mode

Then we can just clamp the range in between them:

set zoom(val){
    this.#zoom = Math.min(Math.max(parseFloat(val), this.#minZoom), this.#maxZoom);
    if(this.dom && this.dom.viewport){
        this.dom.viewport.style.zoom = this.#zoom;
    }
}
Enter fullscreen mode Exit fullscreen mode

Panning and Zooming

One problem is that these don't work well together. If you tried to zoom in and pan you'll find it jumps when you pointer down. This is because the pixel distances don't correspond to the zoom distances. If you are zoomed in by 2x then the starting point is actually X * 2. We need to take this into account.

In onPointerMove we need to divide by the current zoom level:

this.dom.viewport.scroll(this.#lastScroll[0] / this.#zoom - delta[0] / this.#zoom, this.#lastScroll[1] / this.#zoom - delta[1] / this.#zoom, { behavior: "instant" });
Enter fullscreen mode Exit fullscreen mode

Clicking the viewport

One issue that will come up is what happens when you want to click things in the viewport. We previously prevented default on pointer down to get around some of the issues. In the case of an image you'll try to drag-drop it which isn't what we want.

The way around this is to make manipulation explicit by using a modifier key. You might have seen this if you've used embedded Google maps. For us we can add another attribute called "modifier-key" to handle this:

//add "modifier-key" to observed attributes
set ["modifier-key"](val){
    this.#modifierKey = val;
}
get ["modifier-key"](){
    return this.#modifierKey;
}
Enter fullscreen mode Exit fullscreen mode

We'll have a new guard function for when you use it:

#isModifierDown(e){
    if(!this.#modifierKey) return true;
    if(this.#modifierKey === "ctrl" && e.ctrlKey) return true;
    if(this.#modifierKey === "alt" && e.altKey) return true;
    if(this.#modifierKey === "shift" && e.shiftKey) return true;
    return false;
}
Enter fullscreen mode Exit fullscreen mode

You need to supply "ctrl", "alt" or "shift". If none is supplied then you can always drag (we'll consider the key pressed all the time). The way this works is by examining some properties on the event. Even pointer events have these properties for modifier key which is great because we don't need a complex system to store the keyboard state on key up and key down. This also limits the modifier keys to just ones that are supported but that's probably not a big deal since those are probably the ones you want to use anyway.

On the very first line of onPointerDown add this:

if(!this.#isModifierDown(e)) return;
Enter fullscreen mode Exit fullscreen mode

Conclusion

This is a very simple way to achieve this effect. To be honest it's mostly useful for images and canvases but maybe not actual HTML elements, at least that's what it feels like. In the former cases you could probably roll this logic into your drawing routine to have full control over performance. It really feels like this could have been some built in CSS like scroll-behavior: pan; or something like that.

Discussion (0)