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
💡 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;
Let's run it.
yarn start
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);
}
};
Now by clicking in your browser, you'll see the square, and you can draw as many as you can, isn't it awesome? 😎
But let's brake down what's happening here:
- We're using the
fillStyle
to specify the color to use inside shapes. - Method
fillRect
draws a rectangle that is filled according to the currentfillStyle
.
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:
- Whenever we do changes to a canvas, we have to clear and then redraw the whole canvas.
- 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:
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>[];
}
// 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;
}
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
);
}
}
// 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);
}
}
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>
);
}
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 🎉
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);
}
}
}
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>
);
}
🎉 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);
}
}
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;
}
}
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
}
}
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();
}
});
});
}
};
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.
🎉 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)