Our drawing canvas is now created, but it doesn't do anything yet. In this part of our series on creating a drawing canvas with FabricJS and TypeScript, we're going to refactor our drawing editor to draw what might be the simplest kind of shape: a straight line. Unfortunately, doing such a thing is not as easy as it sounds.

A person attempts to walk in a straight line.
This is how a good night becomes a bad one. Photo by Anika Huizinga / Unsplash

Sample Project

As always, there's a sample project that contains the finished code for this series. Check it out!

exceptionnotfound/FabricJSDrawingWalkthrough
Contribute to exceptionnotfound/FabricJSDrawingWalkthrough development by creating an account on GitHub.

Fabric Objects, Drawers, and DrawingMode

FabricJS has a concept of "objects" or two-dimensional things that have been placed onto the drawing canvas. In FabricJS, ALL 2D objects inherit from a base "Object" class. Objects can then implement certain events.

In this tutorial, we have a concept of "drawers" or classes that create objects. For each kind of object that can be drawn onto the canvas, we will have a "drawer" class which is responsible for creating the object, which in turn inherits from Fabric.Object. Each of these drawers should therefore implement an interface, one which allows them to "draw" the object they represent.

Thus, we have the IObjectDrawer interface:

interface IObjectDrawer {
    drawingMode: DrawingMode;
    //Makes the current object
    readonly make: (x: number, //Horizontal starting point
        y: number, //Vertical starting point
        options: fabric.IObjectOptions,
        x2?: number, //Horizontal ending point
        y2?: number) //Vertical ending point
            => Promise<fabric.Object>;
    //Resizes the object (used during the mouseOver event below)
    readonly resize: (object: fabric.Object, x: number, y: number) 
        => Promise<fabric.Object>;
}

Note that this interface expects the classes that implement its methods to return a Promise<fabric.Object> for both the make() and resize() methods.

Also note the drawingMode property. This is an enum which tells the drawing canvas which mode we are currently in (alternatively, which shape will be drawn on the next mouse event); this allows the user to draw a single shape or line multiple times without needing to set the drawer again. The values are as follows:

const enum DrawingMode {
    Line,
    Rectangle,
    Oval,
    Text,
    Polyline,
    Path
}

We will only be implementing the type Line in this post; the others will be implemented in later posts in this series.

The LineDrawer Class

Now we need to implement the actual class that will draw straight lines on our canvas. Here's the code for that:

class LineDrawer implements IObjectDrawer {
    drawingMode: DrawingMode = DrawingMode.Line;
    make(x: number, y: number, options: fabric.IObjectOptions,
         x2?: number, y2?: number)
        : Promise<fabric.Object> { 
        //Return a Promise that will draw a line
        return new Promise<fabric.Object>(resolve => {
            //Inside the Promise, draw the actual line from (x,y) to (x2,y2)
            resolve(new fabric.Line([x, y, x2, y2], options));
        });
    }
    
    resize(object: fabric.Line, x: number, y: number)
    	: Promise<fabric.Object> {
        //Change the secondary point (x2, y2) of the object 
        //This resizes the object between starting point (x,y)
        //and secondary point (x2,y2), where x2 and y2 have new values.
        object.set({
            x2: x,
            y2: y
        }).setCoords();

        //Wrap the resized object in a Promise
        return new Promise<fabric.Object>(resolve => {
            resolve(object);
        });
    }
}

Note that we are using the Fabric.Line class, which is FabricJS's implementation of a straight line. Wherever possible, we will use the FabricJS definitions for our classes.

We now have a class that will draw straight lines for us! Now what we need to do is create events that our canvas can listen to, which will allow the user to actually draw the lines.

Canvas Properties

At this point in the tutorial, we need to make some changes to the root DrawingEditor object we created in Part 1.

The first thing we need to do is allow the drawing editor to keep track of which drawer class is currently being used, as well as all the possible drawer classes that can be selected. For this, we need to modify the DrawingEditor class like so:

class DrawingEditor {
    canvas: fabric.Canvas;

    private _drawer: IObjectDrawer; //Current drawer
    readonly drawerOptions: fabric.IObjectOptions; //Current drawer options
    private readonly drawers: IObjectDrawer[]; //All possible drawers
    private object: fabric.Object; //The object currently being drawn

