opentok-annotation
Version:
OpenTok annotation accelerator pack
1,463 lines (1,245 loc) • 72.1 kB
JavaScript
/*!
* Annotation Plugin for OpenTok
*
* @Author: Trevor Boyer
* @Copyright (c) 2015 TokBox, Inc
**/
/* eslint-disable */
/** Analytics */
(function () {
var _OTKAnalytics;
var _otkanalytics;
var _session;
// vars for the analytics logs. Internal use
var _logEventData = {
clientVersion: 'js-vsol-x.y.z', // x.y.z filled by npm build script
componentId: 'annotationsAccPack',
name: 'guidAnnotationsKit',
actionStartDrawing: 'StartDrawing',
actionEndDrawing: 'EndDrawing',
variationSuccess: 'Success',
};
var _logAnalytics = function () {
// init the analytics logs
var _source = window.location.href;
var otkanalyticsData = {
clientVersion: _logEventData.clientVersion,
source: _source,
componentId: _logEventData.componentId,
name: _logEventData.name
};
_otkanalytics = new _OTKAnalytics(otkanalyticsData);
var sessionInfo = {
sessionId: _session.id,
connectionId: _session.connection.connectionId,
partnerId: _session.apiKey
};
_otkanalytics.addSessionInfo(sessionInfo);
};
var _log = function (action, variation) {
var data = {
action: action,
variation: variation
};
_otkanalytics.logEvent(data);
};
/** End Analytics */
//--------------------------------------
// OPENTOK ANNOTATION CANVAS/VIEW
//--------------------------------------
var DEFAULT_ASSET_URL = 'https://assets.tokbox.com/solutions/images/';
OTSolution = this.OTSolution || {};
OTSolution.Annotations = function (options) {
options = options || {};
this.widgetVersion = 'js-1.0.0-beta';
this.parent = options.container;
this.videoFeed = options.feed;
this.imageAssets = options.imageAssets || DEFAULT_ASSET_URL;
_OTKAnalytics = _OTKAnalytics || options.OTKAnalytics;
if (!_otkanalytics) {
_logAnalytics();
}
if (typeof module === 'object' && typeof module.exports === 'object') {
$ = require('jquery');
}
var context = options.externalWindow ? options.externalWindow.document : window.document;
var self = this;
if (this.parent) {
var canvas = document.createElement('canvas');
canvas.setAttribute('id', 'opentok_canvas'); // session.connection.id?
canvas.style.position = 'absolute';
this.parent.appendChild(canvas);
canvas.setAttribute('width', this.parent.clientWidth + 'px');
canvas.style.width = window.getComputedStyle(this.parent).width;
canvas.setAttribute('height', this.parent.clientHeight + 'px');
canvas.style.height = window.getComputedStyle(this.parent).height;
}
function VideoRelativeCoordinateSet(update) {
var returnedObj = {};
var scale = {
get X() {
if (publishingScreenToMobileDevice || (cobrowsing && subscribingToMobileScreen)) {
return update.canvasWidth / canvas.width;
}
var width = cobrowsing ? canvas.width : self.videoFeed.stream.videoDimensions.width;
return width / canvas.width;
},
get Y() {
if (publishingScreenToMobileDevice || (cobrowsing && subscribingToMobileScreen)) {
return update.canvasHeight / canvas.height;
}
var height = cobrowsing ? canvas.height : self.videoFeed.stream.videoDimensions.height;
return height / canvas.height;
}
};
Object.keys(update).forEach(function (attr) {
returnedObj[attr] = update[attr];
});
['X', 'Y'].forEach(function (coord) {
['to', 'from', 'last', 'm', 'start', 'point'].forEach(function (verb) {
var attr = verb + coord;
returnedObj['_' + attr] = returnedObj[attr];
Object.defineProperty(returnedObj, attr, {
get: function () {
return returnedObj['_' + attr] / scale[coord];
},
set: function (newVal) {
returnedObj['_' + attr] = newVal; // * scale[coord];
}
});
});
});
return returnedObj;
}
var self = this;
var ctx;
var cbs = [];
var isPublisher;
var mirrored;
var scaledToFill;
var batchUpdates = [];
var drawHistory = [];
var drawHistoryReceivedFrom = [];
var updateHistory = [];
var eventHistory = [];
var isStartPoint = false;
var isVideo = self.videoFeed && self.videoFeed.element ? true : false;
var cobrowsing = !self.videoFeed.stream;
var subscribingToMobileScreen = false;
var publishingScreenToMobileDevice = false;
var client = new VideoRelativeCoordinateSet({
dragging: false
});
// INFO Mirrored feeds contain the OT_mirrored class
if (isVideo) {
isPublisher = (' ' + self.videoFeed.element.className + ' ').indexOf(' ' + 'OT_publisher' + ' ') > -1;
mirrored = isPublisher ? (' ' + self.videoFeed.element.className + ' ').indexOf(' ' + 'OT_mirrored' + ' ') > -1 : false;
scaledToFill = (' ' + self.videoFeed.element.className + ' ').indexOf(' ' + 'OT_fit-mode-cover' + ' ') > -1;
} else {
mirrored = false;
scaledToFill = false;
}
this.canvas = function () {
return canvas;
};
/**
* Links an OpenTok session to the annotation canvas. Typically, this is automatically linked
* when using {@link Toolbar#addCanvas}.
* @param session The OpenTok session.
*/
this.link = function (session) {
this.session = session;
};
/**
* Changes the active annotation color for the canvas.
* @param color The hex string representation of the color (#rrggbb).
*/
this.changeColor = function (color) {
self.userColor = color;
if (!self.lineWidth) {
self.lineWidth = 2; // TODO Default to first option in list of line widths
}
};
/**
* Changes the active annotation color for the canvas.
* @param colorIndex - the index regarding the colors array
*/
this.changeColorByIndex = function (colorIndex) {
//set the user color
self.userColor = this.colors[colorIndex];
//activate the change on the toolbar
var colorChoices = context.querySelectorAll('.color-choice');
colorChoices[colorIndex].classList.add('active');
var button = context.getElementById('OT_colors');
button.setAttribute('class', 'OT_color annotation-btn colors');
button.style.borderRadius = '50%';
button.style.backgroundColor = this.colors[colorIndex];
};
/**
* Changes the line/stroke width of the active annotation for the canvas.
* @param size The size in pixels.
*/
this.changeLineWidth = function (size) {
this.lineWidth = size;
};
/**
* Sets the selected menu item from the toolbar. This is typically handled
* automatically by the toolbar, but can be used to programmatically select an item.
* @param item The menu item to set as selected.
*/
this.selectItem = function (item) {
if (self.overlay) {
self.parent.removeChild(self.overlay);
self.overlay = null;
}
/**
* Update classes for toolbar items
*/
var updateSelected = function () {
// Remove the 'selected' class from the currently selected item (or parent)
var current = context.getElementById(self.selectedItem.id);
var shapesBtn = context.getElementById('OT_shapes');
var currentIsShape = shapesBtn.classList.contains('selected');
currentIsShape ? shapesBtn.classList.remove('selected') : current.classList.remove('selected');
// If the newly selected item is a shape, update the shapes subpanel button
var newlySelected = context.getElementById(item.id);
if (newlySelected.parentElement.classList.contains('shapes')) {
shapesBtn.classList.add('selected');
} else {
newlySelected.classList.add('selected');
}
}
if (item && item.id === 'OT_capture') {
self.captureScreenshot();
} else if (item && item.id.indexOf('OT_line_width') !== -1) {
if (item.size) {
self.changeLineWidth(item.size);
}
// 'undo' and 'clear' are actions, not items that can be selected
} else if (item.id !== 'OT_undo' && item.id !== 'OT_clear') {
updateSelected();
self.selectedItem = item;
}
};
/**
* Sets the color palette for the color picker
* @param colors The array of hex color strings (#rrggbb).
*/
this.colors = function (colors) {
this.colors = colors;
this.changeColor(colors[0]);
};
/**
* Clears the canvas for the active user. Only annotations added by the active OpenTok user will
* be removed, leaving the history of all other annotations.
*/
this.clear = function () {
clearCanvas(false, self.session.connection.connectionId);
if (self.session) {
self.session.signal({
type: 'otAnnotation_clear'
});
}
};
this.undo = function () {
undoLast(false, self.session.connection.connectionId);
}
// TODO Allow the user to choose the image type? (jpg, png) Also allow size?
/**
* Captures a screenshot of the annotations displayed on top of the active video feed.
*/
this.captureScreenshot = function (isAnnotationEnd) {
var canvasCopy = document.createElement('canvas');
canvasCopy.width = canvas.width;
canvasCopy.height = canvas.height;
var width = isVideo ? self.videoFeed.videoWidth() : canvas.width;
var height = isVideo ? self.videoFeed.videoHeight() : canvas.height;
var scale = 1;
var offsetX = 0;
var offsetY = 0;
if (scaledToFill) {
if (width < height) {
scale = canvas.width / width;
width = canvas.width;
height = height * scale;
} else {
scale = canvas.height / height;
height = canvas.height;
width = width * scale;
}
// If stretched to fill, we need an offset to center the image
offsetX = (width - canvas.width) / 2;
offsetY = (height - canvas.height) / 2;
} else {
if (width > height) {
scale = canvas.width / width;
width = canvas.width;
height = height * scale;
offsetX = 0;
offsetY = (canvas.height - height) / 2;
} else {
scale = canvas.height / height;
height = canvas.height;
width = width * scale;
offsetX = (canvas.width - width) / 2;
offsetY = 0;
}
}
// Combine the video and annotation images
var image = new Image();
image.onload = function () {
var ctxCopy = canvasCopy.getContext('2d');
if (mirrored) {
ctxCopy.translate(width, 0);
ctxCopy.scale(-1, 1);
}
ctxCopy.drawImage(image, offsetX, offsetY, width, height);
// We want to make sure we draw the annotations the same way, so we need to flip back
if (mirrored) {
ctxCopy.translate(width, 0);
ctxCopy.scale(-1, 1);
}
ctxCopy.drawImage(canvas, 0, 0);
cbs.forEach(function (cb) {
var data = { src: canvasCopy.toDataURL(), isAnnotationEnd: isAnnotationEnd };
cb.call(self, data);
});
// Clear and destroy the canvas copy
canvasCopy = null;
};
if (isVideo) {
imgData = 'data:image/png;base64,' + self.videoFeed.getImgData();
image.src = imgData;
} else {
var currentWindow = options.externalWindow ? options.externalWindow : window;
image.src = currentWindow.getComputedStyle(self.parent)['background-image'].replace(/url\("|"\)/g, '');
}
};
this.onScreenCapture = function (cb) {
cbs.push(cb);
};
/**
* Set flags for sharing with mobile devices
* @param {Boolean} mobile - Is the other party using a mobile device
* @param {Boolean} publishing - Are we publishing our screen?
*/
this.onMobileScreenShare = function (mobile, publishing) {
if (publishing) {
publishingScreenToMobileDevice = mobile;
} else {
subscribingToMobileScreen = mobile;
}
};
this.onResize = function () {
drawUpdates(updateHistory, true);
draw(null, true);
};
this.onClearAnnotation = function () {
dismissTextAnnotation();
};
/** Canvas Handling **/
function addEventListeners(el, s, fn) {
var evts = s.split(' ');
for (var i = 0, iLen = evts.length; i < iLen; i++) {
el.addEventListener(evts[i], fn, true);
}
}
function updateCanvas(event, resizeEvent) {
// Ensure that our canvas has been properly sized
if (canvas.width === 0) {
canvas.width = self.parent.getBoundingClientRect().width;
}
if (canvas.height === 0) {
canvas.height = self.parent.getBoundingClientRect().height;
}
var offsetLeft = !!resizeEvent ? event.canvas.offsetLeft : canvas.offsetLeft;
var offsetTop = !!resizeEvent ? event.canvas.offsetTop : canvas.offsetTop;
if (cobrowsing) {
var baseWidth = !!resizeEvent ? event.canvas.width : self.parent.clientWidth;
var baseHeight = !!resizeEvent ? event.canvas.height : self.parent.clientHeight;
var scaleX = canvas.width / baseWidth;
var scaleY = canvas.height / baseHeight;
var offsetX = event.offsetX || event.pageX - offsetLeft ||
(event.changedTouches && event.changedTouches[0].pageX - offsetLeft);
var offsetY = event.offsetY || event.pageY - offsetTop ||
(event.changedTouches && event.changedTouches[0].pageY - offsetTop);
var x = offsetX * scaleX;
var y = offsetY * scaleY;
} else {
var videoDimensions = self.videoFeed.stream.videoDimensions;
var scaleX = videoDimensions.width / canvas.width;
var scaleY = videoDimensions.height / canvas.height;
if (subscribingToMobileScreen) {
scaleX = 1;
scaleY = 1;
}
var offsetX = event.offsetX || event.pageX - offsetLeft ||
(event.changedTouches && event.changedTouches[0].pageX - offsetLeft);
var offsetY = event.offsetY || event.pageY - offsetTop ||
(event.changedTouches && event.changedTouches[0].pageY - offsetTop);
var x = offsetX * scaleX;
var y = offsetY * scaleY;
}
var update;
var selectedItem = resizeEvent ? event.selectedItem : self.selectedItem;
if (selectedItem) {
if (selectedItem.id === 'OT_pen') {
switch (event.type) {
case 'mousedown':
case 'touchstart':
client.dragging = true;
client.lastX = x;
client.lastY = y;
self.isStartPoint = true;
!resizeEvent && _log(_logEventData.actionStartDrawing, _logEventData.variationSuccess);
break;
case 'mousemove':
case 'touchmove':
if (client.dragging) {
update = {
id: isVideo ? self.videoFeed.stream.connection.connectionId : self.session.connection.connectionId,
fromId: self.session.connection.connectionId,
fromX: client._lastX,
fromY: client._lastY,
toX: x,
toY: y,
color: resizeEvent ? event.userColor : self.userColor,
lineWidth: self.lineWidth,
videoWidth: isVideo ? self.videoFeed.videoElement().clientWidth : canvas.width,
videoHeight: isVideo ? self.videoFeed.videoElement().clientHeight : canvas.height,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
mirrored: mirrored,
startPoint: self.isStartPoint, // Each segment is treated as a new set of points
endPoint: false,
selectedItem: selectedItem,
platform: 'web',
guid: event.guid
};
draw(new VideoRelativeCoordinateSet(update), true);
client.lastX = x;
client.lastY = y;
!resizeEvent && sendUpdate(update);
self.isStartPoint = false;
}
break;
case 'mouseup':
case 'touchend':
client.dragging = false;
update = {
id: isVideo ? self.videoFeed.stream.connection.connectionId : self.session.connection.connectionId,
fromId: self.session.connection.connectionId,
fromX: client._lastX,
fromY: client._lastY,
toX: x,
toY: y,
color: resizeEvent ? event.userColor : self.userColor,
lineWidth: self.lineWidth,
videoWidth: isVideo ? self.videoFeed.videoElement().clientWidth : canvas.width,
videoHeight: isVideo ? self.videoFeed.videoElement().clientHeight : canvas.height,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
mirrored: mirrored,
startPoint: self.isStartPoint, // Each segment is treated as a new set of points
endPoint: true,
selectedItem: selectedItem,
platform: 'web',
guid: event.guid
};
draw(new VideoRelativeCoordinateSet(update), true);
client.lastX = x;
client.lastY = y;
!resizeEvent && sendUpdate(update);
self.isStartPoint = false;
!resizeEvent && _log(_logEventData.actionEndDrawing, _logEventData.variationSuccess);
break;
case 'mouseout':
client.dragging = false;
}
} else if (selectedItem.id === 'OT_text') {
update = {
id: isVideo ? self.videoFeed.stream.connection.connectionId : self.session.connection.connectionId,
fromId: self.session.connection.connectionId,
fromX: x,
fromY: y + event.inputHeight, // Account for the height of the text input
color: event.userColor,
font: event.font,
text: event.text,
videoWidth: isVideo ? self.videoFeed.videoElement().clientWidth : canvas.width,
videoHeight: isVideo ? self.videoFeed.videoElement().clientHeight : canvas.height,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
mirrored: mirrored,
selectedItem: selectedItem,
platform: 'web',
guid: event.guid
};
draw(new VideoRelativeCoordinateSet(update));
!resizeEvent && sendUpdate(update);
} else {
// We have a shape or custom object
// We are currently using a constant default width for shapes
var shapeLineWidth = 2;
if (selectedItem && selectedItem.points) {
client.mX = x;
client.mY = y;
switch (event.type) {
case 'mousedown':
case 'touchstart':
client.isDrawing = true;
client.dragging = true;
client.startX = x;
client.startY = y;
break;
case 'mousemove':
case 'touchmove':
if (client.dragging) {
update = {
color: resizeEvent ? event.userColor : self.userColor,
lineWidth: resizeEvent ? event.lineWidth : shapeLineWidth,
selectedItem: selectedItem
// INFO The points for scaling will get added when drawing is complete
};
draw(new VideoRelativeCoordinateSet(update), true);
}
break;
case 'mouseup':
case 'touchend':
client.isDrawing = false;
var points = selectedItem.points;
if (points.length === 2) {
update = {
id: isVideo ? self.videoFeed.stream.connection.connectionId : self.session.connection.connectionId,
fromId: self.session.connection.connectionId,
fromX: client._startX,
fromY: client._startY,
toX: client._mX,
toY: client._mY,
color: resizeEvent ? event.userColor : self.userColor,
lineWidth: resizeEvent ? event.lineWidth : shapeLineWidth,
videoWidth: isVideo ? self.videoFeed.videoElement().clientWidth : canvas.width,
videoHeight: isVideo ? self.videoFeed.videoElement().clientHeight : canvas.height,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
mirrored: mirrored,
smoothed: false,
startPoint: true,
selectedItem: selectedItem,
platform: 'web',
guid: event.guid
};
drawHistory.push(new VideoRelativeCoordinateSet(update));
!resizeEvent && sendUpdate(update);
} else {
var scale = scaleForPoints(points);
for (var i = 0; i < points.length; i++) {
var firstPoint = false;
var endPoint = false;
// Scale the points according to the difference between the start and end points
var pointX = client._startX + (scale.x * points[i][0]);
var pointY = client._startY + (scale.y * points[i][1]);
if (i === 0) {
client.lastX = pointX;
client.lastY = pointY;
firstPoint = true;
} else if (i === points.length - 1) {
endPoint = true;
}
update = {
id: isVideo ? self.videoFeed.stream.connection.connectionId : self.session.connection.connectionId,
fromId: self.session.connection.connectionId,
fromX: client._lastX,
fromY: client._lastY,
toX: pointX,
toY: pointY,
color: resizeEvent ? event.userColor : self.userColor,
lineWidth: resizeEvent ? event.lineWidth : shapeLineWidth,
videoWidth: isVideo ? self.videoFeed.videoElement().clientWidth : canvas.width,
videoHeight: isVideo ? self.videoFeed.videoElement().clientHeight : canvas.height,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
mirrored: mirrored,
smoothed: selectedItem.enableSmoothing,
startPoint: firstPoint,
endPoint: endPoint,
selectedItem: selectedItem,
platform: 'web',
guid: event.guid
};
drawHistory.push(new VideoRelativeCoordinateSet(update));
!resizeEvent && sendUpdate(update);
client.lastX = pointX; // SCALE BACK!
client.lastY = pointY;
}
draw(null);
}
client.dragging = false;
}
}
}
}
}
function guid() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
addEventListeners(canvas, 'mousedown mousemove mouseup mouseout touchstart touchmove touchend', function (event) {
// Handle text annotation separately and ignore mouse movements if we're not dragging.
var istextEvent = self.selectedItem && self.selectedItem.id === 'OT_text';
var notDragging = event.type === 'mousemove' && !client.dragging;
if (istextEvent || notDragging) {
return;
}
event.preventDefault();
// Save raw events to reprocess on canvas resize
event.selectedItem = self.selectedItem;
if (event.selectedItem) {
event.canvas = {
width: canvas.width,
height: canvas.height,
offsetLeft: canvas.offsetLeft,
offsetTop: canvas.offsetTop
};
event.userColor = self.userColor;
event.lineWidth = self.lineWidth;
event.guid = guid();
eventHistory.push(event);
}
updateCanvas(event);
});
/**
* We need intermediate event handling for text annotation since the user is adding
* text to an input element before it is actually added to the canvas. The original
* click event is assigned to textEvent, which is then updated before being passed
* to updateCanvas.
*/
/** Listen for a click on the canvas. When it occurs, append a text input
* that the user can edit and listen for keydown on the enter key. When enter is
* pressed, processTextEvent is called, the input element is removed, and the text
* is appended to the canvas.
*/
var textEvent;
var textInputId = 'textAnnotation';
var commitPopId = 'commitTextPop';
var commitPopClickId = 'confirm-btn';
var dismissPopId = 'dismiss-btn';
var ignoreClicks = false;
var handleClick = function (event) {
event.preventDefault();
if (!self.selectedItem || self.selectedItem.id !== 'OT_text' || ignoreClicks) {
return;
}
ignoreClicks = true;
// Save raw events to reprocess on canvas resize
event.selectedItem = self.selectedItem;
createTextInput(event);
};
/**
* Get the value of the text input and use it to create an "event".
*/
var processTextEvent = function () {
var textInput = context.getElementById(textInputId);
var inputheight = textInput.clientHeight;
if (!textInput.value) {
textEvent = null;
return;
}
textEvent.text = textInput.value;
textEvent.font = '16px Arial';
textEvent.userColor = self.userColor;
textEvent.canvas = {
width: canvas.width,
height: canvas.height,
offsetLeft: canvas.offsetLeft,
offsetTop: canvas.offsetTop
}
eventHistory.push(textEvent);
updateCanvas(textEvent);
dismissTextAnnotation();
ignoreClicks = false;
};
var createTextInput = function (event) {
var textInput = context.createElement('input');
textInput.setAttribute('type', 'text');
textInput.style.position = 'fixed';
textInput.style.top = event.clientY + 'px';
textInput.style.left = event.clientX + 'px';
textInput.style.background = 'rgba(255,255,255, .5)';
textInput.style.width = '100px';
textInput.style.maxWidth = '200px';
textInput.style.border = '1px dashed red';
textInput.style.fontSize = '16px';
textInput.style.color = self.userColor;
textInput.style.fontFamily = 'Arial';
textInput.style.zIndex = '1001';
textInput.setAttribute('data-canvas-origin', JSON.stringify({
x: event.offsetX,
y: event.offsetY
}));
textInput.setAttribute('data-top', event.clientY - 50)
textInput.id = textInputId;
context.body.appendChild(textInput);
textInput.focus();
textInput.addEventListener('keydown', function (event) {
//If its Enter
if (event.which === 13) {
creteCommitPop(textInput)
}
})
textInput.addEventListener('blur', function () {
creteCommitPop(textInput)
})
textEvent = event;
textEvent.inputHeight = textInput.clientHeight;
ignoreClicks = true;
};
var creteCommitPop = function (textInput) {
if (context.getElementById(commitPopId)) return;
var commitPop = context.createElement('div');
commitPop.style.position = 'fixed';
commitPop.style.top = textInput.dataset.top + 'px';
commitPop.style.left = textInput.style.left;
commitPop.style.width = '200px';
commitPop.style.fontSize = '16px';
commitPop.style.fontFamily = 'Arial';
commitPop.style.zIndex = '2000';
commitPop.style.border = '1px solid grey';
commitPop.style.height = '40px';
commitPop.className = 'ots-annotation-prompt';
commitPop.id = commitPopId;
var commitPopText = context.createElement('span');
var text = context.createTextNode('Commit type?');
commitPopText.appendChild(text);
commitPop.append(commitPopText)
var commitPopClick = context.createElement('div');
commitPopClick.id = commitPopClickId;
commitPopClick.className = commitPopClickId;
var dismissDiv = context.createElement('div');
dismissDiv.id = dismissPopId;
dismissDiv.className = dismissPopId;
commitPop.appendChild(commitPopClick);
commitPop.appendChild(dismissDiv);
context.body.appendChild(commitPop);
dismissDiv.addEventListener('click', dismissTextAnnotation);
commitPopClick.addEventListener('click', function () {
processTextEvent();
});
};
var dismissTextAnnotation = function () {
if (context.getElementById(textInputId)) context.getElementById(textInputId).remove();
if (context.getElementById(commitPopId)) context.getElementById(commitPopId).remove();
textEvent = null;
ignoreClicks = false;
}
addEventListeners(canvas, 'click', handleClick);
/**
* End Handle text markup
*/
var draw = function (update, resizeEvent) {
if (!ctx) {
ctx = canvas.getContext('2d');
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.fillStyle = 'solid';
}
// Clear the canvas
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Repopulate the canvas with items from drawHistory
drawHistory.forEach(function (history) {
ctx.strokeStyle = history.color;
ctx.lineWidth = history.lineWidth;
// INFO iOS serializes bools as 0 or 1
history.smoothed = !!history.smoothed;
history.startPoint = !!history.startPoint;
var secondPoint = false;
var isText = history.hasOwnProperty('text');
if (isText) {
ctx.font = history.font;
ctx.fillStyle = history.color;
ctx.fillText(history.text, history.fromX, history.fromY);
} else {
if (history.smoothed) {
if (history.startPoint) {
self.isStartPoint = true;
} else {
// If the start point flag was already set, we received the next point in the sequence
if (self.isStartPoint) {
secondPoint = true;
self.isStartPoint = false;
}
}
if (history.startPoint) {
// Close the last path and create a new one
ctx.closePath();
ctx.beginPath();
} else if (secondPoint) {
ctx.moveTo((history.fromX + history.toX) / 2, (history.fromY + history.toY) / 2);
} else {
// console.log('Points: (' + (history.fromX + history.toX) / 2 + ', ' + (history.fromY + history.toY) / 2 + ')');
// console.log('Control Points: (' + history.fromX + ', ' + history.fromY + ')');
ctx.quadraticCurveTo(history.fromX, history.fromY, (history.fromX + history.toX) / 2, (history.fromY + history.toY) / 2);
ctx.stroke();
}
} else {
ctx.beginPath();
ctx.moveTo(history.fromX, history.fromY);
ctx.lineTo(history.toX, history.toY);
ctx.stroke();
ctx.closePath();
}
}
});
if (!!resizeEvent && !update) {
return;
}
var selectedItem = !!resizeEvent ? update.selectedItem : self.selectedItem;
if (selectedItem && (selectedItem.title === 'Pen' || selectedItem.title === 'Text')) {
if (update) {
if (selectedItem.title === 'Pen') {
ctx.strokeStyle = update.color;
ctx.lineWidth = update.lineWidth;
ctx.beginPath();
ctx.moveTo(update.fromX, update.fromY);
ctx.lineTo(update.toX, update.toY);
ctx.stroke();
ctx.closePath();
}
if (selectedItem.title === 'Text') {
ctx.font = update.font;
ctx.fillStyle = update.color;
ctx.fillText(update.text, update.fromX, update.fromY);
}
drawHistory.push(update);
}
} else {
if (client.isDrawing) {
if (update) {
ctx.strokeStyle = update.color;
ctx.lineWidth = update.lineWidth;
}
if (selectedItem && selectedItem.points) {
drawPoints(ctx, self.selectedItem.points);
}
}
}
};
var drawPoints = function (ctx, points) {
var scale = scaleForPoints(points);
ctx.beginPath();
if (points.length === 2) {
// We have a line
ctx.moveTo(client.startX, client.startY);
ctx.lineTo(client.mX, client.mY);
} else {
for (var i = 0; i < points.length; i++) {
// Scale the points according to the difference between the start and end points
// Use device independent points here!
client.pointX = client._startX + (scale.x * points[i][0]);
client.pointY = client._startY + (scale.y * points[i][1]);
if (self.selectedItem.enableSmoothing) {
if (i === 0) {
// Do nothing
} else if (i === 1) {
ctx.moveTo((client.pointX + client.lastX) / 2, (client.pointY + client.lastY) / 2);
client.lastX = (client._pointX + client._lastX) / 2;
client.lastX = (client._pointY + client._lastY) / 2;
} else {
ctx.quadraticCurveTo(client.lastX, client.lastY, (client.pointX + client.lastX) / 2,
(client.pointY + client.lastY) / 2);
client.lastX = (client._pointX + client._lastX) / 2;
client.lastY = (client._pointY + client._lastY) / 2;
}
} else {
if (i === 0) {
ctx.moveTo(client.pointX, client.pointY);
} else {
ctx.lineTo(client.pointX, client.pointY);
}
}
client.lastX = client._pointX; // SCALE BACK!
client.lastY = client._pointY;
}
}
ctx.stroke();
ctx.closePath();
};
var scaleForPoints = function (points) {
// mX and mY refer to the end point of the enclosing rectangle (touch up)
var minX = Number.MAX_VALUE;
var minY = Number.MAX_VALUE;
var maxX = 0;
var maxY = 0;
for (var i = 0; i < points.length; i++) {
if (points[i][0] < minX) {
minX = points[i][0];
} else if (points[i][0] > maxX) {
maxX = points[i][0];
}
if (points[i][1] < minY) {
minY = points[i][1];
} else if (points[i][1] > maxY) {
maxY = points[i][1];
}
}
var dx = Math.abs(maxX - minX);
var dy = Math.abs(maxY - minY);
var scaleX = (client._mX - client._startX) / dx;
var scaleY = (client._mY - client._startY) / dy;
return {
x: scaleX,
y: scaleY
};
};
var drawIncoming = function (update, resizeEvent, index) {
var iCanvas = {
width: update.canvasWidth,
height: update.canvasHeight
};
var iVideo = {
width: update.videoWidth,
height: update.videoHeight
};
var video = {
width: isVideo ? self.videoFeed.videoElement().clientWidth : canvas.width,
height: isVideo ? self.videoFeed.videoElement().clientHeight : canvas.height
};
// INFO iOS serializes bools as 0 or 1
update.mirrored = !!update.mirrored;
// Check if the incoming signal was mirrored
if (update.mirrored) {
update.fromX = video.width - update.fromX;
update.toX = video.width - update.toX;
}
// Check to see if the active video feed is also mirrored (double negative)
if (mirrored) {
// Revert (Double negative)
update.fromX = video.width - update.fromX;
update.toX = video.width - update.toX;
}
/** Keep history of updates for resize */
var updateForHistory = JSON.parse(JSON.stringify(update));
updateForHistory.canvasWidth = canvas.width;
updateForHistory.canvasHeight = canvas.height;
updateForHistory.videoWidth = video.width;
updateForHistory.videoHeight = video.height;
if (resizeEvent) {
updateHistory[index] = updateForHistory;
} else {
updateHistory.push(updateForHistory);
drawHistory.push(new VideoRelativeCoordinateSet(update));
}
/** ********************************** */
draw(null);
};
var drawUpdates = function (updates, resizeEvent) {
updates.forEach(function (update, index) {
if (!isVideo || (self.videoFeed && self.videoFeed.stream && update.id === self.videoFeed.stream.connection.connectionId)) {
drawIncoming(update, resizeEvent, index);
}
});
};
var clearCanvas = function (incoming, cid) {
// console.log('cid: ' + cid);
// Remove all elements from history that were drawn by the sender
drawHistory = drawHistory.filter(function (history) {
return history.fromId !== cid;
});
if (!incoming) {
if (self.session) {
self.session.signal({
type: 'otAnnotation_clear'
});
}
eventHistory = [];
} else {
updateHistory = [];
}
// Refresh the canvas
draw();
};
var undoLast = function (incoming, cid, itemsToRemove) {
var historyItem;
var removed;
var endPoint = false;
var removedItems = [];
for (var i = drawHistory.length - 1; i >= 0; i--) {
historyItem = drawHistory[i];
if (historyItem.fromId === cid) {
if (historyItem.platform === 'ios' && !!itemsToRemove && !!itemsToRemove.length && itemsToRemove[0] !== null) {
undoLastIos(incoming, cid, itemsToRemove);
break;
}
endPoint = endPoint || historyItem.endPoint;
removed = drawHistory.splice(i, 1)[0];
removedItems.push(removed.guid);
if (!endPoint || (endPoint && removed.startPoint === true)) {
break;
}
}
}
if (incoming) {
updateHistory = updateHistory.filter(function (history) {
return !itemsToRemove.includes(history.guid);
});
} else {
eventHistory = eventHistory.filter(function (history) {
return !removedItems.includes(history.guid);
});
self.session.signal({
type: 'otAnnotation_undo',
data: JSON.stringify(removedItems)
});
}
draw();
}
var undoLastIos = function (incoming, cid, itemsToRemove) {
var historyItem;
var removed;
var endPoint = false;
var removedItems = [];
for (var i = drawHistory.length - 1; i >= 0; i--) {
historyItem = drawHistory[i];
if (historyItem.fromId === cid) {
if (historyItem.guid === itemsToRemove[0]) {
removed = drawHistory.splice(i, 1)[0];
removedItems.push(removed.guid);
}
}
}
if (incoming) {
updateHistory = updateHistory.filter(function (history) {
return !itemsToRemove.includes(history.guid);
});
} else {
eventHistory = eventHistory.filter(function (history) {
return !removedItems.includes(history.guid);
});
self.session.signal({
type: 'otAnnotation_undo',
data: JSON.stringify(removedItems)
});
}
draw();
}
var count = 0;
/** Signal Handling **/
if (_session) {
_session.on({
'signal:otAnnotation_pen': function (event) {
if (event.from.connectionId !== _session.connection.connectionId) {
var paths = JSON.parse(event.data);
drawUpdates(paths);
}
},
'signal:otAnnotation_text': function (event) {
if (event.from.connectionId !== _session.connection.connectionId) {
drawUpdates(JSON.parse(event.data));
}
},
'signal:otAnnotation_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));
}
},
'signal:otAnnotation_clear': function (event) {
if (event.from.connectionId !== _session.connection.connectionId) {
// Only clear elements drawn by the sender's (from) Id
clearCanvas(true, event.from.connectionId);
}
},
'signal:otAnnotation_undo': function (event) {
if (event.from.connectionId !== _session.connection.connectionId) {
// Only clear elements drawn by the sender's (from) Id
undoLast(true, event.from.connectionId, JSON.parse(event.data));
}
},
connectionCreated: function (event) {
if (drawHistory.length > 0 && event.connection.connectionId !== _session.connection.connectionId) {
batchSignal('otWhiteboard_history', drawHistory, event.connection);
}
}
});
}
var batchSignal = function (data, toConnection) {
var signalError = function (err) {
if (err) {
TB.error(err);
}
};
var type = 'otAnnotation_pen';
var updateType = function (chunk) {
if (!chunk || !chunk[0] || !chunk[0].selectedItem || !chunk[0].selectedItem.id) {
return;
}
var id = chunk[0].selectedItem.id;
type = id === 'OT_text' ? 'otAnnotation_text' : 'otAnnotation_pen';
};
/**
* If the 'type' string exceeds the maximum length (128 bytes), or the
* 'data' string exceeds the maximum size (8 kB), OT will return a 413 error
* and the signal will not be sent. The maximum number of characters that
* can be sent in the signal is 8,192. Currently, the largest updates are
* 995 characters, meaning that the limit for the number of updates per
* signal should be 8, even taking into account the additional characters
* required to convert the entire array of updates as opposed to each one
* individually. However, OT is throwing a 413 error once the size exceeds
* 7,900 characters. So, 7 is the magic number for the time being.
* UPDATE: At 7, we're still seeing errors. So, 6 it is.
*/
var dataChunk;
var start = 0;
var updatesPerSignal = 6;
while (start < data.length) {
dataChunk = data.slice(start, start + updatesPerSignal);
updateType(dataChunk);
start += updatesPerSignal;
var signal = {
type: type,
data: JSON.stringify(dataChunk)
};
if (toConnection) {
signal.to = toConnection;
}
self.session.signal(signal, signalError);
}
};
var updateTimeout;
var sendUpdate = function (update) {
if (self.session) {
batchUpdates.push(update);
if (!updateTimeout) {
updateTimeout = setTimeout(function () {
batchSignal(batchUpdates);
batchUpdates = [];
updateTimeout = null;
}, 100);
}
}
};
};
//--------------------------------------
// OPENTOK ANNOTATION TOOLBAR
//--------------------------------------
OTSolution.Annotations.Toolbar = function (options) {
var self = this;
var _toolbar = this;
options || (options = {});
if (!options.session) {
throw new Error('OpenTok Annotation Widget requires an OpenTok session');
} else {
_session = options.session;
}
if (!_OTKAnalytics && !options.OTKAnalytics) {
throw new Error('OpenTok Annotation Widget requires an OpenTok Solution');
} else {
_OTKAnalytics = _OTKAnalytics || options.OTKAnalytics;
}
if (!_otkanalytics) {
_logAnalytics();
}
this.session = options.session;
this.parent = options.container;
this.externalWindow = options.externalWindow;
// TODO Allow 'style' objects to be passed in for buttons, menu toolbar, etc?
this.backgroundColor = options.backgroundColor || 'rgba(102, 102, 102, 0.90)';
this.subpanelBackgroundColor = options.subpanelBackgroundColor || '#323232';
var imageAssets = options.imageAssets || DEFAULT_ASSET_URL;
var toolbarItems = [{
id: 'OT_pen',
title: 'Pen',
icon: [imageAssets, 'annotation-pencil.png'].join(''),
selectedIcon: [imageAssets, 'annotation-pencil.png'].join(''),
items: { /* Built dynamically */ }
}, {
id: 'OT_colors',
title: 'Colors',
icon: '',
items: { /* Built dynamically */ }
}, {
id: 'OT_shapes',
title: 'Shapes',
icon: [imageAssets, 'annotation-shapes.png'].join(''),
items: [{
id: 'OT_rect',
title: 'Rectangle',
icon: [imageAssets, 'annotation-rectangle.png'].join(''),
points: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
[0, 0] // Reconnect point
]
},
{
id: 'OT_rect_fill',
title: 'Rectangle-Fill',
icon: [imageAssets, 'annotation-rectangle.png'].join(''),
points: [
[0, 0],
[1, 0],
[1, 1],
[0, 1],
[0, 0] // Reconnect point
]
}, {
id: 'OT_oval',
title: 'Oval',
icon: [imageAssets, 'annotation-oval.png'].join(''),
enableSmoothing: true,
points: [
[0, 0.5],
[0.5 + 0.5 * Math.cos(5 * Math.PI / 4), 0.5 + 0.5 * Math.sin(5 * Math.PI / 4)],
[0.5, 0],
[0.5 + 0.5 * Math.cos(7 * Math.PI / 4), 0.5 + 0.5 * Math.sin(7 * Math.PI / 4)],
[1, 0.5],
[0.5 + 0.5 * Math.cos(Math.PI / 4), 0.5 + 0.5 * Math.sin(Math.PI / 4)],
[0.5, 1],
[0.5 + 0.5 * Math.cos(3 * Math.PI / 4), 0.5 + 0.5 * Math.sin(3 * Math.PI / 4)],
[0, 0.5],
[0.5 + 0.5 * Math.cos(5 * Math.PI / 4), 0.5 + 0.5 * Math.sin(5 * Math.PI / 4)]
]
}, {
id: 'OT_oval_fill',
title: 'Oval-Fill',
icon: [imageAssets, 'annotation-oval-fill.png'].join(''),
enableSmoothing: true,
points: [
[0, 0.5],
[0.5 + 0.5 * Math.cos(5 * Math.PI / 4), 0.5 + 0.5 * Math.sin(5 * Math.PI / 4)],
[0.5, 0],
[0.5 + 0.5 * Math.cos(7 * Math.PI / 4), 0.5 + 0.5 * Math.sin(7 * Math.PI / 4)],
[1, 0.5],
[0.5 + 0.5 * Math.cos(Math.PI / 4), 0.5 + 0.5 * Math.sin(Math.PI / 4)],
[0.5, 1],
[0.5 + 0.5 * Math.cos(3 * Math.PI / 4), 0.5 + 0.5 * Math.sin(3 * Math.PI / 4)],
[0, 0.5],
[0.5 + 0.5 * Math.cos(5 * Math.PI / 4), 0.5 + 0.5 * Math.sin(5 * Math.PI / 4)]
]
}, {
id: 'OT_star',
title: 'Star',
icon: [imageAssets, 'annotation-star.png'].join(''),
points: [
/* eslint-disable max-len */
[0.5 + 0.5 * Math.cos(90 * (Math.PI / 180)), 0.5 + 0.5 * Math.sin(90 * (Math.PI / 180))],
[0.5 + 0.25 * Math.cos(126 * (Math.PI / 180)), 0.5 + 0.25 * Math.sin(126 * (Math.PI / 180))],
[0.5 + 0.5 * Math.cos(162 * (Math.PI / 180)), 0.5 + 0.5 * Math.sin(162 * (Math.PI / 180))],
[0.5 + 0.25 * Math.cos(198 * (Math.PI / 180)), 0.5 + 0.25 * Math.sin(198 * (Math.PI / 180))],
[0.5 + 0.5 * Math.cos(234 * (Math.PI / 180)), 0.5 + 0.5 * Math.sin(234 * (Math.PI / 180))],
[0.5 + 0.25 * Math.cos(270 * (Math.PI / 180)), 0.5 + 0.25 * Math.sin(270 * (Math.PI / 180))],
[0.5 + 0.5 * Math.cos(306 * (Math.PI / 180)), 0.5 + 0.5 * Math.sin(306 * (Math.PI / 180))],
[0.5 + 0.25 * Math.cos(342 * (Math.PI / 180)), 0.5 + 0.25 * Math.sin(342 * (Math.PI / 180))],
[0.5 + 0.5 * Math.cos(18 * (Math.PI / 180)), 0.5 + 0.5 * Math.sin(18 * (Math.PI / 180))],
[0.5 + 0.25 * Math.cos(54 * (Math.PI / 180)), 0.5 + 0.25 * Math.sin(54 * (Math.PI / 180))],
[0.5 + 0.5 * Math.cos(90 * (Math.PI / 180)), 0.5 + 0.5 * Math.sin(90 * (Math.PI / 180))]
/* eslint-enable max-len */
]
}, {
id: 'OT_arrow',