A shareable whiteboard with Canvas, Socket.io, and React.
JerryMcDonald
Posted on July 17, 2020
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
Go ahead and bring in the npm packages you will need.
npm i socket.io
npm i socket.io-client
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.
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);
Now you should pass your created server to socket.io.
const socket = require('socket.io');
const io = socket(server);
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));
}
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}`));
Creating our Canvas Application
Before we get to the front end code, let us talk about our file structure.
- Make a
styles
folder inside of thesrc
folder. - Create a
board.css
file inside of your new styles folder. - Create a
Board.js
file in the src folder
Here is a look at my front end file structure:
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')
);
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; }
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';
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();
We will now begin our useEffect
hook. It will make sure our code within runs when the component loads.
useEffect(() => {
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');
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;
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,
});
};
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);
};
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);
}
};
};
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();
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);
}, []);
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>
);
};
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;
Make it live
Run the following commands in your app terminal.
npm run start
Then open another app terminal and begin your server:
node server/index.js
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:
Posted on July 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 30, 2024