    constructor(private readonly selector: string, 
                canvasHeight: number, canvasWidth: number) {
        $(`#${selector}`).replaceWith(`<canvas id="${selector}" height=${canvasHeight} width=${canvasWidth}> </canvas>`);
        
        //Create the Fabric canvas
        this.canvas = new fabric.Canvas(`${selector}`, { selection: false });

        //Create a collection of all possible "drawer" classes
        this.drawers = [
            new LineDrawer(),
        ];
        
        //Set the current "drawer" class
        this._drawer = this.drawers[DrawingMode.Line];
        
        //Set the default options for the "drawer" class, including 
        //stroke color, width, and style
        this.drawerOptions = {
            stroke: 'black',
            strokeWidth: 1,
            selectable: true,
            strokeUniform: true
        };
    }
}

These changes will allow us to modify which drawer is currently selected based on some kind of user input. You'll see specific demos of this in later parts of the series.

Let's now discuss exactly how we want the user to interface with the canvas in order to draw a line.

User Interactions and Events

For this tutorial series, we're going to specify that a user draws an object by clicking down the mouse button, dragging the cursor to a new location, and then releasing the button. In this way, our drawing canvas will behave like many other drawing applications, using a "click-drag-release" pattern.

That means we must define "events" for each part of this sequence: mouse down (click), mouse over while holding button (drag), mouse up (release). We also need to specify what happens if the user is clicking-and-dragging the mouse over an-already created object, where the already-created object should not be modified in any way.

The problem is the "dragging". We need a way to tell the canvas that the user is currently holding down the mouse button, and therefore certain interactions should be modified. We do this by adding a new property to the DrawingEditor class:

class DrawingEditor {
    canvas: fabric.Canvas;

    private _drawer: IObjectDrawer;
    readonly drawerOptions: fabric.IObjectOptions;
    private readonly drawers: IObjectDrawer[];
    private isDown: boolean; //Is user dragging the mouse?

    constructor(private readonly selector: string,
        canvasHeight: number, canvasWidth: number) {
        //...

        this.isDown = false; //To start, user is NOT dragging the mouse
    }
}

We can now start to define our events. First, we need a new method that will hold the events we are initializing:

class DrawingEditor {
    //... Properties

    constructor(private readonly selector: string,
        canvasHeight: number, canvasWidth: number) {
        //... Rest of constructor

        this.initializeCanvasEvents();
    }

    private initializeCanvasEvents() { } //New method
}

MouseDown Event (Click)

Let's create a method within the DrawingEditor class that represents what should happen when the mouse is clicked.

class DrawingEditor {
    //... Properties
    //... Constructor
    //... Other Methods

    private async mouseDown(x: number, y: number): Promise<any> {
        this.isDown = true; //The mouse is being clicked

        //Create an object at the point (x,y)
        this.object = await this.make(x, y);
        
        //Add the object to the canvas
        this.canvas.add(this.object);
        
        //Renders all objects to the canvas
        this.canvas.renderAll();
    }
}

When the user clicks the mouse within the canvas, the drawer should begin to draw an object starting from the point where the mouse was clicked. Fabric provides us with the MouseEvent class to represent mouse events, as well as a canvas.getPointer() method we can use to get the current position of the mouse pointer at the time of the event. By getting the MouseEvent and the pointer location, we can initialize an event and call our mouseDown() method like so:

class DrawingEditor {
    //... Properties

    constructor(private readonly selector: string,
        canvasHeight: number, canvasWidth: number) {
        //... Rest of constructor

        this.initializeCanvasEvents();
    }

    private initializeCanvasEvents() {
        this.canvas.on('mouse:down', (o) => {
            const e = <MouseEvent>o.e;

            const pointer = this.canvas.getPointer(o.e);
            this.mouseDown(pointer.x, pointer.y);
        });
    }
    
    private async mouseDown(x: number, y: number): Promise<any> {
        this.isDown = true;

        this.object = await this.make(x, y);
        this.canvas.add(this.object);
        this.canvas.renderAll();
    }
    
