DEV Community

Cover image for Building a Whiteboard with React and Canvas API. Part 1: First steps.
SmirnovW
SmirnovW

Posted on

Building a Whiteboard with React and Canvas API. Part 1: First steps.

Before you start reading, you can check the result here:
demo

Table of contents

Overview

โ€œWhy do we fall? So that we can learn to pick ourselves upโ€

Hello there! In this series of articles, I'll walk you step by step and together we'll create a whiteboard!

No more words, let's code!

First steps

ยง In this part we will learn basics about the Canvas API and how to draw.

First, let's create a base skeleton of our application. To make it faster, I'll use the Create React App.

๐Ÿ’ก Please note, that we're going to use the Typescript in this project. No worries if you don't have experience with the Typescript, you'll understand the code and probably will like it โค๏ธ

yarn create react-app my-whiteboard --template typescript
Enter fullscreen mode Exit fullscreen mode

๐Ÿ’ก You also can use the npm

Now let's change the App.tsx, and add some code.

// App.tsx

function App() {
    const [context, setContext] = useState<CanvasRenderingContext2D | null>(
        null
    );

    const setCanvasRef = useCallback((element: HTMLCanvasElement) => {
        element.width = window.innerWidth;
        element.height = window.innerHeight;
        const canvasContext = element.getContext('2d');

        if (canvasContext !== null) {
            setContext(canvasContext);
        }
    }, []);

    const onMouseDown = () => {
        console.log('context', context);
    };

    return (
        <div>
            <canvas ref={setCanvasRef} onMouseDown={onMouseDown} />
        </div>
    );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Let's run it.

yarn start
Enter fullscreen mode Exit fullscreen mode

Now if you click in your browser, you'll see the context in the console ๐ŸŽ‰

But what it is the context? Context - this is the interface, that provides access to the Canvas API. Basically any manipulation that we are doing with the canvas, we are doing with context. In more details you can read about it here CanvasRenderingContext2D

Now let's draw our first pixels โœจ

Let's change the method onMouseDown.

// App.tsx
const onMouseDown = (event: MouseEvent) => {
    if (context) {
        context.fillStyle = '#ffd670';
        context.fillRect(event.pageX, event.pageY, 200, 200);
    }
};
Enter fullscreen mode Exit fullscreen mode

Now by clicking in your browser, you'll see the square, and you can draw as many as you can, isn't it awesome? ๐Ÿ˜Ž

Image description

But let's brake down what's happening here:

  1. We're using the fillStyle to specify the color to use inside shapes.
  2. Method fillRect draws a rectangle that is filled according to the current fillStyle.

Architecture.

ยง In this part, we will define the basic architecture of our whiteboard.

Drawing a square is already something we can be proud of, but we need to manipulate it somehow, move it, resize it and etc.

Here we need to understand an important concept of the canvas. Everything we draw on the canvas will stay on the canvas until we don't clean it. All canvas drawings are just drawings and not objects with properties, so we can't manipulate them independently.
Due to this, we need to define the following requirements:

  1. Whenever we do changes to a canvas, we have to clear and then redraw the whole canvas.
  2. We need to have a canvas object model of everything we have on canvas to be able to redraw it.

How can we implement that? We have a good example in front of us, in the browser.
When the browser renders a page, it creates different layers. A layer might contain a bunch of elements. that can be combined to be shown on the screen.

๐Ÿ’ก This is not an accurate explanation of how layers work in the browser. But education-wise, let's keep thinking about it in this way.

Presuming this, we can consider that a square we're drawing should be presented as an object with properties. The object is needed to store information about canvas drawings. And whenever we create (draw) a new object(square) we should create a new layer. Then, as browsers, we should composite these layers in one big picture on the canvas.

Summarising all of these requirements, we can have this schema:

Image description

Let's define the LayerInterface, CanvasObjectInterface and implement the Layer and CanvasObject classes.

// canvas/layer/type.ts
import { CanvasObjectInterface } from 'canvas/canvas-object/type';

export interface LayerInterface {
    isPointInside(pointX: number, pointY: number): boolean;

    setActive(state: boolean): void;

    isActive(): boolean;

    addChild(child: CanvasObjectInterface<any>): void;

    getChildren(): CanvasObjectInterface<any>[];
}

Enter fullscreen mode Exit fullscreen mode
// canvas/canvas-object/type.ts

export interface CanvasObjectInterface<T> {
    getOptions(): T;
    setOptions(options: T): void;

    getXY(): number[];
    setXY(x: number, y: number): void;

    getWidthHeight(): number[];
    setWidthHeight(width: number, height: number): void;

    move(movementX: number, movementY: number): void;
}

Enter fullscreen mode Exit fullscreen mode

LayerInterface - is intended to be a canvas object that contains width, height and coordinates of the layer. Also it contains other canvas objects.

CanvasObjectInterface - is intended to be a canvas object that contains width, height and will be drawn on the canvas.

Now we will implement these interfaces:

// canvas/layer/layer.ts

export class Layer extends CanvasObject implements LayerInterface {
    private active = false;
    private children: Array<CanvasObjectInterface> = [];

    setActive(state: boolean) {
        this.active = state;
    }

    isActive() {
        return this.active;
    }

    addChild(child: CanvasObjectInterface) {
        this.children.push(child);
    }

    getChildren(): CanvasObjectInterface[] {
        return this.children;
    }

    isPointInside(pointX: number, pointY: number, padding = 0) {
        const { x, y, w, h } = this.getOptions();

        return (
            pointX > x - padding &&
            pointX < x + w + padding &&
            pointY > y - padding &&
            pointY < y + h + padding
        );
    }
}

Enter fullscreen mode Exit fullscreen mode
// canvas/canvas-object/canvas-object.ts
import { CanvasObjectInterface } from './type';

export class CanvasObject<T extends BaseDrawOptions>
    implements CanvasObjectInterface<T>
{
    options: T = {
        x: 0,
        y: 0,
        w: 0,
        h: 0,
    } as T;

    constructor(options: T) {
        this.options = { ...options };
    }

    getOptions(): T {
        return this.options;
    }

    setOptions(options: T) {
        this.options = { ...this.options, ...options };
    }

    getXY(): number[] {
        return [this.options.x, this.options.y];
    }

    setXY(x: number, y: number) {
        this.options.x = x;
        this.options.y = y;
    }

    setWidthHeight(width: number, height: number) {
        this.options.w = width;
        this.options.h = height;
    }

    getWidthHeight(): number[] {
        return [this.options.w, this.options.h];
    }

    move(movementX: number, movementY: number) {
        const { x, y } = this.options;
        const layerX = x + movementX;
        const layerY = y + movementY;

        this.setXY(layerX, layerY);
    }
}

Enter fullscreen mode Exit fullscreen mode

We've created Layer and CanvasObject classes, now let's see how they can help us.

Let's update the App.tsx.

// App.tsx

function App() {
    const [isMousePressed, setMousePressed] = useState<boolean>(false);
    const [context, setContext] = useState<CanvasRenderingContext2D | null>(
        null
    );

    const [layers, setLayers] = useState<Array<Layer>>([]);
    const [selectedLayer, setSelectedLayer] = useState<Layer | null>(null);

    const setCanvasRef = useCallback((element: HTMLCanvasElement) => {
        element.width = window.innerWidth;
        element.height = window.innerHeight;
        const canvasContext = element.getContext('2d');

        if (canvasContext !== null) {
            setContext(canvasContext);
        }
    }, []);

    const reDraw = () => {
        if (context) {
            context?.clearRect(0, 0, window.innerWidth, window.innerHeight);
            layers.forEach((layer) => {
                const children = layer.getChildren();

                children.forEach((child) => {
                    const options = child.getOptions();
                    if (layer.isActive()) {
                        context.fillStyle = '#70d6ff';
                    } else {
                        context.fillStyle = '#ffd670';
                    }

                    context.fillRect(
                        options.x,
                        options.y,
                        options.w,
                        options.h
                    );
                });
            });
        }
    };

    useEffect(() => {
        reDraw();
    }, [layers]);

    const onMouseDown = (event: MouseEvent) => {
        setMousePressed(true);

        if (context) {
            const detectedLayer = layers.find((layer) =>
                layer.isPointInside(event.pageX, event.pageY)
            );

            if (detectedLayer) {
                if (selectedLayer) {
                    selectedLayer.setActive(false);
                }

                detectedLayer.setActive(true);
                setSelectedLayer(detectedLayer);
                reDraw();
            } else {
                selectedLayer?.setActive(false);
                setSelectedLayer(null);

                const options = {
                    x: event.pageX,
                    y: event.pageY,
                    w: 200,
                    h: 200,
                };
                const rect = new CanvasObject(options);
                const layer = new Layer(options);
                layer.addChild(rect);
                setLayers([...layers, layer]);
            }
        }
    };

    return (
        <div>
            <canvas
                ref={setCanvasRef}
                onMouseDown={onMouseDown}
            />
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

The code above will be triggered on the mouse down event and the method onMouseDown will search for a layer using the method isPointInside, which we implemented for the Layer class.

๐ŸŽ‰ Now we can draw and select squares ๐ŸŽ‰

Image description

Move it

ยง In this part, we create a mechanism of moving canvas elements by mouse.

We can draw, we can select, next we'll add the movement functionality.

First, we will update the Layer class and add this code:

// canvas/layer/layer.ts
export class Layer extends CanvasObject implements LayerInterface {

    // rest of the code ...

    move(movementX: number, movementY: number) {
        super.move(movementX, movementY);
        this.moveChildrenAccordingly(movementX, movementY);
    }

    moveChildrenAccordingly(movementX: number, movementY: number) {
        for (const child of this.children) {
            child.move(movementX, movementY);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Update the App.tsx file and add new methods, onMouseUp and onMouseMove.

function App() {
    // rest of the code ...

    const onMouseMove = (event: MouseEvent) => {
        if (isMousePressed && selectedLayer) {
            selectedLayer.move(event.movementX, event.movementY);
            reDraw();
        }
    };

    const onMouseUp = () => {
        setMousePressed(false);
    };

    return (
        <div>
            <canvas
                ref={setCanvasRef}
                onMouseDown={onMouseDown}
                onMouseUp={onMouseUp}
                onMouseMove={onMouseMove}
            />
        </div>
    );
}

Enter fullscreen mode Exit fullscreen mode

Image description

๐ŸŽ‰ We can draw, select and move objects on the canvas ๐ŸŽ‰

Writing text

ยง In this part, we learn how to draw text in the canvas.

Let's make the stickers a bit more useful by adding text on them ๐Ÿ˜Ž

We're going to introduce a CanvasText object, it will contain text specific information: text, font size, color and etc.

// canvas/canvas-text/type.ts
export interface TextDrawOptions extends BaseDrawOptions {
    text: string;
    color: string;
    fontSize: number;
}

// canvas/canvas-text/canvas-text.ts
import { CanvasObject } from 'canvas/canvas-object';
import { TextDrawOptions } from './type';

export class CanvasText extends CanvasObject<TextDrawOptions> {
    constructor(options: TextDrawOptions) {
        super(options);
    }
}

Enter fullscreen mode Exit fullscreen mode

Now we need to draw text, for this purpose, the canvas rendering context provides the fillText method to render text.

We will change the reDraw method by adding the fillText.

Also, we need to mark the CanvasText and CanvasObject to be able to choose between the fillRect and fillText methods and understand what should be used to draw objects.

For that purpose, we will add a new property type to the CanvasObjectInterface and will update its implementation.

// canvas/enums.ts
export enum TYPES {
    TEXT,
    RECT,
}

// canvas/canvas-object/type.ts
export interface CanvasObjectInterface<T> {
    // rest of the code ...

    getType(): string;
    setType(type: string): void;
}

// canvas/canvas-object/canvas-object.ts
import { TYPES } from 'canvas/enums';

export class CanvasObject<T extends BaseDrawOptions>
    implements CanvasObjectInterface<T>
{
    // rest of the code ...
    private type = TYPES.RECT;

    // rest of the code ...
    setType(type: TYPES) {
        this.type = type;
    }

    getType(): TYPES {
        return this.type;
    }
}



Enter fullscreen mode Exit fullscreen mode

And small changes for the CanvasObject.

export class CanvasText extends CanvasObject<TextDrawOptions> {
    constructor(options: TextDrawOptions) {
        super(options);
        this.setType(TYPES.TEXT); // we're setting the object type
    }
}
Enter fullscreen mode Exit fullscreen mode

Next let's change the reDraw method.

    const reDraw = () => {
        if (context) {
            context?.clearRect(0, 0, window.innerWidth, window.innerHeight);
            layers.forEach((layer) => {
                const children = layer.getChildren();

                children.forEach((child) => {
                    if (layer.isActive()) {
                        context.fillStyle = '#70d6ff';
                    } else {
                        context.fillStyle = '#ffd670';
                    }

                    const type = child.getType();

                    if (type === TYPES.RECT) {
                        const options: BaseDrawOptions = child.getOptions();

                        context.fillRect(
                            options.x,
                            options.y,
                            options.w,
                            options.h
                        );
                    }

                    if (type === TYPES.TEXT) {
                        const options: TextDrawOptions = child.getOptions();

                        context.save();
                        context.fillStyle = options.color;
                        context.font = `${options.fontSize}px monospace`;
                        context.fillText(
                            options.text,
                            options.x,
                            options.y,
                            options.w
                        );
                        context.restore();
                    }
                });
            });
        }
    };
Enter fullscreen mode Exit fullscreen mode

Well, let's now focus on what is happening in the code snippet above. We're checking the current type if it equals to TEXT and we start rendering the text object. You can see new context methods that we didn't see before, save() and restore().
We're using this method to save the current context state and restore it later.
What does it mean? We're using the fillStyle to set the font's color, but by changing this property, we will change NOT ONLY the font color but also change the current canvas color.
For example, if we try to call fillRect right after the fillText, without using save() and restore(), it will render a square with the same color that we applied before for text.
Another property we've used here is font, it specifies the current text style. This string uses the same syntax as the CSS font specifier.

Image description

๐ŸŽ‰ Now we can draw text on the canvas! ๐ŸŽ‰

We did our first steps in building our own white board! And it wasn't so difficult right?

In the next part we will improve it!

P.S. I'm working on a second part now. If you like this article, please share you feedback in the comments, thanks in advance!

Top comments (0)