Home Reference Source

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 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);
		}
	}
}