src/whiteboard/Whiteboard.js
import {select as d3Select, event as d3Event, mouse as d3Mouse} from 'd3-selection';
import {drag as d3Drag} from 'd3-drag';
import {line as d3Line} from 'd3-shape';
import nanoid from 'nanoid';
import {EventEmitter} from '../eventemitter';
import {bindMethods} from '../utils';
import {DataPipeStatus} from '../datapipe/DataPipeStatus';
import {DataPipeType} from '../datapipe/DataPipeType';
import WhiteboardHistory from './WhiteboardHistory';
function blob2Base64(blob) {
return new Promise((resolve) => {
let reader = new window.FileReader();
reader.readAsDataURL(blob);
reader.onloadend = function() {
resolve(reader.result);
};
});
}
// Minimum time in ms for sending shapes using the data pipe
const SHAPE_SEND_INTERVAL = 500;
/**
* Provides access to methods for creating whiteboards.
* This class must not be directly instantiated. It can be obtained using
* {@link session#createWhiteboard} or listening to {@link session#incommingWhiteBoard}
* event.
*
* ## Events
*
* - **`shape`** is emitted every time a new shape is added.
* - **`clear`** is emitted every time the other end clears every shape from the whiteboard.
* - **`closed`** is emitted when the whiteboard session has finished.
*
* You can check EventEmitter2 documentation for additional information about
* adding and removing listeners for this events events.
*
* @see https://github.com/asyncly/EventEmitter2#api
*
* @example <caption>How to create a Whiteboard</caption>
* session.createWhiteboard(participants).then(function(whiteboard));
* whiteboard.setArea('body');
* whiteboard.tool = 'add-line';
* whiteboard.on('closed', function() {
* board.setArea(null);
* });
* ...
* board.close();
* });
*
* @example <caption>How to handle incoming Whiteboards</caption>
* session.on('incomingWhiteBoard', function(board) {
* board.setArea('body');
* board.on('closed', function() {
* board.setArea(null);
* });
* });
*/
export default class Whiteboard extends EventEmitter {
/**
* Gets the version of the protocol implemented.
* @type {string}
*/
static get PROTOCOL() {
return 'whiteboard/2.0';
}
/**
* Gets the valid values for the tool field.
* @type {Array<tool>}
*/
static get TOOL() {
return [null, 'select', 'add-line', 'freedraw', 'add-rect', 'add-circle', 'remove'];
}
/**
* Initializes a new whiteboard with its default values.
* @param {Datapipe} pipe The datapipe that will be used.
* @protected
*/
constructor(pipe) {
super();
/**
* @private
* @type {Datapipe}
*/
this._pipe = pipe;
/**
* Keeps the width of the whiteboard
* @private
* @type {number}
*/
this._width = 1920;
/**
* Keeps the height of the whiteboard
* @private
* @type {number}
*/
this._height = 1080;
/**
* Keeps the active tool.
* @private
* @type {tool}
*/
this._tool = null;
/**
* Keeps the current chosen color.
* @private
* @type {color}
*/
this._color = 'black';
/**
* Keeps the current background image URL.
* @private
* @type {string}
*/
this._backgroundImage = '';
/**
* Keeps the current background video.
* @private
* @type {string}
*/
this._backgroundVideo = '';
/**
* Keeps the d3 single element selector of the DOM element
* where the whiteboard was inserted.
* @private
* @type {Object}
*/
this._element = null;
/**
* Keeps the d3 single element selector of the svg element
* where the whiteboard is painted.
* @private
* @type {Object}
*/
this._svg = this._createCanvas();
/**
* A value used to check if the Whiteboard is already initialized.
* @private
* @type {Promise}
*/
this._initializing = null;
/**
* @private
* @type {number}
*/
this._lastShapeSendTimestamp = 0;
/** @private */
this._pendingShapes = new Map();
/** @private */
this._pendingShapesTimer = 0;
/**
* @private
* @type {Whiteboard}
*/
this._history = new WhiteboardHistory();
/**
* Keeps the current line width.
* @private
*/
this._lineWidth = 1;
bindMethods(this, [
'_onPointerDown',
]);
}
/**
* Creates the svg element
* @private
* @return {Selection} A SVGElement wrapped in a d3 selector
*/
_createCanvas() {
let svg = d3Select(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
.attr('height', '100%')
.attr('width', '100%')
.attr('viewBox', '0 0 1920 1440')
.attr('preserveAspectRatio', 'xMidYMid meet')
.attr('clip-path', 'url(#clip)');
svg.append('rect')
.attr('width', '1920')
.attr('height', '1440')
.attr('x', '0')
.attr('y', '0')
.attr('fill', 'none')
.attr('stroke', 'gray')
.attr('stroke-width', 1);
return svg;
}
/**
* @private
*/
_onPointerDown() {
if (d3Event.type === 'mousedown' && d3Event.button !== 0) {
return;
}
switch (this.tool) {
case 'select':
this._selectPointedElement(this, d3Event.target);
break;
case 'remove': {
const shape = d3Select(d3Event.target).data()[0];
if (typeof shape === 'object' && typeof shape.id === 'string') {
d3Select(d3Event.target).data([]).exit().remove();
this._send({id: shape.id}, true);
}
break;
}
case 'add-line':
return this._createLine();
case 'freedraw':
return this._createFreedraw();
case 'add-rect':
return this._createRect();
case 'add-circle':
return this._createCircle();
default:
this._unselectAllElements();
}
}
/**
* Selects or unselects a DOMElement
*
* @param that a reference to the class's this
* @param element the DOMElement
* @private
*/
_selectPointedElement(that, element) {
switch (element.tagName.toLowerCase()) {
case 'svg':
that._svg.selectAll('.shape.selected').classList.remove('selected');
break;
case 'path':
case 'circle':
case 'rect':
if (d3Event.ctrlKey) {
if (element.classList.contains('selected')) {
element.classList.add('selected');
} else {
element.classList.remove('selected');
}
} else {
that._unselectAllElements();
element.classList.add('selected');
}
break;
default:
}
}
/**
* Unselects all previously selected objects
*/
_unselectAllElements() {
this._svg.selectAll('.selected').classed('selected', false);
}
/**
* Removes every shape from the whiteboard
* @return {undefined}
*/
clear() {
this._svg.selectAll('.shape').data([], d => d.id).exit().remove();
this._pipe.send(JSON.stringify({cmd: 'clear'}));
this._history.clear();
}
/**
* Update a shape stored in history, updating it locally and remotely.
* @param {Object} shape the shape to update.
*/
_updateShape(shape) {
if (!shape) {
return;
}
this._renderShape(shape);
this.emit('shape', shape);
this._pipe.send(JSON.stringify({
cmd: 'shape',
shape: shape,
}));
}
/**
* Check if last action can be reverted.
* @return {boolean} true if there are actions that can be reverted.
*/
canUndo() {
return this._history.canUndo();
}
/**
* Revert last change.
*/
undo() {
this._updateShape(this._history.undo());
}
/**
* Check if last undo call can be reverted.
* @return {boolean} true if a previous undo can be reverted.
*/
canRedo() {
return this._history.canRedo();
}
/**
* Revert last undo call.
*/
redo() {
this._updateShape(this._history.redo());
}
/**
* Allows to set the area that will be used as the canvas for the whiteboard.
* @param {string|HTMLElement|null} selector Selector of the DOM element where the svg canvas
* will be included. It also works if the HTMLElement itself is provided.
* @returns {Whiteboard} The whiteboard object.
*/
setArea(selector) {
if (this._element !== null) {
this._element.node().removeChild(this._svg.node());
this._element.html('');
this._element = null;
}
if (selector === null) {
return this;
}
this._element = d3Select(selector);
if (this._element.empty()) {
throw Error('No elements exist for selector ' + selector);
}
this._element.html('');
this._element.node().appendChild(this._svg.node());
this._svg.on('mousedown', this._onPointerDown);
this._svg.on('touchstart', this._onPointerDown);
return this;
}
/**
* Closes this whiteboard session.
* @returns {Whiteboard} The whiteboard object.
*/
close() {
this._pipe.disconnect();
return this;
}
/**
* Returns the current width of the whiteboard
* @type {number}
*/
get width() {
return this._width;
}
/**
* Returns the current height of the whiteboard
* @type {number}
*/
get height() {
return this._height;
}
/**
* Returns the current aspect ratio of the whiteboard
* @type {number}
*/
get aspectRatio() {
return this._width / this._height;
}
/**
* Updates the aspect ratio of the whiteboard
* @type {number}
*/
set aspectRatio(aspectRatio) {
this._height = this._width / aspectRatio;
this._svg.attr('viewBox', `0 0 ${this._width} ${this._height}`)
.select('rect').attr('height', this._height);
}
/**
* Gets the identities of the remote peers attached to this Whiteboard.
* @type {Array<String>}
*/
get remoteParticipants() {
return this._pipe.remoteParticipants;
}
/**
* @typedef {string} color
* @desc The color used for the shapes. It is represented in a string
* (e.g.: 'green', 'red') or RGB (e.g.: '#00FF00', '#FF0000').
*/
/**
* Gets the color used for the shapes.
* @type {color}
*/
get color() {
return this._color;
}
/**
* Sets the color used for the shapes.
* @type {color}
*/
set color(color) {
this._color = color;
this._svg.selectAll('.selected').each((d) => {
d.fill = d.fill === 'none' ? 'none' : color;
d.stroke = d.stroke === 'none' ? 'none' : color;
this._send(d, true);
this._renderShape(d);
});
}
/**
* @typedef {string} tool
* @desc The tool to use on user interaction. It is represented in a string.
* Valid values: null, 'select', 'add-line', 'freedraw', 'add-rect', 'add-circle', 'remove'
* (null means no tool is selected)
*/
/**
* Gets the current tool to use on user interaction.
* @type {tool}
*/
get tool() {
return this._tool;
}
/**
* Sets the current tool to use on user interaction.
* @type {tool}
*/
set tool(tool) {
if (this._element === null) {
throw Error('No area selected. Method setArea must be called before.');
}
if (!Whiteboard.TOOL.some(value => tool === value)) {
throw Error('Invalid tool: ' + tool);
}
if (tool !== 'select') {
this._unselectAllElements();
}
this._tool = tool;
}
/**
* @typedef {string|Blob} backgroundImage
* @desc The URL of the image that is set in the background or a Blob
* containing an image.
*/
/**
* Gets the current background image shown.
* @type {backgroundImage}
*/
get backgroundImage() {
return this._backgroundImage;
}
/**
* Sets the current background image shown.
* @type {backgroundImage}
*/
set backgroundImage(image) {
Promise.resolve(image instanceof Blob ? blob2Base64(image) : image).then((imageUrl) => {
this._backgroundImage = imageUrl;
let shape = {
type: 'background',
url: imageUrl,
};
this._send(shape, true);
this._renderBackground(shape);
});
}
/**
* Gets the current background video shown.
* @type {string}
*/
get backgroundVideo() {
return this._backgroundVideo;
}
/**
* Sets the current background video shown. Valid values are "local", "remote" and ""
* @type {string}
*/
set backgroundVideo(video) {
if (video !== 'local' && video !== 'remote' && video !== '') {
throw new TypeError(`${video} is not a valid value. Valid values are "local" "remote" and ""`);
}
this._backgroundVideo = video;
switch (video) {
case 'local':
video = 'remote';
break;
case 'remote':
video = 'local';
break;
default:
}
let shape = {
type: 'backgroundVideo',
source: video,
};
this._send(shape, true);
}
get lineWidth() {
return this._lineWidth;
}
set lineWidth(lineWidth) {
this._lineWidth = lineWidth;
}
/**
* Initializes the Whiteboard
* Sets the datapipe object that will be used for receiving and transmiting data.
* @protected
* @return {Promise<Whiteboard>} The whiteboard object.
*/
init() {
if (this._initializing) {
return this._initializing;
}
this._initializing = new Promise(async (resolve) => {
this._pipe.on('data', (data) => {
let msg = JSON.parse(data);
switch (msg.cmd) {
case 'shape':
this._renderShape(msg.shape);
this.emit('shape', msg.shape);
break;
case 'clear':
this._svg.selectAll('.shape').data([], d => d.id).exit().remove();
this.emit('clear');
break;
case 'ready':
resolve(this);
break;
default:
}
});
this._pipe.when(DataPipeStatus.DISCONNECTED).then(() => {
this._pipe.removeAllListeners();
this._pipe = null;
this.emit('closed');
});
await this._pipe.connect();
if (this._pipe.type === DataPipeType.OUTGOING) {
await this._pipe.send(JSON.stringify({cmd: 'ready'}));
}
resolve(this);
});
return this._initializing;
}
/**
* Sends a shape
* @private
* @param {object} shape The shape being sent
* @param {boolean} [force=false] If true the shape will be inmediately sent
* ignoring the shape send interval timer.
* @return {Promise} A promise that is fulfilled after the shape has been sent
*/
_send(shape, force = false) {
this.emit('shape', shape);
if (!force && this._lastShapeSendTimestamp + SHAPE_SEND_INTERVAL > Date.now()) {
this._pendingShapes.set(shape.id, shape);
if (!this._pendingShapesTimer) {
this._pendingShapesTimer = setTimeout(() => {
this._pendingShapes.forEach(s => this._sendShape(s));
this._lastShapeSendTimestamp = Date.now();
this._pendingShapesTimer = 0;
this._pendingShapes.clear();
}, this._lastShapeSendTimestamp + SHAPE_SEND_INTERVAL - Date.now());
}
return Promise.resolve();
}
this._pendingShapes.delete(shape.id);
this._lastShapeSendTimestamp = Date.now();
return this._sendShape(shape);
}
/**
* Sends a shape using the data pipe.
* @private
* @param {object} shape The shape being sent
* @return {Promise} A promise that is fulfilled after the shape has been sent
*/
_sendShape(shape) {
this._history.add(shape);
return this._pipe.send(JSON.stringify({
cmd: 'shape',
shape: shape,
}));
}
_updateOrAppend(shapes, shape) {
let oldIndex = shapes.findIndex(s => s.id === shape.id);
if (oldIndex < 0) {
shapes.push(shape);
} else {
shapes[oldIndex] = shape;
}
return shapes;
}
_addDragBehaviour(that, graph, callback) {
let dragBehaviour = d3Drag();
dragBehaviour.on('start', () => {
that._selectPointedElement(this, d3Event.sourceEvent.target);
});
dragBehaviour.on('drag', (d) => {
callback(d);
});
// Ignore the drag event if the tool is not select
dragBehaviour.filter(function() {
return that._tool === 'select';
});
graph.call(dragBehaviour);
}
/**
* Renders a shape of type line.
* @private
* @param {object} shape The shape being sent
* @return {undefined}
*/
_renderLine(shape) {
let element = this._svg.selectAll('path');
let data = this._updateOrAppend(element.data(), shape);
let line = d3Line().x(d => d.x).y(d => d.y);
let graph = element.data(data, d => d.id);
graph.exit().remove();
graph.enter().append('path')
.attr('id', d => d.id)
.attr('stroke-width', this._lineWidth)
.classed('shape', true);
graph
.attr('d', d => line(d.path))
.attr('stroke', d => d.stroke)
.attr('fill', d => d.fill);
this._addDragBehaviour(this, graph, (d) => {
d.path.forEach((point) => {
point.x += d3Event.dx;
point.y += d3Event.dy;
});
graph.attr('d', datum => line(datum.path));
this._send(d);
});
}
/**
* Renders a shape of type circle.
* @param {object} shape The shape being sent
* @private
*/
_renderCircle(shape) {
let element = this._svg.selectAll('circle');
let data = this._updateOrAppend(element.data(), shape);
let graph = element.data(data, d => d.id);
graph.exit().remove();
graph.enter().append('circle')
.attr('id', d => d.id)
.classed('shape', true);
graph
.attr('cx', d => d.cx)
.attr('cy', d => d.cy)
.attr('r', d => d.r)
.attr('stroke', d => d.stroke)
.attr('fill', d => d.fill);
this._addDragBehaviour(this, graph, (d) => {
d.cx += d3Event.dx;
d.cy += d3Event.dy;
graph.attr('cx', datum => datum.cx).attr('cy', datum => datum.cy);
this._send(d);
});
}
/**
* Renders a shape of type rect.
* @private
*/
_renderRect(shape) {
let element = this._svg.selectAll('rect.shape');
let data = this._updateOrAppend(element.data(), shape);
let graph = element.data(data, d => d.id);
graph.exit().remove();
graph.enter().append('rect')
.attr('id', d => d.id)
.classed('shape', true);
graph.attr('x', d => d.x)
.attr('y', d => d.y)
.attr('width', d => d.width)
.attr('height', d => d.height)
.attr('stroke', d => d.stroke)
.attr('fill', d => d.fill);
this._addDragBehaviour(this, graph, (d) => {
d.x += d3Event.dx;
d.y += d3Event.dy;
graph.attr('x', datum => datum.x).attr('y', datum => datum.y);
this._send(d);
});
}
/**
* Removes a shape of the specified id
* @private
*/
_renderRemoveShape(shape) {
let element = this._svg.selectAll('.shape');
let newData = element.data().filter(datum => datum.id !== shape.id);
element.data(newData, d => d.id).exit().remove();
}
/**
* @private
*/
_bindEndActionEvents(callback) {
this._svg.on('mouseup', () => {
this._svg.on('mousemove', null);
this._svg.on('mouseup', null);
callback();
});
this._svg.on('touchend', () => {
this._svg.on('touchmove', null);
this._svg.on('touchend', null);
callback();
});
}
/**
* Creates and renders a shape of type line
* @private
*/
_createLine() {
let coordinates = d3Mouse(this._svg.node());
let shape = {
id: nanoid(),
type: 'line',
path: [{
x: coordinates[0],
y: coordinates[1],
}, {
x: coordinates[0],
y: coordinates[1],
}],
stroke: this.color,
fill: this.color,
};
this._renderLine(shape);
let onMove = () => {
coordinates = d3Mouse(this._svg.node());
shape.path[1].x = coordinates[0];
shape.path[1].y = coordinates[1];
this._renderLine(shape);
this._send(shape);
};
this._svg.on('mousemove', onMove);
this._svg.on('touchmove', onMove);
this._bindEndActionEvents(() => this._send(shape, true));
}
/**
* @private
*/
_createFreedraw() {
let coordinates = d3Mouse(this._svg.node());
let shape = {
id: nanoid(),
type: 'freedraw',
path: [{
x: coordinates[0],
y: coordinates[1],
}],
stroke: this.color,
fill: 'none',
};
this._renderLine(shape);
let onMove = () => {
coordinates = d3Mouse(this._svg.node());
shape.path.push({
x: coordinates[0],
y: coordinates[1],
});
this._renderLine(shape);
this._send(shape);
};
this._svg.on('mousemove', onMove);
this._svg.on('touchmove', onMove);
this._bindEndActionEvents(() => this._send(shape, true));
}
/**
* @private
*/
_createCircle() {
let startPoint = d3Mouse(this._svg.node());
let shape = {
id: nanoid(),
type: 'circle',
cx: startPoint[0],
cy: startPoint[1],
r: 0,
stroke: this.color,
fill: this.color,
};
this._renderCircle(shape);
let onMove = () => {
let coordinates = d3Mouse(this._svg.node());
shape.cx = (coordinates[0] + startPoint[0]) / 2;
shape.cy = (coordinates[1] + startPoint[1]) / 2;
shape.r = Math.sqrt(Math.pow(coordinates[0] - startPoint[0], 2) + Math.pow(coordinates[1] - startPoint[1], 2)) / 2;
this._renderCircle(shape);
this._send(shape);
};
this._svg.on('mousemove', onMove);
this._svg.on('touchmove', onMove);
this._bindEndActionEvents(() => this._send(shape, true));
}
/**
* @private
*/
_createRect() {
let coordinates = d3Mouse(this._svg.node());
let shape = {
id: nanoid(),
type: 'rect',
x: coordinates[0],
y: coordinates[1],
width: 0,
height: 0,
stroke: this.color,
fill: this.color,
};
this._renderRect(shape);
let onMove = () => {
let newCoordinates = d3Mouse(this._svg.node());
let w = newCoordinates[0] - coordinates[0];
let h = newCoordinates[1] - coordinates[1];
if (w < 0) {
shape.x = newCoordinates[0];
shape.width = -w;
} else {
shape.x = coordinates[0];
shape.width = w;
}
if (h < 0) {
shape.y = newCoordinates[1];
shape.height = -h;
} else {
shape.y = coordinates[1];
shape.height = h;
}
this._renderRect(shape);
this._send(shape);
};
this._svg.on('mousemove', onMove);
this._svg.on('touchmove', onMove);
this._bindEndActionEvents(() => this._send(shape, true));
}
/**
* @private
*/
_renderBackground(shape) {
let image = this._svg.select('#background');
if (image.empty()) {
image = this._svg.insert('svg:image', ':first-child')
.attr('id', 'background')
.attr('width', '100%')
.attr('height', '100%')
.attr('x', 0)
.attr('y', 0);
}
image.attr('xlink:href', shape.url);
}
/**
* @private
*/
_renderShape(shape) {
switch (shape.type) {
case 'background':
this._backgroundImage = shape.url;
this._renderBackground(shape);
break;
case 'backgroundVideo':
this._backgroundVideo = shape.source;
break;
case 'line':
this._renderLine(shape);
break;
case 'freedraw':
this._renderLine(shape);
break;
case 'rect':
this._renderRect(shape);
break;
case 'circle':
this._renderCircle(shape);
break;
default:
this._renderRemoveShape(shape);
}
}
}