DEV Community

Cover image for A shareable whiteboard with Canvas, Socket.io, and React.
JerryMcDonald
JerryMcDonald

Posted on • Edited on

A shareable whiteboard with Canvas, Socket.io, and React.

Do you need a shareable Canvas for your React Project?

The Socket.io documentation does a great job of getting you started with a whiteboard, but unfortunately, it is built-in HTML. In this blog, we see what it takes to create a whiteboard With React. Then make it shareable through a socket.io connection!


Start by using create-react-app.

npx create-react-app my-whiteboard
cd my-whiteboard
Enter fullscreen mode Exit fullscreen mode

Go ahead and bring in the npm packages you will need.

npm i socket.io
npm i socket.io-client
Enter fullscreen mode Exit fullscreen mode

Starting with the back end

Let us begin with the easy part. Creating our socket server and listening for a connection. In your my-whiteboard directory, you want to create a folder called server and a file named index.js.

Alt Text


Create-react-app already comes with express as an installed node package, so let us make use of it at the top of our index.js. The var app = express() statement creates a new express application for you.

We can transfer data over the HyperText Transfer Protocol (HTTP) with a Node.js built-in module called HTTP. So bring it in with const HTTP = require('HTTP').

Next, we will essentially turn our computer into a server with HTTP.createServer(app).

Here is where we are at so far:

const express = require('express');
const app = express();

const http = require('http');
const server = http.createServer(app);
Enter fullscreen mode Exit fullscreen mode

Now you should pass your created server to socket.io.

const socket = require('socket.io');
const io = socket(server);
Enter fullscreen mode Exit fullscreen mode

If you do not understand how socket works, the socket.io documentation sums it up quite nicely.

Socket.io is a library that enables real-time, bidirectional, and event-based communication between the browser and the server.

We will essentially establish our server, and socket.io will be listening for a 'connection.' When the client-side socket emits a signal, our server-side will make that connection and execute any commands create. Usually, this will involve 'emitting' data back to the client.

After we establish the connection, we want to listen for a drawing event. We can then take that data passed to use and broadcast it, attaching the 'drawing' label.

io.on('connection', onConnection);

function onConnection(socket){
  socket.on('drawing', (data) => socket.broadcast.emit('drawing', data));
}
Enter fullscreen mode Exit fullscreen mode

Next, we can decide on a port to listen on. Make sure to call .listen on the server, not the app.

Here is a look at our entire index.js:

const express = require('express');
const app = express();
const http = require('http');
const server = http.createServer(app);
const socket = require('socket.io');
const io = socket(server);

io.on('connection', onConnection);

function onConnection(socket){
  socket.on('drawing', (data) => socket.broadcast.emit('drawing', data));
}

const port = 8080;
server.listen(port, () => console.log(`server is running on port ${port}`));
Enter fullscreen mode Exit fullscreen mode

Creating our Canvas Application

Before we get to the front end code, let us talk about our file structure.

  1. Make a styles folder inside of the src folder.
  2. Create a board.css file inside of your new styles folder.
  3. Create a Board.js file in the src folder

Here is a look at my front end file structure:

Alt Text


In this demo, we can take the app.js file out of the application and replace it with the board. Go into your index.js file and render your new Board Component.

import Board from './Board.js';

