diff --git a/client-data/js/board.js b/client-data/js/board.js index ebb0d03d..bde36010 100644 --- a/client-data/js/board.js +++ b/client-data/js/board.js @@ -363,9 +363,9 @@ function messageForTool(message) { else Tools.pendingMessages[name].push(message); } - if (message.tool !== 'Hand' && message.deltax != null && message.deltay != null) { + if (message.tool !== 'Hand' && message.transform != null) { //this message has special info for the mover - messageForTool({ tool: 'Hand', type: 'update', deltax: message.deltax || 0, deltay: message.deltay || 0, id: message.id }); + messageForTool({ tool: 'Hand', type: 'update', transform: message.transform, id: message.id}); } } @@ -685,8 +685,8 @@ Tools.svg.height.baseVal.value = document.body.clientHeight; (function () { - let pos = {top: 0, scroll:0}; - let menu = document.getElementById("menu"); + var pos = {top: 0, scroll:0}; + var menu = document.getElementById("menu"); function menu_mousedown(evt) { pos = { top: menu.scrollTop, @@ -696,7 +696,7 @@ Tools.svg.height.baseVal.value = document.body.clientHeight; document.addEventListener("mouseup", menu_mouseup); } function menu_mousemove(evt) { - const dy = evt.clientY - pos.scroll; + var dy = evt.clientY - pos.scroll; menu.scrollTop = pos.top - dy; } function menu_mouseup(evt) { diff --git a/client-data/js/intersect.js b/client-data/js/intersect.js index 3cd09b8c..13c37a37 100644 --- a/client-data/js/intersect.js +++ b/client-data/js/intersect.js @@ -28,20 +28,48 @@ if (!SVGGraphicsElement.prototype.transformedBBox || !SVGGraphicsElement.prototy [pointInTransformedBBox, transformedBBoxIntersects] = (function () { - let applyTransform = function (m,t) { + var get_transform_matrix = function (elem) { + // Returns the first translate or transform matrix or makes one + var transform = null; + for (var i = 0; i < elem.transform.baseVal.numberOfItems; ++i) { + var baseVal = elem.transform.baseVal[i]; + // quick tests showed that even if one changes only the fields e and f or uses createSVGTransformFromMatrix + // the brower may add a SVG_TRANSFORM_MATRIX instead of a SVG_TRANSFORM_TRANSLATE + if (baseVal.type === SVGTransform.SVG_TRANSFORM_MATRIX) { + transform = baseVal; + break; + } + } + if (transform == null) { + transform = elem.transform.baseVal.createSVGTransformFromMatrix(Tools.svg.createSVGMatrix()); + elem.transform.baseVal.appendItem(transform); + } + return transform.matrix; + } + + var transformRelative = function (m,t) { return [ m.a*t[0]+m.c*t[1], m.b*t[0]+m.d*t[1] ] } + var transformAbsolute = function (m,t) { + return [ + m.a*t[0]+m.c*t[1]+m.e, + m.b*t[0]+m.d*t[1]+m.f + ] + } + SVGGraphicsElement.prototype.transformedBBox = function (scale=1) { bbox = this.getBBox(); - tmatrix = this.getCTM(); + tmatrix = get_transform_matrix(this); + tmatrix.e /= scale; + tmatrix.f /= scale; return { - r: [bbox.x + tmatrix.e/scale, bbox.y + tmatrix.f/scale], - a: applyTransform(tmatrix,[bbox.width/scale,0]), - b: applyTransform(tmatrix,[0,bbox.height/scale]) + r: transformAbsolute(tmatrix,[bbox.x/scale,bbox.y/scale]), + a: transformRelative(tmatrix,[bbox.width/scale,0]), + b: transformRelative(tmatrix,[0,bbox.height/scale]) } } @@ -52,15 +80,17 @@ if (!SVGGraphicsElement.prototype.transformedBBox || !SVGGraphicsElement.prototy width: this.width.baseVal.value, height: this.height.baseVal.value }; - tmatrix = this.getCTM(); + tmatrix = get_transform_matrix(this); + tmatrix.e /= scale; + tmatrix.f /= scale; return { - r: [bbox.x + tmatrix.e/scale, bbox.y + tmatrix.f/scale], - a: applyTransform(tmatrix,[bbox.width/scale,0]), - b: applyTransform(tmatrix,[0,bbox.height/scale]) + r: transformAbsolute(tmatrix,[bbox.x/scale,bbox.y/scale]), + a: transformRelative(tmatrix,[bbox.width/scale,0]), + b: transformRelative(tmatrix,[0,bbox.height/scale]) } } - let pointInTransformedBBox = function ([x,y],{r,a,b}) { + var pointInTransformedBBox = function ([x,y],{r,a,b}) { var d = [x-r[0],y-r[1]]; var idet = (a[0]*b[1]-a[1]*b[0]); var c1 = (d[0]*b[1]-d[1]*b[0]) / idet; @@ -79,7 +109,9 @@ if (!SVGGraphicsElement.prototype.transformedBBox || !SVGGraphicsElement.prototy [bbox_b.r[0] + bbox_b.b[0], bbox_b.r[1] + bbox_b.b[1]], [bbox_b.r[0] + bbox_b.a[0] + bbox_b.b[0], bbox_b.r[1] + bbox_b.a[1] + bbox_b.b[1]] ] - return corners.every(corner=>pointInTransformedBBox(corner,bbox_a)) + return corners.every(function(corner) { + return pointInTransformedBBox(corner, bbox_a); + }) } SVGGraphicsElement.prototype.transformedBBoxIntersects= function (bbox) { diff --git a/client-data/tools/hand/delete.svg b/client-data/tools/hand/delete.svg new file mode 100644 index 00000000..5b90f8ff --- /dev/null +++ b/client-data/tools/hand/delete.svg @@ -0,0 +1,5 @@ + + + Delete + + diff --git a/client-data/tools/hand/duplicate.svg b/client-data/tools/hand/duplicate.svg new file mode 100644 index 00000000..5285eab4 --- /dev/null +++ b/client-data/tools/hand/duplicate.svg @@ -0,0 +1,8 @@ + + + Duplicate + + + + + diff --git a/client-data/tools/hand/hand.js b/client-data/tools/hand/hand.js index 8f95db67..8f5c5425 100644 --- a/client-data/tools/hand/hand.js +++ b/client-data/tools/hand/hand.js @@ -25,32 +25,108 @@ */ (function hand() { //Code isolation - const selectorStates = { + var selectorStates = { pointing: 0, selecting: 1, - moving: 2 + transform: 2 } var selected = null; var selected_els = []; var selectionRect = createSelectorRect(); - var selectionRectTranslation; - var translation_elements = []; + var selectionRectTransform; + var currentTransform = null; + var transform_elements = []; var selectorState = selectorStates.pointing; var last_sent = 0; + var blockedSelectionButtons = Tools.server_config.BLOCKED_SELECTION_BUTTONS; + var selectionButtons = [ + createButton("delete", "delete", 24, 24, + function (me, bbox, s) { + me.width.baseVal.value = me.origWidth / s; + me.height.baseVal.value = me.origHeight / s; + me.x.baseVal.value = bbox.r[0]; + me.y.baseVal.value = bbox.r[1] - (me.origHeight + 3) / s; + me.style.display = ""; + }, + deleteSelection), + + createButton("duplicate", "duplicate", 24, 24, + function (me, bbox, s) { + me.width.baseVal.value = me.origWidth / s; + me.height.baseVal.value = me.origHeight / s; + me.x.baseVal.value = bbox.r[0] + (me.origWidth + 2) / s; + me.y.baseVal.value = bbox.r[1] - (me.origHeight + 3) / s; + me.style.display = ""; + }, + duplicateSelection), + + createButton("scaleHandle", "handle", 14, 14, + function (me, bbox, s) { + me.width.baseVal.value = me.origWidth / s; + me.height.baseVal.value = me.origHeight / s; + me.x.baseVal.value = bbox.r[0] + bbox.a[0] - me.origWidth / (2 * s); + me.y.baseVal.value = bbox.r[1] + bbox.b[1] - me.origHeight / (2 * s); + me.style.display = ""; + }, + startScalingTransform) + ]; + + for (i in blockedSelectionButtons) { + delete selectionButtons[blockedSelectionButtons[i]]; + } + + var getScale = Tools.getScale; function getParentMathematics(el) { - var target - var a = el + var target; + var a = el; var els = []; while (a) { els.unshift(a); a = a.parentElement; } - var parentMathematics = els.find(el => el.getAttribute("class") === "MathElement"); + var parentMathematics = els.find(function (el) { + return el.getAttribute("class") === "MathElement"; + }); if ((parentMathematics) && parentMathematics.tagName === "svg") { target = parentMathematics; } - return target ?? el; + return target || el; + } + + function deleteSelection() { + var msgs = selected_els.map(function (el) { + return ({ + "type": "delete", + "id": el.id + }); + }); + var data = { + _children: msgs + } + Tools.drawAndSend(data); + selected_els = []; + hideSelectionUI(); + } + + function duplicateSelection() { + if (!(selectorState == selectorStates.pointing) + || (selected_els.length == 0)) return; + var msgs = []; + var newids = []; + for (var i = 0; i < selected_els.length; i++) { + var id = selected_els[i].id; + msgs[i] = { + type: "copy", + id: id, + newid: Tools.generateUID(id[0]) + }; + newids[i] = id; + } + Tools.drawAndSend({ _children: msgs }); + selected_els = newids.map(function (id) { + return Tools.svg.getElementById(id); + }); } function createSelectorRect() { @@ -70,22 +146,82 @@ return shape; } + function createButton(name, icon, width, height, drawCallback, clickCallback) { + var shape = Tools.createSVGElement("use", {href: "tools/hand/" + icon + ".svg#root"}); + shape.style.display = "none"; + shape.origWidth = width; + shape.origHeight = height; + shape.drawCallback = drawCallback; + shape.clickCallback = clickCallback; + Tools.svg.appendChild(shape); + return shape; + } + + function showSelectionButtons() { + var scale = getScale(); + var selectionBBox = selectionRect.transformedBBox(); + for (var i = 0; i < selectionButtons.length; i++) { + selectionButtons[i].drawCallback(selectionButtons[i], + selectionBBox, + scale); + } + } + + function hideSelectionButtons() { + for (var i = 0; i < selectionButtons.length; i++) { + selectionButtons[i].style.display = "none"; + } + } + + function hideSelectionUI() { + hideSelectionButtons(); + selectionRect.style.display = "none"; + } + function startMovingElements(x, y, evt) { evt.preventDefault(); - selectorState = selectorStates.moving; + selectorState = selectorStates.transform; + currentTransform = moveSelection; selected = { x: x, y: y }; // Some of the selected elements could have been deleted - selected_els = selected_els.filter(el => { - return Tools.svg.getElementById(el.id) !== null + selected_els = selected_els.filter(function (el) { + return Tools.svg.getElementById(el.id) !== null; }); - translation_elements = selected_els.map(el => { - let tmatrix = get_translate_matrix(el); - return { x: tmatrix.e, y: tmatrix.f } + transform_elements = selected_els.map(function (el) { + var tmatrix = get_transform_matrix(el); + return { + a: tmatrix.a, b: tmatrix.b, c: tmatrix.c, + d: tmatrix.d, e: tmatrix.e, f: tmatrix.f + }; }); - { - let tmatrix = get_translate_matrix(selectionRect); - selectionRectTranslation = { x: tmatrix.e, y: tmatrix.f }; - } + var tmatrix = get_transform_matrix(selectionRect); + selectionRectTransform = { x: tmatrix.e, y: tmatrix.f }; + } + + function startScalingTransform(x, y, evt) { + evt.preventDefault(); + hideSelectionButtons(); + selectorState = selectorStates.transform; + var bbox = selectionRect.transformedBBox(); + selected = { + x: bbox.r[0], + y: bbox.r[1], + w: bbox.a[0], + h: bbox.b[1], + }; + transform_elements = selected_els.map(function (el) { + var tmatrix = get_transform_matrix(el); + return { + a: tmatrix.a, b: tmatrix.b, c: tmatrix.c, + d: tmatrix.d, e: tmatrix.e, f: tmatrix.f + }; + }); + var tmatrix = get_transform_matrix(selectionRect); + selectionRectTransform = { + a: tmatrix.a, d: tmatrix.d, + e: tmatrix.e, f: tmatrix.f + }; + currentTransform = scaleSelection; } function startSelector(x, y, evt) { @@ -98,42 +234,93 @@ selectionRect.width.baseVal.value = 0; selectionRect.height.baseVal.value = 0; selectionRect.style.display = ""; - tmatrix = get_translate_matrix(selectionRect); + tmatrix = get_transform_matrix(selectionRect); tmatrix.e = 0; tmatrix.f = 0; } function calculateSelection() { - var scale = Tools.drawingArea.getCTM().a; - var selectionTBBox = selectionRect.transformedBBox(scale); - return Array.from(Tools.drawingArea.children).filter(el => { - return transformedBBoxIntersects( - selectionTBBox, - el.transformedBBox(scale) - ) - }); + var selectionTBBox = selectionRect.transformedBBox(); + var elements = Tools.drawingArea.children; + var selected = []; + for (var i = 0; i < elements.length; i++) { + if (transformedBBoxIntersects(selectionTBBox, elements[i].transformedBBox())) + selected.push(Tools.drawingArea.children[i]); + } + return selected; } function moveSelection(x, y) { var dx = x - selected.x; var dy = y - selected.y; - var msgs = selected_els.map((el, i) => { + var msgs = selected_els.map(function (el, i) { + var oldTransform = transform_elements[i]; return { type: "update", id: el.id, - deltax: dx + translation_elements[i].x, - deltay: dy + translation_elements[i].y - } + transform: { + a: oldTransform.a, + b: oldTransform.b, + c: oldTransform.c, + d: oldTransform.d, + e: dx + oldTransform.e, + f: dy + oldTransform.f + } + }; }) var msg = { _children: msgs }; - { - let tmatrix = get_translate_matrix(selectionRect); - tmatrix.e = dx + selectionRectTranslation.x; - tmatrix.f = dy + selectionRectTranslation.y; + var tmatrix = get_transform_matrix(selectionRect); + tmatrix.e = dx + selectionRectTransform.x; + tmatrix.f = dy + selectionRectTransform.y; + var now = performance.now(); + if (now - last_sent > 70) { + last_sent = now; + Tools.drawAndSend(msg); + } else { + draw(msg); } + } + + function scaleSelection(x, y) { + var rx = (x - selected.x) / (selected.w); + var ry = (y - selected.y) / (selected.h); + var msgs = selected_els.map(function (el, i) { + var oldTransform = transform_elements[i]; + var x = el.transformedBBox().r[0]; + var y = el.transformedBBox().r[1]; + var a = oldTransform.a * rx; + var d = oldTransform.d * ry; + var e = selected.x * (1 - rx) - x * a + + (x * oldTransform.a + oldTransform.e) * rx + var f = selected.y * (1 - ry) - y * d + + (y * oldTransform.d + oldTransform.f) * ry + return { + type: "update", + id: el.id, + transform: { + a: a, + b: oldTransform.b, + c: oldTransform.c, + d: d, + e: e, + f: f + } + }; + }) + var msg = { + _children: msgs + }; + + var tmatrix = get_transform_matrix(selectionRect); + tmatrix.a = rx; + tmatrix.d = ry; + tmatrix.e = selectionRectTransform.e + + selectionRect.x.baseVal.value * (selectionRectTransform.a - rx) + tmatrix.f = selectionRectTransform.f + + selectionRect.y.baseVal.value * (selectionRectTransform.d - ry) var now = performance.now(); if (now - last_sent > 70) { last_sent = now; @@ -150,23 +337,34 @@ rect.height.baseVal.value = Math.abs(y - selected.y); } - function get_translate_matrix(elem) { + function resetSelectionRect() { + var bbox = selectionRect.transformedBBox(); + var tmatrix = get_transform_matrix(selectionRect); + selectionRect.x.baseVal.value = bbox.r[0]; + selectionRect.y.baseVal.value = bbox.r[1]; + selectionRect.width.baseVal.value = bbox.a[0]; + selectionRect.height.baseVal.value = bbox.b[1]; + tmatrix.a = 1; tmatrix.b = 0; tmatrix.c = 0; + tmatrix.d = 1; tmatrix.e = 0; tmatrix.f = 0; + } + + function get_transform_matrix(elem) { // Returns the first translate or transform matrix or makes one - var translate = null; + var transform = null; for (var i = 0; i < elem.transform.baseVal.numberOfItems; ++i) { var baseVal = elem.transform.baseVal[i]; // quick tests showed that even if one changes only the fields e and f or uses createSVGTransformFromMatrix // the brower may add a SVG_TRANSFORM_MATRIX instead of a SVG_TRANSFORM_TRANSLATE - if (baseVal.type === SVGTransform.SVG_TRANSFORM_TRANSLATE || baseVal.type === SVGTransform.SVG_TRANSFORM_MATRIX) { - translate = baseVal; + if (baseVal.type === SVGTransform.SVG_TRANSFORM_MATRIX) { + transform = baseVal; break; } } - if (translate == null) { - translate = elem.transform.baseVal.createSVGTransformFromMatrix(Tools.svg.createSVGMatrix()); - elem.transform.baseVal.appendItem(translate); + if (transform == null) { + transform = elem.transform.baseVal.createSVGTransformFromMatrix(Tools.svg.createSVGMatrix()); + elem.transform.baseVal.appendItem(transform); } - return translate.matrix; + return transform.matrix; } function draw(data) { @@ -178,9 +376,19 @@ case "update": var elem = Tools.svg.getElementById(data.id); if (!elem) throw new Error("Mover: Tried to move an element that does not exist."); - var tmatrix = get_translate_matrix(elem); - tmatrix.e = data.deltax || 0; - tmatrix.f = data.deltay || 0; + var tmatrix = get_transform_matrix(elem); + for (i in data.transform) { + tmatrix[i] = data.transform[i] + } + break; + case "copy": + var newElement = Tools.svg.getElementById(data.id).cloneNode(true); + newElement.id = data.newid; + Tools.drawingArea.appendChild(newElement); + break; + case "delete": + data.tool = "Eraser"; + messageForTool(data); break; default: throw new Error("Mover: 'move' instruction with unknown type. ", data); @@ -189,15 +397,23 @@ } function clickSelector(x, y, evt) { - var scale = Tools.drawingArea.getCTM().a - selectionRect = selectionRect ?? createSelectorRect(); - if (pointInTransformedBBox([x, y], selectionRect.transformedBBox(scale))) { + selectionRect = selectionRect || createSelectorRect(); + for (var i = 0; i < selectionButtons.length; i++) { + if (selectionButtons[i].contains(evt.target)) { + var button = selectionButtons[i]; + } + } + if (button) { + button.clickCallback(x, y, evt); + } else if (pointInTransformedBBox([x, y], selectionRect.transformedBBox())) { + hideSelectionButtons(); startMovingElements(x, y, evt); } else if (Tools.drawingArea.contains(evt.target)) { - selectionRect.style.display = "none"; + hideSelectionUI(); selected_els = [getParentMathematics(evt.target)]; startMovingElements(x, y, evt); } else { + hideSelectionButtons(); startSelector(x, y, evt); } } @@ -206,18 +422,20 @@ if (selectorState == selectorStates.selecting) { selected_els = calculateSelection(); if (selected_els.length == 0) { - selectionRect.style.display = "none"; + hideSelectionUI(); } - } - translation_elements = []; + } else if (selectorState == selectorStates.transform) + resetSelectionRect(); + if (selected_els.length != 0) showSelectionButtons(); + transform_elements = []; selectorState = selectorStates.pointing; } function moveSelector(x, y, evt) { if (selectorState == selectorStates.selecting) { updateRect(x, y, selectionRect); - } else if (selectorState == selectorStates.moving) { - moveSelection(x, y, selectionRect); + } else if (selectorState == selectorStates.transform && currentTransform) { + currentTransform(x, y); } } @@ -252,8 +470,31 @@ selected = null; } + function deleteShortcut(e) { + if (e.key == "Delete" && + !e.target.matches("input[type=text], textarea")) + deleteSelection(); + } + + function duplicateShortcut(e) { + if (e.key == "d" && + !e.target.matches("input[type=text], textarea")) + duplicateSelection(); + } + function switchTool() { + onquit(); + if (handTool.secondary.active) { + window.addEventListener("keydown", deleteShortcut); + window.addEventListener("keydown", duplicateShortcut); + } + } + + function onquit() { selected = null; + hideSelectionUI(); + window.removeEventListener("keydown", deleteShortcut); + window.removeEventListener("keydown", duplicateShortcut); } var handTool = { //The new tool @@ -264,6 +505,7 @@ "move": move, "release": release, }, + "onquit": onquit, "secondary": { "name": "Selector", "icon": "tools/hand/selector.svg", diff --git a/client-data/tools/hand/handle.svg b/client-data/tools/hand/handle.svg new file mode 100644 index 00000000..f805b7c0 --- /dev/null +++ b/client-data/tools/hand/handle.svg @@ -0,0 +1,16 @@ + + + Instagram icon + + + + + + + + + Instagram icon + + + + diff --git a/package.json b/package.json index 3a8ae026..7bbabeb7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "whitebophir", "description": "Online collaborative whiteboard", - "version": "1.11.0", + "version": "1.12.0", "keywords": [ "collaborative", "whiteboard" diff --git a/server/boardData.js b/server/boardData.js index 54efb087..d7867ccb 100644 --- a/server/boardData.js +++ b/server/boardData.js @@ -100,6 +100,28 @@ class BoardData { this.delaySave(); } + /** Copy elements in the board + * @param {string} id - Identifier of the data to copy. + * @param {BoardElem} data - Object containing the id of the new copied element. + */ + copy(id, data) { + var obj = this.board[id]; + var newid = data.newid; + if (obj) { + var newobj = JSON.parse(JSON.stringify(obj)); + newobj.id = newid; + if (newobj._children) { + for (var child of newobj._children) { + child.parent = newid; + } + } + this.board[newid] = newobj; + } else { + log("Copied object does not exist in board.", {object: id}); + } + this.delaySave(); + } + /** Removes data from the board * @param {string} id - Identifier of the data to delete. */ @@ -137,6 +159,9 @@ class BoardData { case "update": if (id) this.update(id, message); break; + case "copy": + if (id) this.copy(id, message); + break; case "child": this.addChild(message.parent, message); break; diff --git a/server/client_configuration.js b/server/client_configuration.js index e623400e..e8957a1d 100644 --- a/server/client_configuration.js +++ b/server/client_configuration.js @@ -6,5 +6,6 @@ module.exports = { MAX_EMIT_COUNT: config.MAX_EMIT_COUNT, MAX_EMIT_COUNT_PERIOD: config.MAX_EMIT_COUNT_PERIOD, BLOCKED_TOOLS: config.BLOCKED_TOOLS, + BLOCKED_SELECTION_BUTTONS: config.BLOCKED_SELECTION_BUTTONS, AUTO_FINGER_WHITEOUT: config.AUTO_FINGER_WHITEOUT, }; diff --git a/server/configuration.js b/server/configuration.js index 6010134c..334663c0 100644 --- a/server/configuration.js +++ b/server/configuration.js @@ -42,6 +42,9 @@ module.exports = { /** Blocked Tools. A comma-separated list of tools that should not appear on boards. */ BLOCKED_TOOLS: (process.env["WBO_BLOCKED_TOOLS"] || "").split(","), + /** Selection Buttons. A comma-separated list of selection buttons that should not be available. */ + BLOCKED_SELECTION_BUTTONS: (process.env["WBO_BLOCKED_SELECTION_BUTTONS"] || "").split(","), + /** Automatically switch to White-out on finger touch after drawing with Pencil using a stylus. Only supported on iPad with Apple Pencil. */ AUTO_FINGER_WHITEOUT: process.env['AUTO_FINGER_WHITEOUT'] !== "disabled",