Virtual Whiteboard


Creation date: 2015-10-18

Tags: sockets, js, javascript

In this entry we will create a virtual collaborative whiteboard using WebSockets. Sockets.io in NodeJS makes it easy to build real-time tools like chat platforms or this collaborative whiteboard.

In all "normal" webapps you will use http as the standard protocol, where the user (client) makes a request and the server responses. It's impractical to build something like a collaborative whiteboard using this technology, because the client has to check on the server if someone else changed something and this has to be done using Ajax every some milliseconds which would create a heavy overload. The special thing using WebSockets is that the server can send something to the client whenever it wants.

You are the guy, who just wants to see the code? GitHub

Or the guy, who only wants to play with the Demo? Virtual Collaborative whiteboard

In the last entries we always used Python as the programming language, here we will use NodeJS which is basically just javascript for the server, which makes it pretty simple to build this kind of app using one language instead of two, one for the client and one for the server.

In a NodeJS project the first thing which we need to do is to create a package.json file inside our project root directory.

{
	"name": "Node.js-Whiteboard",
	"description": "An easy Whiteboard using nodejs",
	"dependencies" : {
		"express": "3.x",
		"socket.io": "*"
	},  
  "devDependencies": {
    "gulp": "^3.9.0",
    "gulp-jshint": "*",
    "gulp-livereload": "^3.8.1",
    "gulp-nodemon": "^2.0.4",
    "gulp-notify": "^2.2.0"
  }
}

We need only two node modules for our project, the express framework and socket.io for the WebSockets. For an easy development we will use gulp. If you want to work on some NodeJS projects, it's a good idea to install Gulp, anyway.

npm install -g gulp

You can install all our dependencies using:

npm install

In this kind of project it's nice to have two folders, one for all stuff which runs on the server, therefore I will call the folder server and one for the client, which I call public.

It's an nice practice in NodeJS to use gulp to reload our application whenever we change something and to reload the website as well. We need the devDependencies for this automatic tasks as well as a gulpfile inside our root directory.

gulpfile.js

Just load all dependencies:

var gulp = require('gulp');
var jshint = require('gulp-jshint');
var nodemon = require('gulp-nodemon');
var notify = require('gulp-notify');
var livereload = require('gulp-livereload');

The basic thing in gulp is to use tasks which use some code of your project, specified using gulp.src and do some tasks which are specified inside the pipe. We have a lint task which lints every js file inside public/ or server/ which isn't a minified js file or keymaster.

// Lint Tasks
gulp.task('lint', function() {
    return gulp.src(['public/js/*.js', 'server/*.js', '!public/js/*.min.js','!public/js/keymaster.js'])
        .pipe(jshint())
        .pipe(jshint.reporter('default'));
});

Our run task looks for changes and restarts the script and the website using nodemon and livereload.

// Task
gulp.task('run', function() {
	// listen for changes
	livereload.listen();
	// configure nodemon
	nodemon({
		// the script to run the app
		script: 'server/server.js',
		ext: 'js',
    env: {
        'DEBUG': 'whiteboard*'
    }
	}).on('restart', function(){
		// when the app has restarted, run livereload.
		gulp.src('server/server.js')
			.pipe(livereload())
			.pipe(notify('Reloading page, please wait...'));
	})
});

gulp.task('default', ['lint', 'run']);

Now we can start to code the real project. At first we will "design" our whiteboard app. I have to say, that in all my projects design isn't really a big part, unfortunately. Here it is really just a canvas for our whiteboard. Of course you can design it for your purpose ;)

Client

Everything in this section is something will see, so it will be inside the public folder. First of all we need the base index.html.

Inside of it we only need two parts: The div cursors, where we will append all cursors of all connected clients, so everyone can see all cursors. And we need the canvas.

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Node.js Whiteboard</title>
        <link rel="stylesheet" href="css/styles.css" />
    </head>

    <body>
		<div id="cursors">

		</div>

		<canvas id="board" width="1000" height="500">
			The browser has to support JS canvas to use this whiteboard.
		</canvas>

		<script src="/socket.io/socket.io.js"></script>
		<script src="js/jquery.min.js"></script>
		<script src="js/keymaster.js"></script>
		<script src="js/FileSaver.min.js"></script>
		<script src="js/canvas-toBlob.js"></script>
		<script src="js/index.js"></script>

    </body>
</html>

