Making and Moving Selectable Shapes on an HTML5 Canvas: A Simple Example

By  · Interview
Comment
Save
38.8K Views

This is part one in a series. Part 2 can be found here.

This tutorial will show you how to create a simple data structure for shapes on an HTML5 canvas and how to have them be selectable. The finished canvas will look like this:

This text is displayed if your browser does not support HTML5 Canvas.


You'll be able to click to drag boxes and double click to add new boxes.

This article’s code is written primarily to be easy to understand. It isn’t optimized for performance, though a little bit of the drawing is set up so that more complex shapes can easily be added in the future.

We’ll be going over a few things that are also essential to game-development (drawing loop, hit testing), and in later tutorials I will probably turn this example into a small game.

The HTML5 Canvas

A Canvas is made by using the <canvas> tag in HTML:

   <canvas id="canvas" width="400" height="300">
    This text is displayed if your browser does not support HTML5 Canvas.
   </canvas>

A canvas isn’t smart: it’s just a place for drawing pixels. If you ask it to draw something it will do so and then immediately forget everything about what you have just done. Because of this we have to keep track ourselves of all the things we want to draw (and re-draw) each frame.

So we’ll need to add:

  1. Code for keeping track of objects
  2. Code for initialization
  3. Code for drawing the objects as they are made and move around
  4. Code for mouse events

Keeping track of what we draw

To keep things simple for this example we will just make a rectangular object called Box. We’ll also make a method for creating Boxes a little easier.

// holds all our rectangles
var boxes = []; 
 
//Box object to hold data for all drawn rects
function Box() {
  this.x = 0;
  this.y = 0;
  this.w = 1; // default width and height?
  this.h = 1;
  this.fill = '#444444';
}
 
//Initialize a new Box, add it, and invalidate the canvas
function addRect(x, y, w, h, fill) {
  var rect = new Box;
  rect.x = x;
  rect.y = y;
  rect.w = w
  rect.h = h;
  rect.fill = fill;
  boxes.push(rect);
  invalidate();
}

Initialization

I’m going to add a bunch of variables for keeping track of the drawing and mouse state. I already added boxes[] to keep track of each object, but we’ll also need a var for the canvas, the canvas’ 2d context (where wall drawing is done), whether the mouse is dragging, width/height of the canvas, and so on. We’ll also want to make a second canvas, for selection purposes, but I’ll talk about that later.

var canvas;
var ctx;
var WIDTH;
var HEIGHT;
var INTERVAL = 20;  // how often, in milliseconds, we check to see if a redraw is needed
 
var isDrag = false;
var mx, my; // mouse coordinates
 
 // when set to true, the canvas will redraw everything
 // invalidate() just sets this to false right now
 // we want to call invalidate() whenever we make a change
var canvasValid = false;
 
// The node (if any) being selected.
// If in the future we want to select multiple objects, this will get turned into an array
var mySel; 
 
// The selection color and width. Right now we have a red selection with a small width
var mySelColor = '#CC0000';
var mySelWidth = 2;
 
// we use a fake canvas to draw individual shapes for selection testing
var ghostcanvas;
var gctx; // fake canvas context
 
// since we can drag from anywhere in a node
// instead of just its x/y corner, we need to save
// the offset of the mouse when we start dragging.
var offsetx, offsety;
 
// Padding and border style widths for mouse offsets
var stylePaddingLeft, stylePaddingTop, styleBorderLeft, styleBorderTop;
 
// initialize our canvas, add a ghost canvas, set draw loop
// then add everything we want to intially exist on the canvas
function init() {
  canvas = document.getElementById('canvas');
  HEIGHT = canvas.height;
  WIDTH = canvas.width;
  ctx = canvas.getContext('2d');
  ghostcanvas = document.createElement('canvas');
  ghostcanvas.height = HEIGHT;
  ghostcanvas.width = WIDTH;
  gctx = ghostcanvas.getContext('2d');
 
  //fixes a problem where double clicking causes text to get selected on the canvas
  canvas.onselectstart = function () { return false; }
 
  // fixes mouse co-ordinate problems when there's a border or padding
  // see getMouse for more detail
  if (document.defaultView && document.defaultView.getComputedStyle) {
    stylePaddingLeft = parseInt(document.defaultView.getComputedStyle(canvas, null)['paddingLeft'], 10)      || 0;
    stylePaddingTop  = parseInt(document.defaultView.getComputedStyle(canvas, null)['paddingTop'], 10)       || 0;
    styleBorderLeft  = parseInt(document.defaultView.getComputedStyle(canvas, null)['borderLeftWidth'], 10)  || 0;
    styleBorderTop   = parseInt(document.defaultView.getComputedStyle(canvas, null)['borderTopWidth'], 10)   || 0;
  }
 
  // make draw() fire every INTERVAL milliseconds.
  setInterval(draw, INTERVAL);
 
  // add our events. Up and down are for dragging,
  // double click is for making new boxes
  canvas.onmousedown = myDown;
  canvas.onmouseup = myUp;
  canvas.ondblclick = myDblClick;
 
  // add custom initialization here:
 
  // add an orange rectangle
  addRect(200, 200, 40, 40, '#FFC02B');
 
  // add a smaller blue rectangle
  addRect(25, 90, 25, 25, '#2BB8FF');
}

Drawing

