mykola.rykov
mykola.rykov

Reputation: 572

How to implement zooming with a mouse wheel like it's done in a typical graphic editor

Almost every Graphic Editor allows to zoom the image and a "working area background" with a Ctrl + mouse wheel.

zoom + move + scrollbars behavior

The tricky part is that there are many UI elements involved:

  1. "working area background" - that darker area behind the image that is zoomed
  2. scrollbars which are updated correctly and can be used to pan around the working area
  3. actual image (that blue blueprint pattern) - element that visually get's zoomed

Notice that the zooming behavior is different depending on whether the mouse pointer was above the image or not:

It seems to be nicely implemented decades ago.

Are there any open-source projects with a similar "zoom + move + scrollbars" behavior to look at their code and learn from it?

Thanks!

Upvotes: 7

Views: 6618

Answers (2)

Roko C. Buljan
Roko C. Buljan

Reputation: 206048

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

Zoom pan area with scrollbars like image edit tool (photoshop)

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

enter image description here

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.

enter image description here

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:

Upvotes: 5

Raffobaffo
Raffobaffo

Reputation: 2856

The trigger is called wheel evenent, you can read about it here.

Don't confuse the wheel event with the scroll event. The default action of a wheel event is implementation-specific, and doesn't necessarily dispatch a scroll event

An implementation of what you show, would start taking the current position of the mouse position, and use it to enlarge/reduce the container size consequentially. The effect that when you zoom on a particular part of the image is kept "centered" in the screen, is made by continually reposition the image based on the actual scale.

The scrollbars are reacting to the dimensions change 'cause they have a fixed width and height, you can see it because zooming in or out does not change the "editor" dimensions.

Upvotes: 0

Related Questions