/*! * opentok-whiteboard (http://github.com/aullman/opentok-whiteboard) * * Shared Whiteboard that works with OpenTok * * @Author: Adam Ullman (http://github.com/aullman) * @Copyright (c) 2014 Adam Ullman * @License: Released under the MIT license (http://opensource.org/licenses/MIT) **/ var ng, p; if (typeof angular === 'undefined' && typeof require !== 'undefined') { ng = require('angular'); } else { ng = angular; } if (typeof paper === 'undefined' && typeof require !== 'undefined') { p = require('paper'); } else { p = paper; } var OpenTokWhiteboard = ng.module('opentok-whiteboard', ['opentok']) .directive('otWhiteboard', ['OTSession', '$window', function (OTSession, $window) { return { restrict: 'E', template: '' + '
' + '' + '' + '' + '{{captureText}}' + '' + '' + '', link: function (scope, element, attrs) { var canvas = element.context.querySelector("canvas"), captureButton = element.context.querySelector('.OT_capture'), client = {dragging:false}, count = 0, //Grabs the total count of each continuous stroke undoStack = [], //Stores the value of start and count for each continuous stroke redoStack = [], //When undo pops, data is sent to redoStack pathStack = [], drawHistory = [], drawHistoryReceivedFrom, drawHistoryReceived, batchUpdates = [], resizeTimeout, iOS = /(iPad|iPhone|iPod)/g.test( navigator.userAgent ); // Create an empty project and a view for the canvas p.setup(canvas); // Set canvas size canvas.width = attrs.width || element.width(); canvas.height = attrs.height || element.height(); // Set paper.js view size p.view.viewSize = new p.Size(canvas.width, canvas.height); p.view.draw(); scope.colors = [{'background-color': 'black'}, {'background-color': 'blue'}, {'background-color': 'red'}, {'background-color': 'green'}, {'background-color': 'orange'}, {'background-color': 'purple'}, {'background-color': 'brown'}]; scope.captureText = iOS ? 'Email' : 'Capture'; scope.strokeCap = 'round'; scope.strokeJoin = 'round'; scope.lineWidth = 2; var clearCanvas = function () { p.project.clear(); p.view.update(); drawHistory = []; pathStack = []; undoStack = []; redoStack = []; count = 0; }; scope.changeColor = function (color) { scope.color = color['background-color']; scope.erasing = false; }; scope.changeColor(scope.colors[Math.floor(Math.random() * scope.colors.length)]); scope.clear = function () { clearCanvas(); if (OTSession.session) { OTSession.session.signal({ type: 'otWhiteboard_clear' }); } }; scope.erase = function () { scope.erasing = true; }; scope.capture = function () { if (iOS) { // On iOS you can put HTML in a mailto: link $window.location.href = "mailto:?subject=Whiteboard&Body="; } else { // We just download the canvas captureButton.href = canvas.toDataURL('image/png'); } }; scope.undo = function () { if (!undoStack.length) return; var uuid = undoStack.pop(); undoWhiteBoard(uuid); sendUpdate('otWhiteboard_undo', uuid); }; var undoWhiteBoard = function (uuid) { redoStack.push(uuid); pathStack.forEach(function(path) { if (path.uuid === uuid) { path.visible = false; p.view.update(); } }); drawHistory.forEach(function(update) { if (update.uuid === uuid) { update.visible = false; } }); }; scope.redo = function () { if (!redoStack.length) return; var uuid = redoStack.pop(); redoWhiteBoard(uuid); sendUpdate('otWhiteboard_redo', uuid); }; var redoWhiteBoard = function (uuid) { undoStack.push(uuid); pathStack.forEach(function(path) { if (path.uuid === uuid) { path.visible = true; p.view.update(); } }); drawHistory.forEach(function(update) { if (update.uuid === uuid) { update.visible = true; } }); }; var draw = function (update) { drawHistory.push(update); switch (update.event) { case 'start': var path = new p.Path(); path.selected = false; path.strokeColor = update.color; path.strokeWidth = scope.lineWidth; path.strokeCap = scope.strokeCap; path.strokeJoin = scope.strokeJoin; path.uuid = update.uuid; if (update.mode === 'eraser') { path.blendMode = 'destination-out'; path.strokeWidth = 50; } if (ng.isDefined(update.visible)) { path.visible = update.visible; } var start = new p.Point(update.fromX, update.fromY); path.moveTo(start); p.view.draw(); pathStack.push(path); break; case 'drag': pathStack.forEach(function(path) { if (path.uuid === update.uuid) { path.add(update.toX, update.toY); p.view.draw(); } }); break; case 'end': pathStack.forEach(function(path) { if (path.uuid === update.uuid) { undoStack.push(path.uuid); path.simplify(); p.view.draw(); } }); break; } }; var drawUpdates = function (updates) { updates.forEach(function (update) { draw(update); }); }; var batchSignal = function (type, data, toConnection) { // We send data in small chunks so that they fit in a signal // Each packet is maximum ~250 chars, we can fit 8192/250 ~= 32 updates per signal var dataCopy = data.slice(); var signalError = function (err) { if (err) { TB.error(err); } }; while(dataCopy.length) { var dataChunk = dataCopy.splice(0, Math.min(dataCopy.length, 32)); var signal = { type: type, data: JSON.stringify(dataChunk) }; if (toConnection) signal.to = toConnection; OTSession.session.signal(signal, signalError); } }; var updateTimeout; var sendUpdate = function (type, update, toConnection) { if (OTSession.session) { batchUpdates.push(update); if (!updateTimeout) { updateTimeout = setTimeout(function () { batchSignal(type, batchUpdates, toConnection); batchUpdates = []; updateTimeout = null; }, 100); } } }; var requestHistory = function() { OTSession.session.signal({ type: 'otWhiteboard_request_history' }); }; ng.element(document).on('keyup', function (event) { if (event.ctrlKey) { if (event.keyCode === 90) scope.undo(); if (event.keyCode === 89) scope.redo(); } }); /* * The Nuts * During the process of drawing, we collect coordinates on every [mouse|touch]move event. * These events occur as fast as the browser can create them, and is computer/browser dependent * */ ng.element(canvas).on('mousedown mousemove mouseup mouseout touchstart touchmove touchend touchcancel', function (event) { if ((event.type === 'mousemove' || event.type === 'touchmove' || event.type === 'mouseout') && !client.dragging) { // Ignore mouse move Events if we're not dragging return; } event.preventDefault(); var offset = ng.element(canvas).offset(), scaleX = canvas.width / element.width(), scaleY = canvas.height / element.height(), offsetX = event.offsetX || event.originalEvent.pageX - offset.left || event.originalEvent.touches[0].pageX - offset.left, offsetY = event.offsetY || event.originalEvent.pageY - offset.top || event.originalEvent.touches[0].pageY - offset.top, x = offsetX * scaleX, y = offsetY * scaleY, mode = scope.erasing ? 'eraser' : 'pen', update; switch (event.type) { case 'mousedown': case 'touchstart': // Start dragging client.dragging = true; client.lastX = x; client.lastY = y; client.uuid = parseInt(x) + parseInt(y) + Math.random().toString(36).substring(2); update = { id: OTSession.session && OTSession.session.connection && OTSession.session.connection.connectionId, uuid: client.uuid, fromX: client.lastX, fromY: client.lastY, mode: mode, color: scope.color, event: 'start' }; draw(update); sendUpdate('otWhiteboard_update', update); break; case 'mousemove': case 'touchmove': offsetX = event.offsetX || event.originalEvent.pageX - offset.left || event.originalEvent.touches[0].pageX - offset.left, offsetY = event.offsetY || event.originalEvent.pageY - offset.top || event.originalEvent.touches[0].pageY - offset.top, x = offsetX * scaleX, y = offsetY * scaleY; if (client.dragging) { // Build update object update = { id: OTSession.session && OTSession.session.connection && OTSession.session.connection.connectionId, uuid: client.uuid, fromX: client.lastX, fromY: client.lastY, toX: x, toY: y, event: 'drag' }; count++; redoStack = []; client.lastX = x; client.lastY = y; draw(update); sendUpdate('otWhiteboard_update', update); } break; case 'touchcancel': case 'mouseup': case 'touchend': case 'mouseout': if (count) { update = { id: OTSession.session && OTSession.session.connection && OTSession.session.connection.connectionId, uuid: client.uuid, event: 'end' }; draw(update); sendUpdate('otWhiteboard_update', update); } client.dragging = false; client.uuid = false; } }); if (OTSession.session) { if (OTSession.session.isConnected()) { requestHistory(); } OTSession.session.on({ sessionConnected: function() { requestHistory(); }, 'signal:otWhiteboard_update': function (event) { if (event.from.connectionId !== OTSession.session.connection.connectionId) { drawUpdates(JSON.parse(event.data)); scope.$emit('otWhiteboardUpdate'); } }, 'signal:otWhiteboard_undo': function (event) { if (event.from.connectionId !== OTSession.session.connection.connectionId) { JSON.parse(event.data).forEach(function (data) { undoWhiteBoard(data); }); scope.$emit('otWhiteboardUpdate'); } }, 'signal:otWhiteboard_redo': function (event) { if (event.from.connectionId !== OTSession.session.connection.connectionId) { JSON.parse(event.data).forEach(function (data) { redoWhiteBoard(data); }); scope.$emit('otWhiteboardUpdate'); } }, 'signal:otWhiteboard_history': function (event) { // We will receive these from everyone in the room, only listen to the first // person. Also the data is chunked together so we need all of that person's if (!drawHistoryReceivedFrom || drawHistoryReceivedFrom === event.from.connectionId) { drawHistoryReceivedFrom = event.from.connectionId; drawUpdates(JSON.parse(event.data)); scope.$emit('otWhiteboardUpdate'); } }, 'signal:otWhiteboard_clear': function (event) { if (event.from.connectionId !== OTSession.session.connection.connectionId) { clearCanvas(); } }, 'signal:otWhiteboard_request_history': function (event) { if (drawHistory.length > 0) { batchSignal('otWhiteboard_history', drawHistory, event.from); } } }); } } }; }]);