DEV Community

Cover image for Building a Collaborative Whiteboard using ReactJS, Socket.io and NodeJS šŸ¤
Fidal Mathew
Fidal Mathew

Posted on

Building a Collaborative Whiteboard using ReactJS, Socket.io and NodeJS šŸ¤

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.

Project whiteboard

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;


Enter fullscreen mode Exit fullscreen mode

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);
        };
    }, []);



Enter fullscreen mode Exit fullscreen mode

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);


Enter fullscreen mode Exit fullscreen mode

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]);


Enter fullscreen mode Exit fullscreen mode

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;
        };


Enter fullscreen mode Exit fullscreen mode

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;


Enter fullscreen mode Exit fullscreen mode

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;
    }


}


Enter fullscreen mode Exit fullscreen mode

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;
}


Enter fullscreen mode Exit fullscreen mode

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]);


Enter fullscreen mode Exit fullscreen mode

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' }}
        />
    );


Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
gabrielctroia profile image
Gabriel C. Troia

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 šŸ™

Collapse
 
fidalmathew profile image
Fidal Mathew

Movex looks awesome! It would be really interesting to build using it! Thank you for sharing!šŸ˜Š

Collapse
 
gabrielctroia profile image
Gabriel C. Troia

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! šŸ™Œ

Thread Thread
 
fidalmathew profile image
Fidal Mathew

I reached out on the Discord channel! Could you check?