Javascript/HTML canvas: drawing on dynamically sized image

Viewed 40

I'm trying to create an interface (HTML canvas/Vanilla JS) where a user can:

  1. Upload an image
  2. Draw on the image
  3. Save the new image in the same resolution as the original from step (1)

I'm stuck on (2). The x and y coordinates are all wrong when I try to draw. How far off they are depends on the size/shape of the image uploaded. I have made a codepen to demonstrate, because the Stack code snippet below was behaving strangely when run.

I am a JS/HTML rookie but my hypothesis is that the incorrect coordinates are based on the image's original resolution (which is retained when I save the resultant image) and not the image's resolution as it is displayed in the canvas.

Any help much appreciated.

const fileInput = document.querySelector(".file-input"),
  inputImg = document.querySelector(".input-img img"),
  chooseImgBtn = document.querySelector(".choose-img"),
  saveImgBtn = document.querySelector(".save-img");

drawOnImage();

fileInput.addEventListener("change", async(e) => {
  const [file] = fileInput.files;

  // displaying the uploaded image
  const image = document.createElement("img");
  image.src = await fileToDataUri(file);

  // enbaling the brush after after the image
  // has been uploaded
  image.addEventListener("load", () => {
    drawOnImage(image);
  });

  return false;
});

function fileToDataUri(field) {
  return new Promise((resolve) => {
    const reader = new FileReader();

    reader.addEventListener("load", () => {
      resolve(reader.result);
    });

    reader.readAsDataURL(field);
  });
}

const colorElement = document.getElementsByName("colorRadio");
let color;
colorElement.forEach((c) => {
  if (c.checked) color = c.value;
});

colorElement.forEach((c) => {
  c.onclick = () => {
    color = c.value;
  };
});

function drawOnImage(image = null) {
  const canvasElement = document.getElementById("canvas");
  const context = canvasElement.getContext("2d");

  // if an image is present,
  // the image passed as a parameter is drawn in the canvas
  if (image) {
    const imageWidth = image.width;
    const imageHeight = image.height;

    // rescaling the canvas element
    canvasElement.width = imageWidth;
    canvasElement.height = imageHeight;

    context.drawImage(image, 0, 0, imageWidth, imageHeight);
  }

  let isDrawing;

  canvasElement.onmousedown = (e) => {
    isDrawing = true;
    context.beginPath();
    context.lineWidth = size;
    context.strokeStyle = "black";
    context.lineJoin = "round";
    context.lineCap = "round";
    context.moveTo(e.clientX, e.clientY);
  };

  canvasElement.onmousemove = (e) => {
    if (isDrawing) {
      context.lineTo(e.clientX, e.clientY);
      context.stroke();
    }
  };

  canvasElement.onmouseup = function() {
    isDrawing = false;
    context.closePath();
  };
}

chooseImgBtn.addEventListener("click", () => fileInput.click());
/* Import Google font - Poppins */

@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600&display=swap');
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
  font-family: 'Poppins', sans-serif;
}

body {
  display: flex;
  padding: 10px;
  min-height: 90vh;
  align-items: center;
  justify-content: center;
  background: #9c9c9c;
}

.container {
  width: 1200px;
  padding: 30px 35px 35px;
  background: #fff;
  border-radius: 10px;
  box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}

.container .editor-panel,
.container .controls .reset-filter,
.container .controls .save-img {
  opacity: 0.5;
  pointer-events: none;
}

.container h2 {
  margin-top: -8px;
  font-size: 22px;
  font-weight: 500;
}

.container .wrapper {
  display: flex;
  margin: 20px 0;
  min-height: 335px;
}

.editor-panel .options,
.controls {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
}

.wrapper .canvas-wrapper {
  flex-grow: 1;
  display: flex;
  overflow: hidden;
  margin-left: 20px;
  border-radius: 5px;
  align-items: center;
  justify-content: center;
}

#canvas {
  max-width: 700px;
  max-height: 650px;
  width: 100%;
  height: 100%;
  border-radius: 5px;
  border: 1px solid #ccc;
  object-fit: contain;
}

.controls button {
  padding: 11px 20px;
  font-size: 14px;
  border-radius: 3px;
  outline: none;
  color: #fff;
  cursor: pointer;
  background: none;
  transition: all 0.3s ease;
  text-transform: uppercase;
}

.controls .choose-img {
  background: #6C757D;
  border: 1px solid #6C757D;
}

.controls .save-img {
  margin-left: 5px;
  background: #5372F0;
  border: 1px solid #5372F0;
}
<link rel="stylesheet" href="https://unpkg.com/boxicons@2.1.2/css/boxicons.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.1.1/css/all.min.css" />
<div class="container">
  <h2>Drawing</h2>
  <div class="wrapper">
    <div class="canvas-wrapper">
      <canvas id="canvas" style="border: 1px solid black;"></canvas>
    </div>
  </div>
  <div class="controls">
    <input type="file" class="file-input" accept="image/*" hidden>
    <button class="choose-img">Choose Input Image</button>
    <div class="row">
      <button class="save-img">Save</button>
    </div>
  </div>
</div>

1 Answers

You've got two separate problems here. The first is using clientX and clientY to get the mouse position. These are the X and Y coordinates relative to the viewport, such that (0, 0) is the top left corner of the browser window, not the canvas. You want to use offsetX and offsetY instead, which are relative to the element that triggered the event.

The second problem is using CSS to modify the size of the canvas (width: 100%; height: 100%). This changes the physical size of the element on the screen, but not the dimensions of the canvas context, which you set when uploading an image. For example, let's say you set canvas.width and canvas.height (the size of the canvas context) to be 200x100 and you set its CSS width and height to be 400x200. The element will be 400 pixels wide on your screen and if you place your mouse halfway across, the offsetX will be 200. However, drawing anything in the canvas context with an X of 200 will place it at the far right edge, because the context width is 200, not 400.

To deal with this, you either need to not change the canvas's size in CSS at all, or account for the difference in your JS by dividing the mouse's position by the ratio between the element's physical size and the canvas's context size.

Here's an example. The canvas is upscaled by 2x via CSS to be 400x200. The black rectangle is drawn using the exact position of the mouse, while the red rectangle divides down to get the correct corresponding position on the canvas.

const mouse = document.getElementById("mouse");
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

// 200x100 canvas size
canvas.width = 200;
canvas.height = 100;

canvas.addEventListener("mousemove", e => {
  mouse.innerText = `mouse x: ${e.offsetX}`;
  
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // draw at 1:1 physical size to canvas size
  ctx.fillStyle = "#000";
  ctx.fillRect(e.offsetX, e.offsetY, 20, 20);
  
  // draw at 2:1 physical size to canvas size
  ctx.fillStyle = "#f00";
  ctx.fillRect(e.offsetX / 2, e.offsetY / 2, 20, 20);

  // or if the ratio is not known:
  const width_ratio = canvas.clientWidth / canvas.width;
  const height_ratio = canvas.clientHeight / canvas.height;
  ctx.fillRect(e.offsetX / width_ratio, e.offsetY / height_ratio, 20, 20);
});
#canvas {
  width: 400px;
  height: 200px;
  border: 1px solid #000;
}
<div id="mouse"></div>
<canvas id="canvas"></canvas>

Related