1

How can I detect whether my mouse is over a piece of text rendered on a Canvas? For example:

<canvas id="myCanvas" width="200" height="100" style="border:1px solid #000000;"></canvas>
var c = document.getElementById("myCanvas");
var ctx = c.getContext("2d");
ctx.font = '30px Arial'
ctx.fillText('Test', 100, 50)

If it is not possible to detect whether the mouse is over the actual text, I have thought about using .measureText to find the bounding box of the rendered text, and using that instead. But that doesn't work very well for rotated text, and I am not sure how to find a rotated bounding box.

To summarise:

  1. Is it possible to detect mouseover events on text rendered on a canvas?
  2. If not, is there some way to use .measureText to find some kind of rotated bounding box?

Thank you in advance!

Luuc van der Zee
  • 103
  • 1
  • 11

1 Answers1

2

The getTransform() method does return a DOMMatrix object representing the context's current transformation, which itself has a transformPoint() method you can use to transform DOMPoints (actually any JS object with a x, y, z or w numeric property).

So if you want to check if a point is in the transformed BBox, you just have to transform that point with the reversed current context's transform and check if this transformed point fits in the original BBox.

function checkCollision() {
  const mat = ctx.getTransform().inverse()
  const pt = mat.transformPoint( viewport_point );
  const x = pt.x - text_pos.x;
  const y = pt.y - text_pos.y;
  const collides =  x >= text_bbox.left &&
                    x <= text_bbox.right &&
                    y >= text_bbox.top &&
                    y <= text_bbox.bottom;
  //...
}

As a reminder, to get the BBox from a TextMetrics object you need to use its actualBoundingBox[XXX] properties:

function getTextBBox( ctx, text ) {
  const metrics = ctx.measureText( text );
  const left    = metrics.actualBoundingBoxLeft * -1;
  const top     = metrics.actualBoundingBoxAscent * -1;
  const right   = metrics.actualBoundingBoxRight;
  const bottom  = metrics.actualBoundingBoxDescent;
  const width   = right - left;
  const height  = bottom - top;
  return { left, top, right, bottom, width, height };
}

const canvas = document.querySelector( "canvas" );
const ctx = canvas.getContext( "2d" );
const width = canvas.width = 500;
const height = canvas.height = 180;
const text = "Hello world";

ctx.font = "800 40px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";

const text_pos = { x: width / 2, y: height / 2 };
const text_bbox = getTextBBox( ctx, text );
const bbox_path = drawBBoxPath( text_bbox, text_pos );

const start_time = performance.now();

ctx.strokeStyle = "red";

let viewport_point = new DOMPoint(0, 0);
onmousemove = ( { clientX, clientY } ) => {
  const canvas_bbox = canvas.getBoundingClientRect();
  viewport_point = new DOMPoint(
    clientX - canvas_bbox.left,
    clientY - canvas_bbox.top
  );
};

anim();

function getTextBBox( ctx, text ) {
  const metrics = ctx.measureText( text );
  const left = metrics.actualBoundingBoxLeft * -1;
  const top = metrics.actualBoundingBoxAscent * -1;
  const right = metrics.actualBoundingBoxRight;
  const bottom = metrics.actualBoundingBoxDescent;
  const width = right - left;
  const height = bottom - top;
  return { left, top, right, bottom, width, height };
}
function drawBBoxPath( bbox, offset = { x: 0, y: 0 } ) {
  const path = new Path2D();
  const { left, top, width, height } = bbox;
  path.rect( left + offset.x, top + offset.y, width, height );
  return path;
}
function anim( t ) {
  clear();
  updateTransform( t );
  checkCollision();
  drawText();
  drawBoundingBox();
  requestAnimationFrame( anim );
}
function clear() {
  ctx.setTransform( 1, 0, 0, 1, 0, 0 );
  ctx.clearRect( 0, 0, width, height );
}
function updateTransform( t ) {
  const dur = 10000;
  const delta = (t - start_time) % dur;

  const pos = Math.PI * 2 / dur * delta;
  const angle = Math.cos( pos );
  const scale = Math.sin( pos ) * 4;
  ctx.translate( width / 2, height / 2 );
  ctx.rotate( angle );
  ctx.scale( scale, 1 );
  ctx.translate( -width / 2, -height / 2 );
}
function checkCollision() {
  const mat = ctx.getTransform().inverse()
  const pt = mat.transformPoint( viewport_point );
  const x = pt.x - text_pos.x;
  const y = pt.y - text_pos.y;
  const collides =  x >= text_bbox.left &&
                    x <= text_bbox.right &&
                    y >= text_bbox.top &&
                    y <= text_bbox.bottom;
  ctx.fillStyle = collides ? "green" : "blue";  
}
function drawText() {
  ctx.fillText( text, text_pos.x, text_pos.y );
}
function drawBoundingBox() {
  ctx.stroke( bbox_path );
}
<canvas></canvas>

Of course one could also be lazier and let the context's isPointInPath() method do all of this for us:

function checkCollision() {
  const collides = ctx.isPointInPath( bbox_path, viewport_point.x, viewport_point.y );
  //...
}

const canvas = document.querySelector( "canvas" );
const ctx = canvas.getContext( "2d" );
const width = canvas.width = 500;
const height = canvas.height = 180;
const text = "Hello world";

ctx.font = "800 40px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";

const text_pos = { x: width / 2, y: height / 2 };
const text_bbox = getTextBBox( ctx, text );
const bbox_path = drawBBoxPath( text_bbox, text_pos );

const start_time = performance.now();

ctx.strokeStyle = "red";

