7

First, I know this question has been asked many times. However, the answers provided are not consistent and a variety of methods are used to get the mouse position. A few examples:

Method 1:

canvas.onmousemove = function (event) { // this  object refers to canvas object  
    Mouse = {
        x: event.pageX - this.offsetLeft,
        y: event.pageY - this.offsetTop
    }
}

Method 2:

function getMousePos(canvas, evt) {
    var rect = canvas.getBoundingClientRect();
    return {
        x: evt.clientX - rect.left,
        y: evt.clientY - rect.top
    };
}

Method 3:

var findPos = function(obj) {
    var curleft = curtop = 0;
    if (obj.offsetParent) { 
        do {
            curleft += obj.offsetLeft;
            curtop += obj.offsetTop; 
        } while (obj = obj.offsetParent);
    }
    return { x : curleft, y : curtop };
};

Method 4:

var x;
var y;
if (e.pageX || e.pageY)
{
    x = e.pageX;
    y = e.pageY;
}
else {
    x = e.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
    y = e.clientY + document.body.scrollTop + document.documentElement.scrollTop; 
} 
x -= gCanvasElement.offsetLeft;
y -= gCanvasElement.offsetTop;

and so on.

What I am curious is which method is the most modern in terms of browser support and convenience in getting the mouse position in a canvas. Or is it those kind of things that have marginal impact and any of the above is a good choice? (Yes I realize the codes above are not exactly the same)

jsea
  • 3,859
  • 1
  • 17
  • 21
No Harm In Trying
  • 483
  • 1
  • 6
  • 12
  • 1
    You can always run a test on jsperf.com –  Nov 19 '13 at 00:20
  • last one was used as cross browser method since not all browsers supported `pageX` ( older IE for example). Likely all normalized now for browsers that support canvas...but I am not 100% sure – charlietfl Nov 19 '13 at 00:22

3 Answers3

8

This seems to work. I think this is basically what K3N said.

function getRelativeMousePosition(event, target) {
  target = target || event.target;
  var rect = target.getBoundingClientRect();

  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  }
}

function getStyleSize(style, propName) {
  return parseInt(style.getPropertyValue(propName));
}

// assumes target or event.target is canvas
function getCanvasRelativeMousePosition(event, target) {
  target = target || event.target;
  var pos = getRelativeMousePosition(event, target);

  // you can remove this if padding is 0. 
  // I hope this always returns "px"
  var style = window.getComputedStyle(target);
  var nonContentWidthLeft   = getStyleSize(style, "padding-left") +
                              getStyleSize(style, "border-left");
  var nonContentWidthTop    = getStyleSize(style, "padding-top") +
                              getStyleSize(style, "border-top");
  var nonContentWidthRight  = getStyleSize(style, "padding-right") +
                              getStyleSize(style, "border-right");
  var nonContentWidthBottom = getStyleSize(style, "padding-bottom") +
                              getStyleSize(style, "border-bottom");

  var rect = target.getBoundingClientRect();
  var contentDisplayWidth  = rect.width  - nonContentWidthLeft - nonContentWidthRight;
  var contentDisplayHeight = rect.height - nonContentWidthTop  - nonContentWidthBottom;

  pos.x = (pos.x - nonContentWidthLeft) * target.width  / contentDisplayWidth;
  pos.y = (pos.y - nonContentWidthTop ) * target.height / contentDisplayHeight;

  return pos;  
}

If you run the sample below and move the mouse over the blue area it will draw under the cursor. The border (black), padding (red), width, and height are all set to non-pixel values values. The blue area is the actual canvas pixels. The canvas's resolution is not set so it's 300x150 regardless of the size it's stretched to.

Move the mouse over the blue area and it will draw a pixel under it.

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

function clearCanvas() {
  ctx.fillStyle = "blue";
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
clearCanvas();

var posNode = document.createTextNode("");
document.querySelector("#position").appendChild(posNode);

function getRelativeMousePosition(event, target) {
  target = target || event.target;
  var rect = target.getBoundingClientRect();

  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  }
}

function getStyleSize(style, propName) {
  return parseInt(style.getPropertyValue(propName));
}

