Enhancing HTML5 Canvas with Fabric.js & Angular

Josh Dunn

Have you ever wanted to leverage the power of the HTML canvas element but didn't know where to start? In this article we take a look at integrating a canvas element into an Angular application and leveraging Fabric.js to provide an object model for the canvas.

hero background

What is a HTML5 canvas?

The HTML canvas element has been around for quite some time and is a versatile tool used to draw graphics directly in a web page. It serves as a space in the DOM where you can render 2D shapes, images, animations and more to create dynamic visuals such as charts, image manipulation tools, 3D modelling tools, and even game graphics.

Unlike traditional HTML elements, everything displayed in a canvas element is rendered by JavaScript which allows you to update the content dynamically and provides a deeper level of interaction and manipulation than static DOM elements.

What is Fabric.js?

Fabric.js is a JavaScript library that aims to simplify the task of working with the HTML5 canvas element by providing an object model for the canvas, as well as an SVG parser, layer of interactivity, as well as a large number of utilities to support complex scenarios and data modelling.

This article will focus on version 5 of the Fabric.js library, however it's worth noting the at the time of writing this, version 6.3.0 just released and is well on it's way to being a stable platform. You can view and follow along with the v6 breaking changes in this GitHub issue to determine which version is right for you.

Why use a JavaScript library instead of the Canvas API?

For anyone who has tried to use the canvas element for anything more than just rendering basic shapes, you will have quickly noticed that the canvas API is quite a low level API and once you start looking at complex scenarios and manipulation, the API begins to get more and more complex to understand which only increases the time it takes to fully develop your project.

The MDN Docs even make note of this by saying "The Canvas API is extremely powerful, but not always simple to use." and provide a list of Libraries to use which can help alleviate some of the complexities of the Canvas API.

Getting started with Fabric.js using Angular

Fabric.js provides a large amount of documentation and demos to help showcase what Fabric.js can do as well as how to use each feature.

The initial setup is very straightforward and involves referencing a native canvas element to create a new Fabric canvas.

First we need to install the relevant packages for Fabric.js

npm i fabric
npm i @types/fabric

Then we can create the HTML canvas and wrap it in a container div as this helps with styling it later.

<div class="canvas-container" #canvasContainer>
    <canvas id="canvas"></canvas>
</div>

Inside our component all we have to do is initialize the canvas variable with a new fabric.Canvas and pass in the ID of the HTML canvas element from above.

import { fabric } from 'fabric';

@Component({
    standalone: true,
    selector: 'app-canvas'
})
export class BaseCanvasComponent implements AfterViewInit {
    private canvas?: fabric.Canvas;

    @ViewChild('canvasContainer')
    private container!: ElementRef;
    
    public ngAfterViewInit(): void {
        const width = (this.container.nativeElement as HTMLDivElement).clientWidth;
        const height = (this.container.nativeElement as HTMLDivElement).clientHeight;

        this.canvas = new fabric.Canvas('canvas', {
            width: width,
            height: height
        });
    }
}

And that's it! Now we have a fully functioning Fabric.js canvas that we can manipulate to our needs.

Practical applications of Fabric.js

So we have a canvas to use and a world of endless possibilities at our fingertips, but what do we build?

The task

To see the true power of Fabric.js I wanted to revisit a product I created in a previous job which allowed users to view and build a "floorplan" of all the dealers and tables on a Casino floor. At the time of writing that, the decision was made to use SVG's and leveraging Angular's dynamic component loaders I was able to create a "virtual" infinite floorplan which loaded and unloaded elements from the DOM as they entered and left the view. This whole process was rather complex and took around 3 months to create.

If I could create something similar using a canvas and Fabric.js in even a fraction of that time, I would consider this a a huge success (spoiler alert; it took me about 3 days).

I decided to build a grid based drawing system which could then be further expanded upon to provide drawing abilities for things like building floorplans, restaurant table layouts, landscaping and drafting mockups, as well as anything else that required a "top-down" view of things.

The setup

First, lets get some styles going. This is going to be a base component and doesn't need much in the way of styling, but we do need to ensure the component grows to the size of the element it will sit in.

:host {
    display: flex;
    flex-direction: column;
    height: 100%;

    .canvas-container {
        display: flex;
        flex-grow: 1;
    }

    .lower-canvas {
        background-size: 50px 50px;
        background-image: linear-gradient(to right, rgba(0, 0, 0, 0.1) 1px, transparent 1px),
            linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 1px, transparent 1px);
    }
}

