Hi fellow readers! I hope youāre doing great. In this article, we will build our collaborative whiteboard similar to Google Meetās whiteboard. We are going to use HTML canvas with ReactJS to create our whiteboard and socket.io to implement real-time communication.
Hereās how the project is gonna look when implemented. Letās get started.
In the project directory, we will create folders, and create the frontend using the command npm create vite@latest
and choose āreactā and ātypescriptā.
Create Canvas
In the front end, create a component Board. This component will include our Canvas and its functionalities.
import React, { useRef } from 'react';
const Board = () => {
const canvasRef = useRef<HTMLCanvasElement>(null);
return (
<canvas
ref={canvasRef}
width={600}
height={400}
style={{ backgroundColor: 'white' }}
/>
);
};
export default Board;
Here, we create a reference of the current state of the canvas using useRef hook which is now known as canvasRef. We have kept the background color to be white to create a whiteboard environment, it could be any color you want or none at all.
Draw on canvas
Now, we created the canvas, letās see how we can draw/write on it.
useEffect(() => {
// Variables to store drawing state
let isDrawing = false;
let lastX = 0;
let lastY = 0;
const startDrawing = (e: { offsetX: number; offsetY: number; }) => {
isDrawing = true;
[lastX, lastY] = [e.offsetX, e.offsetY];
};
// Function to draw
const draw = (e: { offsetX: number; offsetY: number; }) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
[lastX, lastY] = [e.offsetX, e.offsetY];
};
// Function to end drawing
const endDrawing = () => {
isDrawing = false;
};
const canvas: HTMLCanvasElement | null = canvasRef.current;
const ctx = canvasRef.current?.getContext('2d');
// Set initial drawing styles
if (ctx) {
ctx.strokeStyle = black;
ctx.lineWidth = 5;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
// Event listeners for drawing
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDrawing);
canvas.addEventListener('mouseout', endDrawing);
return () => {
// Clean up event listeners when component unmounts
canvas.removeEventListener('mousedown', startDrawing);
canvas.removeEventListener('mousemove', draw);
canvas.removeEventListener('mouseup', endDrawing);
canvas.removeEventListener('mouseout', endDrawing);
};
}, []);
We are managing different mouse events on our canvas using JavaScript event listeners. As soon as the mouse is right-clicked on canvas, we perform the startDrawing() function, and when the mouse moves, draw() is invoked, and as soon as we leave the mouse click, the endDrawing() function is called. Letās see this in detail.
We use the ctx
variable to get the current canvas reference and perform actions on it. We have kept some default properties of our ctx variable, such as strokeStyle to be black, lineWidth is 5, lineCap, and lineJoin to be round.
We setup the event listeners on Canvas and also clean up the event listeners when the component unmounts or runs before the next effect. We are using isDrawing
, lastX
, and lastY
variables to control the drawing state. Initially, isDrawing is set to false, until mousedown occurs. It changes the state of isDrawing to true
. When the mouse moves, the ctx
variable creates strokes according to the position of our cursor on the canvas. When we stop the right click, or move the cursor out of the canvas, the endDrawing() function is called which sets the isDrawing to false. With this, we can freely draw on canvas :)
Backend
Now, letās add the feature to allow multiple users to use the whiteboard at the same time. Go to the backend directory, first, execute the command
npm install socket.io
, and code the following in an index.js file.
const { Server } = require('socket.io');
const io = new Server({
cors: "http://localhost:5173/"
})
io.on('connection', function (socket) {
socket.on('canvasImage', (data) => {
socket.broadcast.emit('canvasImage', data);
});
});
io.listen(5000);
Here, we have initialized a new Socket.IO server instance named io. The cors option is set to allow connections from the specified URL (http://localhost:5173/), which is our front-end URL. If we donāt put this, our frontend will not be able to share resources with the backend.
The io.on(āconnectionā)
sets up an event listener for whenever a client connects to the Socket.IO server. Inside this, we have another event listener listening to the event ācanvasImageā from the front end via users.
When a user emits an event from the front end, it will contain the most recent copy of the canvas. We will receive the data using this event and then socket.broadcast.emit(ācanvasImageā, data)
broadcasts the received canvasImage data to all connected clients except the client that sent the original 'canvasImage' event. Last, but not least, we start our server using the command node index.js
which runs on port 5000.
Client Socket Connection
In Board.tsx, we will now make some changes. Before that, weāll install 'socket.io-client' package in the frontend directory.
import React, { useRef, useEffect, useState } from 'react';
import io from 'socket.io-client';
const [socket, setSocket] = useState(null);
useEffect(() => {
const newSocket = io('http://localhost:5000');
console.log(newSocket, "Connected to socket");
setSocket(newSocket);
}, []);
useEffect(() => {
if (socket) {
// Event listener for receiving canvas data from the socket
socket.on('canvasImage', (data) => {
// Create an image object from the data URL
const image = new Image();
image.src = data;
const canvas = canvasRef.current;
// eslint-disable-next-line react-hooks/exhaustive-deps
const ctx = canvas.getContext('2d');
// Draw the image onto the canvas
image.onload = () => {
ctx.drawImage(image, 0, 0);
};
});
}
}, [socket]);
Weāll create a new useState hook named socket for managing sockets.
const newSocket = io('http://localhost:5000')
establishes a connection with the server and client.
To get updates from all users using the whiteboard, we are listening for the ācanvasImageā event from the server using the useEffect hook. If we receive an event, we are going to draw that image on our current canvas.
So, shall we send the image to the server? Every time we draw? No, we send the image to the server when we are drawn i.e. once, we lift our right-click. Hereās the updated endDrawing() function.
const endDrawing = () => {
const canvas = canvasRef.current;
const dataURL = canvas.toDataURL(); // Get the data URL of the canvas content
// Send the dataURL or image data to the socket
// console.log('drawing ended')
if (socket) {
socket.emit('canvasImage', dataURL);
console.log('drawing ended')
}
isDrawing = false;
};
Simple! With this, we are done with a collaborative whiteboard. But, if you want an option to adjust the color and size of the brush. Hold on, we are going to do it next.
Adjust Color and Size
We are going to use two states for controlling these two brush properties. In App.tsx, we are going to use onChange() function on the inputs to update each state.
import { useEffect, useState } from 'react';
import './App.css';
import Board from './component/Board';
const CanvasDrawing = () => {
const [brushColor, setBrushColor] = useState('black');
const [brushSize, setBrushSize] = useState<number>(5);
return (
<div className="App" >
<h1>Collaborative Whiteboard</h1>
<div>
<Board brushColor={brushColor} brushSize={brushSize} />
<div className='tools' >
<div>
<span>Color: </span>
<input type="color" value={brushColor} onChange={(e) => setBrushColor(e.target.value)} />
</div>
<div>
<span>Size: </span>
<input type="range" color='#fac176'
min="1" max="100" value={brushSize} onChange={(e) => setBrushSize(Number(e.target.value))} />
<span>{brushSize}</span>
</div>
</div>
</div>
</div>
);
};
export default CanvasDrawing;
Letās add some CSS in App.css to make it look a bit more pretty.
.App {
background-color: #4158D0;
background-image: linear-gradient(43deg, #4158D0 0%, #C850C0 46%, #FFCC70 100%);
height: 100vh;
width: 100%;
padding: 3em 1em;
display: flex;
flex-direction: column;
align-items: center;
}
.tools {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
width: 100%;
padding: 1em;
background-color: black;
color: white;
}
h1 {
margin-bottom: 1rem;
text-align: center;
}
input {
margin: 0 5px;
}
span {
font-size: 1.3em;
}
@media screen and (max-width: 600px) {
.tools {
flex-direction: column;
align-items: start;
}
}
Index.css
@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap');
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Roboto', sans-serif;
}
We are passing brushSize and brushColor states as props to the Board component.
interface MyBoard {
brushColor: string;
brushSize: number;
}
const Board: React.FC<MyBoard> = (props) => {
const { brushColor, brushSize } = props;
useEffect(() => {
// Variables to store drawing state
let isDrawing = false;
let lastX = 0;
let lastY = 0;
const startDrawing = (e: { offsetX: number; offsetY: number; }) => {
isDrawing = true;
console.log(`drawing started`, brushColor, brushSize);
[lastX, lastY] = [e.offsetX, e.offsetY];
};
// Function to draw
const draw = (e: { offsetX: number; offsetY: number; }) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.lineTo(e.offsetX, e.offsetY);
ctx.stroke();
}
[lastX, lastY] = [e.offsetX, e.offsetY];
};
// Function to end drawing
const endDrawing = () => {
const canvas = canvasRef.current;
const dataURL = canvas.toDataURL(); // Get the data URL of the canvas content
// Send the dataURL or image data to the socket
// console.log('drawing ended')
if (socket) {
socket.emit('canvasImage', dataURL);
console.log('drawing ended')
}
isDrawing = false;
};
const canvas: HTMLCanvasElement | null = canvasRef.current;
const ctx = canvasRef.current?.getContext('2d');
// Set initial drawing styles
if (ctx) {
ctx.strokeStyle = brushColor;
ctx.lineWidth = brushSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
}
// Event listeners for drawing
canvas.addEventListener('mousedown', startDrawing);
canvas.addEventListener('mousemove', draw);
canvas.addEventListener('mouseup', endDrawing);
canvas.addEventListener('mouseout', endDrawing);
return () => {
// Clean up event listeners when component unmounts
canvas.removeEventListener('mousedown', startDrawing);
canvas.removeEventListener('mousemove', draw);
canvas.removeEventListener('mouseup', endDrawing);
canvas.removeEventListener('mouseout', endDrawing);
};
}, [brushColor, brushSize, socket]);
In the useEffect, we are making changes ctx.strokeStyle = brushColor;
and ctx.lineWidth = brushSize;
and adding brushColor, brushSize, socket in the dependency array.
Responsive Canvas
We are going to make our canvas responsive with a bit of JavaScript. The window object to fetch the width and height of the device and accordingly, we will show the canvas.
const [windowSize, setWindowSize] = useState([
window.innerWidth,
window.innerHeight,
]);
useEffect(() => {
const handleWindowResize = () => {
setWindowSize([window.innerWidth, window.innerHeight]);
};
window.addEventListener('resize', handleWindowResize);
return () => {
window.removeEventListener('resize', handleWindowResize);
};
}, []);
return (
<canvas
ref={canvasRef}
width={windowSize[0] > 600 ? 600 : 300}
height={windowSize[1] > 400 ? 400 : 200}
style={{ backgroundColor: 'white' }}
/>
);
We are using āresizeā event listener to update the state of the width and height of the windowSize state. And finally, we have completed our web app.
I hope you liked this project.
Thank you for reading!
If you have any doubts or wanna get a peek at the source code. You can check it out here.
Connect with me on-
Top comments (4)
Great article Fidal! I'd love to see you build this app using my framework Movex github.com/movesthatmatter/movex. It abstracts the socket + node piece away giving you a frontend only (serverless) approach while still maintaining all of the control of you application and data. It comes with a few things like private/secret state, realtime updates and fully typed out of the box.
I'd be honored if you took a look at it and let me know your opinion or start building anything with it. I'm also here to help if you need anything š
Movex looks awesome! It would be really interesting to build using it! Thank you for sharing!š
Thank you Fidal! Looking forward to see what you end up building!
Btw, I'm still working on Movex (fixing bugs, improve the docs, add more features) so if there's anything you need or it isn't clear to you please let me know! š
I reached out on the Discord channel! Could you check?