    //Method which allows any drawer to Promise their make() function
    private async make(x: number, y: number): Promise<fabric.Object> {
        return await this._drawer.make(x, y, this.drawerOptions);
    }
}

MouseMove Event (Drag)

Now we need to handle the "drag" portion of click-drag-release. When the user is moving the mouse, we should resize the "dragged" object to the new size based on the current location of the pointer.

The implementation looks like:

private initializeCanvasEvents() {
    this.canvas.on('mouse:move', (o) => {
        const pointer = this.canvas.getPointer(o.e);
        this.mouseMove(pointer.x, pointer.y);
    });
}

private mouseMove(x: number, y: number): any {
    if (!this.isDown) {
        return; //If the user isn't holding the mouse button, do nothing
    }
    
    //Use the Resize method from the IObjectDrawer interface
    this._drawer.resize(this.object, x, y);
    this.canvas.renderAll();
}  

MouseUp Event (Release)

Finally, in our mouseUp event, all we need to do is set isDown to false.

private initializeCanvasEvents() {
    //... Other events
    this.canvas.on('mouse:up', (o) => {
       this.isDown = false;
    });
}

Example Lines

With all of the events in place, we can now draw straight lines! Here's a GIF of this code in action:

Bug: Drawing Lines when Moving Objects

We've done quite a lot so far! However, there's a problem with our current implementation: while trying to drag an already-created object (a function which Fabric supports natively) we will accidentally draw another line! It looks like this:

We need to do some additional work to ensure this situation doesn't happen.

First, we need to keep track of what "mode" the cursor is in at any given time. This means we need to know if it is currently drawing or selecting an object. Hence, we need a new enumeration:

const enum CursorMode {
    Draw,
    Select
}

We also need our DrawingEditor object to keep track of the current CursorMode:

class DrawingEditor {
    private cursorMode: CursorMode;
    //... Other properties

    constructor(private readonly selector: string,
        canvasHeight: number, canvasWidth: number) {
        this.cursorMode = CursorMode.Draw;
        //..Rest of constructor
    }
}

The mouseDown() and mouseMove() functions need to be modified so that an object is not drawn if the cursor mode is Select:

private async mouseDown(x: number, y: number): Promise<any> {
    this.isDown = true;

    if (this.cursorMode !== CursorMode.Draw) {
        return;
    }

    this.object = await this.make(x, y);
    this.canvas.add(this.object);
    this.canvas.renderAll();
}
    
private mouseMove(x: number, y: number): any {
    if (!(this.cursorMode.valueOf() === CursorMode.Draw.valueOf() 
        && this.isDown)) {
        return;
    }
    this._drawer.resize(this.object, x, y);
    this.canvas.renderAll();
} 

Finally, we need two new events: one in which when an object is selected the cursor mode is changed to Select; and one in which when the selected object is "cleared" (i.e. no longer selected) the cursor mode is reset to the default Draw.

private initializeCanvasEvents() {
    //... Other events
    
    this.canvas.on('object:selected', (o) => {
        this.cursorMode = CursorMode.Select;
        //sets currently selected object
        this.object = o.target;
    });

    this.canvas.on('selection:cleared', (o) => {
        this.cursorMode = CursorMode.Draw;
    });
}

GIF Time!

With all of this in place, we no longer draw lines when moving already created objects, as shown by this GIF:

Summary

In the second part of our Drawing with FabricJS and TypeScript series, we used our previously-created DrawingEditor object repesenting a canvas and refactored it to code up the ability to draw straight lines. In the process, we learned about "drawer" classes and mouse events, and which ones we needed to implement. We also encountered and fixed a bug that happened when dragging already-created lines.

Don't forget to check out the sample project over on GitHub!

In the next part of the series, we'll start working on drawers for basic shapes, including ovals and rectangles, as well as for text. We'll also need to work out a way for the user to select which drawer they want to use. For all of that, check out Part 3 of Drawing with FabricJS and TypeScript!

Drawing with FabricJS and TypeScript Part 3: Basic Shapes
Let’s add basic shapes like rectangles, ovals, and triangles, and create a toolbar so the user can select whichever they want!

Happy Drawing!