Note: The .lower-canvas class is a class that Fabric creates, all we want to add to that class is a grid for the background. The background size will eventually match the grid size we create in the Fabric.js canvas implementation to give a "snap to grid" functionality.

Now that we have that out of the way, lets add some features to the canvas.

Panning

Panning is a key part of any grid based drawing system. To add this we're going to create a new setupPanning() method and call it after our canvas is initialized.

First we're going to need some properties for tracking the pan action.

private lastPosX = 0;
private lastPosY = 0;
private isDragging = false;

Now we can define the method.

private setupPanning(): void {
    if (this.canvas) {
        this.canvas.on('mouse:down', (opt) => {
            const evt = opt.e;

            this.isDragging = true;
            this.lastPosX = evt.clientX;
            this.lastPosY = evt.clientY;
        });

        this.canvas.on('mouse:move', (opt) => {
            if (this.isDragging) {
                const e = opt.e;
                const vpt = this.canvas!.viewportTransform!;
                vpt[4] += e.clientX - this.lastPosX;
                vpt[5] += e.clientY - this.lastPosY;
                this.canvas!.requestRenderAll();
                this.lastPosX = e.clientX;
                this.lastPosY = e.clientY;
            }
        });

        this.canvas.on('mouse:up', (e) => {
            this.mouseUpFired.emit(e);
            // On mouse up we want to recalculate new interaction
            // for all objects, so we call setViewportTransform
            this.canvas!.setViewportTransform(this.canvas!.viewportTransform!);
            this.isDragging = false;
        });
    }
}

Now that we have that defined, lets refactor our canvas creation a bit and create a constructCanvas() method which we'll call in our ngAfterViewInit().

public ngAfterViewInit(): void {
    this.constructCanvas();
}

private constructCanvas(): void {
    const width = (this.container.nativeElement as HTMLDivElement).clientWidth;
    const height = (this.container.nativeElement as HTMLDivElement).clientHeight;

    this.canvas = new fabric.Canvas('canvas', {
        width: width,
        height: height
    });

    this.setupPanning();
}

Zooming

Panning is sorted so lets add some zoom capabilities. We'll define some constraints for the min and max zoom, but these can be changed to suit each use case.

const MIN_ZOOM = 0.05;
const MAX_ZOOM = 18;

Then in our constructCanvas() method, we can add the following

this.canvas.on('mouse:wheel', (opt) => {
    const delta = opt.e.deltaY;
    let zoom = this.canvas!.getZoom();
    zoom *= 0.999 ** delta;

    // Set the min and max zoom.
    if (zoom > MAX_ZOOM) zoom = MAX_ZOOM;
    if (zoom < MIN_ZOOM) zoom = MIN_ZOOM;

    this.canvas!.zoomToPoint({ x: opt.e.offsetX, y: opt.e.offsetY }, zoom);
    opt.e.preventDefault();
    opt.e.stopPropagation();
});

Grid Snapping

We have defined the background for the canvas to be a 50px grid, so lets make the dragging on shapes etc. line up and snap to that grid pattern.

Define the grid size property (in pixels). This must match the size defined in the css style otherwise it'll snap to a different size than the grid.

private grid = 50;

Define how to actually snap to the grid.

private snapToGrid(target: fabric.Object): void {
    target.set({
        left: Math.round(target.left! / this.grid) * this.grid,
        top: Math.round(target.top! / this.grid) * this.grid,
    });
}

Then in our constructCanvas() we can add the following

this.canvas.on('object:moving', (e) => {
    if (e.target) {
        this.snapToGrid(e.target);
    }
});

this.canvas.on('object:modified', (options) => {
    // TODO: This still gives some weird behaviour at times.
    if (options.target) {
        const newWidth = Math.round(options.target.getScaledWidth() / this.grid) * this.grid;
        const newHeight = Math.round(options.target.getScaledHeight() / this.grid) * this.grid;
        options.target.set({
            width: newWidth,
            height: newHeight,
            scaleX: 1,
            scaleY: 1,
        });
    }
});

Note: Sometimes when scaling an object it can give off some weird behaviour and still needs some further investigation.

Adding Elements to the Canvas

Once all the controls are set up, we can start adding elements to the canvas. Fabric.js makes the job of adding elements to the canvas very simple, and by default any item that is on the canvas has full manipulation enabled. This means you don't have to add any code to allow dragging, scaling, resizing, or rotating of an element - Fabric.js does it all for you!

Lets define a helper method which consumers can call to add anything to the canvas.

public addItemToCanvas(entity: fabric.Object): void {
    if (this.canvas) {
        this.canvas.add(entity);
    }
}

