You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@openmeetings.apache.org by so...@apache.org on 2017/09/28 12:03:52 UTC

[2/2] openmeetings git commit: [OPENMEETINGS-1714] js improvements

[OPENMEETINGS-1714] js improvements


Project: http://git-wip-us.apache.org/repos/asf/openmeetings/repo
Commit: http://git-wip-us.apache.org/repos/asf/openmeetings/commit/965cba71
Tree: http://git-wip-us.apache.org/repos/asf/openmeetings/tree/965cba71
Diff: http://git-wip-us.apache.org/repos/asf/openmeetings/diff/965cba71

Branch: refs/heads/master
Commit: 965cba711b6e48a33b44adbcc631f4e43f130895
Parents: 9b53f02
Author: Maxim Solodovnik <so...@gmail.com>
Authored: Thu Sep 28 19:03:43 2017 +0700
Committer: Maxim Solodovnik <so...@gmail.com>
Committed: Thu Sep 28 19:03:43 2017 +0700

----------------------------------------------------------------------
 .../src/main/assembly/components/templates.xml  |    7 +
 openmeetings-web/pom.xml                        |  104 +-
 .../openmeetings/web/room/wb/interview-area.js  |   87 +
 .../openmeetings/web/room/wb/interviewwb.js     |   88 -
 .../apache/openmeetings/web/room/wb/player.js   |  230 +++
 .../openmeetings/web/room/wb/tool-apointer.js   |   95 +
 .../openmeetings/web/room/wb/tool-arrow.js      |   56 +
 .../openmeetings/web/room/wb/tool-base.js       |   11 +
 .../openmeetings/web/room/wb/tool-clipart.js    |   31 +
 .../openmeetings/web/room/wb/tool-ellipse.js    |   25 +
 .../openmeetings/web/room/wb/tool-line.js       |   20 +
 .../openmeetings/web/room/wb/tool-paint.js      |   19 +
 .../openmeetings/web/room/wb/tool-pointer.js    |   25 +
 .../openmeetings/web/room/wb/tool-rect.js       |   32 +
 .../openmeetings/web/room/wb/tool-shape-base.js |   29 +
 .../openmeetings/web/room/wb/tool-shape.js      |   58 +
 .../openmeetings/web/room/wb/tool-text.js       |   73 +
 .../openmeetings/web/room/wb/tool-uline.js      |    7 +
 .../org/apache/openmeetings/web/room/wb/uuid.js |   21 +
 .../apache/openmeetings/web/room/wb/wb-all.js   |    2 +
 .../apache/openmeetings/web/room/wb/wb-area.js  |  302 +++
 .../apache/openmeetings/web/room/wb/wb-board.js |  735 ++++++++
 .../org/apache/openmeetings/web/room/wb/wb.js   | 1752 ------------------
 pom.xml                                         |    2 +-
 24 files changed, 1968 insertions(+), 1843 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-server/src/main/assembly/components/templates.xml
----------------------------------------------------------------------
diff --git a/openmeetings-server/src/main/assembly/components/templates.xml b/openmeetings-server/src/main/assembly/components/templates.xml
index 6e0c92a..25cadf2 100644
--- a/openmeetings-server/src/main/assembly/components/templates.xml
+++ b/openmeetings-server/src/main/assembly/components/templates.xml
@@ -32,6 +32,13 @@
 			</includes>
 		</fileSet>
 		<fileSet>