Since our canvas is animated (boxes move over time), we have to set up a draw loop as I did in the init() function.

We have to draw at a frame rate, maybe every 20 milliseconds or so. However, redrawing doesn’t just mean drawing the shapes over and over; we also have to clear the canvas before every redraw. If we don’t clear it, dragging will look like the box is making a solid line because none of the old box-positions will go away.

Because of this, we clear the entire canvas before each Draw frame. This can get expensive, and we only want to draw if something has actually changed within our framework, so we will consider the canvas to be either valid or invalid.

If everything just got drawn, the canvas is valid and there’s no need to draw again. However, if we do something like add a new Box or try to move a box by dragging it, the canvas will get invalidated and draw() will do a clear-redraw-validate.

This isn’t the only way to optimize drawing, after all clearing and redrawing the entire canvas when one little box moves is excessive, but canvas invalidation is the only optimization we’re going to use for now.

// While draw is called as often as the INTERVAL variable demands,
// It only ever does something if the canvas gets invalidated by our code
function draw() {
  if (canvasValid == false) {
    clear(ctx);
 
    // Add stuff you want drawn in the background all the time here
 
    // draw all boxes
    var l = boxes.length;
    for (var i = 0; i < l; i++) {
        drawshape(ctx, boxes[i], boxes[i].fill);
    }
 
    // draw selection
    // right now this is just a stroke along the edge of the selected box
    if (mySel != null) {
      ctx.strokeStyle = mySelColor;
      ctx.lineWidth = mySelWidth;
      ctx.strokeRect(mySel.x,mySel.y,mySel.w,mySel.h);
    }
 
    // Add stuff you want drawn on top all the time here
 
 
    canvasValid = true;
  }
}

As you can see, we go through all of boxes[] and draw each one, in order from first to last. This will give the nice appearance of later boxes looking as if they are on top of earlier boxes. After all the boxes are drawn, a selection handle (if there’s a selection) gets drawn around the box that mySel references.

If you wanted a background (like a city) or a foreground (like clouds), one way to add them is to put them before or after the main two drawing bits. There are better ways though, like using multiple canvases, but we won’t go over that here.

Mouse events

Now we have objects, initialization, and a loop that will constantly re-draw when needed. All thats left is to make the mouse do things upon pressing, releasing, and double clicking.

With our MouseDown event we need to see if there are any objects we could have clicked on. And we don’t want just any object; selections make the most sense when we only grab the top-most object.

Now we could do something very easy and just check the bounds of each of our boxes – see if the mouse co-ordinates lie within the boxes width and height range – but that isn’t as extendable as I’d like. After all, What if later we want to select lines instead of boxes? Or select triangles? Or select text?

So we’re going to do selection in a more general way: We will draw each shape, one at a time, onto a “ghost” canvas, and see if the mouse co-ordinates lie on a drawn pixel or not.

A ghost canvas (or fake canvas, or temporary canvas) is a second canvas that we created in the same size and shape as our normal one. Only nothing from it will ever get seen, because we only created it in code and never added it to the page. Go back and look at ghostcanvas and its context (gctx) in the init() function to see how it was made.

// Happens when the mouse is clicked in the canvas
function myDown(e){
  getMouse(e);
  clear(gctx); // clear the ghost canvas from its last use
 
  // run through all the boxes
  var l = boxes.length;
  for (var i = l-1; i >= 0; i--) {
    // draw shape onto ghost context
    drawshape(gctx, boxes[i], 'black');
 
    // get image data at the mouse x,y pixel
    var imageData = gctx.getImageData(mx, my, 1, 1);
    var index = (mx + my * imageData.width) * 4;
 
    // if the mouse pixel exists, select and break
    if (imageData.data[3] > 0) {
      mySel = boxes[i];
      offsetx = mx - mySel.x;
      offsety = my - mySel.y;
      mySel.x = mx - offsetx;
      mySel.y = my - offsety;
      isDrag = true;
      canvas.onmousemove = myMove;
      invalidate();
      clear(gctx);
      return;
    }
 
  }
  // havent returned means we have selected nothing
  mySel = null;
  // clear the ghost canvas for next time
  clear(gctx);
  // invalidate because we might need the selection border to disappear
  invalidate();
}

myMove and myUp are pretty self explanatory. the var isDrag becomes true if myDown found something to select, and it becomes false again when the mouse is released (myUp).

// Happens when the mouse is moving inside the canvas
function myMove(e){
  if (isDrag){
    getMouse(e);
 
    mySel.x = mx - offsetx;
    mySel.y = my - offsety;   
 
    // something is changing position so we better invalidate the canvas!
    invalidate();
  }
}
 
function myUp(){
  isDrag = false;
  canvas.onmousemove = null;
}

There are a few little methods I added that are not shown, such as one to correctly get the mouse position in a canvas. You can see and download the full demo source here.

Now that we have a basic structure down, it is easy to write code that handles more complex shapes, like paths or images or video. Rotation and scaling these things takes a bit more work, but is quite doable with the Canvas and our selection method is already set up to deal with them.

If you would like to see this code enhanced in future posts (or have any fixes), let me know how in the comments.

Part 2 of this tutorial is about resizing the shapes and can be found here.



Source: http://simonsarris.com/blog/140-canvas-moving-selectable-shapes

Opinions expressed by DZone contributors are their own.


Comments