// assumes target or event.target is canvas
function getCanvasRelativeMousePosition(event, target) {
  target = target || event.target;
  var pos = getRelativeMousePosition(event, target);
  
  // you can remove this if padding is 0. 
  // I hope this always returns "px"
  var style = window.getComputedStyle(target);
  var nonContentWidthLeft   = getStyleSize(style, "padding-left") +
                              getStyleSize(style, "border-left");
  var nonContentWidthTop    = getStyleSize(style, "padding-top") +
                              getStyleSize(style, "border-top");
  var nonContentWidthRight  = getStyleSize(style, "padding-right") +
                              getStyleSize(style, "border-right");
  var nonContentWidthBottom = getStyleSize(style, "padding-bottom") +
                              getStyleSize(style, "border-bottom");
  
  var rect = target.getBoundingClientRect();
  var contentDisplayWidth  = rect.width  - nonContentWidthLeft - nonContentWidthRight;
  var contentDisplayHeight = rect.height - nonContentWidthTop  - nonContentWidthBottom;

  pos.x = (pos.x - nonContentWidthLeft) * target.width  / contentDisplayWidth;
  pos.y = (pos.y - nonContentWidthTop ) * target.height / contentDisplayHeight;
  
  return pos;  
}

  
function handleMouseEvent(event) {
  var pos = getCanvasRelativeMousePosition(event);
  posNode.nodeValue = JSON.stringify(pos, null, 2);
  ctx.fillStyle = "white";
  ctx.fillRect(pos.x | 0, pos.y | 0, 1, 1);
}

canvas.addEventListener('mousemove', handleMouseEvent);
canvas.addEventListener('click', clearCanvas);
* {
  box-sizing: border-box;
  cursor: crosshair;
}
html, body {
  width: 100%;
  height: 100%;
  color: white;
}
.outer {
  background-color: green;
  display: flex;
  display: -webkit-flex;
  
  -webkit-justify-content: center;
  -webkit-align-content: center;
  -webkit-align-items: center;

  justify-content: center;
  align-content: center;
  align-items: center;  
  
  width: 100%;
  height: 100%;
}
.inner {
  border: 1em solid black;
  background-color: red;
  padding: 1.5em;
  width: 90%;
  height: 90%;
}
#position {
  position: absolute;
  left: 1em;
  top: 1em;
  z-index: 2;
  pointer-events: none;
}
<div class="outer">
  <canvas class="inner"></canvas>
</div>
<pre id="position"></pre>

So, best advice?, always have the border and padding of a canvas be 0 if unless you want to go through all these steps. If the border and padding are zero you can just canvas.clientWidth and canvas.clientHeight for contentDisplayWidth and contentDisplayHeight in the example below and all the nonContextXXX values become 0.

function getRelativeMousePosition(event, target) {
  target = target || event.target;
  var rect = target.getBoundingClientRect();

  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  }
}

// assumes target or event.target is canvas
function getNoPaddingNoBorderCanvasRelativeMousePosition(event, target) {
  target = target || event.target;
  var pos = getRelativeMousePosition(event, target);

  pos.x = pos.x * target.width  / canvas.clientWidth;
  pos.y = pos.y * target.height / canvas.clientHeight;

  return pos;  
}

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

function clearCanvas() {
  ctx.fillStyle = "blue";
  ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
clearCanvas();

var posNode = document.createTextNode("");
document.querySelector("#position").appendChild(posNode);

function getRelativeMousePosition(event, target) {
  target = target || event.target;
  var rect = target.getBoundingClientRect();

  return {
    x: event.clientX - rect.left,
    y: event.clientY - rect.top,
  }
}

// assumes target or event.target is canvas
function getNoPaddingNoBorderCanvasRelativeMousePosition(event, target) {
  target = target || event.target;
  var pos = getRelativeMousePosition(event, target);
  
  pos.x = pos.x * target.width  / canvas.clientWidth;
  pos.y = pos.y * target.height / canvas.clientHeight;
  
  return pos;  
}

  
function handleMouseEvent(event) {
  var pos = getNoPaddingNoBorderCanvasRelativeMousePosition(event);
  posNode.nodeValue = JSON.stringify(pos, null, 2);
  ctx.fillStyle = "white";
  ctx.fillRect(pos.x | 0, pos.y | 0, 1, 1);
}

canvas.addEventListener('mousemove', handleMouseEvent);
canvas.addEventListener('click', clearCanvas);
* {
  box-sizing: border-box;
  cursor: crosshair;
}
html, body {
  width: 100%;
  height: 100%;
  color: white;
}
.outer {
  background-color: green;
  display: flex;
  display: -webkit-flex;
  
  -webkit-justify-content: center;
  -webkit-align-content: center;
  -webkit-align-items: center;

  justify-content: center;
  align-content: center;
  align-items: center;  
  
  width: 100%;
  height: 100%;
}
.inner {
  background-color: red;
  width: 90%;
  height: 80%;
  display: block;
}
#position {
  position: absolute;
  left: 1em;
  top: 1em;
  z-index: 2;
  pointer-events: none;
}
<div class="outer">
  <canvas class="inner"></canvas>
