javascripthtmlcanvashtml5-canvas

Zoom/scale at mouse position


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.


Solution

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

    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() {
          dirty && this.update();
          ctx.setTransform(...m);
        },
        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) {
           pos.x += amount.x;
           pos.y += amount.y;
           dirty = true;
        },
        scaleAt(at, amount) { // at in canvas pixel coords 
          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);
    }
    
    const EVT_OPTS = {passive: true};
    canvas.addEventListener("mousemove", mouseEvent, EVT_OPTS);
    canvas.addEventListener("mousedown", mouseEvent, EVT_OPTS);
    canvas.addEventListener("mouseup",   mouseEvent, EVT_OPTS);
    canvas.addEventListener("mouseout",  mouseEvent, EVT_OPTS);
    canvas.addEventListener("wheel",     mouseWheelEvent, EVT_OPTS);
    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 if button down
            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.

    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 = (() => {
        var dirty = true;             // If true transform matrix needs to update
        var scale = 1;                // current scale
        const matrix = [1,0,0,1,0,0]; // current view transform
        const m = matrix;             // alias 
        const pos = { x: 0, y: 0 };   // current position of origin
        const API = {
            applyTo(element) {
                dirty && this.update();
                element.style.transform = `matrix(${m.join(`,`)})`;
            },
            update() {
                dirty = false;
                m[3] = m[0] = scale;
                m[2] = m[1] = 0;
                m[4] = pos.x;
                m[5] = pos.y;
            },
            pan(amount) {
                pos.x += amount.x;
                pos.y += amount.y;
                dirty = true;
            },
            scaleAt(at, amount) { // at in screen coords
                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 if button down
            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);
        const scaleBy = event.deltaY < 0 ? 1.1 : 1 / 1.1;
        view.scaleAt({x, y}, scaleBy);
        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.sstatic.net/C7qq2.png?s=328&g=1">