Search code examples
javascripthtml5-canvas

How to translate, scale, and rotate an image around an origin point


I'm building an image editing tool, which currently translates, scales, and rotates an image that can be dragged around the canvas. I'm using the DOMMatrix API to apply a transform to a canvas. This works (see working example below), but only around the centre point of the image.

How can I apply the rotation to any origin point of the canvas, e.g. (0.5, 0.5) would be the centre, (0.0, 0.1) would be the bottom left, etc.?

I currently have the translation applied to the image, so that is can rotate around the image given an origin point. I need to have it so that when the image is large and slightly off-canvas, the user can see the centre canvas is what the origin point is, and the image rotates around that.

Here is a CodeSandbox demo that demonstrates rotating around the canvas centre point, but causes the drag padding with the pointer to be misaligned.

Demo

Editable CodeSandbox

Shortcut keys:

  • "r" rotates by 5°
  • "-" zooms out 0.1
  • "=" zooms in -0.1

let rotation = 0;
let scale = 1;
let x = 0;
let y = 0;
let startX = 0;
let startY = 0;
let lastX = 0;
let lastY = 0;
let pointerDown = false;

const canvas = document.querySelector("#canvas");
const ctx = canvas.getContext("2d");

const imgWidth = 480;
const imgHeight = 300;

function resizeCanvas() {
  canvas.width = window.innerWidth;
  canvas.height = window.innerHeight;
}

resizeCanvas();
window.addEventListener("resize", resizeCanvas);

const img = new Image();
img.crossOrigin = "anonymous";
img.src = "https://i.imgur.com/3q3kNGh.png";

function onPointerDown(event) {
  pointerDown = true;
  startX = (event.clientX - canvas.offsetLeft) / imgWidth;
  startY = (event.clientY - canvas.offsetTop) / imgHeight;
}

function onPointerMove(event) {
  if (!pointerDown) return;
  x = lastX + ((event.clientX - canvas.offsetLeft) / imgWidth - startX);
  y = lastY + ((event.clientY - canvas.offsetTop) / imgHeight - startY);
}

function onPointerUp() {
  pointerDown = false;
  lastX = x;
  lastY = y;
}

window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);

window.addEventListener("keydown", (event) => {
  const key = event.key.toLowerCase();
  switch (key) {
    case "r":
      rotation = (rotation + 5) % 360;
      break;
    case "-":
      scale = Math.max(0, scale - 0.1);
      break;
    case "=":
      scale = Math.min(2, scale + 0.1);
      break;
    default:
      break;
  }
});

function rotate(x, y, rotation) {
  const panXX = x * Math.cos((rotation * Math.PI) / 180);
  const panXY = y * Math.sin((rotation * Math.PI) / 180);
  const panYY = y * Math.cos((rotation * Math.PI) / 180);
  const panYX = x * Math.sin((rotation * Math.PI) / 180);
  const panX = panXX + panXY;
  const panY = panYY - panYX;
  return { x: panX, y: panY };
}

(function draw() {
  requestAnimationFrame(draw);

  const imgX = imgWidth * x;
  const imgY = imgHeight * y;

  const { x: tX, y: tY } = rotate(imgX, imgY, rotation);

  const matrix = new DOMMatrix()
    .translate(imgWidth / 2, imgHeight / 2)
    .rotate(rotation)
    .translate(imgWidth / -2, imgHeight / -2)
    .translate(tX, tY)
    .scale(scale);

  ctx.clearRect(0, 0, canvas.width, canvas.height);

  ctx.setTransform(matrix);

  ctx.drawImage(img, 0, 0, imgWidth, imgHeight);

  ctx.resetTransform();
})();
html,
body {
  margin: 0;
  padding: 0;
}
canvas {
  display: block;
}
<canvas id="canvas"></canvas>


Solution

  • You are already playing with the rotation origin... Just before the rotate, you translate one way then translate the other way:

    .translate(imgWidth / 2, imgHeight / 2)
    .rotate(rotation)
    .translate(imgWidth / -2, imgHeight / -2)
    

    If you change those parts to translate accordingly you could achieve just what you want. Without translation (0,0) you'd have the origin at top left corner. For top right you'd translate by imgWidth,0 and then -imgWidth,0

    You'll simply need to set some variable to know which point you want and use it.

    EDIT: as per requested after, OP wanted to have the rotation be relative to the canvas and not the image, so the translate should use the canvas dimension instead. Also there was a problem in translating when the image was rotated, this is fixed by applying the rotation to the translation, as per OP's code:

      const imgX = imgWidth * x;
      const imgY = imgHeight * y;
    
      const { x: tX, y: tY } = rotate(imgX, imgY, rotation);
    
      const ox = canvas.width / 2 - x;
      const oy = canvas.height / 2 - imgY;
    
      const matrix = new DOMMatrix()
        .translate(ox, oy)
        .rotate(rotation)
        .translate(ox * -1, oy * -1)
        .translate(tX, tY)
        .scale(scale);