let viewport_point = new DOMPoint(0, 0);
onmousemove = ( { clientX, clientY } ) => {
  const canvas_bbox = canvas.getBoundingClientRect();
  viewport_point = new DOMPoint(
    clientX - canvas_bbox.left,
    clientY - canvas_bbox.top
  );
};

anim();

function getTextBBox( ctx, text ) {
  const metrics = ctx.measureText( text );
  const left = metrics.actualBoundingBoxLeft * -1;
  const top = metrics.actualBoundingBoxAscent * -1;
  const right = metrics.actualBoundingBoxRight;
  const bottom = metrics.actualBoundingBoxDescent;
  const width = right - left;
  const height = bottom - top;
  return { left, top, right, bottom, width, height };
}
function drawBBoxPath( bbox, offset = { x: 0, y: 0 } ) {
  const path = new Path2D();
  const { left, top, width, height } = bbox;
  path.rect( left + offset.x, top + offset.y, width, height );
  return path;
}
function anim( t ) {
  clear();
  updateTransform( t );
  checkCollision();
  drawText();
  drawBoundingBox();
  requestAnimationFrame( anim );
}
function clear() {
  ctx.setTransform( 1, 0, 0, 1, 0, 0 );
  ctx.clearRect( 0, 0, width, height );
}
function updateTransform( t ) {
  const dur = 10000;
  const delta = (t - start_time) % dur;

  const pos = Math.PI * 2 / dur * delta;
  const angle = Math.cos( pos );
  const scale = Math.sin( pos ) * 4;
  ctx.translate( width / 2, height / 2 );
  ctx.rotate( angle );
  ctx.scale( scale, 1 );
  ctx.translate( -width / 2, -height / 2 );
}
function checkCollision() {
  const collides = ctx.isPointInPath( bbox_path, viewport_point.x, viewport_point.y );
  ctx.fillStyle = collides ? "green" : "blue";  
}
function drawText() {
  ctx.fillText( text, text_pos.x, text_pos.y );
}
function drawBoundingBox() {
  ctx.stroke( bbox_path );
}
<canvas></canvas>

Regarding collision against the painted pixels of the text,
the canvas API doesn't expose the tracing of text, so if you wanted to know when the mouse is really over a pixel painted by the fillText() method, you'd have to read the pixels data after you draw this text.

Once we get that pixels data, we just have to use the same method as in the first snippet, and check if the pixel at transformed-point's coordinates was painted.

// at init, draw once, untransformed,
// ensure it's the only thing being painted on the canvas
clear();
drawText();
// grab the pixels data, once
const img_data = ctx.getImageData( 0, 0, width, height );
const pixels_data = new Uint32Array( img_data.data.buffer );

function checkCollision() {
  const mat = ctx.getTransform().inverse();
  const { x, y } = mat.transformPoint( viewport_point );
  const index =  (Math.floor( y ) * width) + Math.floor( x );
  const collides = !!pixels_data[ index ];
  //...
}

const canvas = document.querySelector( "canvas" );
const ctx = canvas.getContext( "2d" );
const width = canvas.width = 500;
const height = canvas.height = 180;
const text = "Hello world";

const font_settings = {
  font: "800 40px sans-serif",
  textAlign: "center",
  textBaseline: "middle"
};
Object.assign( ctx, font_settings );

const text_pos = {
  x: width / 2,
  y: height / 2
};
let clicked = false;
onclick = e => clicked = true;

// grab the pixels data
const pixels_data = (() => {
  // draw once, untransformed on a new context
  // getting the image data of a context will mark it as
  // deaccelerated and since we only want to do this once
  // it's not a good idea to do it on our visible canvas
  // also it helps ensure it's the only thing being painted on the canvas
  const temp_canvas = canvas.cloneNode();
  const temp_ctx = temp_canvas.getContext("2d");
  Object.assign( temp_ctx, font_settings);  

  drawText(temp_ctx);
  const img_data = temp_ctx.getImageData( 0, 0, width, height );
  // Safari has issues releasing canvas buffers...
  temp_canvas.width = temp_canvas.height = 0;
  return new Uint32Array( img_data.data.buffer );
})();

const start_time = performance.now();

let viewport_point = new DOMPoint( 0, 0 );
onmousemove = ( { clientX, clientY } ) => {
  const canvas_bbox = canvas.getBoundingClientRect();
  viewport_point = new DOMPoint(
    clientX - canvas_bbox.left,
    clientY - canvas_bbox.top
  );
};

anim();

function anim( t ) {
  clear();
  updateTransform( t );
  checkCollision();
  drawText();
  requestAnimationFrame( anim );
}
function clear() {
  ctx.setTransform( 1, 0, 0, 1, 0, 0 );
  ctx.clearRect( 0, 0, width, height );
}
function updateTransform( t ) {
  const dur = 10000;
  const delta = (t - start_time) % dur;

  const pos = Math.PI * 2 / dur * delta;
  const angle = Math.cos( pos );
  const scale = Math.sin( pos ) * 4;
  ctx.translate( width / 2, height / 2 );
  ctx.rotate( angle );
  ctx.scale( scale, 1 );
  ctx.translate( -width / 2, -height / 2 );
}
function checkCollision() {
  const mat = ctx.getTransform().inverse();
  const { x, y } = mat.transformPoint( viewport_point );
  const index =  (Math.floor( y ) * width) + Math.floor( x );
  const collides = !!pixels_data[ index ];
  ctx.fillStyle = collides ? "green" : "blue";
}
function drawBoundingBox() {
  ctx.stroke( bbox_path );
}
// used by both 'ctx' and 'temp_ctx'
function drawText(context = ctx) {
  context.fillText( text, text_pos.x, text_pos.y );
}
<canvas></canvas>
Kaiido
  • 123,334
  • 13
  • 219
  • 285