11

I am struggling to figure out and determine how to zoom on my mouse position based on this example. (https://stackblitz.com/edit/js-fxnmkm?file=index.js)

let node,
    scale = 1,
    posX = 0,
    posY = 0,
    node = document.querySelector('.frame');

const render = () => {
  window.requestAnimationFrame(() => {
    let val = `translate3D(${posX}px, ${posY}px, 0px) scale(${scale})`
    node.style.transform = val
  })
}

window.addEventListener('wheel', (e) => {
  e.preventDefault();

  // Zooming happens here
  if (e.ctrlKey) {
    scale -= e.deltaY * 0.01;
  } else {
    posX -= e.deltaX * 2;
    posY -= e.deltaY * 2;
  }

  render();
});

My desired effect is based on this example (https://codepen.io/techslides/pen/zowLd?editors=0010) when zooming in. Currently my example above only scales to the center of the "viewport" but I want it to be where my cursor currently is.

I have searched high and low for a solution that is not implemented via canvas. Any help would be appreciated!

Caveat The reason why I am using the wheel event is to mimic the interaction of Figma (the design tool) panning and zooming.

Nige
  • 1,206
  • 2
  • 10
  • 21
  • Where and how do your vars come in `scale`, `posX`, and `posY`? Also have you researched `e.deltaX` and `e.deltaY`. I ask because if they produce a *change* on that vector, then shouldn't you have `+=`? – GetSet Feb 12 '20 at 14:52
  • @GetSet I have updated the example to include the whole basic example from my link. (https://stackblitz.com/edit/js-fxnmkm?file=index.js) – Nige Feb 12 '20 at 14:57
  • what should be your expeacted output the first link or 2nd? – Aashif Ahamed Feb 14 '20 at 18:33
  • The zoom should be like the second link – Nige Feb 14 '20 at 20:09

5 Answers5

27

Use the canvas for zoomable content

Zooming and panning elements is very problematic. It can be done but the list of issues is very long. I would never implement such an interface.

Consider using the canvas, via 2D or WebGL to display such content to save your self many many problems.

The first part of the answer is implemented using the canvas. The same interface view is used in the second example that pans and zooms an element.

A simple 2D view.

As you are only panning and zooming then a very simple method can be used.

The example below implements an object called view. This holds the current scale and position (pan)

It provides two function for user interaction.

  • Panning the function view.pan(amount) will pan the view by distance in pixels held by amount.x, amount.y
  • Zooming the function view.scaleAt(at, amount) will scale (zoom in out) the view by amount (a number representing change in scale), at the position held by at.x, at.y in pixels.

In the example the view is applied to the canvas rendering context using view.apply() and a set of random boxes are rendered whenever the view changes. The panning and zooming is via mouse events

Example using canvas 2D context

Use mouse button drag to pan, wheel to zoom

const ctx = canvas.getContext("2d");
canvas.width = 500;
canvas.height = 500;
const rand = (m = 255, M = m + (m = 0)) => (Math.random() * (M - m) + m) | 0;


const objects = [];
for (let i = 0; i < 100; i++) {
  objects.push({x: rand(canvas.width), y: rand(canvas.height),w: rand(40),h: rand(40), col: `rgb(${rand()},${rand()},${rand()})`});
}

requestAnimationFrame(drawCanvas); 

const view = (() => {
  const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
  var m = matrix;             // alias 
  var scale = 1;              // current scale
  var ctx;                    // reference to the 2D context
  const pos = { x: 0, y: 0 }; // current position of origin
  var dirty = true;
  const API = {
    set context(_ctx) { ctx = _ctx; dirty = true },
    apply() {
      if (dirty) { this.update() }
      ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5])
    },
    get scale() { return scale },
    get position() { return pos },
    isDirty() { return dirty },
    update() {
      dirty = false;
      m[3] = m[0] = scale;
      m[2] = m[1] = 0;
      m[4] = pos.x;
      m[5] = pos.y;
    },
    pan(amount) {
      if (dirty) { this.update() }
       pos.x += amount.x;
       pos.y += amount.y;
       dirty = true;
    },
    scaleAt(at, amount) { // at in screen coords
      if (dirty) { this.update() }
      scale *= amount;
      pos.x = at.x - (at.x - pos.x) * amount;
      pos.y = at.y - (at.y - pos.y) * amount;
      dirty = true;
    },
  };
  return API;
})();
view.context = ctx;
function drawCanvas() {
    if (view.isDirty()) { 
        ctx.setTransform(1, 0, 0, 1, 0, 0); 
        ctx.clearRect(0, 0, canvas.width, canvas.height);

        view.apply(); // set the 2D context transform to the view
        for (i = 0; i < objects.length; i++) {
            var obj = objects[i];
            ctx.fillStyle = obj.col;
            ctx.fillRect(obj.x, obj.y, obj.h, obj.h);
        }
    }
    requestAnimationFrame(drawCanvas);
}


canvas.addEventListener("mousemove", mouseEvent, {passive: true});
canvas.addEventListener("mousedown", mouseEvent, {passive: true});
canvas.addEventListener("mouseup", mouseEvent, {passive: true});
canvas.addEventListener("mouseout", mouseEvent, {passive: true});
canvas.addEventListener("wheel", mouseWheelEvent, {passive: false});
const mouse = {x: 0, y: 0, oldX: 0, oldY: 0, button: false};
function mouseEvent(event) {
    if (event.type === "mousedown") { mouse.button = true }
    if (event.type === "mouseup" || event.type === "mouseout") { mouse.button = false }
    mouse.oldX = mouse.x;
    mouse.oldY = mouse.y;
    mouse.x = event.offsetX;
    mouse.y = event.offsetY    
    if(mouse.button) { // pan
        view.pan({x: mouse.x - mouse.oldX, y: mouse.y - mouse.oldY});
    }
}
function mouseWheelEvent(event) {
    var x = event.offsetX;
    var y = event.offsetY;
    if (event.deltaY < 0) { view.scaleAt({x, y}, 1.1) }
    else { view.scaleAt({x, y}, 1 / 1.1) }
    event.preventDefault();
}
body {
  background: gainsboro;
  margin: 0;
}
canvas {
  background: white;
  box-shadow: 1px 1px 1px rgba(0, 0, 0, .2);
}
<canvas id="canvas"></canvas>

Example using element.style.transform

This example uses the element style transform property to zoom and pan.

  • Note that I use a 2D matrix rather than the 3d matrix as that can introduce many problems not compatible with the simple zoom and pan used below.

  • Note that CSS transforms are not applied to the top left of the element in all cases. In the example below the origin is in the center of the element. Thus when zooming the zoom at point must be adjusted by subtracting half the elements size. The element size is not effected by the transform.

  • Note borders, padding, and margins will also change the location of the origin. To work with view.scaleAt(at, amount) at must be relative to the top left most pixel of the element

  • Note there are many more problems and caveats you need to consider when you zoom and pan elements, too many to fit in a single answer. That is why this answer starts with a canvas example as it is by far the safer method to managing zoom-able visual content.

Use mouse button drag to pan, wheel to zoom. If you lose your position (zoom too far in out or panned of the page restart the snippet)

const view = (() => {
  const matrix = [1, 0, 0, 1, 0, 0]; // current view transform
  var m = matrix;             // alias 
  var scale = 1;              // current scale
  const pos = { x: 0, y: 0 }; // current position of origin
  var dirty = true;
  const API = {
    applyTo(el) {
      if (dirty) { this.update() }
      el.style.transform = `matrix(${m[0]},${m[1]},${m[2]},${m[3]},${m[4]},${m[5]})`;
    },
    update() {
      dirty = false;
      m[3] = m[0] = scale;
      m[2] = m[1] = 0;
      m[4] = pos.x;
      m[5] = pos.y;
    },
    pan(amount) {
      if (dirty) { this.update() }
       pos.x += amount.x;
       pos.y += amount.y;
       dirty = true;
    },
    scaleAt(at, amount) { // at in screen coords
      if (dirty) { this.update() }
      scale *= amount;
      pos.x = at.x - (at.x - pos.x) * amount;
      pos.y = at.y - (at.y - pos.y) * amount;
      dirty = true;
    },
  };
  return API;
})();


document.addEventListener("mousemove", mouseEvent, {passive: false});
document.addEventListener("mousedown", mouseEvent, {passive: false});
document.addEventListener("mouseup", mouseEvent, {passive: false});
document.addEventListener("mouseout", mouseEvent, {passive: false});
document.addEventListener("wheel", mouseWheelEvent, {passive: false});
const mouse = {x: 0, y: 0, oldX: 0, oldY: 0, button: false};
function mouseEvent(event) {
    if (event.type === "mousedown") { mouse.button = true }
    if (event.type === "mouseup" || event.type === "mouseout") { mouse.button = false }
    mouse.oldX = mouse.x;
    mouse.oldY = mouse.y;
    mouse.x = event.pageX;
    mouse.y = event.pageY;
    if(mouse.button) { // pan
        view.pan({x: mouse.x - mouse.oldX, y: mouse.y - mouse.oldY});
        view.applyTo(zoomMe);
    }
    event.preventDefault();
}
function mouseWheelEvent(event) {
    const x = event.pageX - (zoomMe.width / 2);
    const y = event.pageY - (zoomMe.height / 2);
    if (event.deltaY < 0) { 
        view.scaleAt({x, y}, 1.1);
        view.applyTo(zoomMe);
    } else { 
        view.scaleAt({x, y}, 1 / 1.1);
        view.applyTo(zoomMe);
    }
    event.preventDefault();
}
body {
   user-select: none;    
   -moz-user-select: none;    
}
.zoomables {
    pointer-events: none;
    border: 1px solid black;
}
#zoomMe {
    position: absolute;
    top: 0px;
    left: 0px;
}
  
