I did recently something exactly like i.e: Photoshop.
Heads up: The task was tricky. Here are my discoveries and implementation suggestions.
GitHub: ZoomPan.js

Custom scrollbars
Don't use the browser's default scrollbars on your Viewport element. I really tried to be lazy and create a wrapping, bigger #area
Element that will be used to force native scrollbars on the #viewport
. Too many messy calculations and scrollTo()
adjustments.
I ended up opting for my own custom scrollbars. Regarding the missing #area
- I just decided to calculate that "area" size width
and height
on the fly (recalculated on init and zoom), used only to determine the scrollbar-thumbs sizes and to prevent the panned canvas go beyond some defined "safe" padd edge. Another pros is that I can position and size them however it fits the app with other neighboring UI elements.
HTML and CSS
Here's the markup
<div id="editor">
<div id="viewport"><div id="canvas"></div></div>
<div class="scrollTrack" id="scrollTrack-x"><div class="scrollThumb"></div></div>
<div class="scrollTrack" id="scrollTrack-y"><div class="scrollThumb"></div></div>
</div>
the important part is the absolute centering of #canvas
within the #viewport
. Easily done with CSS and flex:
#viewport {
position: relative;
overflow: hidden; /* We will make or own scrollbars */
width: 100%; /* Fit into #editor */
height: 100%;
display: flex; /* Center the #canvas */
align-items: center; /* Center the #canvas */
justify-content: center; /* Center the #canvas */
}
#canvas {
flex: none; /* Prevent flexing */
transform-origin: 50% 50%;
/* width and height here or rather via your app settings */
}
The logic
The offsetting and scaling logic is all calculated from the #canvas's center coordinates. Also notice the CSS: transform-origin: 50% 50%;
.
const offset = {x:0, y:0}; // Canvas offset (0,0 from center)
Area

There's an important aspect of the editor, and that's the aforementioned fictive pannable area. Say you want to pan the Canvas inside the Viewport, you want to prevent the canvas to exit completely the viewport on any side. You need to restrict the pan motion to a specified padd
amount of canvas min visibility pixels.
You need to recalculate that fictive area size after every scale (zoom) operation:
const bcrVpt = elVpt.getBoundingClientRect();
const bcrCvs = elCvs.getBoundingClientRect();
// Fictive "outer bounding area" size:
areaWidth = (bcrVpt.width - padd) * 2 + bcrCvs.width;
areaHeight = (bcrVpt.height - padd) * 2 + bcrCvs.height;
Before applying translate and scale to your #canvas you can always make sure to fix the canvas's offset.x,y
to not exceed the available pan space given by areaWidth
and areaHeight
.

Panning
Panning is the simplest. it's calculated like:
offset.x += evt.movementX;
offset.y += evt.movementY;
where evt.movement[XY]
is the difference in the pointer (mouse) start / current positions, or if you will:
offset.x += pointerStartX - pointerCurrentX;
offset.y += pointerStartY - pointerCurrentY;
and you can apply immediately the transformations to the #canvas
element. More on that later.
Scaling
Scaling is nothing but changing the scale by a scale factor by a given delta
(-1
or +1
)
// On wheel or +/- buttons set delta to +1 or -1
const changeScale = (delta) => {
scale *= Math.exp(delta * scaleFactor);
}
Transform by scale
Scaling was easy, now we have to change the #canvas offset
translation depending on the pointer position inside the #viewport by the change in scale to allow the user to wheel-zoom on an exact point.
To calculate scale-transforms you first need to normalize the mouse position relative to the #canvas center in its current sizes state (which might be: original, down-scaled or up-scaled).
Let's say, for the X axis: how much px to offset the #canvas?
const bcrCvs = elCvs.getBoundingClientRect();
// Get XY coords of #canvas FROM CENTER!
// This values are "current" (on the currently transformed #canvas)
const x = evt.x - bcrCvs.left - bcrCvs.width / 2;
// Remember the current scale
const scaleOld = scale;
// Change the scale value by delta
changeScale(delta); // PS: scale is now changed!
// Calculate the XY as if the element is in its original, non-scaled size:
const xOrg = xReal / scaleOld;
// Calculate the scaled XY
const xNew = xOrg * scale; // PS: scale here is the new scale.
// Retrieve the XY difference to be used as the change in offset.
const xDiff = xReal - xNew;
offset.x += xDiff;
add also for Y axis.
Scrollbars
Zooming and panning should work by now. One left thing to do is: scrollbars.
Thanks to the changing fictive area width, height values, we can now determine the scrollbars size. For the X axis scrollbar:
const bcrVpt = elVpt.getBoundingClientRect();
const bcrCvs = elCvs.getBoundingClientRect();
// Fictive "outer bounding area" size:
areaWidth = (bcrVpt.width - padd) * 2 + bcrCvs.width;
const thumbSizeX = bcrVpt.width ** 2 / areaWidth;
const cvsRelX = bcrCvs.left - bcrVpt.left;
const thumbPosX = (bcrVpt.width - cvsRelX - padd) / bcrVpt.width * thumbSizeX;
elScrXThumb.style.width = `${thumbSizeX}px`;
elScrXThumb.style.left = `${thumbPosX}px`;
Drag scrollbars
one thing left to do is the scrollbars dragging. Simply change the #canvas offset by:
offset.x -= (areaWidth / elVpt.offsetWidth) * evt.movementX;
Apply the above for the Y scrollbar as well.
That was pretty much it.
Some more missing improvements are functions like scaleToFit(), to scale on init the #canvas to best fit the #viewport and the mouse controls.
Regarding the keyboard + mouse, use the JS's Event.ctrlKey || Event.metaKey
to register for if such keys were pressed during i.e: the "wheel" event. etc.
Find more in the provided github example.
Some other related resources: