After spending a little quality time with the JavaScript-based real-time communication library SignalR, I had the idea of putting it to use in a little whiteboard application. The idea is that users could join a board in their web browser, and then share a collaborative space to doodle. Of course, it would have to support phones and tablets as well as full PCs.
It’s still very much a proof-of-concept, but I’m happy to say a weekend of tinkering came up with this: https://drawthing.azurewebsites.net/Draw/View/wholeworld
Just join up with your favorite HTML5-supporting browser and start drawing! Anybody else connected will also see what you draw. To create new boards, you can go here:
https://drawthing.azurewebsites.net
It doesn’t currently have any fancy features like saving boards or color. But it does illustrate a slightly different use for SignalR beyond chat, and let me learn some iOS web app idiosyncrasies I wasn’t aware of before.
How I Built It
Part of the beauty of SignalR is the minimal amount of wireup necessary to get real-time communications going. After a creating an empty MVC project and using NuGet to install Microsoft.Aspnet.SignalR –pre and jQuery, it just takes a few lines on the client:
<script src="~/scripts/json2.min.js" type="text/javascript"></script>
<script src="~/scripts/jquery-1.8.3.min.js" type="text/javascript"></script>
<script src="~/scripts/jquery.signalR-1.0.0-rc1.min.js" type="text/javascript"></script>
<script src="~/signalr/hubs" type="text/javascript"></script>
…
this.drawHub = $.connection.drawHub;
$.connection.hub.start();
And one line on the server, in Global.asax:
RouteTable.Routes.MapHubs();
To get the actual whiteboard functionality going, the only server-side code necessary was a little to allow for joining groups and sending commands between clients. SignalR handles all the communications goo and provides the ‘magic’ for shuffling calls between server and client using WebSockets, a variety of long-polling techniques, or carrier pigeon. Below is the hub for my little whiteboard.
public class DrawHub : Hub
{
public void JoinBoard(string userName, string boardName)
{
boardName = "BOARD" + boardName.ToUpper();
Groups.Add(Context.ConnectionId, boardName);
Clients.OthersInGroup(boardName).onUserJoined(userName, boardName);
}
public void SendPath(BoardPath path)
{
var boardName = "BOARD" + path.BoardName.ToUpper();
Clients.OthersInGroup(boardName).drawPath(path.Points);
}
public void SendClear(string boardName)
{
boardName = "BOARD" + boardName.ToUpper();
Clients.OthersInGroup( boardName).clear();
}
}
If you’ve never worked with SignalR, this code may look a little odd. First, Hub abstracts away all the usual chat-like goo, like joining and leaving clients. Clients can remotely call into the methods, and calls like ‘Clients.OthersInGroup(boardName).onUserJoined’ actually send calls _up_ to the other connected clients. It’s worth noting ‘onUserJoined()’, ‘drawPath()’ and ‘clear'()’ aren’t real server-side methods at all. SignalR is using some dynamics trickery to capture those calls and shuffle them up to the correct clients, to be called in JavaScript.
The JavaScript, then, looks like this:
this.drawHub.client.onUserJoined = function (userName, boardName) {
$('#uiLog').html('Welcome, ' + userName);
}
Let that soak in a bit: the server is calling up to the clients and executing JavaScript like it was nothing. Wicked. And we can call back down to the server just as easily:
self.drawHub.server.sendClear('@ViewBag.BoardName');
To make this happen, SignalR is establishing either a WebSockets or long-polling connection and then using that transport to do client/server communication. Fortunately, this is almost completely abstracted away, and works with just about any browser. I even tested on the Kindle ‘Experimental Browser’, and while I wouldn’t recommend it, it did function.
With the basic client/server communication happening, all that was necessary was to take in mouse and touch events, draw on the local HTML5 canvas, and then send those to the other clients. The key wireup for those looks like this:
self.drawCanvasElement = document.getElementById("drawCanvas");
self.drawCanvasContext = self.drawCanvasElement.getContext('2d');
$('#drawCanvas')
.mousedown(self.canvasMouseDown)
.mouseup(self.canvasMouseUp)
.mousemove(self.canvasMouseMove)
.on('touchstart', self.canvasMouseDown)
.on('touchmove', self.canvasMouseMove)
.on('touchend', self.canvasMouseUp);
This sets up the graphics and mouse/touch events. When the user draws a path, it draws the path on drawCanvasContext (not shown here- view source if you want to see the drawing code), then sends it to the hub:
self.drawHub.server.sendPath({ BoardName: '@ViewBag.BoardName', Points: path })
The hub, in turn, calls other clients to draw the path as well:
this.drawHub.client.drawPath = function (path) {
$('#uiLog').html('received ' + path.length + ' points.');
self.drawCanvasContext.beginPath();
self.drawCanvasContext.moveTo(path[0].X, path[0].Y);
for (var i = 0; i < path.length; i++) {
var point = path[i];
self.drawCanvasContext.lineTo(point.X, point.Y);
self.drawCanvasContext.stroke();
}
}
iOS Long Polling Quirk
This all worked fine, and to my three-year-old son’s amusement, I was soon able to doodle on the iPhone and have it show on 2 other nearby screens. But once I started testing in iOS, I got some strange behavior. Some paths would show immediately, while others took a while, or seemed to never come at all. Debugging was tricky- was the problem at the sender, hub, or receiver? I was pretty stumped until I ran across some forum posts suggesting Apple changed the behavior of long polling in iOS6. It’s a bit unclear what exactly they did, but essentially it reduces the number of connections the browser may have open at a time, meaning that chatty apps like this one can break. As best I can tell, they reduced it to one background thread, that randomly handles any ajax calls. My app was sending a path every time the user lifted their finger, and iOS would decide to send this path seemingly at random. To work around this, I decided to queue up my calls:
this.canvasMouseUp = function (e) {
self.isMouseDown = false;
self.sendQueue.unshift(self.pathToSend);
e.preventDefault();
}
this.processSendQueue = function () {
if (self.sending) return;
if (self.sendQueue.length == 0) return;
self.sending = true;
var path = self.sendQueue.pop();
self.drawHub.server.sendPath({ BoardName: '@ViewBag.BoardName', Points: path })
.done(function () {
$('#uiLog').html('sent ' + self.pathToSend.length + ' points');
self.sending = false;
})
.fail(function (er) {
$('#uiLog').html('error sending' + er);
self.sending = false;
});
}
I use window.setInterval to call processSendQueue every few milliseconds. This ensures that only one path at a time is being sent, and may (or may not- are race conditions possible in JavaScript?) be a good idea to do anyway.
Once in place, iOS fell into line and started working the way you’d expect.
Trouble Brewing?
I do have one issue that I’m currently trying to solve. On one network I’ve tested on, the browser can send, but not receive paths. This is odd to me, since it would seem like it would be agnostic to any sort of network conditions: as long as port 80 is open it should work. I’ve posted more about that on StackOverflow.
What’s Next?
It’s hard to know what I’ll do next with this sort of project. Oftentimes, nothing- this was just a little ‘spike’ to keep me up on the latest cool toys from Redmond. But, I’d like to at least wire up snapshots of some sort, so that late-comers to a board can see the current image, and perhaps to allow saving images, intelligently scaling across browser sizes, etc. Color and multitouch also seem like interesting endeavors, as do Evernote/Dropbox integrations. I’m also slightly embarrassed by the pop-up username prompt, and would rather a more buttery, if still minimal experience. Or, I reserve the right to leave it as a fun little experiment and excellent massively multiplayer tic-tac-toe platform.