Now we can look at adding elements to the canvas, for this example we're going to focus on lines, squares, and textboxes as they act as a good proof of concept for this drawing system.

Lines

Lines provide a little bit of a challenge as too small of a stroke width will make the line impossible to select with a cursor and move. To avoid this, set the stroke width a little larger and adjust as you see fit.

const lineOptions: fabric.ILineOptions & { targetFindTolerance?: number } = {
    borderColor: 'black',
    stroke: 'black',
    snapAngle: 45,
    strokeWidth: 10,
    perPixelTargetFind: true,
    targetFindTolerance: 10,
    hasControls: true,
    hasBorders: false,
    selectable: true,
    evented: true,
    padding: 10,
};

// Specify the starting and ending coordinates of the line which are in the sequence like:
// start x-coordinate, start y-coordinate, end x-coordinate and end y-coordinate.
const line = new fabric.Line([200, 200, 400, 200], lineOptions);

line.setControlsVisibility({
    mt: false,
    mb: false,
});

this.addItemToCanvas(line);
Squares

To make a squares we can use a basic Rect element as you'd expect from a normal canvas element.

let square = new fabric.Rect({
    width: 200,
    height: 200,
    borderColor: 'black',
    fill: 'rgba(255, 255, 255, 0.5)',
    stroke: 'black',
    hasBorders: true,
    snapAngle: 45,
    top: 200,
    left: 200,
    strokeWidth: 10,
})

this.addItemToCanvas(square);
Textboxes

Textboxes follow the same convention where we can define the textbox object and add it to the canvas.

let text = new fabric.Textbox('Enter text here...', {
    width: 100,
    height: 100,
    borderColor: 'black',
    stroke: 'black',
    hasBorders: true,
    snapAngle: 45,
    top: 200,
    left: 200,
})

this.addItemToCanvas(text);

Loading & Saving

We now have everything we need when it comes to element manipulation which means the next step is being able to persist the data across sessions. The easiest way of doing this is to leverage the inbuilt methods in Fabric.js to save and load the canvas elements as JSON.

/**
 * Saves the current canvas state to a JSON string using the inbuilt Fabric methods.
 * @returns A JSON string of the current canvas state.
 */
public saveToJSON(): string {
    return JSON.stringify(this.canvas?.toJSON());
}

/**
 * Loads the given JSON string to the canvas.
 * @param inputJSON The JSON string to load.
 */
public loadFromJSON(inputJSON: string): void {
    if (this.canvas && inputJSON !== '') {
        this.loading = true;

        this.canvas.loadFromJSON(inputJSON, () => {
            this.loading = false;
            this.canvas!.renderAll();
        });
    }
}

Limitations, Constraints, & Idiosyncrasies

Fabric.js is a great library to use and covers a wide range of use cases however it does come with it's own set of unique behaviours.

Fabric & Angular

This was unfortunately one of the more tedious things to decipher. Whilst you can use Fabric with Angular and serve up the application, the build itself will actually fail. This is due to Fabric.js being built using node specific dependencies (jsdom for example) which Angular does not like. To get around this, Fabric.js also publishes a browser version of each of their package versions which is not mentioned in the docs anywhere and I only found after a lot of exploring.

If you are using Fabric.js with Angular, use the -browser version of their packages. For example if you want to use version 5.3.0, you would instead install 5.3.0-browser

Custom Builds & Touch Support

The documentation isn't exactly clear on this from the outset, but in order to enable touch events you need to use what is referred to as a "custom build" of Fabric.js. You can do this from their builder and toggle the specific features you want however this then downloads you a .js file to use in your project.

Since we're already using a package manager for the actual Fabric.js package, it seemed odd that custom builds were handled this way - all I wanted was touch support, odd that it's not included in the default package.

To get around this, the "accepted" answer when researching a solution is to set up a post install script which jumps into the installed Fabric.js directory (in node_modules) and runs a custom build with gestures enabled.

To do this, add this to your scripts section in your package.json file.

"postinstall": "cd node_modules/fabric && npm install && npm run build_with_gestures"

Final Thoughts

I'm very impressed with the overall usability and design of the Fabric.js library. A task that previously took me many months now took me only a few days, and adding more and more functionality is relatively easy thanks to the object model that Fabric.js provides. There are plenty of canvas libraries out there and each have their own use cases but for the needs that I had, Fabric.js fit the bill almost perfectly and although there were a few odd behaviours with the development experience, it was not enough to negatively impact my overall experience using this library.