<img id="zoomMe" class="zoomables" src="https://i.stack.imgur.com/C7qq2.png?s=328&g=1">
Community
  • 1
  • 1
Blindman67
  • 51,134
  • 11
  • 73
  • 136
  • 2
    Few notes: The event name is `wheel`, not `mousewheel`, which is an old version only implemented by some UAs. You may want to handle smooth scrolling (`const scroll_ratio = (Math.abs(event.deltaY) / 100) + 1; if (event.deltaY < 0) { view.scaleAt({x, y}, scroll_ratio); (...) view.scaleAt({x, y}, 1 / scroll_ratio);`) – Kaiido Feb 16 '20 at 02:59
  • Smooth scrolling can be here much easily done with [`transition`](https://dev.mozilla.org/docs/Web/CSS/transition "MDN Web Docs") on [`transform`](https://dev.mozilla.org/docs/Web/CSS/transform "MDN Web Docs"). Take a step down and you'll see. –  Jul 13 '21 at 15:20
  • @Blindman67, thanks for the answer its helpful, but i am facing a problem where in if my image is centered using flex then the calculation does not work. Any idea ? – karan roy Feb 14 '23 at 11:28
9

This zoom in the 2nd link is a bit extreme so I tried to add some constraints. You can uncomment them and play more. For now looks and works exactly the same IMHO.

const container = document.querySelector('.container');
const image = document.querySelector('.image');
const speed = 0.5;
let size = { 
  w: image.offsetWidth, 
  h: image.offsetHeight 
};
let pos = { x: 0, y: 0 };
let target = { x: 0, y: 0 };
let pointer = { x: 0, y: 0 };
let scale = 1;

window.addEventListener('wheel', event => {
  event.preventDefault();
  
  pointer.x = event.pageX - container.offsetLeft;
  pointer.y = event.pageY - container.offsetTop;
  target.x = (pointer.x - pos.x) / scale;
  target.y = (pointer.y - pos.y) / scale;
 
  scale += -1 * Math.max(-1, Math.min(1, event.deltaY)) * speed * scale;
  
  // Uncomment to constrain scale
  // const max_scale = 4;
  // const min_scale = 1;
  // scale = Math.max(min_scale, Math.min(max_scale, scale));

  pos.x = -target.x * scale + pointer.x;
  pos.y = -target.y * scale + pointer.y;

  // Uncomment for keeping the image within area (works with min scale = 1)
  // if (pos.x > 0) pos.x = 0;
  // if (pos.x + size.w * scale < size.w) pos.x = -size.w * (scale - 1);
  // if (pos.y > 0) pos.y = 0;
  // if (pos.y + size.h * scale < size.h) pos.y = -size.h * (scale - 1);

  image.style.transform = `translate(${pos.x}px,${pos.y}px) scale(${scale},${scale})`;
}, { passive: false });
.container {
  width: 400px;
  height: 400px;
  overflow: hidden;
  outline: 1px solid gray;
}

.image {
  width: 100%;
  height: 100%;
  transition: transform .3s;
  transform-origin: 0 0;
}

img {
  width: auto;
  height: auto;
  max-width: 100%;
}
<div class="container">
  <div class="image">
    <img src="https://picsum.photos/400/400" />
  </div>
</div>
Buszmen
  • 1,978
  • 18
  • 25
  • Thanks for the post I had to mark one as the bounty winner. So I gave it to the person who gave the most thorough answer. If I could split it I would of. I have upvoted your answer tho. – Nige Feb 16 '20 at 18:13
  • 1
    Best solution for **non-canvas** zooming. Btw the whole `pointer.x` can be easily replaced with [`event.clientX`](https://dev.mozilla.org/Web/API/Touch/clientX "MDN Web Docs"). Implementation of this with panning can be found [here](https://dev.to/stackfindover/zoom-image-point-with-mouse-wheel-11n3 "DEV") –  Jul 13 '21 at 09:47
3

Here's my version, support pan and zoom (hold CTRL key).

let editor = document.getElementById("editor");
let editorCanvas = editor.querySelector(".canvas");
let scale = 1.0;

const minScale = 0.1;
const maxScale = 8;
const scaleStep = 0.003;

let ctrlDown = false;
let dragging = false;
let dragStartX = 0;
let dragStartY = 0;
let previousScrollLeft = 0;
let previousScrollTop = 0;

window.addEventListener("keydown", (e) => {
    if (e.ctrlKey) {
        ctrlDown = true;
        editorCanvas.style.cursor = "move";
    }
});

window.addEventListener("keyup", (e) => {
    ctrlDown = false;
    editorCanvas.style.cursor = "default";
});

editor.addEventListener("mousedown", (e) => {
    dragging = true;
    dragStartX = e.x - editor.offsetLeft;
    dragStartY = e.y - editor.offsetTop;
    previousScrollLeft = editor.scrollLeft;
    previousScrollTop = editor.scrollTop;
});

editor.addEventListener("mouseup", (e) => {
    dragging = false;
});

editor.addEventListener("mousemove", (e) => {
    if (ctrlDown && dragging) {

        requestAnimationFrame(() => {
            let currentX = e.x - editor.offsetLeft;
            let currentY = e.y - editor.offsetTop;

            let scrollX = previousScrollLeft + (dragStartX - currentX)
            let scrollY = previousScrollTop + (dragStartY - currentY);

            editor.scroll(scrollX, scrollY);
        });
    }
});

editor.addEventListener("wheel", (e) => {
    e.preventDefault();

    requestAnimationFrame(() => {
        if (e.ctrlKey) {
            scale -= e.deltaY * scaleStep;

            if (scale < minScale) {
                scale = minScale;
            }

            if (scale > maxScale) {
                scale = maxScale;
            }

            if (scale < 1) {
                editorCanvas.style.transformOrigin = "50% 50% 0";
            } else {
                editorCanvas.style.transformOrigin = "0 0 0";
            }

            editorCanvas.style.transform = `matrix(${scale}, 0, 0, ${scale}, 0, 0)`;

            let rect = editorCanvas.getBoundingClientRect();

            let ew = rect.width;
            let eh = rect.height;

            let mx = e.x - editor.offsetLeft;
            let my = e.y - editor.offsetTop;

            editor.scroll((ew - editor.offsetWidth) * (mx / editor.clientWidth), (eh - editor.offsetHeight) * (my / editor.clientHeight));
        } else {
            editor.scrollTop += e.deltaY;
            editor.scrollLeft += e.deltaX;
        }
    });
}, { passive: false });
body {
    background-color: lightgray;
}

#editor {
    position: relative;
    width: 1024px;
    height: 768px;
    box-sizing: border-box;
    border: 1px solid darkgray;
    background-color: gray;
    overflow: auto;
}

.canvas {
    position: relative;
    width: 100%;
    height: 100%;
    background-color: white;
}

.frame {
    position: absolute;
    box-sizing: border-box;
    border: 1px solid darkslategrey;
    transition: all 0.25s;
}

.frame.one {
    top: 80px;
    left: 400px;
    width: 300px;
    height: 250px;
    background-color: pink;
}

.frame.two {
    top: 350px;
    left: 150px;
    width: 200px;
    height: 150px;
    background-color: gold;
}

.frame.three {
    top: 130px;
    left: 70px;
    width: 100px;
    height: 150px;
    background-color: cyan;
}

.frame.four {
    top: 368px;
    left: 496px;
    width: 32px;
    height: 32px;
    background-color: lime;
}

.frame:hover {
    cursor: pointer;
    border: 3px solid darkslategrey;
}

.frame:active {
    filter: invert();
}
<!DOCTYPE html>
<html lang="en">
  <head>
      <meta charset="UTF-8">
      <meta name="viewport" content="width=device-width, initial-scale=1.0">
      <meta http-equiv="X-UA-Compatible" content="ie=edge">
      <title>Zoom Editor</title>
  </head>
  <body>
      <div id="editor">
          <div class="canvas">
              <div class="frame one"></div>
              <div class="frame two"></div>
              <div class="frame three"></div>
              <div class="frame four"></div>
          </div>
      </div>
  </body>
</html>
DK Dhilip
  • 2,614
  • 1
  • 8
  • 17
  • Thanks for the post I had to mark one as the bounty winner. So I gave it to the person who gave the most thorough answer. If I could split it I would of. I have upvoted your answer tho. – Nige Feb 16 '20 at 18:13
1

I think you need to use an external jquery plugin to achieve this:

js file : https://ariutta.github.io/svg-pan-zoom/dist/svg-pan-zoom.js

working demo : https://ariutta.github.io/svg-pan-zoom/

for more clarification please visit: https://github.com/ariutta/svg-pan-zoom

Aashif Ahamed
  • 1,143
  • 1
  • 9
  • 21
0

If anyone is looking for Zoom and Pan in ReactJs.

Check out this: https://github.com/maazakn/react-zoom-Pan/settings

maazakn
  • 384
  • 2
  • 10