+			<directory>${project.parent.basedir}/openmeetings-web/target/generated-sources/main/java</directory>
+			<outputDirectory>${om.webapp}/WEB-INF/classes</outputDirectory>
+			<includes>
+				<include>**/*.js</include>
+			</includes>
+		</fileSet>
+		<fileSet>
 			<directory>${project.parent.basedir}/openmeetings-service/src/main/java</directory>
 			<outputDirectory>${om.webapp}/WEB-INF/classes</outputDirectory>
 			<includes>

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/pom.xml
----------------------------------------------------------------------
diff --git a/openmeetings-web/pom.xml b/openmeetings-web/pom.xml
index aef265b..21f8189 100644
--- a/openmeetings-web/pom.xml
+++ b/openmeetings-web/pom.xml
@@ -97,7 +97,6 @@
 						</goals>
 						<configuration>
 							<charset>UTF-8</charset>
-							<cssSourceDir>css</cssSourceDir>
 							<cssSourceFiles>
 								<cssSourceFile>general.css</cssSourceFile>
 								<cssSourceFile>activities.css</cssSourceFile>
@@ -119,13 +118,114 @@
 						</goals>
 						<configuration>
 							<charset>UTF-8</charset>
-							<cssSourceDir>css</cssSourceDir>
 							<cssSourceFiles>
 								<cssSourceFile>general-rtl.css</cssSourceFile>
 							</cssSourceFiles>
 							<cssFinalFile>theme-rtl.css</cssFinalFile>
 						</configuration>
 					</execution>
+					<execution>
+						<id>interview-wb-js</id>
+						<goals>
+							<goal>minify</goal>
+						</goals>
+						<configuration>
+							<charset>UTF-8</charset>
+							<jsSourceDir>../java/org/apache/openmeetings/web/room/wb</jsSourceDir>
+							<jsSourceFiles>
+								<jsSourceFile>wb-all.js</jsSourceFile>
+								<jsSourceFile>interview-area.js</jsSourceFile>
+							</jsSourceFiles>
+							<jsFinalFile>interviewwb.js</jsFinalFile>
+							<jsTargetDir>../generated-sources/main/java/org/apache/openmeetings/web/room/wb/</jsTargetDir>
+							<skipMinify>true</skipMinify>
+							<suffix></suffix>
+						</configuration>
+					</execution>
+					<execution>
+						<id>interview-wb-js-minify</id>
+						<goals>
+							<goal>minify</goal>
+						</goals>
+						<configuration>
+							<charset>UTF-8</charset>
+							<jsSourceDir>../java/org/apache/openmeetings/web/room/wb</jsSourceDir>
+							<jsSourceFiles>
+								<jsSourceFile>wb-all.js</jsSourceFile>
+								<jsSourceFile>interview-area.js</jsSourceFile>
+							</jsSourceFiles>
+							<jsFinalFile>interviewwb.js</jsFinalFile>
+							<jsTargetDir>../generated-sources/main/java/org/apache/openmeetings/web/room/wb/</jsTargetDir>
+							<jsEngine>CLOSURE</jsEngine>
+						</configuration>
+					</execution>
+					<execution>
+						<id>wb-js</id>
+						<goals>
+							<goal>minify</goal>
+						</goals>
+						<configuration>
+							<charset>UTF-8</charset>
+							<jsSourceDir>../java/org/apache/openmeetings/web/room/wb</jsSourceDir>
+							<jsSourceFiles>
+								<jsSourceFile>wb-all.js</jsSourceFile>
+								<jsSourceFile>uuid.js</jsSourceFile>
+								<jsSourceFile>player.js</jsSourceFile>
+								<jsSourceFile>tool-base.js</jsSourceFile>
+								<jsSourceFile>tool-pointer.js</jsSourceFile>
+								<jsSourceFile>tool-apointer.js</jsSourceFile>
+								<jsSourceFile>tool-shape-base.js</jsSourceFile>
+								<jsSourceFile>tool-text.js</jsSourceFile>
+								<jsSourceFile>tool-paint.js</jsSourceFile>
+								<jsSourceFile>tool-shape.js</jsSourceFile>
+								<jsSourceFile>tool-line.js</jsSourceFile>
+								<jsSourceFile>tool-uline.js</jsSourceFile>
+								<jsSourceFile>tool-rect.js</jsSourceFile>
+								<jsSourceFile>tool-ellipse.js</jsSourceFile>
+								<jsSourceFile>tool-arrow.js</jsSourceFile>
+								<jsSourceFile>tool-clipart.js</jsSourceFile>
+								<jsSourceFile>wb-board.js</jsSourceFile>
+								<jsSourceFile>wb-area.js</jsSourceFile>
+							</jsSourceFiles>
+							<jsFinalFile>wb.js</jsFinalFile>
+							<jsTargetDir>../generated-sources/main/java/org/apache/openmeetings/web/room/wb/</jsTargetDir>
+							<skipMinify>true</skipMinify>
+							<suffix></suffix>
+						</configuration>
+					</execution>
+					<execution>
+						<id>wb-js-minify</id>
+						<goals>
+							<goal>minify</goal>
+						</goals>
+						<configuration>
+							<charset>UTF-8</charset>
+							<jsSourceDir>../java/org/apache/openmeetings/web/room/wb</jsSourceDir>
+							<jsSourceFiles>
+								<jsSourceFile>wb-all.js</jsSourceFile>
+								<jsSourceFile>uuid.js</jsSourceFile>
+								<jsSourceFile>player.js</jsSourceFile>
+								<jsSourceFile>tool-base.js</jsSourceFile>
+								<jsSourceFile>tool-pointer.js</jsSourceFile>
+								<jsSourceFile>tool-apointer.js</jsSourceFile>
+								<jsSourceFile>tool-shape-base.js</jsSourceFile>
+								<jsSourceFile>tool-text.js</jsSourceFile>
+								<jsSourceFile>tool-paint.js</jsSourceFile>
+								<jsSourceFile>tool-shape.js</jsSourceFile>
+								<jsSourceFile>tool-line.js</jsSourceFile>
+								<jsSourceFile>tool-uline.js</jsSourceFile>
+								<jsSourceFile>tool-rect.js</jsSourceFile>
+								<jsSourceFile>tool-ellipse.js</jsSourceFile>
+								<jsSourceFile>tool-arrow.js</jsSourceFile>
+								<jsSourceFile>tool-clipart.js</jsSourceFile>
+								<jsSourceFile>wb-board.js</jsSourceFile>
+								<jsSourceFile>wb-area.js</jsSourceFile>
+							</jsSourceFiles>
+							<jsFinalFile>wb.js</jsFinalFile>
+							<jsTargetDir>../generated-sources/main/java/org/apache/openmeetings/web/room/wb/</jsTargetDir>
+							<jsEngine>CLOSURE</jsEngine>
+						</configuration>
+					</execution>
 				</executions>
 			</plugin>
 			<plugin>

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interview-area.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interview-area.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interview-area.js
new file mode 100644
index 0000000..9623bf9
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interview-area.js
@@ -0,0 +1,87 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var WbArea = (function() {
+	var container, area, role = NONE, self = {}, choose, btns
+		, _inited = false, recStart, recStop;
+
+	function _init() {
+		container = $(".room.wb.area");
+		area = container.find(".wb-area");
+		btns = $('.pod-row .pod-container .pod a.choose-btn');
+		btns.button()
+			.click(function() {
+				choose.dialog('open');
+				let sel = choose.find('.users').html('');
+				let users = $('.user.list .user.entry');
+				for (let i = 0; i < users.length; ++i) {
+					let u = $(users[i]);
+					sel.append($('<option></option>').text(u.attr('title')).val(u.attr('id').substr(4)));
+				}
+				choose.find('.pod-name').val($(this).data('pod'));
+				return false;
+			});
+		recStart = $('.pod-row .pod-container a.rec-btn.start').button({
+			disabled: true
+			, icon: "ui-icon-play"
+		}).click(function() {
+			wbAction('startRecording', '');
+			return false;
+		});
+		recStop = $('.pod-row .pod-container a.rec-btn.stop').button({
+			disabled: true
+			, icon: "ui-icon-stop"
+		}).click(function() {
+			wbAction('stopRecording', '');
+			return false;
+		});
+		choose = $('#interview-choose-video');
+		choose.dialog({
+			modal: true
+			, autoOpen: false
+			, buttons: [
+				{
+					text: choose.data('btn-ok')
+					, click: function() {
+						toggleActivity('broadcastAV', choose.find('.users').val(), choose.find('.pod-name').val());
+						$(this).dialog('close');
+					}
+				}
+				, {
+					text: choose.data('btn-cancel')
+					, click: function() {
+						$(this).dialog('close');
+					}
+				}
+			]
+		});
+		_inited = true;
+	}
+	function _setRole(_role) {
+		if (!_inited) return;
+		role = _role;
+		if (role !== NONE) {
+			btns.show();
+		} else {
+			btns.hide();
+		}
+	}
+	function _resize(posX, w, h) {
+		if (!container || !_inited) return;
+		var hh = h - 5;
+		container.width(w).height(h).css('left', posX + "px");
+		area.width(w).height(hh);
+	}
+	function _setRecStartEnabled(en) {
+		recStart.button("option", "disabled", !en);
+	}
+	function _setRecStopEnabled(en) {
+		recStop.button("option", "disabled", !en);
+	}
+
+	self.init = _init;
+	self.destroy = function() {};
+	self.setRole = _setRole;
+	self.resize = _resize;
+	self.setRecStartEnabled = _setRecStartEnabled;
+	self.setRecStopEnabled = _setRecStopEnabled;
+	return self;
+})();

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interviewwb.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interviewwb.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interviewwb.js
deleted file mode 100644
index a3fe7fc..0000000
--- a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/interviewwb.js
+++ /dev/null
@@ -1,88 +0,0 @@
-/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
-var NONE = 'none';
-var WbArea = (function() {
-	var container, area, role = NONE, self = {}, choose, btns
-		, _inited = false, recStart, recStop;
-
-	function _init() {
-		container = $(".room.wb.area");
-		area = container.find(".wb-area");
-		btns = $('.pod-row .pod-container .pod a.choose-btn');
-		btns.button()
-			.click(function() {
-				choose.dialog('open');
-				let sel = choose.find('.users').html('');
-				let users = $('.user.list .user.entry');
-				for (let i = 0; i < users.length; ++i) {
-					let u = $(users[i]);
-					sel.append($('<option></option>').text(u.attr('title')).val(u.attr('id').substr(4)));
-				}
-				choose.find('.pod-name').val($(this).data('pod'));
-				return false;
-			});
-		recStart = $('.pod-row .pod-container a.rec-btn.start').button({
-			disabled: true
-			, icon: "ui-icon-play"
-		}).click(function() {
-			wbAction('startRecording', '');
-			return false;
-		});
-		recStop = $('.pod-row .pod-container a.rec-btn.stop').button({
-			disabled: true
-			, icon: "ui-icon-stop"
-		}).click(function() {
-			wbAction('stopRecording', '');
-			return false;
-		});
-		choose = $('#interview-choose-video');
-		choose.dialog({
-			modal: true
-			, autoOpen: false
-			, buttons: [
-				{
-					text: choose.data('btn-ok')
-					, click: function() {
-						toggleActivity('broadcastAV', choose.find('.users').val(), choose.find('.pod-name').val());
-						$(this).dialog('close');
-					}
-				}
-				, {
-					text: choose.data('btn-cancel')
-					, click: function() {
-						$(this).dialog('close');
-					}
-				}
-			]
-		});
-		_inited = true;
-	}
-	function _setRole(_role) {
-		if (!_inited) return;
-		role = _role;
-		if (role !== NONE) {
-			btns.show();
-		} else {
-			btns.hide();
-		}
-	}
-	function _resize(posX, w, h) {
-		if (!container || !_inited) return;
-		var hh = h - 5;
-		container.width(w).height(h).css('left', posX + "px");
-		area.width(w).height(hh);
-	}
-	function _setRecStartEnabled(en) {
-		recStart.button("option", "disabled", !en);
-	}
-	function _setRecStopEnabled(en) {
-		recStop.button("option", "disabled", !en);
-	}
-
-	self.init = _init;
-	self.destroy = function() {};
-	self.setRole = _setRole;
-	self.resize = _resize;
-	self.setRecStartEnabled = _setRecStartEnabled;
-	self.setRecStopEnabled = _setRecStopEnabled;
-	return self;
-})();

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/player.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/player.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/player.js
new file mode 100644
index 0000000..9509ceb
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/player.js
@@ -0,0 +1,230 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Player = (function() {
+	let player = {}, mainColor = '#ff6600', rad = 20;
+	function _filter(_o, props) {
+		return props.reduce((result, key) => { result[key] = _o[key]; return result; }, {});
+	}
+	function _sendStatus(g, _paused, _pos) {
+		g.status.paused = _paused;
+		g.status.pos = _pos;
+		wbAction('videoStatus', JSON.stringify({
+			wbId: g.canvas.wbId
+			, uid: g.uid
+			, status: {
+				paused: _paused
+				, pos: _pos
+			}
+		}));
+	}
+
+	player.create = function(canvas, _o, _role) {
+		let vid = $('<video>').hide().attr('class', 'wb-video slide-' + canvas.slide).attr('id', 'wb-video-' + _o.uid)
+			.attr("width", _o.width).attr("height", _o.height)
+			.append($('<source>').attr('type', 'video/mp4').attr('src', _o._src));
+		$('#wb-tab-' + canvas.wbId).append(vid);
+		new fabric.Image.fromURL(_o._poster, function(poster) {
+			new fabric.Image(vid[0], {}, function (video) {
+				video.visible = false;
+				poster.width = _o.width;
+				poster.height = _o.height;
+				if (typeof _o.status === 'undefined') {
+					_o.status = {paused: true};
+				}
+				let playable = false;
+				let trg = new fabric.Triangle({
+					left: 2.7 * rad
+					, top: _o.height - 2.5 * rad
+					, visible: _o.status.paused
+					, angle: 90
+					, width: rad
+					, height: rad
+					, stroke: mainColor
+					, fill: mainColor
+				});
+				let rectPause1 = new fabric.Rect({
+					left: 1.6 * rad
+					, top: _o.height - 2.5 * rad
+					, visible: !_o.status.paused
+					, width: rad / 3
+					, height: rad
+					, stroke: mainColor
+					, fill: mainColor
+				});
+				let rectPause2 = new fabric.Rect({
+					left: 2.1 * rad
+					, top: _o.height - 2.5 * rad
+					, visible: !_o.status.paused
+					, width: rad / 3
+					, height: rad
+					, stroke: mainColor
+					, fill: mainColor
+				});
+				let play = new fabric.Group([
+						new fabric.Circle({
+							left: rad
+							, top: _o.height - 3 * rad
+							, radius: rad
+							, stroke: mainColor
+							, strokeWidth: 2
+							, fill: null
+						})
+						, trg, rectPause1, rectPause2]
+					, {
+						objectCaching: false
+						, visible: false
+					});
+				let cProgress = new fabric.Rect({
+					left: 3.5 * rad
+					, top: _o.height - 1.5 * rad
+					, visible: false
+					, width: _o.width - 5 * rad
+					, height: rad / 2
+					, stroke: mainColor
+					, fill: null
+					, rx: 5
+					, ry: 5
+				});
+				let isDone = function() {
+					return video.getElement().currentTime == video.getElement().duration;
+				};
+				let updateProgress = function() {
+					progress.set('width', (video.getElement().currentTime * cProgress.width) / video.getElement().duration);
+					canvas.renderAll();
+				};
+				let progress = new fabric.Rect({
+					left: 3.5 * rad
+					, top: _o.height - 1.5 * rad
+					, visible: false
+					, width: 0
+					, height: rad / 2
+					, stroke: mainColor
+					, fill: mainColor
+					, rx: 5
+					, ry: 5
+				});
+				let request;
+
+				let opts = $.extend({
+					subTargetCheck: true
+					, objectCaching: false
+					, omType: 'Video'
+					, selectable: canvas.selection
+				}, _filter(_o, ['fileId', 'fileType', 'slide', 'uid', '_poster', '_src', 'width', 'height', 'status']));
+				let group = new fabric.Group([video, poster, play, progress, cProgress], opts);
+
+				let updateControls = function() {
+					video.visible = true;
+					poster.visible = false;
+
+					trg.visible = group.status.paused;
+					rectPause1.visible = !group.status.paused;
+					rectPause2.visible = !group.status.paused;
+					canvas.renderAll();
+				};
+				let render = function () {
+					if (isDone()) {
+						_sendStatus(group, true, video.getElement().duration);
+						updateControls();
+					}
+					updateProgress();
+					if (group.status.paused) {
+						cancelAnimationFrame(request);
+						canvas.renderAll();
+					} else {
+						request = fabric.util.requestAnimFrame(render);
+					}
+				};
+				cProgress.on({
+					'mousedown': function (evt) {
+						let _ptr = canvas.getPointer(evt.e)
+							, ptr = canvas._normalizePointer(group, _ptr)
+							, l = (group.width / 2 + ptr.x) * canvas.getZoom() - cProgress.aCoords.bl.x;
+						_sendStatus(group, group.status.paused, l * video.getElement().duration / cProgress.width)
+					}
+				});
+				play.on({
+					/*
+					 * https://github.com/kangax/fabric.js/issues/4115
+					 *
+					'mouseover': function() {
+						circle1.set({strokeWidth: 4});
+						canvas.renderAll();
+					}
+					, 'mouseout': function() {
+						circle1.set({
+							left: pos.left
+							, top: pos.top
+							, strokeWidth: 2
+						});
+						canvas.renderAll();
+					}
+					, */'mousedown': function () {
+						play.set({
+							left: pos.left + 3
+							, top: pos.top + 3
+						});
+						canvas.renderAll();
+					}
+					, 'mouseup': function () {
+						play.set({
+							left: pos.left
+							, top: pos.top
+						});
+						if (!group.status.paused && isDone()) {
+							video.getElement().currentTime = 0;
+						}
+						_sendStatus(group, !group.status.paused, video.getElement().currentTime)
+						updateControls();
+					}
+				});
+				group.on({
+					'mouseover': function() {
+						play.visible = playable;
+						cProgress.visible = playable;
+						progress.visible = playable;
+						canvas.renderAll();
+					}
+					, 'mouseout': function() {
+						play.visible = false;
+						cProgress.visible = false;
+						progress.visible = false;
+						canvas.renderAll();
+					}
+				});
+				group.setPlayable = function(_r) {
+					playable = _r !== NONE;
+				};
+				group.videoStatus = function(_status) {
+					group.status = _status;
+					updateControls();
+					video.getElement().currentTime = group.status.pos;
+					updateProgress();
+					if (group.status.paused) {
+						video.getElement().pause();
+					} else {
+						video.getElement().play();
+						fabric.util.requestAnimFrame(render);
+					}
+				}
+				group.setPlayable(_role);
+				canvas.add(group);
+				canvas.renderAll();
+				player.modify(group, _o);
+
+				let pos = {left: play.left, top: play.top};
+			});
+		});
+	};
+	player.modify = function(g, _o) {
+		let opts = $.extend({
+			angle: 0
+			, left: 10
+			, scaleX: 1
+			, scaleY: 1
+			, top: 10
+		}, _filter(_o, ['angle', 'left', 'scaleX', 'scaleY', 'top']));
+		g.set(opts);
+		g.canvas.renderAll();
+	};
+	return player;
+})();

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-apointer.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-apointer.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-apointer.js
new file mode 100644
index 0000000..c7c9d8e
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-apointer.js
@@ -0,0 +1,95 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var APointer = function(wb) {
+	let pointer = Base();
+	pointer.user = '';
+	pointer.create = function(canvas, o) {
+		fabric.Image.fromURL('./css/images/pointer.png', function(img) {
+			img.set({
+				left:15
+				, originX: 'right'
+				, originY: 'top'
+			});
+			let circle1 = new fabric.Circle({
+				radius: 20
+				, stroke: '#ff6600'
+				, strokeWidth: 2
+				, fill: 'rgba(0,0,0,0)'
+				, originX: 'center'
+				, originY: 'center'
+			});
+			let circle2 = new fabric.Circle({
+				radius: 6
+				, stroke: '#ff6600'
+				, strokeWidth: 2
+				, fill: 'rgba(0,0,0,0)'
+				, originX: 'center'
+				, originY: 'center'
+			});
+			let text = new fabric.Text(o.user, {
+				fontSize: 12
+				, left: 10
+				, originX: 'left'
+				, originY: 'bottom'
+			});
+			let group = new fabric.Group([circle1, circle2, img, text], {
+				left: o.x - 20
+				, top: o.y - 20
+			});
+
+			canvas.add(group);
+			group.uid = o.uid;
+			group.loaded = !!o.loaded;
+
+			let count = 3;
+			function go(_cnt) {
+				if (_cnt < 0) {
+					canvas.remove(group);
+					return;
+				}
+				circle1.set({radius: 3});
+				circle2.set({radius: 6});
+				circle1.animate(
+					'radius', '20'
+					, {
+						onChange: canvas.renderAll.bind(canvas)
+						, duration: 1000
+						, onComplete: function() {go(_cnt - 1);}
+					});
+				circle2.animate(
+					'radius', '20'
+					, {
+						onChange: canvas.renderAll.bind(canvas)
+						, duration: 1000
+					});
+			}
+			go(count);
+		});
+	}
+	pointer.mouseUp = function(o) {
+		let canvas = this
+			, ptr = canvas.getPointer(o.e);
+		if (pointer.user === '') {
+			pointer.user = $('.room.sidebar.left .user.list .current .name').text();
+		}
+		let obj = {
+			type: 'pointer'
+			, x: ptr.x
+			, y: ptr.y
+			, user: pointer.user
+		};
+		obj.uid = uid = pointer.objectCreated(obj, canvas);
+		pointer.create(canvas, obj);
+	}
+	pointer.activate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.selection = false;
+			canvas.on('mouse:up', pointer.mouseUp);
+		});
+	}
+	pointer.deactivate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.off('mouse:up', pointer.mouseUp);
+		});
+	};
+	return pointer;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-arrow.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-arrow.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-arrow.js
new file mode 100644
index 0000000..cec004b
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-arrow.js
@@ -0,0 +1,56 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Arrow = function(wb, s) {
+	let arrow = Line(wb, s);
+	arrow.createShape = function(canvas) {
+		arrow.obj = new fabric.Polygon([
+			{x: 0, y: 0},
+			{x: 0, y: 0},
+			{x: 0, y: 0},
+			{x: 0, y: 0},
+			{x: 0, y: 0},
+			{x: 0, y: 0},
+			{x: 0, y: 0}]
+			, {
+				left: arrow.orig.x
+				, top: arrow.orig.y
+				, angle: 0
+				, strokeWidth: 2
+				, fill: arrow.fill.enabled ? arrow.fill.color : 'rgba(0,0,0,0)'
+				, stroke: arrow.stroke.enabled ? arrow.stroke.color : 'rgba(0,0,0,0)'
+			});
+
+		return arrow.obj;
+	};
+	arrow.updateShape = function(pointer) {
+		var dx = pointer.x - arrow.orig.x
+		, dy = pointer.y - arrow.orig.y
+		, d = Math.sqrt(dx * dx + dy * dy)
+		, sw = arrow.stroke.width
+		, hl = sw * 3
+		, h = 1.5 * sw
+		, points = [
+			{x: 0, y: sw},
+			{x: Math.max(0, d - hl), y: sw},
+			{x: Math.max(0, d - hl), y: h},
+			{x: d, y: 3 * sw / 4},
+			{x: Math.max(0, d - hl), y: 0},
+			{x: Math.max(0, d - hl), y: sw / 2},
+			{x: 0, y: sw / 2}];
+		arrow.obj.set({
+			points: points
+			, angle: Math.atan2(dy, dx) * 180 / Math.PI
+			, width: d
+			, height: h
+			, maxX: d
+			, maxY: h
+			, pathOffset: {
+				x: d / 2,
+				y: h / 2
+			}
+		});
+	};
+	arrow.internalActivate = function() {
+		arrow.enableAllProps(s);
+	};
+	return arrow;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-base.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-base.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-base.js
new file mode 100644
index 0000000..dc983c5
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-base.js
@@ -0,0 +1,11 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Base = function() {
+	let base = {};
+	base.objectCreated = function(o, canvas) {
+		o.uid = UUID.generate();
+		o.slide = canvas.slide;
+		canvas.trigger("wb:object:created", o);
+		return o.uid;
+	}
+	return base;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-clipart.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-clipart.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-clipart.js
new file mode 100644
index 0000000..8949086
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-clipart.js
@@ -0,0 +1,31 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Clipart = function(wb, btn) {
+	let art = Shape(wb);
+	art.add2Canvas = function(canvas) {}
+	art.createShape = function(canvas) {
+		fabric.Image.fromURL(btn.data('image'), function(img) {
+			art.orig.width = img.width;
+			art.orig.height = img.height;
+			art.obj = img.set({
+				left: art.orig.x
+				, top: art.orig.y
+				, width: 0
+				, height: 0
+			});
+			canvas.add(art.obj);
+		});
+	}
+	art.updateShape = function(pointer) {
+		if (!art.obj) {
+			return; // not ready
+		}
+		var dx = pointer.x - art.orig.x, dy = pointer.y - art.orig.y;
+		var d = Math.sqrt(dx * dx + dy * dy);
+		art.obj.set({
+			width: d
+			, height: art.orig.height * d / art.orig.width
+			, angle: Math.atan2(dy, dx) * 180 / Math.PI
+		});
+	}
+	return art;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-ellipse.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-ellipse.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-ellipse.js
new file mode 100644
index 0000000..1f2461f
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-ellipse.js
@@ -0,0 +1,25 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Ellipse = function(wb, s) {
+	let ellipse = Rect(wb, s);
+	ellipse.createShape = function(canvas) {
+		ellipse.obj = new fabric.Ellipse({
+			strokeWidth: ellipse.stroke.width
+			, fill: ellipse.fill.enabled ? ellipse.fill.color : 'rgba(0,0,0,0)'
+			, stroke: ellipse.stroke.enabled ? ellipse.stroke.color : 'rgba(0,0,0,0)'
+			, left: ellipse.orig.x
+			, top: ellipse.orig.y
+			, rx: 0
+			, ry: 0
+			, originX: 'center'
+			, originY: 'center'
+		});
+		return ellipse.obj;
+	};
+	ellipse.updateShape = function(pointer) {
+		ellipse.obj.set({
+			rx: Math.abs(ellipse.orig.x - pointer.x)
+			, ry: Math.abs(ellipse.orig.y - pointer.y)
+		});
+	};
+	return ellipse;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-line.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-line.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-line.js
new file mode 100644
index 0000000..fa7f319
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-line.js
@@ -0,0 +1,20 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Line = function(wb, s) {
+	let line = Shape(wb);
+	line.createShape = function(canvas) {
+		line.obj = new fabric.Line([line.orig.x, line.orig.y, line.orig.x, line.orig.y], {
+			strokeWidth: line.stroke.width
+			, fill: line.stroke.color
+			, stroke: line.stroke.color
+			, opacity: line.opacity
+		});
+		return line.obj;
+	};
+	line.internalActivate = function() {
+		line.enableLineProps(s);
+	};
+	line.updateShape = function(pointer) {
+		line.obj.set({ x2: pointer.x, y2: pointer.y });
+	};
+	return line;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-paint.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-paint.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-paint.js
new file mode 100644
index 0000000..18bcb9b
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-paint.js
@@ -0,0 +1,19 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Paint = function(wb, s) {
+	let paint = ShapeBase(wb);
+	paint.activate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.isDrawingMode = true;
+			canvas.freeDrawingBrush.width = paint.stroke.width;
+			canvas.freeDrawingBrush.color = paint.stroke.color;
+			canvas.freeDrawingBrush.opacity = paint.opacity; //TODO not working
+		});
+		paint.enableLineProps(s).o.prop('disabled', true); //TODO not working
+	};
+	paint.deactivate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.isDrawingMode = false;
+		});
+	};
+	return paint;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-pointer.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-pointer.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-pointer.js
new file mode 100644
index 0000000..d69dd96
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-pointer.js
@@ -0,0 +1,25 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Pointer = function(wb, s) {
+	return {
+		activate: function() {
+			wb.eachCanvas(function(canvas) {
+				canvas.selection = true;
+				canvas.forEachObject(function(o) {
+					o.selectable = true;
+				});
+			});
+			s.find('[class^="wb-prop"]').prop('disabled', true);
+			if (!!s.find('.wb-prop-b').button("instance")) {
+				s.find('.wb-prop-b, .wb-prop-i, .wb-prop-lock-color, .wb-prop-lock-fill').button("disable");
+			}
+		}
+		, deactivate: function() {
+			wb.eachCanvas(function(canvas) {
+				canvas.selection = false;
+				canvas.forEachObject(function(o) {
+					o.selectable = false;
+				});
+			});
+		}
+	};
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-rect.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-rect.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-rect.js
new file mode 100644
index 0000000..99859ee
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-rect.js
@@ -0,0 +1,32 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Rect = function(wb, s) {
+	let rect = Shape(wb);
+	rect.createShape = function(canvas) {
+		rect.obj = new fabric.Rect({
+			strokeWidth: rect.stroke.width
+			, fill: rect.fill.enabled ? rect.fill.color : 'rgba(0,0,0,0)'
+			, stroke: rect.stroke.enabled ? rect.stroke.color : 'rgba(0,0,0,0)'
+			, left: rect.orig.x
+			, top: rect.orig.y
+			, width: 0
+			, height: 0
+		});
+		return rect.obj;
+	};
+	rect.internalActivate = function() {
+		rect.enableAllProps(s);
+	};
+	rect.updateShape = function(pointer) {
+		if (rect.orig.x > pointer.x) {
+			rect.obj.set({ left: pointer.x });
+		}
+		if (rect.orig.y > pointer.y) {
+			rect.obj.set({ top: pointer.y });
+		}
+		rect.obj.set({
+			width: Math.abs(rect.orig.x - pointer.x)
+			, height: Math.abs(rect.orig.y - pointer.y)
+		});
+	};
+	return rect;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape-base.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape-base.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape-base.js
new file mode 100644
index 0000000..30ee5ba
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape-base.js
@@ -0,0 +1,29 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var ShapeBase = function() {
+	let base = Base();
+	base.fill = {enabled: true, color: '#FFFF33'};
+	base.stroke = {enabled: true, color: '#FF6600', width: 5};
+	base.opacity = 1;
+	base.enableLineProps = function(s) {
+		var c = s.find('.wb-prop-color'), w = s.find('.wb-prop-width'), o = s.find('.wb-prop-opacity');
+		s.find('.wb-prop-fill').prop('disabled', true);
+		s.find('.wb-prop-b, .wb-prop-i, .wb-prop-lock-color, .wb-prop-lock-fill').button("disable");
+		c.val(base.stroke.color).prop('disabled', false);
+		w.val(base.stroke.width).prop('disabled', false);
+		o.val(100 * base.opacity).prop('disabled', false);
+		return {c: c, w: w, o: o};
+	};
+	base.enableAllProps = function(s) {
+		var c = s.find('.wb-prop-color'), w = s.find('.wb-prop-width')
+			, o = s.find('.wb-prop-opacity'), f = s.find('.wb-prop-fill')
+			, lc = s.find('.wb-prop-lock-color'), lf = s.find('.wb-prop-lock-fill');
+		s.find('.wb-prop-b, .wb-prop-i').button("disable");
+		lc.button("enable").button('option', 'icon', base.stroke.enabled ? 'ui-icon-unlocked' : 'ui-icon-locked');
+		lf.button("enable").button('option', 'icon', base.fill.enabled ? 'ui-icon-unlocked' : 'ui-icon-locked');
+		c.val(base.stroke.color).prop('disabled', !base.stroke.enabled);
+		w.val(base.stroke.width).prop('disabled', false);
+		o.val(100 * base.opacity).prop('disabled', false);
+		f.val(base.fill.color).prop('disabled', !base.fill.enabled);
+	};
+	return base;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape.js
new file mode 100644
index 0000000..77e6e6e
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-shape.js
@@ -0,0 +1,58 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Shape = function(wb) {
+	let shape = ShapeBase(wb);
+	shape.obj = null;
+	shape.isDown = false;
+	shape.orig = {x: 0, y: 0};
+
+	shape.add2Canvas = function(canvas) {
+		canvas.add(shape.obj);
+	}
+	shape.mouseDown = function(o) {
+		var canvas = this;
+		shape.isDown = true;
+		var pointer = canvas.getPointer(o.e);
+		shape.orig = {x: pointer.x, y: pointer.y};
+		shape.createShape(canvas);
+		shape.add2Canvas(canvas);
+	};
+	shape.mouseMove = function(o) {
+		var canvas = this;
+		if (!shape.isDown) return;
+		var pointer = canvas.getPointer(o.e);
+		shape.updateShape(pointer);
+		canvas.renderAll();
+	};
+	shape.updateCreated = function(o) {
+		return o;
+	};
+	shape.mouseUp = function(o) {
+		var canvas = this;
+		shape.isDown = false;
+		shape.obj.setCoords();
+		shape.obj.selectable = false;
+		canvas.renderAll();
+		shape.objectCreated(shape.obj, canvas);
+	};
+	shape.internalActivate = function() {};
+	shape.activate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.on({
+				'mouse:down': shape.mouseDown
+				, 'mouse:move': shape.mouseMove
+				, 'mouse:up': shape.mouseUp
+			});
+		});
+		shape.internalActivate();
+	};
+	shape.deactivate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.off({
+				'mouse:down': shape.mouseDown
+				, 'mouse:move': shape.mouseMove
+				, 'mouse:up': shape.mouseUp
+			});
+		});
+	};
+	return shape;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-text.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-text.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-text.js
new file mode 100644
index 0000000..2656a0a
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-text.js
@@ -0,0 +1,73 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Text = function(wb, s) {
+	let text = ShapeBase();
+	text.obj = null;
+	text.fill.color = '#000000';
+	text.stroke.width = 1;
+	text.stroke.color = '#000000';
+	text.style = {bold: false, italic: false};
+	//TODO font size, background color
+
+	text.mouseDown = function(o) {
+		var canvas = this;
+		var pointer = canvas.getPointer(o.e);
+		var ao = canvas.getActiveObject();
+		if (!!ao && ao.type == 'i-text') {
+			text.obj = ao;
+		} else {
+			text.obj = new fabric.IText('', {
+				left: pointer.x
+				, top: pointer.y
+				, padding: 7
+				, fill: text.fill.enabled ? text.fill.color : 'rgba(0,0,0,0)'
+				, stroke: text.stroke.enabled ? text.stroke.color : 'rgba(0,0,0,0)'
+				, strokeWidth: text.stroke.width
+				, opacity: text.opacity
+			});
+			if (text.style.bold) {
+				text.obj.fontWeight = 'bold'
+			}
+			if (text.style.italic) {
+				text.obj.fontStyle = 'italic'
+			}
+			canvas.add(text.obj).setActiveObject(text.obj);
+		}
+		text.obj.enterEditing();
+	};
+	text.activate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.on('mouse:down', text.mouseDown);
+			canvas.selection = true;
+			canvas.forEachObject(function(o) {
+				if (o.type == 'i-text') {
+					o.selectable = true;
+				}
+			});
+		});
+		text.enableAllProps(s);
+		var b = s.find('.wb-prop-b').button("enable");
+		if (text.style.bold) {
+			b.addClass('ui-state-active selected');
+		} else {
+			b.removeClass('ui-state-active selected');
+		}
+		var i = s.find('.wb-prop-i').button("enable");
+		if (text.style.italic) {
+			i.addClass('ui-state-active selected');
+		} else {
+			i.removeClass('ui-state-active selected');
+		}
+	};
+	text.deactivate = function() {
+		wb.eachCanvas(function(canvas) {
+			canvas.off('mouse:down', text.mouseDown);
+			canvas.selection = false;
+			canvas.forEachObject(function(o) {
+				if (o.type == 'i-text') {
+					o.selectable = false;
+				}
+			});
+		});
+	};
+	return text;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-uline.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-uline.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-uline.js
new file mode 100644
index 0000000..ffdc76b
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/tool-uline.js
@@ -0,0 +1,7 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var ULine = function(wb, s) {
+	let uline = Line(wb, s);
+	uline.stroke.width = 20;
+	uline.opacity = .5;
+	return uline;
+};

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/uuid.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/uuid.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/uuid.js
new file mode 100644
index 0000000..95100e2
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/uuid.js
@@ -0,0 +1,21 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+// https://stackoverflow.com/questions/105034/create-guid-uuid-in-javascript
+// author Jeff Ward
+var UUID = (function() {
+	var self = {};
+	var lut = [];
+	for (var i = 0; i < 256; i++) {
+		lut[i] = (i < 16 ? '0' : '') + (i).toString(16);
+	}
+	self.generate = function() {
+		var d0 = Math.random() * 0xffffffff | 0;
+		var d1 = Math.random() * 0xffffffff | 0;
+		var d2 = Math.random() * 0xffffffff | 0;
+		var d3 = Math.random() * 0xffffffff | 0;
+		return lut[d0 & 0xff] + lut[d0 >> 8 & 0xff] + lut[d0 >> 16 & 0xff] + lut[d0 >> 24 & 0xff] + '-' +
+			lut[d1 & 0xff] + lut[d1 >> 8 & 0xff] + '-' + lut[d1 >> 16 & 0x0f | 0x40] + lut[d1 >> 24 & 0xff] + '-' +
+			lut[d2 & 0x3f | 0x80] + lut[d2 >> 8 & 0xff] + '-' + lut[d2 >> 16 & 0xff] + lut[d2 >> 24 & 0xff] +
+			lut[d3 & 0xff] + lut[d3 >> 8 & 0xff] + lut[d3 >> 16 & 0xff] + lut[d3 >> 24 & 0xff];
+	}
+	return self;
+})();

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-all.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-all.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-all.js
new file mode 100644
index 0000000..923d7ad
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-all.js
@@ -0,0 +1,2 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var NONE = 'none';

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-area.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-area.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-area.js
new file mode 100644
index 0000000..c88e125
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-area.js
@@ -0,0 +1,302 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var PRESENTER = 'presenter';
+var WHITEBOARD = 'whiteBoard';
+var WbArea = (function() {
+	let container, area, tabs, scroll, role = NONE, self = {}, _inited = false;
+
+	function refreshTabs() {
+		tabs.tabs("refresh").find('ul').removeClass('ui-corner-all').removeClass('ui-widget-header');
+	}
+	function getActive() {
+		var idx = tabs.tabs("option", 'active');
+		if (idx > -1) {
+			var href = tabs.find('a')[idx];
+			if (!!href) {
+				return $($(href).attr('href'));
+			}
+		}
+		return null;
+	}
+	function deleteHandler(e) {
+		switch (e.which) {
+			case 8:  // backspace
+			case 46: // delete
+				{
+					var wb = getActive().data();
+					var canvas = wb.getCanvas();
+					if (!!canvas) {
+						var arr = [];
+						if (!!canvas.getActiveGroup()) {
+							canvas.getActiveGroup().forEachObject(function(o) {
+								arr.push({
+									uid: o.uid
+									, slide: o.slide
+								});
+							});
+						} else {
+							var o = canvas.getActiveObject();
+							if (!!o) {
+								arr.push({
+									uid: o.uid
+									, slide: o.slide
+								});
+							}
+						}
+						wbAction('deleteObj', JSON.stringify({
+							wbId: wb.id
+							, obj: arr
+						}));
+						return false;
+					}
+				}
+				break;
+		}
+	}
+	function _activateTab(wbId) {
+		container.find('.wb-tabbar li').each(function(idx) {
+			if (wbId == 1 * $(this).data('wb-id')) {
+				tabs.tabs("option", "active", idx);
+				$(this)[0].scrollIntoView();
+				return false;
+			}
+		});
+	}
+	function _resizeWbs() {
+		var w = area.width(), hh = area.height();
+		var wbTabs = area.find(".tabs.ui-tabs");
+		var tabPanels = wbTabs.find(".ui-tabs-panel");
+		var wbah = hh - 5 - wbTabs.find("ul.ui-tabs-nav").height();
+		tabPanels.height(wbah);
+		tabPanels.each(function(idx) {
+			$(this).data().resize(w - 25, wbah - 20);
+		});
+		wbTabs.find(".ui-tabs-panel .scroll-container").height(wbah);
+	}
+	function _addCloseBtn(li) {
+		if (role != PRESENTER) {
+			return;
+		}
+		li.append($('#wb-tab-close').clone().attr('id', ''));
+		li.find('button').click(function() {
+			wbAction('removeWb', JSON.stringify({wbId: li.data().wbId}));
+		});
+	}
+	function _getImage(cnv, fmt) {
+		//TODO zoom ???
+		return cnv.toDataURL({
+			format: fmt
+			, width: cnv.width
+			, height: cnv.height
+			, multiplier: 1. / cnv.getZoom()
+			, left: 0
+			, top: 0
+		});
+	}
+	function _videoStatus(json) {
+		self.getWb(json.wbId).videoStatus(json);
+	}
+	function _initVideos(arr) {
+		for (let i = 0; i < arr.length; ++i) {
+			 _videoStatus(arr[i]);
+		}
+	}
+
+	self.getWbTabId = function(id) {
+		return "wb-tab-" + id;
+	};
+	self.getWb = function(id) {
+		return $('#' + self.getWbTabId(id)).data();
+	};
+	self.getCanvas = function(id) {
+		return self.getWb(id).getCanvas();
+	};
+	self.setRole = function(_role) {
+		if (!_inited) return;
+		role = _role;
+		var tabsNav = tabs.find(".ui-tabs-nav");
+		tabsNav.sortable(role === PRESENTER ? "enable" : "disable");
+		var prev = tabs.find('.prev.om-icon'), next = tabs.find('.next.om-icon');
+		if (role === PRESENTER) {
+			if (prev.length == 0) {
+				var cc = tabs.find('.wb-tabbar .scroll-container')
+					, left = $('#wb-tabbar-ctrls-left').clone().attr('id', '')
+					, right = $('#wb-tabbar-ctrls-right').clone().attr('id', '');
+				cc.before(left).after(right);
+				tabs.find('.add.om-icon').click(function() {
+					wbAction('createWb');
+				});
+				tabs.find('.prev.om-icon').click(function() {
+					scroll.scrollLeft(scroll.scrollLeft() - 30);
+				});
+				tabs.find('.next.om-icon').click(function() {
+					scroll.scrollLeft(scroll.scrollLeft() + 30);
+				});
+				tabsNav.find('li').each(function(idx) {
+					var li = $(this);
+					_addCloseBtn(li);
+				});
+				$(window).keyup(deleteHandler);
+			}
+		} else {
+			if (prev.length > 0) {
+				prev.parent().remove();
+				next.parent().remove();
+				tabsNav.find('li button').remove();
+			}
+			$(window).off('keyup', deleteHandler);
+		}
+		tabs.find(".ui-tabs-panel").each(function(idx) {
+			$(this).data().setRole(role);
+		});
+	}
+	self.init = function() {
+		container = $(".room.wb.area");
+		tabs = container.find('.tabs').tabs({
+			beforeActivate: function(e, ui) {
+				var res = true;
+				if (e.originalEvent && e.originalEvent.type === 'click') {
+					res = role === PRESENTER;
+				}
+				return res;
+			}
+			, activate: function(e, ui) {
+				wbAction('activateWb', JSON.stringify({wbId: ui.newTab.data('wb-id')}));
+			}
+		});
+		scroll = tabs.find('.scroll-container');
+		area = container.find(".wb-area");
+		tabs.find(".ui-tabs-nav").sortable({
+			axis: "x"
+			, stop: function() {
+				refreshTabs();
+			}
+		});
+		_inited = true;
+		self.setRole(role);
+	};
+	self.destroy = function() {
+		$(window).off('keyup', deleteHandler);
+	};
+	self.create = function(obj) {
+		if (!_inited) return;
+		var tid = self.getWbTabId(obj.wbId)
+			, li = $('#wb-area-tab').clone().attr('id', '').data('wb-id', obj.wbId)
+			, wb = $('#wb-area').clone().attr('id', tid);
+		li.find('a').text(obj.name).attr('title', obj.name).attr('href', "#" + tid);
+
+		tabs.find(".ui-tabs-nav").append(li);
+		tabs.append(wb);
+		refreshTabs();
+		_addCloseBtn(li);
+
+		var wbo = Wb();
+		wbo.init(obj, tid, role);
+		wb.data(wbo);
+		_resizeWbs();
+	}
+	self.createWb = function(obj) {
+		if (!_inited) return;
+		self.create(obj);
+		self.setRole(role);
+		_activateTab(obj.wbId);
+	};
+	self.activateWb = function(obj) {
+		if (!_inited) return;
+		_activateTab(obj.wbId);
+	}
+	self.load = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).load(json.obj);
+	};
+	self.setSlide = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).setSlide(json.slide);
+	};
+	self.createObj = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).createObj(json.obj);
+	};
+	self.modifyObj = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).modifyObj(json.obj);
+	};
+	self.deleteObj = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).removeObj(json.obj);
+	};
+	self.clearAll = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).clearAll();
+		Room.setSize();
+	};
+	self.clearSlide = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).clearSlide(json.slide);
+	};
+	self.removeWb = function(obj) {
+		if (!_inited) return;
+		var tabId = self.getWbTabId(obj.wbId);
+		tabs.find('li[aria-controls="' + tabId + '"]').remove();
+		$("#" + tabId).remove();
+		refreshTabs();
+	};
+	self.resize = function(posX, w, h) {
+		if (!container || !_inited) return;
+		var hh = h - 5;
+		container.width(w).height(h).css('left', posX + "px");
+		area.width(w).height(hh);
+
+		var wbTabs = area.find(".tabs.ui-tabs");
+		wbTabs.height(hh);
+		_resizeWbs();
+	}
+	self.setSize = function(json) {
+		if (!_inited) return;
+		self.getWb(json.wbId).setSize(json);
+	}
+	self.download = function(fmt) {
+		var wb = getActive().data();
+		if ('pdf' === fmt) {
+			var arr = [];
+			wb.eachCanvas(function(cnv) {
+				arr.push(_getImage(cnv, 'image/png'));
+			});
+			wbAction('downloadPdf', JSON.stringify({
+				slides: arr
+			}));
+		} else {
+			var cnv = wb.getCanvas()
+				, a = document.createElement('a');
+			a.setAttribute('target', '_blank')
+			a.setAttribute('download', wb.name + '.' + fmt);
+			a.setAttribute('href', _getImage(cnv, fmt));
+			a.dispatchEvent(new MouseEvent('click', {view: window, bubbles: false, cancelable: true}));
+		}
+	}
+	self.videoStatus = _videoStatus;
+	self.loadVideos = function() { wbAction('loadVideos'); };
+	self.initVideos = _initVideos;
+	return self;
+})();
+$(function() {
+	Wicket.Event.subscribe("/websocket/message", function(jqEvent, msg) {
+		try {
+			if (msg instanceof Blob) {
+				return; //ping
+			}
+			var m = jQuery.parseJSON(msg);
+			if (m) {
+				switch(m.type) {
+					case "wb":
+						if (typeof WbArea !== 'undefined') {
+							eval(m.func);
+						}
+						break;
+				}
+			}
+		} catch (err) {
+			console.log(err);
+			//no-op
+		}
+	});
+});

