Sunday, December 18, 2016

Using mouse events on an HTML 5 Canvas in typescript

In this tutorial, I will show how to add a mouse event handler to an HTML 5 canvas using typescript.

Acknowledgements: Writing this would not have been possible without the excellent documentation I have found in http://www.typescriptgames.com/ 

The sample document:

To demonstrate the use of the mouse on a canvas, I have created a very simple page:


This page is a primitive drawing program that just draws a line on the canvas till the point where the mouse was clicked.

The html is below:


<!doctype html>
<html>
<head>
<title>Demonstration of Canvas and Typescript</title>
<link rel="stylesheet" href="canvas.css">
</head>
<body>
<header>
<h1>Mouse events on a canvas using typescript</h1>
</header>
<aside>
<h2>
Instructions
</h2>
<p>
Click on the canvas to draw a line from the last point to the point where you have clicked The first point is at the canvas center.
</p>
<p>
note: the canvas is cleared on resize.
</p>
</aside>
<canvas id="canvas">This is the canvas</canvas>
<script src=canvas.js></script>
</body>
</html>
And this is the CSS


h1{
    margin: 10px;
}
h2{
    margin: 0px;
}
aside {
    float: left;
    border: 1px solid black;
    padding: 10px;
    margin: 20px;
    width: 20%;
}
aside p{
    font-family: Arial, Helvetica, sans-serif;
}
#canvas {
    float: left;
    background: lightgray;
    border: 1px solid black;
    margin: 20px;
    width: 65%;
}

Handling the mouse event

Since we are using typescript, It is a good opportunity to make use of OOP. So let's create a class named canvasDraw that will handle all the operations related to drawing.

This class shall include an event handler that will receive the "mousedown" events
class CanvasDraw {
    canvas: HTMLCanvasElement;
    context: CanvasRenderingContext2D;
    private x: number;
    private y: number;
    public constructor() {
        this.canvas = <HTMLCanvasElement>document.getElementById("canvas");
        this.context = this.canvas.getContext("2d");
        this.canvas.addEventListener("mousedown", this.click, false);
    }
    public click = (event: MouseEvent): void => {
        this.context.beginPath();
        this.context.moveTo(this.x, this.y);
        let rect = this.canvas.getBoundingClientRect();
        this.x = (event.clientX - rect.left)/rect.width*this.canvas.width;
        this.y = (event.clientY - rect.top)/rect.height*this.canvas.height;
        this.context.lineTo(this.x, this.y);
        this.context.stroke();
        event.preventDefault();
    }
}
let canvasDraw: CanvasDraw = new CanvasDraw();

Syntax to declare the event handler:

We assign a lambda function to an attribute of the class:

 public click = (event: MouseEvent): void => { ... }

and we use this attribute to as a mousedown event listenr

this.canvas.addEventListener("mousedown", this.click, false);

This allows us to define the full code inside the class

Finding the clicked x/y position inside the canvas

The mouse event attributes clientX and ClientY cannot be used directly.

They need to be offset by the client top/left position and must be adapted by the canvas  scaling.

        let rect = this.canvas.getBoundingClientRect();
        x = (event.clientX - rect.left)/rect.width*this.canvas.width;
        y = (event.clientY - rect.top)/rect.height*this.canvas.height;

The canvas scaling problem

 If you try the above program (don't forget to compile the typescript into JavaScript) You will see that the result is not as good as expected. This is because the HTML 5 canvas maintain a distinction between the canvas size (i.e. the image drawn) and the displayed size.

By default the canvas size is 300px x 150 px but the displayed size is determined by the css. This means that most probably the displayed size will be much larger than the canvas drawing size leading to a "zoomed" image where all the pixels are visible and blurry.




To avoid this we should align the canvas size on the display size. But doing this is not straight forward.

Aligning canvas size on display size

To do this we use the following code

        this.canvas.height = this.canvas.offsetHeight;
        this.canvas.width = this.canvas.offsetWidth;

This works great but we need to find a way to call it at program startup  and each time the canvas is resized.

The resize event

There is no resize event on canvas in HTML 5 so we will have to use the event associated with the window object instead of the canvas object

class CanvasDraw {
    canvas: HTMLCanvasElement;
    context: CanvasRenderingContext2D;
    private x: number;
    private y: number;
    public constructor() {
        this.canvas = <HTMLCanvasElement>document.getElementById("canvas");
        this.context = this.canvas.getContext("2d");
        this.canvas.addEventListener("mousedown", this.click, false);
        //note resize event cannot be attached to the canvas
        window.addEventListener("resize", this.resize, false);
        this.resize(null);
    }
    public click = (event: MouseEvent): void => {
...
    }
    public resize = (event: UIEvent): void => {
        this.canvas.height = this.canvas.offsetHeight;
        this.canvas.width = this.canvas.offsetWidth;
    }
}

Note the call to resize at the end of the constructor that ensure that the initial canvas drawing area size is correct at the application startup

Important note: on most browser the canvas is cleared after resize but this behavior is not guaranteed. To avoid compatibility problems  we always clear the canvas with
 this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);

The Complete program

Just for completeness and if you want to try it, this is the full listing of canvas.ts

class CanvasDraw {
    canvas: HTMLCanvasElement;
    context: CanvasRenderingContext2D;
    private x: number;
    private y: number;
    public constructor() {
        this.canvas = <HTMLCanvasElement>document.getElementById("canvas");
        this.context = this.canvas.getContext("2d");
        this.canvas.addEventListener("mousedown", this.click, false);
        //note resize event cannot be attached to the canvas
        window.addEventListener("resize", this.resize, false);
        this.resize(null);
    }
    public click = (event: MouseEvent): void => {
        this.context.beginPath();
        this.context.moveTo(this.x, this.y);
        let rect = this.canvas.getBoundingClientRect();
        this.x = (event.clientX - rect.left);///rect.width*this.canvas.width;
        this.y = (event.clientY - rect.top);///rect.height*this.canvas.height;
        this.context.lineTo(this.x, this.y);
        this.context.stroke();
        event.preventDefault();
    }
    public resize = (event: UIEvent): void => {
        //the problem is that we must align canvas resolution on CSS set display size
        // otherwise there will be a zoom at display that will blur the lines
        this.canvas.height = this.canvas.offsetHeight;
        this.canvas.width = this.canvas.offsetWidth;
        this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
        this.x = this.canvas.width / 2;
        this.y = this.canvas.height / 2;
    }
}
let canvasDraw: CanvasDraw = new CanvasDraw();