ReactDOM.render(
  <React.StrictMode>
    <Board />
  </React.StrictMode>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

I have included the full code for the board.js at the bottom of this blog.

If you would like to hang around and read about what's inside, we can do that too!

board.css

In our board.css can set our board height and width to 100%, then set the position to absolute. Then the whiteboard fills in the entirety of its container.

We can then set our color classes, necessary because of how we will be changing colors on click.

Here is a look at the board.css:

 * {
  box-sizing: border-box;
}

.whiteboard {
  height: 100%;
  width: 100%;
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  top: 0;
}

.colors {
  position: fixed;
}

.color {
  display: inline-block;
  height: 48px;
  width: 48px;
}

.color.black { background-color: black; }
.color.red { background-color: red; }
.color.green { background-color: green; }
.color.blue { background-color: blue; }
.color.yellow { background-color: yellow; }
Enter fullscreen mode Exit fullscreen mode

Board.js

At the top of your Board.js, you should bring in your React tools, the board.css, and io from the socket.io-client package we installed earlier.

import React, { useRef, useEffect } from 'react';
import io from 'socket.io-client';
import './styles/board.css';
Enter fullscreen mode Exit fullscreen mode

We can now useRef to grab our whiteboard, color, and socket elements, references we will create later when we render our HTML. If you are unfamiliar with useRef and would like to learn more, there is an excellent blog about it here

const Board = () => {
  const canvasRef = useRef(null);
  const colorsRef = useRef(null);
  const socketRef = useRef();
Enter fullscreen mode Exit fullscreen mode

We will now begin our useEffect hook. It will make sure our code within runs when the component loads.

useEffect(() => {
Enter fullscreen mode Exit fullscreen mode

Set the current property of our canvisRef element to the variable canvas. Then, by calling getcontext('2d') we will set the drawing context of our canvas to a 2d plane and thus giving us access to some unique 2d methods built into the canvas.

  useEffect(() => {
    const canvas = canvasRef.current;
    const context = canvas.getContext('2d');
Enter fullscreen mode Exit fullscreen mode

The Colors

Now add click listeners to your color palette. Set your starting color to black. Then create a color update function. Then loop through your color elements and execute onColorUpdate when clicked.

    const colors = document.getElementsByClassName('color');

    const current = {
      color: 'black',
    };

    const onColorUpdate = (e) => {
      current.color = e.target.className.split(' ')[1];
    };

    for (let i = 0; i < colors.length; i++) {
      colors[i].addEventListener('click', onColorUpdate, false);
    }
    let drawing = false;
Enter fullscreen mode Exit fullscreen mode

The Drawing

Create a drawLine function. The beginPath() and moveTo() methods move the path to the canvas's specified point. The lineTo() method will take in the coordinates of our ending point. Then stroke() will draw the path we have defined. Finally, we can use closePath() to bring us back to our starting point.

When creating draw lines, we will emit the line back to the socket server. The emit variable is set to a boolean because we only want to send the lines we draw, not the lines we have received. (we do not want to create an infinite loop)!

    const drawLine = (x0, y0, x1, y1, color, emit) => {
      context.beginPath();
      context.moveTo(x0, y0);
      context.lineTo(x1, y1);
      context.strokeStyle = color;
      context.lineWidth = 2;
      context.stroke();
      context.closePath();

      if (!emit) { return; }
      const w = canvas.width;
      const h = canvas.height;

      socketRef.current.emit('drawing', {
        x0: x0 / w,
        y0: y0 / h,
        x1: x1 / w,
        y1: y1 / h,
        color,
      });
    };
Enter fullscreen mode Exit fullscreen mode

Mouse movement and clicks

The next three functions will check for mouse movement, a mouse click, or finger touch. They will utilize the drawLine function we just created. onMouseDown will check for a click, then set the drawing variable to true and begin our line.

The onMouseMove function will check if we have begun our line. If so, It will grab our mouse's start position and track its path, sending the place to our drawLine function to be rendered and shared.

Finally, when the user lets off their mouse or finger, the drawing variable will get to false, and a final small line is drawn and emitted.

Without some throttle, onMouseMove would get called a ridiculous amount of times to create a throttle function in the following step.

    const onMouseDown = (e) => {
      drawing = true;
      current.x = e.clientX || e.touches[0].clientX;
      current.y = e.clientY || e.touches[0].clientY;
    };

    const onMouseMove = (e) => {
      if (!drawing) { return; }
      drawLine(current.x, current.y, e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY, current.color, true);
      current.x = e.clientX || e.touches[0].clientX;
      current.y = e.clientY || e.touches[0].clientY;
    };

    const onMouseUp = (e) => {
      if (!drawing) { return; }
      drawing = false;
      drawLine(current.x, current.y, e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY, current.color, true);
    };
Enter fullscreen mode Exit fullscreen mode

The throttle

We do not want to overload our socket connection. A throttle can help us. Essentially this function will take a callback and a delay time (in milliseconds). It works by grabbing the current time and comparing it to the time throttle was called initially.

    const throttle = (callback, delay) => {
      let previousCall = new Date().getTime();
      return function() {
        const time = new Date().getTime();

        if ((time - previousCall) >= delay) {
          previousCall = time;
          callback.apply(null, arguments);
        }
      };
    };
Enter fullscreen mode Exit fullscreen mode

Setting the event listeners and canvas resizing

Now you can have the canvas listen for mouse clicks or touch.

You also want to have the canvas listen for a screen resize and set the current canvas width and height to the changed window.

    const onResize = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    };

    window.addEventListener('resize', onResize, false);
    onResize();
Enter fullscreen mode Exit fullscreen mode

Our client-side socket connection:

Now create the onDrawingEvent function that will fire when the client-side socket receives a 'drawing' signal from the server. When the drawing object is received, we want to send it to the drawLine function, omitting the last emit parameter.

    const onDrawingEvent = (data) => {
      const w = canvas.width;
      const h = canvas.height;
      drawLine(data.x0 * w, data.y0 * h, data.x1 * w, data.y1 * h, data.color);
    }

    socketRef.current = io.connect('/');
    socketRef.current.on('drawing', onDrawingEvent);
  }, []);
Enter fullscreen mode Exit fullscreen mode

The Canvas and Color elements

Return and render your canvas and color elements along with their references and classes. If you wanted to give the color blocks or the canvas a unique look you can pick a style library or modify the CSS

   return (
    <div>
      <canvas ref={canvasRef} className="whiteboard" />

      <div ref={colorsRef} className="colors">
        <div className="color black" />
        <div className="color red" />
        <div className="color green" />
        <div className="color blue" />
        <div className="color yellow" />
      </div>
    </div>
  );
}; 
Enter fullscreen mode Exit fullscreen mode

Alt Text

Here is the full board.jsx for an easy copy and paste


import React, { useRef, useEffect } from 'react';
import io from 'socket.io-client';
import './styles/board.css';


const Board = () => {
  const canvasRef = useRef(null);
  const colorsRef = useRef(null);
  const socketRef = useRef();

  useEffect(() => {

    // --------------- getContext() method returns a drawing context on the canvas-----

    const canvas = canvasRef.current;
    const test = colorsRef.current;
    const context = canvas.getContext('2d');

    // ----------------------- Colors --------------------------------------------------

    const colors = document.getElementsByClassName('color');
    console.log(colors, 'the colors');
    console.log(test);
    // set the current color
    const current = {
      color: 'black',
    };

    // helper that will update the current color
    const onColorUpdate = (e) => {
      current.color = e.target.className.split(' ')[1];
    };

    // loop through the color elements and add the click event listeners
    for (let i = 0; i < colors.length; i++) {
      colors[i].addEventListener('click', onColorUpdate, false);
    }
    let drawing = false;

    // ------------------------------- create the drawing ----------------------------

    const drawLine = (x0, y0, x1, y1, color, emit) => {
      context.beginPath();
      context.moveTo(x0, y0);
      context.lineTo(x1, y1);
      context.strokeStyle = color;
      context.lineWidth = 2;
      context.stroke();
      context.closePath();

      if (!emit) { return; }
      const w = canvas.width;
      const h = canvas.height;

      socketRef.current.emit('drawing', {
        x0: x0 / w,
        y0: y0 / h,
        x1: x1 / w,
        y1: y1 / h,
        color,
      });
    };

    // ---------------- mouse movement --------------------------------------

    const onMouseDown = (e) => {
      drawing = true;
      current.x = e.clientX || e.touches[0].clientX;
      current.y = e.clientY || e.touches[0].clientY;
    };

    const onMouseMove = (e) => {
      if (!drawing) { return; }
      drawLine(current.x, current.y, e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY, current.color, true);
      current.x = e.clientX || e.touches[0].clientX;
      current.y = e.clientY || e.touches[0].clientY;
    };

    const onMouseUp = (e) => {
      if (!drawing) { return; }
      drawing = false;
      drawLine(current.x, current.y, e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY, current.color, true);
    };

    // ----------- limit the number of events per second -----------------------

    const throttle = (callback, delay) => {
      let previousCall = new Date().getTime();
      return function() {
        const time = new Date().getTime();

        if ((time - previousCall) >= delay) {
          previousCall = time;
          callback.apply(null, arguments);
        }
      };
    };

    // -----------------add event listeners to our canvas ----------------------

    canvas.addEventListener('mousedown', onMouseDown, false);
    canvas.addEventListener('mouseup', onMouseUp, false);
    canvas.addEventListener('mouseout', onMouseUp, false);
    canvas.addEventListener('mousemove', throttle(onMouseMove, 10), false);

    // Touch support for mobile devices
    canvas.addEventListener('touchstart', onMouseDown, false);
    canvas.addEventListener('touchend', onMouseUp, false);
    canvas.addEventListener('touchcancel', onMouseUp, false);
    canvas.addEventListener('touchmove', throttle(onMouseMove, 10), false);

    // -------------- make the canvas fill its parent component -----------------

    const onResize = () => {
      canvas.width = window.innerWidth;
      canvas.height = window.innerHeight;
    };

    window.addEventListener('resize', onResize, false);
    onResize();

    // ----------------------- socket.io connection ----------------------------
    const onDrawingEvent = (data) => {
      const w = canvas.width;
      const h = canvas.height;
      drawLine(data.x0 * w, data.y0 * h, data.x1 * w, data.y1 * h, data.color);
    }

    socketRef.current = io.connect('/');
    socketRef.current.on('drawing', onDrawingEvent);
  }, []);

  // ------------- The Canvas and color elements --------------------------

  return (
    <div>
      <canvas ref={canvasRef} className="whiteboard" />

      <div ref={colorsRef} className="colors">
        <div className="color black" />
        <div className="color red" />
        <div className="color green" />
        <div className="color blue" />
        <div className="color yellow" />
      </div>
    </div>
  );
};

export default Board;

Enter fullscreen mode Exit fullscreen mode

Make it live

Run the following commands in your app terminal.

npm run start
Enter fullscreen mode Exit fullscreen mode

Then open another app terminal and begin your server:

node server/index.js
Enter fullscreen mode Exit fullscreen mode

You can visit your localhost and see your whiteboard. If you open another window at the localhost, you establish the socket connection and share your drawings.

If you were having trouble getting a canvas whiteboard working with a React front end, I hope this has helped jump-start your project.

Stay Focused || Love your code!

Resources:

  1. Node.js HTTP modules
  2. socket.io Docs
  3. MDN web docs getContext()
  4. HTML canvas reference
  5. HTML 5 Canvas Tutorial Point

Top comments (6)

Collapse
 
thomascrane profile image
Thomas Crane.

Nice Guide

Collapse
 
jerrymcdonald profile image
JerryMcDonald

Thanks!

Collapse
 
himanshuthakur profile image
Himanshu Thakur

hi sir, my canvas x and y axis are not matching with mouse x and y please share solution. i use above code.

import React, { useRef, useEffect } from 'react';
import APIs from '../../config';
// import io from 'socket.io-client';

const Whiteboard = ({socket, roomid, userRole}) =>{
const canvasRef = useRef(null);
const colorsRef = useRef(null);
const socketRef = useRef();
// const recordWebcam = useRecordWebcam();

useEffect(() => {

  // --------------- getContext() method returns a drawing context on the canvas-----

  const canvas = canvasRef.current;
  const test = colorsRef.current;
  const context = canvas.getContext('2d');

  // ----------------------- Colors --------------------------------------------------

  const colors = document.getElementsByClassName('color');
  // console.log(colors, 'the colors');
  // console.log(test);
  // set the current color
  const current = {
    color: 'black',
  };

  // helper that will update the current color
  const onColorUpdate = (e) => {
    current.color = e.target.className.split(' ')[1];
  };

  // loop through the color elements and add the click event listeners
  for (let i = 0; i < colors.length; i++) {
    colors[i].addEventListener('click', onColorUpdate, false);
  }
  let drawing = false;

  // ------------------------------- create the drawing ----------------------------

  const drawLine = (x0, y0, x1, y1, color, emit) => {
    context.beginPath();
    context.moveTo(x0, y0);
    context.lineTo(x1, y1);
    context.strokeStyle = color;
    context.lineWidth = 2;
    context.stroke();
    context.closePath();

    if (!emit) { return; }
    const w = canvas.width;
    const h = canvas.height;


    const whitedata = {
      x0: x0 / w,
      y0: y0 / h,
      x1: x1 / w,
      y1: y1 / h,
      color,
    }



    socketRef.current.emit("draw-coordinates", {roomid, whitedata});



  };


  // ---------------- mouse movement --------------------------------------

  const onMouseDown = (e) => {
    drawing = true;
    current.x = e.clientX || e.touches[0].clientX;
    current.y = e.clientY || e.touches[0].clientY;
  };

  const onMouseMove = (e) => {
    if (!drawing) { return; }
    drawLine(current.x, current.y, e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY, current.color, true);
    current.x = e.clientX || e.touches[0].clientX;
    current.y = e.clientY || e.touches[0].clientY; 
  };

  const onMouseUp = (e) => {
    if (!drawing) { return; }
    drawing = false;
    drawLine(current.x, current.y, e.clientX || e.touches[0].clientX, e.clientY || e.touches[0].clientY, current.color, true);
  };

  // ----------- limit the number of events per second -----------------------

  const throttle = (callback, delay) => {
    let previousCall = new Date().getTime();
    return function() {
      const time = new Date().getTime();

      if ((time - previousCall) >= delay) {
        previousCall = time;
        callback.apply(null, arguments);
      }
    };
  };

  // -----------------add event listeners to our canvas ----------------------
  if(userRole === APIs.roles[0]){
  canvas.addEventListener('mousedown', onMouseDown, false);
  canvas.addEventListener('mouseup', onMouseUp, false);
  canvas.addEventListener('mouseout', onMouseUp, false);
  canvas.addEventListener('mousemove', throttle(onMouseMove, 10), false);

  // Touch support for mobile devices
  canvas.addEventListener('touchstart', onMouseDown, false);
  canvas.addEventListener('touchend', onMouseUp, false);
  canvas.addEventListener('touchcancel', onMouseUp, false);
  canvas.addEventListener('touchmove', throttle(onMouseMove, 10), false);
}
  // -------------- make the canvas fill its parent component -----------------

  const onResize = () => {
    canvas.width = window.innerWidth;
    canvas.height = window.innerHeight;

  };

  window.addEventListener('resize', onResize, false);
  onResize();

  // ----------------------- socket.io connection ----------------------------
  const onDrawingEvent = (data) => {
    //console.log(data)
    const w = canvas.width;
    const h = canvas.height;
    drawLine(data.x0 * w, data.y0 * h, data.x1 * w, data.y1 * h, data.color);
  }

  socketRef.current = socket;
 socketRef.current?.on("draw", onDrawingEvent);
}, [socket]);


return(
    <>
    <div className='whiteboardmain'>
            <canvas ref={canvasRef} className="whiteboard" id="canvas" />
           {(userRole === APIs.roles[0])?      
                    <div ref={colorsRef} className="colors">
                            <div className="color black" />
                            <div className="color red" />
                            <div className="color green" />
                            <div className="color blue" />
                            <div className="color yellow" />
                    </div>
          :""}
    </div> 
    </>
)
Enter fullscreen mode Exit fullscreen mode

}

export default Whiteboard;

Collapse
 
ddinhftieens profile image
TienNguyenDinh • Edited

(Beginner) I tried it but socket doesn't seem to work
project on my github: github.com/ddinhftieens/whiteboard...
please support me !

Collapse
 
hkirat profile image
harkirat singh

Great blog post.
There's an official one by socket.io as well - github.com/socketio/socket.io/blob...

Collapse
 
jaredlo profile image
jaredLo • Edited

If you came here for the shareable part with socket.io then turn back.

Just spent like an hour learning this just to see that socket didn't work. Thanks for the front end though, I guess.