How to make a pan and zoom control with Web Components
Tags: webcomponents, vanillajs, pointerevents
Published: 2021-11-09 05:34:39

{% codepen https://codepen.io/ndesmic/pen/oNeqxBa %}

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 

```js
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);
```
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.  

```js
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);
}
```
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.

```js
#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;
}
```

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:

```js
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>
`;
```

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

```js
//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;
}
```
Then we can just clamp the range in between them:

```js
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;
	}
}
```

## 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:

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

## 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:

```js
//add "modifier-key" to observed attributes
set ["modifier-key"](val){
	this.#modifierKey = val;
}
get ["modifier-key"](){
	return this.#modifierKey;
}
```
We'll have a new guard function for when you use it:

```js
#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;
}
```
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:
```js
if(!this.#isModifierDown(e)) return;
```

## 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.