http://git-wip-us.apache.org/repos/asf/openmeetings/blob/965cba71/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-board.js
----------------------------------------------------------------------
diff --git a/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-board.js b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-board.js
new file mode 100644
index 0000000..79d0338
--- /dev/null
+++ b/openmeetings-web/src/main/java/org/apache/openmeetings/web/room/wb/wb-board.js
@@ -0,0 +1,735 @@
+/* Licensed under the Apache License, Version 2.0 (the "License") http://www.apache.org/licenses/LICENSE-2.0 */
+var Wb = function() {
+	const ACTIVE = 'active';
+	const BUMPER = 100;
+	let wb = {id: -1, name: ''}, a, t, z, s, canvases = [], mode, slide = 0, width = 0, height = 0
+			, zoom = 1., zoomMode = 'fullFit', role = null, extraProps = ['uid', 'fileId', 'fileType', 'count', 'slide'];
+
+	function getBtn(m) {
+		return !!t ? t.find(".om-icon." + (m || mode)) : null;
+	}
+	function initToolBtn(m, def, obj) {
+		var btn = getBtn(m);
+		btn.data({
+			obj: obj
+			, activate: function() {
+				if (!btn.hasClass(ACTIVE)) {
+					mode = m;
+					btn.addClass(ACTIVE);
+					obj.activate();
+				}
+			}
+			, deactivate: function() {
+				btn.removeClass(ACTIVE);
+				obj.deactivate();
+			}
+		}).click(function() {
+			var b = getBtn();
+			if (b.length && b.hasClass(ACTIVE)) {
+				b.data().deactivate();
+			}
+			btn.data().activate();
+		});
+		if (def) {
+			btn.data().activate();
+		}
+	}
+	function initCliparts() {
+		var c = $('#wb-area-cliparts').clone().attr('id', '');
+		getBtn('arrow').after(c);
+		c.find('a').prepend(c.find('div.om-icon.big:first'));
+		c.find('.om-icon.clipart').each(function(idx) {
+			var cur = $(this);
+			cur.css('background-image', 'url(' + cur.data('image') + ')')
+				.click(function() {
+					var old = c.find('a .om-icon.clipart');
+					c.find('ul li').prepend(old);
+					c.find('a').prepend(cur);
+				});
+			initToolBtn(cur.data('mode'), false, Clipart(wb, cur));
+		});
+	}
+	function confirmDlg(_id, okHandler) {
+		var confirm = $('#' + _id);
+		confirm.dialog({
+			modal: true
+			, buttons: [
+				{
+					text: confirm.data('btn-ok')
+					, click: function() {
+						okHandler();
+						$(this).dialog("close");
+					}
+				}
+				, {
+					text: confirm.data('btn-cancel')
+					, click: function() {
+						$(this).dialog("close");
+					}
+				}
+			]
+		});
+		return confirm;
+	}
+	function _updateZoomPanel() {
+		var ccount = canvases.length;
+		if (ccount > 1 && role === PRESENTER) {
+			z.find('.doc-group').show();
+			var ns = 1 * slide;
+			z.find('.doc-group .curr-slide').val(ns + 1).attr('max', ccount);
+			z.find('.doc-group .up').prop('disabled', ns < 1);
+			z.find('.doc-group .down').prop('disabled', ns > ccount - 2);
+			z.find('.doc-group .last-page').text(ccount);
+		} else {
+			z.find('.doc-group').hide();
+		}
+	}
+	function _setSlide(_sld) {
+		slide = _sld;
+		wbAction('setSlide', JSON.stringify({
+			wbId: wb.id
+			, slide: _sld
+		}));
+		_updateZoomPanel();
+	}
+	function internalInit() {
+		t.draggable({
+			snap: "parent"
+			, containment: "parent"
+			, scroll: false
+			, stop: function(event, ui) {
+				var pos = ui.helper.position();
+				if (pos.left == 0 || pos.left + ui.helper.width() == ui.helper.parent().width()) {
+					ui.helper.removeClass('horisontal').addClass('vertical');
+				} else if (pos.top == 0 || pos.top + ui.helper.height() == ui.helper.parent().height()) {
+					ui.helper.removeClass('vertical').addClass('horisontal');
+				}
+			}
+		});
+		z.draggable({
+			snap: "parent"
+			, containment: "parent"
+			, scroll: false
+		});
+		var _firstToolItem = true;
+		var clearAll = t.find('.om-icon.clear-all');
+		switch (role) {
+			case PRESENTER:
+				clearAll.click(function() {
+					confirmDlg('clear-all-confirm', function() { wbAction('clearAll', JSON.stringify({wbId: wb.id})); });
+				}).removeClass('disabled');
+				z.find('.curr-slide').change(function() {
+					_setSlide($(this).val() - 1);
+					showCurrentSlide();
+				});
+				z.find('.doc-group .up').click(function () {
+					_setSlide(1 * slide - 1);
+					showCurrentSlide();
+				});
+				z.find('.doc-group .down').click(function () {
+					_setSlide(1 * slide + 1);
+					showCurrentSlide();
+				});
+			case WHITEBOARD:
+				if (role === WHITEBOARD) {
+					clearAll.addClass('disabled');
+				}
+				initToolBtn('pointer', _firstToolItem, Pointer(wb, s));
+				_firstToolItem = false;
+				initToolBtn('text', _firstToolItem, Text(wb, s));
+				initToolBtn('paint', _firstToolItem, Paint(wb, s));
+				initToolBtn('line', _firstToolItem, Line(wb, s));
+				initToolBtn('uline', _firstToolItem, ULine(wb, s));
+				initToolBtn('rect', _firstToolItem, Rect(wb, s));
+				initToolBtn('ellipse', _firstToolItem, Ellipse(wb, s));
+				initToolBtn('arrow', _firstToolItem, Arrow(wb, s));
+				initCliparts();
+				t.find(".om-icon.settings").click(function() {
+					s.show();
+				});
+				t.find('.om-icon.clear-slide').click(function() {
+					confirmDlg('clear-slide-confirm', function() { wbAction('clearSlide', JSON.stringify({wbId: wb.id, slide: slide})); });
+				});
+				t.find('.om-icon.save').click(function() {
+					wbAction('save', JSON.stringify({wbId: wb.id}));
+				});
+				t.find('.om-icon.undo').click(function() {
+					wbAction('undo', JSON.stringify({wbId: wb.id}));
+				});
+				s.find('.wb-prop-b, .wb-prop-i')
+					.button()
+					.click(function() {
+						$(this).toggleClass('ui-state-active selected');
+						var btn = getBtn();
+						var isB = $(this).hasClass('wb-prop-b');
+						btn.data().obj.style[isB ? 'bold' : 'italic'] = $(this).hasClass('selected');
+					});
+				s.find('.wb-prop-lock-color, .wb-prop-lock-fill')
+					.button({icon: 'ui-icon-locked', showLabel: false})
+					.click(function() {
+						var btn = getBtn();
+						var isColor = $(this).hasClass('wb-prop-lock-color');
+						var c = s.find(isColor ? '.wb-prop-color' : '.wb-prop-fill');
+						var enabled = $(this).button('option', 'icon') == 'ui-icon-locked';
+						$(this).button('option', 'icon', enabled ? 'ui-icon-unlocked' : 'ui-icon-locked');
+						c.prop('disabled', !enabled);
+						btn.data().obj[isColor ? 'stroke' : 'fill'].enabled = enabled;
+					});
+				s.find('.wb-prop-color').change(function() {
+					var btn = getBtn();
+					if (btn.length == 1) {
+						var v = $(this).val();
+						btn.data().obj.stroke.color = v;
+						if ('paint' == mode) {
+							wb.eachCanvas(function(canvas) {
+								canvas.freeDrawingBrush.color = v;
+							});
+						}
+					}
+				});
+				s.find('.wb-prop-width').change(function() {
+					var btn = getBtn();
+					if (btn.length == 1) {
+						var v = 1 * $(this).val();
+						btn.data().obj.stroke.width = v;
+						if ('paint' == mode) {
+							wb.eachCanvas(function(canvas) {
+								canvas.freeDrawingBrush.width = v;
+							});
+						}
+					}
+				});
+				s.find('.wb-prop-fill').change(function() {
+					var btn = getBtn();
+					if (btn.length == 1) {
+						var v = $(this).val();
+						btn.data().obj.fill.color = v;
+					}
+				});
+				s.find('.wb-prop-opacity').change(function() {
+					var btn = getBtn();
+					if (btn.length == 1) {
+						var v = (1 * $(this).val()) / 100;
+						btn.data().obj.opacity = v;
+						if ('paint' == mode) {
+							wb.eachCanvas(function(canvas) {
+								canvas.freeDrawingBrush.opacity = v;
+							});
+						}
+					}
+				});
+				s.find('.ui-dialog-titlebar-close').click(function() {
+					s.hide();
+				});
+				s.draggable({
+					scroll: false
+					, containment: "body"
+					, start: function(event, ui) {
+						if (!!s.css("bottom")) {
+							s.css("bottom", "").css("right", "");
+						}
+					}
+					, drag: function(event, ui) {
+						if (s.position().x + s.width() >= s.parent().width()) {
+							return false;
+						}
+					}
+				});
+			case NONE:
+				_updateZoomPanel();
+				z.find('.zoom-out').click(function() {
+					zoom -= .2;
+					if (zoom < .1) {
+						zoom = .1;
+					}
+					zoomMode = 'zoom';
+					_setSize();
+					wbAction('setSize', JSON.stringify({
+						wbId: wb.id
+						, zoom: zoom
+						, zoomMode: zoomMode
+					}));
+				});
+				z.find('.zoom-in').click(function() {
+					zoom += .2;
+					zoomMode = 'zoom';
+					_setSize();
+					wbAction('setSize', JSON.stringify({
+						wbId: wb.id
+						, zoom: zoom
+						, zoomMode: zoomMode
+					}));
+				});
+				z.find('.zoom').change(function() {
+					var zzz = $(this).val();
+					zoomMode = 'zoom';
+					if (isNaN(zzz)) {
+						switch (zzz) {
+							case 'fullFit':
+							case 'pageWidth':
+								zoomMode = zzz;
+								break;
+							case 'custom':
+								zoom = 1. * $(this).data('custom-val');
+								break;
+						}
+					} else {
+						zoom = 1. * zzz;
+					}
+					_setSize();
+					wbAction('setSize', JSON.stringify({
+						wbId: wb.id
+						, zoom: zoom
+						, zoomMode: zoomMode
+					}));
+				});
+				_setSize();
+				initToolBtn('apointer', _firstToolItem, APointer(wb));
+		}
+	}
+	function _findObject(o) {
+		var _o = null;
+		canvases[o.slide].forEachObject(function(__o) {
+			if (!!__o && o.uid === __o.uid) {
+				_o = __o;
+				return false;
+			}
+		});
+		return _o;
+	}
+	function _removeHandler(o) {
+		var __o = _findObject(o);
+		if (!!__o) {
+			var cnvs = canvases[o.slide];
+			if (!!cnvs) {
+				cnvs.discardActiveGroup();
+				if ('Video' === __o.omType) {
+					$('#wb-video-' + __o.uid).remove();
+				}
+				cnvs.remove(__o);
+			}
+		}
+	}
+	function _modifyHandler(_o) {
+		_removeHandler(_o);
+		_createHandler(_o);
+	}
+	function _createHandler(_o) {
+		switch (_o.fileType) {
+			case 'Video':
+			case 'Recording':
+				//no-op
+				break;
+			case 'Presentation':
+			{
+				let ccount = canvases.length;
+				let count = _o.deleted ? 1 : _o.count;
+				for (let i = 0; i < count; ++i) {
+					if (canvases.length < i + 1) {
+						addCanvas();
+					}
+					let canvas = canvases[i];
+					canvas.setBackgroundImage(_o._src + "&slide=" + i, canvas.renderAll.bind(canvas), {});
+				}
+				_updateZoomPanel();
+				if (ccount != canvases.length) {
+					let b = getBtn();
+					if (b.length && b.hasClass(ACTIVE)) {
+						b.data().deactivate();
+						b.data().activate();
+					}
+				}
+			}
+				break;
+			default:
+			{
+				let canvas = canvases[_o.slide];
+				if (!!canvas) {
+					_o.selectable = canvas.selection;
+					canvas.add(_o);
+				}
+			}
+				break;
+		}
+	}
+	function _createObject(arr, handler) {
+		fabric.util.enlivenObjects(arr, function(objects) {
+			wb.eachCanvas(function(canvas) {
+				canvas.renderOnAddRemove = false;
+			});
+
+			for (var i = 0; i < objects.length; ++i) {
+				var _o = objects[i];
+				_o.loaded = true;
+				handler(_o);
+			}
+
+			wb.eachCanvas(function(canvas) {
+				canvas.renderOnAddRemove = true;
+				canvas.renderAll();
+			});
+		});
+	};
+
+	function toOmJson(o) {
+		let r = o.toJSON(extraProps);
+		if (o.omType === 'Video') {
+			r.type = 'video';
+			delete r.objects;
+			return r;
+		}
+		return r;
+	}
+	//events
+	function wbObjCreatedHandler(o) {
+		if (role === NONE && o.type != 'pointer') return;
+
+		var json = {};
+		switch(o.type) {
+			case 'pointer':
+				json = o;
+				break;
+			default:
+				o.includeDefaultValues = false;
+				json = toOmJson(o);
+				break;
+		}
+		wbAction('createObj', JSON.stringify({
+			wbId: wb.id
+			, obj: json
+		}));
+	};
+	function objAddedHandler(e) {
+		var o = e.target;
+		if (!!o.loaded) return;
+		switch(o.type) {
+			case 'i-text':
+				o.uid = UUID.generate();
+				o.slide = this.slide;
+				wbObjCreatedHandler(o);
+				break;
+			default:
+				o.selectable = this.selection;
+				break;
+		}
+	};
+	function objModifiedHandler(e) {
+		var o = e.target;
+		if (role === NONE && o.type != 'pointer') return;
+
+		o.includeDefaultValues = false;
+		var items = [];
+		if ("group" === o.type && o.omType !== 'Video') {
+			o.clone(function(_o) {
+				// ungrouping
+				_o.includeDefaultValues = false;
+				var _items = _o.destroy().getObjects();
+				for (var i = 0; i < _items.length; ++i) {
+					items.push(toOmJson(_items[i]));
+				}
+			}, extraProps);
+		} else {
+			items.push(toOmJson(o));
+		}
+		wbAction('modifyObj', JSON.stringify({
+			wbId: wb.id
+			, obj: items
+		}));
+	};
+	function objSelectedHandler(e) {
+		var o = e.target;
+		s.find('.wb-dim-x').val(o.left);
+		s.find('.wb-dim-y').val(o.top);
+		s.find('.wb-dim-w').val(o.width);
+		s.find('.wb-dim-h').val(o.height);
+	}
+	function pathCreatedHandler(o) {
+		o.path.uid = UUID.generate();
+		o.path.slide = this.slide;
+		wbObjCreatedHandler(o.path);
+	};
+	function scrollHandler(e) {
+		$(this).find('.canvas-container').each(function(idx) {
+			var h = $(this).height(), pos = $(this).position();
+			if (slide != idx && pos.top > BUMPER - h && pos.top < BUMPER) {
+				//TODO FIXME might be done without iterating
+				//console.log("Found:", idx);
+				_setSlide(idx);
+				return false;
+			}
+		});
+	}
+	function showCurrentSlide() {
+		a.find('.scroll-container .canvas-container').each(function(idx) {
+			if (role === PRESENTER) {
+				$(this).show();
+				a.find('.scroll-container .canvas-container')[slide].scrollIntoView();
+			} else {
+				if (idx == slide) {
+					$(this).show();
+				} else {
+					$(this).hide();
+				}
+			}
+		});
+	}
+	/*TODO interactive text change
+	var textEditedHandler = function (e) {
+		var obj = e.target;
+		console.log('Text Edit Exit', obj);
+	};
+	var textChangedHandler = function (e) {
+		var obj = e.target;
+		console.log('Text Changed', obj);
+	};*/
+	function setHandlers(canvas) {
+		// off everything first to prevent duplicates
+		canvas.off({
+			'wb:object:created': wbObjCreatedHandler
+			, 'object:modified': objModifiedHandler
+			, 'object:added': objAddedHandler
+			, 'object:selected': objSelectedHandler
+			, 'path:created': pathCreatedHandler
+			//, 'text:editing:exited': textEditedHandler
+			//, 'text:changed': textChangedHandler
+		});
+		canvas.on({
+			'wb:object:created': wbObjCreatedHandler
+			, 'object:modified': objModifiedHandler
+		});
+		if (role !== NONE) {
+			canvas.on({
+				'object:added': objAddedHandler
+				, 'object:selected': objSelectedHandler
+				, 'path:created': pathCreatedHandler
+				//, 'text:editing:exited': textEditedHandler
+				//, 'text:changed': textChangedHandler
+			});
+		}
+	}
+	function addCanvas() {
+		var sl = canvases.length;
+		var cid = 'can-' + a.attr('id') + '-slide-' + sl;
+		var c = $('<canvas></canvas>').attr('id', cid);
+		a.find('.canvases').append(c);
+		var canvas = new fabric.Canvas(c.attr('id'), {
+			preserveObjectStacking: true
+		});
+		canvas.wbId = wb.id;
+		canvas.slide = sl;
+		canvases.push(canvas);
+		var cc = $('#' + cid).closest('.canvas-container');
+		if (role === NONE) {
+			if (sl == slide) {
+				cc.show();
+			} else {
+				cc.hide();
+			}
+		}
+		__setSize(canvas);
+		setHandlers(canvas);
+	}
+	function __setSize(_cnv) {
+		_cnv.setWidth(zoom * width).setHeight(zoom * height).setZoom(zoom);
+	}
+	function _setSize() {
+		switch (zoomMode) {
+			case 'fullFit':
+				zoom = Math.min((a.width() - 10) / width, (a.height() - 10) / height);
+				z.find('.zoom').val(zoomMode);
+				break;
+			case 'pageWidth':
+				zoom = (a.width() - 10) / width;
+				z.find('.zoom').val(zoomMode);
+				break;
+			default:
+			{
+				var oo = z.find('.zoom').find('option[value="' + zoom.toFixed(2) + '"]');
+				if (oo.length == 1) {
+					oo.prop('selected', true);
+				} else {
+					z.find('.zoom').data('custom-val', zoom).find('option[value=custom]')
+						.text((100. * zoom).toFixed(0) + '%')
+						.prop('selected', true);
+				}
+			}
+				break;
+		}
+		wb.eachCanvas(function(canvas) {
+			__setSize(canvas);
+		});
+	}
+	function _videoStatus(json) {
+		let g = _findObject(json);
+		if (!!g) {
+			g.videoStatus(json.status);
+		}
+	}
+	wb.setRole = function(_role) {
+		if (role != _role) {
+			var btn = getBtn();
+			if (!!btn && btn.length == 1) {
+				btn.data().deactivate();
+			}
+			a.find('.tools').remove();
+			a.find('.wb-settings').remove();
+			a.find('.wb-zoom').remove();
+			role = _role;
+			var sc = a.find('.scroll-container');
+			z = $('#wb-zoom').clone().attr('id', '');
+			if (role === NONE) {
+				t = $('#wb-tools-readonly').clone().attr('id', '');
+				sc.off('scroll', scrollHandler);
+			} else {
+				t = $('#wb-tools').clone().attr('id', '');
+				s = $("#wb-settings").clone().attr('id', '');
+				a.append(s);
+				sc.on('scroll', scrollHandler);
+			}
+			a.append(t).append(z);
+			showCurrentSlide();
+			t = a.find('.tools'), s = a.find(".wb-settings");
+			wb.eachCanvas(function(canvas) {
+				setHandlers(canvas);
+				canvas.forEachObject(function(__o) { //TODO reduce iterations
+					if (!!__o && __o.omType === 'Video') {
+						__o.setPlayable(role);
+					}
+				});
+			});
+			internalInit();
+		}
+	};
+	wb.init = function(wbo, tid, _role) {
+		wb.id = wbo.wbId;
+		wb.name = wbo.name;
+		width = wbo.width;
+		height = wbo.height;
+		zoom = wbo.zoom;
+		zoomMode = wbo.zoomMode;
+		a = $('#' + tid);
+		addCanvas();
+		wb.setRole(_role);
+	};
+	wb.setSize = function(wbo) {
+		width = wbo.width;
+		height = wbo.height;
+		zoom = wbo.zoom;
+		zoomMode = wbo.zoomMode;
+		_setSize();
+	}
+	wb.resize = function(w, h) {
+		if (t.position().left + t.width() > a.width()) {
+			t.position({
+				my: "right"
+				, at: "right-20"
+				, of: '#' + a[0].id
+				, collision: "fit"
+			});
+		}
+		if (z.position().left + z.width() > a.width()) {
+			z.position({
+				my: "left top"
+				, at: "center top"
+				, of: '#' + a[0].id
+				, collision: "fit"
+			});
+		}
+		if (zoomMode !== 'zoom') {
+			_setSize();
+		}
+	};
+	wb.setSlide = function(_sl) {
+		slide = _sl;
+		showCurrentSlide();
+	};
+	wb.createObj = function(obj) {
+		let arr = [], _arr = Array.isArray(obj) ? obj : [obj];
+		for (let i = 0; i < _arr.length; ++i) {
+			let o = _arr[i];
+			switch(o.type) {
+				case 'pointer':
+					APointer().create(canvases[o.slide], o);
+					break;
+				case 'video':
+					Player.create(canvases[o.slide], o, role);
+					break;
+				default:
+					var __o = _findObject(o);
+					if (!__o) {
+						arr.push(o);
+					}
+					break;
+			}
+		}
+		if (arr.length > 0) {
+			_createObject(arr, _createHandler);
+		}
+	};
+	wb.load = wb.createObj;
+	wb.modifyObj = function(obj) { //TODO need to be unified
+		let arr = [], _arr = Array.isArray(obj) ? obj : [obj];
+		for (let i = 0; i < _arr.length; ++i) {
+			let o = _arr[i];
+			switch(o.type) {
+				case 'pointer':
+					_modifyHandler(APointer().create(canvases[o.slide], o))
+					break;
+				case 'video':
+				{
+					let g = _findObject(o);
+					if (!!g) {
+						Player.modify(g, o);
+					}
+				}
+					break;
+				default:
+					arr.push(o);
+					break;
+			}
+		}
+		if (arr.length > 0) {
+			_createObject(arr, _modifyHandler);
+		}
+	};
+	wb.removeObj = function(arr) {
+		for (var i = 0; i < arr.length; ++i) {
+			_removeHandler(arr[i]);
+		}
+	};
+	wb.clearAll = function() {
+		for (var i = 1; i < canvases.length; ++i) {
+			let cc = $('#can-wb-tab-0-slide-' + i).closest('.canvas-container');
+			cc.remove();
+			canvases[i].dispose();
+		}
+		$('.room.wb.area .wb-video').remove();
+		canvases.splice(1);
+		canvases[0].clear();
+		_updateZoomPanel();
+	};
+	wb.clearSlide = function(_sl) {
+		if (canvases.length > _sl) {
+			let canvas = canvases[_sl];
+			canvas.renderOnAddRemove = false;
+			let arr = canvas.getObjects();
+			while (arr.length > 0) {
+				canvas.remove(arr[arr.length - 1]);
+				arr = canvas.getObjects();
+			}
+			$('.room.wb.area .wb-video.slide-' + _sl).remove();
+			canvas.renderOnAddRemove = true;
+			canvas.renderAll();
+		}
+	};
+	wb.getCanvas = function() {
+		return canvases[slide];
+	};
+	wb.eachCanvas = function(func) {
+		for (var i = 0; i < canvases.length; ++i) {
+			func(canvases[i]);
+		}
+	}
+	wb.videoStatus = _videoStatus;
+	return wb;
+};