</div>
<pre id="position"></pre>
gman
  • 100,619
  • 31
  • 269
  • 393
3

You target canvas, so you target only recent browsers.
So you can forget about the pageX stuff of Method 4.
Method 1 fails in case of nested canvas.
Method 3 is just like Method 2, but slower since you do it by hand.

-->> The way to go is option 2.

Now since you worry about performances, you don't want to call to the DOM on each mouse move : cache the boundingRect left and top inside some var/property.

If your page allows scrolling, do not forget to handle the 'scroll' event and to re-compute the bounding rect on scroll.

The coordinates are provided in css pixels : If you scale the Canvas with css, be sure its border is 0 and use offsetWidth and offsetHeight to compute correct position. Since you will want to cache also those values for performances and avoid too many globals, code will look like :

var mouse = { x:0, y:0, down:false };

function setupMouse() {

    var rect = cv.getBoundingClientRect();
    var rectLeft = rect.left;
    var rectTop = rect.top;

    var cssScaleX = cv.width / cv.offsetWidth;
    var cssScaleY = cv.height / cv.offsetHeight;

    function handleMouseEvent(e) {
        mouse.x = (e.clientX - rectLeft) * cssScaleX;
        mouse.y = (e.clientY - rectTop) * cssScaleY;
    }

    window.addEventListener('mousedown', function (e) {
        mouse.down = true;
        handleMouseEvent(e);
    });

    window.addEventListener('mouseup', function (e) {
        mouse.down = false;
        handleMouseEvent(e);
    });

    window.addEventListener('mouseout', function (e) {
        mouse.down = false;
        handleMouseEvent(e);
    });

    window.addEventListener('mousemove',  handleMouseEvent );
};

Last word : performance testing an event handler is, to say the least, questionable, unless you can ensure that the very same moves/clicks are performed during each test. There no way to handle things faster than in the code above. Well, you might save 2 muls if you are sure canvas isn't css scaled, but anyway as of now the browser overhead for input handling is so big that it won't change a thing.

GameAlchemist
  • 18,995
  • 7
  • 36
  • 59
  • 1
    Thanks. Seems like `getBoundingClientRect()` is the way to go then. I'm not really worried about performance as much as I want to know which approach is the most modern and applicable. – No Harm In Trying Nov 19 '13 at 01:48
  • 1
    You're welcome. Caching the bounding rect (+updating on scroll) if is worth the little effort, since DOM access is veeery slow. (unless maybe in early parts of a project). – GameAlchemist Nov 19 '13 at 02:04
  • See below for situations this answer doesn't work in – gman May 22 '16 at 12:46
1

I would recommend use of getBoundingClientRect().

When the browser do a re-flow/update this method returns the position (relative to view-port) of the element.

It's widely supported cross-browser so there is really no reason not to use it IMO. Though, if you need backward-compatibility to support old browsers you should use a different method.

However, there are a few of things you need to be aware of when using this method:

  • Element CSS padding affects the position relative to canvas if > 0.
  • Element CSS border width affects the position relative to canvas if > 0.
  • The resulting object is static, ie. it is not updated even if for example view-port changed before you use the object.

You need to add the widths of those, top and left, to your position manually. These are included by the method which means you need to compensate for this to get the position to be relative to canvas.

If you don't use border and/or padding it's straightforward. But when you do you either need to add the absolute widths of those in pixels, or if they are unknown or dynamic, you would need to mess with getComputedStyle method and getPropertyValue to get those (these gives the size always in pixels even if the original definition of the border/padding was in a different unit).

These are values you can cache for the most part if used, unless border and padding is also changing but in most use-cases this is not the case.

You clarified that performance isn't the issue and you are correct in doing so as none of these methods you list are the real bottlenecks (but of course fully possible to measure if you know how to do performance testing). Which method you use becomes in essence a personal taste rather than a performance issue as the bottleneck lays in pushing the events themselves through the event chain.

But the most "modern" (if we define modern as newer and more convenient) is getBoundingClientRect() and avoiding border/padding on the element makes it a breeze to use.