At the end of the body we included some dependencies like jquery and socket.io. For some "advanced" usage we will use keymaster, FileSaver and canvas-toBlob.

We will use the following files inside the public directory.

css/
  styles.css
img/
  pointer.png
js/
  canvas-toBlob.js
  FileSaver.min.js
  index.js
  jquery.min.js
  keymaster.js
index.html

Inside the styles.css we include the pointer.png and set some basic "design" stuff.

*{
	margin:0;
	padding:0;
}

html{
	background-color: #fff;
	overflow:hidden;
}

body{
	color:#444;
}

#board {
    border: 1px solid #000;
}

#cursors{
  position:relative;
}

#cursors .cursor{
    position:absolute;
    width:15px;
    height:22px;
    background:url('../img/pointer.png') no-repeat -4px 0;
}

Inside the index.js we need to connect to the server which we will code later on and do some drawing stuff.

The first step is to set some variables like the board and a unique id which we will send to the server and to the other clients so each client can use this id to display the cursors and remove them in case of a disconnect.

$(function(){
  var canvas = $('#board');
  var ctx = canvas[0].getContext('2d');

	// Generate an unique ID
	var id = Math.round($.now()*Math.random());

  // the variables for the user
  var cColor = '#000';
  var cLineWidth = 1;
  var cDrawing = false;
  var cLastEmit = $.now();

	var clients = {};
	var cursors = {};

Now we have to connect to the server using sockets. This line will call a function connection on the server which we will use inside our server/server.js script.

var socket = io.connect();

We need to listen to some commands which the server can send to us, this will be achieved by using socket.on and then the name of the command, in this case moving. That means that some client moved his cursor and we need to receive the new position. If the client only moved the cursor, without holding it down (drawing), the key drawing inside the data object will be false.

socket.on('moving', function (data) {
  // not current user and new? create a cursor
	if(id !== data.id && !(data.id in clients)){
		cursors[data.id] = jQuery('.cursor').appendTo('#cursors');
    // Move the mouse pointer
    cursors[data.id].css({
      'left' : data.x,
      'top' : data.y
    });    
	}

	// Is the user drawing?
	if(data.drawing && clients[data.id]){
		draw(clients[data.id], data, data.color, data.lineWidth);
	}

	// Save the last data state
	clients[data.id] = data;
});

If a user left the whiteboard, the server will call the command left and we can remove the cursor of the user, who left the whiteboard using the id of the user.

socket.on('left', function (id) {
  cursors[id].remove();
});

Now we have implemented the commands the server can send to us, but we need some functions to send some information the other way around and we need the draw function which actually draws something on the whiteboard.

We need to decide if the user is drawing (holding the mouse down and moves) or only moves the cursor. Therefore we defined the variable cDrawing at the beginning.

// set cDrawing to true
canvas.on('mousedown',function(e){
	e.preventDefault();
	cDrawing = true;
});

// not drawing anymore
canvas.on('mouseup mouseleave',function(){
	cDrawing = false;
});

The client should send a command to the server, if the cursors moves, so we define the mousemove function.

// send the current state to the server if the time difference from last emit is big enough
canvas.on('mousemove',function(e){
  var cPos = {x: e.pageX, y: e.pageY};
	if($.now() - cLastEmit > 30){
		socket.emit('mousemove',{
			'x': cPos.x,
			'y': cPos.y,
			'drawing': cDrawing,
			'id': id,
      'color': cColor,
      'lineWidth': cLineWidth
		});
		cLastEmit = $.now();
	}
});

We don't want to send something to the server too often, so we use the if statement to check that a little time has passed.

Using the socket.emit function we call a function on the server. The server will listen on the command mousemove and can react on it. In our case the server will just send the command moving to every client. Here "every" means to really everyone, who connected to the server, including ourself, therefore we don't need to call the draw method here.

Let's specify the draw method. We need the parameters from and to, as well as the color and the line width. We will always draw a line to avoid that there will appear holes in a drawn line only because we send the mousemove command not every millisecond.

function draw(from, to, color, w){
  ctx.strokeStyle = color;
  ctx.lineWidth = w;
  ctx.beginPath();
	ctx.moveTo(from.x, from.y);
	ctx.lineTo(to.x, to.y);
	ctx.stroke();
  ctx.closePath();
}

That's basically all of our client side code, but I think that it might be cool to have some variation using colors and different line widths. We will only use shortcuts for this specials. A legend or some buttons would be nice as well ;)

// Shortcuts
// Color
key('b,g,y,r', function(event,handler){
  switch(handler.shortcut) {
    case 'b':
      cColor = '#000';
      break;
    case 'g':
      cColor = '#0f0';
      break;
    case 'y':
      cColor = '#ff0';
      break;
    case 'r':
      cColor = '#f00';
      break;
  }
});

// LineWidth
key('1,2,3,4,5,6,7,8,9', function(event,handler){
    cLineWidth = parseInt(handler.shortcut);
});

And we don't save the whiteboard on the server, so all clients should connect to the same time before there is anything on the whiteboard. If you want to improve this code, that will be a nice step to start.

At the moment we will allow to save the whiteboard on the client side.

key('⌘+s, ctrl+s', function(e) {
  e.preventDefault();
  document.getElementById('board').toBlob(function(blob) {
    saveAs(blob, "whiteboard.png");
  });
});

Server

We finished the client side, now we need some basic code on the server to send the client commands to all other connected clients.

One thing we need in every NodeJS project which is different to some other server scripts like PHP, we need a port to listen on. The port is essential and should be easy to change, so we create a config file for it.

server/config.json
{
    "port": 4242
}

We can use this json file inside our server/server.js. Let's start with including our dependencies and the config file.

var express = require('express');
var app = express();
var server = require('http').createServer(app);
var io = require('socket.io').listen(server);
var conf = require('./config.json');

var debug_log = require('debug')('whiteboard:log');
var debug_error = require('debug')('whiteboard:error');

In NodeJS it's pretty simple to debug code in a better way than just using console.log, using the debug package. In this small project we will not use the commands debug_log('...') or debug_error('...') but it might be interesting if you want to add some improvements to this application.

We used the lines

env: {
  'DEBUG': 'whiteboard*'
}

inside the gulpfile.js to see all logs and errors in this application during the development, where we run the app using the command gulp or without the linting part: gulp run. If you run the app afterwards using node server/server.js you will not see any debug logs or debug errors.

Now we need to specify on which port we want to listen and where our static files are located.

// Webserver listens on conf.port
server.listen(conf.port);

// set static folder
app.configure(function(){
    app.use(express.static(__dirname + '/../public'));
});

We have to configure which html side should be visible for the user, if he enters the address: http://localhost:4242

// show index.html if the user opens localhost: conf.port /
app.get('/', function (req, res) {
	res.sendfile(__dirname + '/../public/index.html');
});

__dirname just uses the current directory path.

The last part of the server is used to send some data to all connected clients that someone moved his cursor and or draws something on the whiteboard.

io.sockets.on('connection', function (socket) {
     socket.on('mousemove', function(data) {
      socket.id = data.id;
      io.emit('moving', data);
     });
     socket.on('disconnect', function() {
      socket.broadcast.emit('left', socket.id);
     });
});

console.log('Whiteboard runs on: localhost:'+conf.port);

The first function will be called if a new client connects to the server and is a predefined function, named connection. Inside this connection function we have two functions. The first one mousemove will called if one client calls the command mousemove, like we did using socket.emit('mousemove'). Then we want to save the id of the client which we need afterwards to delete the cursor of the client in case of a disconnection. We want to inform all connected clients (including the current socket, as mentioned above), that someone moved his mouse io.emit('moving', data);.

The second function will be used to inform everyone (but not the client itself), that a client left the whiteboard site.

socket.broadcast.emit('left', socket.id);

Here we need the saved socket id to remove the cursor of the specified user on our whiteboard.

Basic NodeJS information

If you are interested in running this code on your server you have to use a server that supports NodeJS, which unfortunately isn't the normal case in webspaces.

If you want to run the application without mentioning the port inside the url, you have to use the port 80 which is the normal http port. Therefore you need to change the port inside the config.json and you need to run either gulp or node server/server.js using your root account because the "normal" user doesn't have the right to use this port. Another special thing is that if you run any server like Apache on your localhost you will not be able to use the port, because it will be already in use. There are some tricks around it like building a proxy server using NodeJS. Here is an explanation.

You can download all the code on my OpenSourcES GitHub repo.



Want to be updated? Consider subscribing and receiving a mail whenever a new post comes out.

Powered by Buttondown.


Subscribe to RSS