diff --git a/client-data/board.css b/client-data/board.css
index cdab39cb..7f3e5b8e 100644
--- a/client-data/board.css
+++ b/client-data/board.css
@@ -276,7 +276,6 @@ circle.opcursor {
transition: 0s;
}
-
/* Internet Explorer specific CSS */
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
#chooseColor {
@@ -285,4 +284,4 @@ circle.opcursor {
label.tool-name[for=chooseColor] {
line-height: 10px;
}
-}
\ No newline at end of file
+}
diff --git a/client-data/board.html b/client-data/board.html
index b5f65940..c894bfa6 100644
--- a/client-data/board.html
+++ b/client-data/board.html
@@ -88,6 +88,7 @@
+
diff --git a/client-data/js/intersect.js b/client-data/js/intersect.js
new file mode 100644
index 00000000..3cd09b8c
--- /dev/null
+++ b/client-data/js/intersect.js
@@ -0,0 +1,92 @@
+/**
+ * INTERSEC
+ *********************************************************
+ * @licstart The following is the entire license notice for the
+ * JavaScript code in this page.
+ *
+ * Copyright (C) 2021 Ophir LOJKINE
+ *
+ *
+ * The JavaScript code in this page is free software: you can
+ * redistribute it and/or modify it under the terms of the GNU
+ * General Public License (GNU GPL) as published by the Free Software
+ * Foundation, either version 3 of the License, or (at your option)
+ * any later version. The code is distributed WITHOUT ANY WARRANTY;
+ * without even the implied warranty of MERCHANTABILITY or FITNESS
+ * FOR A PARTICULAR PURPOSE. See the GNU GPL for more details.
+ *
+ * As additional permission under GNU GPL version 3 section 7, you
+ * may distribute non-source (e.g., minimized or compacted) forms of
+ * that code without the copy of the GNU GPL normally required by
+ * section 4, provided you include this license notice and a URL
+ * through which recipients can access the Corresponding Source.
+ *
+ * @licend
+ */
+
+if (!SVGGraphicsElement.prototype.transformedBBox || !SVGGraphicsElement.prototype.transformedBBoxContains) {
+ [pointInTransformedBBox,
+ transformedBBoxIntersects] = (function () {
+
+ let applyTransform = function (m,t) {
+ return [
+ m.a*t[0]+m.c*t[1],
+ m.b*t[0]+m.d*t[1]
+ ]
+ }
+
+ SVGGraphicsElement.prototype.transformedBBox = function (scale=1) {
+ bbox = this.getBBox();
+ tmatrix = this.getCTM();
+ 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])
+ }
+ }
+
+ SVGSVGElement.prototype.transformedBBox = function (scale=1) {
+ bbox = {
+ x: this.x.baseVal.value,
+ y: this.y.baseVal.value,
+ width: this.width.baseVal.value,
+ height: this.height.baseVal.value
+ };
+ tmatrix = this.getCTM();
+ 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])
+ }
+ }
+
+ let 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;
+ var c2 = (d[1]*a[0]-d[0]*a[1]) / idet;
+ return (c1>=0 && c1<=1 && c2>=0 && c2<=1)
+ }
+
+ SVGGraphicsElement.prototype.transformedBBoxContains = function (x,y) {
+ return pointInTransformedBBox([x, y], this.transformedBBox())
+ }
+
+ function transformedBBoxIntersects(bbox_a,bbox_b) {
+ var corners = [
+ bbox_b.r,
+ [bbox_b.r[0] + bbox_b.a[0], bbox_b.r[1] + bbox_b.a[1]],
+ [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))
+ }
+
+ SVGGraphicsElement.prototype.transformedBBoxIntersects= function (bbox) {
+ return transformedBBoxIntersects(this.transformedBBox(),bbox)
+ }
+
+ return [pointInTransformedBBox,
+ transformedBBoxIntersects]
+ })();
+}
diff --git a/client-data/tools/hand/hand.js b/client-data/tools/hand/hand.js
index ffba5ee7..8f95db67 100644
--- a/client-data/tools/hand/hand.js
+++ b/client-data/tools/hand/hand.js
@@ -25,23 +25,115 @@
*/
(function hand() { //Code isolation
+ const selectorStates = {
+ pointing: 0,
+ selecting: 1,
+ moving: 2
+ }
var selected = null;
+ var selected_els = [];
+ var selectionRect = createSelectorRect();
+ var selectionRectTranslation;
+ var translation_elements = [];
+ var selectorState = selectorStates.pointing;
var last_sent = 0;
+ function getParentMathematics(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");
+ if ((parentMathematics) && parentMathematics.tagName === "svg") {
+ target = parentMathematics;
+ }
+ return target ?? el;
+ }
+
+ function createSelectorRect() {
+ var shape = Tools.createSVGElement("rect");
+ shape.id = "selectionRect";
+ shape.x.baseVal.value = 0;
+ shape.y.baseVal.value = 0;
+ shape.width.baseVal.value = 0;
+ shape.height.baseVal.value = 0;
+ shape.setAttribute("stroke", "black");
+ shape.setAttribute("stroke-width", 1);
+ shape.setAttribute("vector-effect", "non-scaling-stroke");
+ shape.setAttribute("fill", "none");
+ shape.setAttribute("stroke-dasharray", "5 5");
+ shape.setAttribute("opacity", 1);
+ Tools.svg.appendChild(shape);
+ return shape;
+ }
- function startMovingElement(x, y, evt) {
- //Prevent the press from being interpreted by the browser
+ function startMovingElements(x, y, evt) {
evt.preventDefault();
- if (!evt.target || !Tools.drawingArea.contains(evt.target)) return;
- var tmatrix = get_translate_matrix(evt.target);
- selected = { x: x - tmatrix.e, y: y - tmatrix.f, elem: evt.target };
+ selectorState = selectorStates.moving;
+ 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
+ });
+ translation_elements = selected_els.map(el => {
+ let tmatrix = get_translate_matrix(el);
+ return { x: tmatrix.e, y: tmatrix.f }
+ });
+ {
+ let tmatrix = get_translate_matrix(selectionRect);
+ selectionRectTranslation = { x: tmatrix.e, y: tmatrix.f };
+ }
}
- function moveElement(x, y) {
- if (!selected) return;
- var deltax = x - selected.x;
- var deltay = y - selected.y;
- var msg = { type: "update", id: selected.elem.id, deltax: deltax, deltay: deltay };
+ function startSelector(x, y, evt) {
+ evt.preventDefault();
+ selected = { x: x, y: y };
+ selected_els = [];
+ selectorState = selectorStates.selecting;
+ selectionRect.x.baseVal.value = x;
+ selectionRect.y.baseVal.value = y;
+ selectionRect.width.baseVal.value = 0;
+ selectionRect.height.baseVal.value = 0;
+ selectionRect.style.display = "";
+ tmatrix = get_translate_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)
+ )
+ });
+ }
+
+ function moveSelection(x, y) {
+ var dx = x - selected.x;
+ var dy = y - selected.y;
+ var msgs = selected_els.map((el, i) => {
+ return {
+ type: "update",
+ id: el.id,
+ deltax: dx + translation_elements[i].x,
+ deltay: dy + translation_elements[i].y
+ }
+ })
+ var msg = {
+ _children: msgs
+ };
+ {
+ let tmatrix = get_translate_matrix(selectionRect);
+ tmatrix.e = dx + selectionRectTranslation.x;
+ tmatrix.f = dy + selectionRectTranslation.y;
+ }
var now = performance.now();
if (now - last_sent > 70) {
last_sent = now;
@@ -51,6 +143,13 @@
}
}
+ function updateRect(x, y, rect) {
+ rect.x.baseVal.value = Math.min(x, selected.x);
+ rect.y.baseVal.value = Math.min(y, selected.y);
+ rect.width.baseVal.value = Math.abs(x - selected.x);
+ rect.height.baseVal.value = Math.abs(y - selected.y);
+ }
+
function get_translate_matrix(elem) {
// Returns the first translate or transform matrix or makes one
var translate = null;
@@ -71,17 +170,54 @@
}
function draw(data) {
- switch (data.type) {
- 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;
- break;
+ if (data._children) {
+ batchCall(draw, data._children);
+ }
+ else {
+ switch (data.type) {
+ 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;
+ break;
+ default:
+ throw new Error("Mover: 'move' instruction with unknown type. ", data);
+ }
+ }
+ }
+
+ function clickSelector(x, y, evt) {
+ var scale = Tools.drawingArea.getCTM().a
+ selectionRect = selectionRect ?? createSelectorRect();
+ if (pointInTransformedBBox([x, y], selectionRect.transformedBBox(scale))) {
+ startMovingElements(x, y, evt);
+ } else if (Tools.drawingArea.contains(evt.target)) {
+ selectionRect.style.display = "none";
+ selected_els = [getParentMathematics(evt.target)];
+ startMovingElements(x, y, evt);
+ } else {
+ startSelector(x, y, evt);
+ }
+ }
+
+ function releaseSelector(x, y, evt) {
+ if (selectorState == selectorStates.selecting) {
+ selected_els = calculateSelection();
+ if (selected_els.length == 0) {
+ selectionRect.style.display = "none";
+ }
+ }
+ translation_elements = [];
+ selectorState = selectorStates.pointing;
+ }
- default:
- throw new Error("Mover: 'move' instruction with unknown type. ", data);
+ function moveSelector(x, y, evt) {
+ if (selectorState == selectorStates.selecting) {
+ updateRect(x, y, selectionRect);
+ } else if (selectorState == selectorStates.moving) {
+ moveSelection(x, y, selectionRect);
}
}
@@ -101,17 +237,18 @@
function press(x, y, evt, isTouchEvent) {
if (!handTool.secondary.active) startHand(x, y, evt, isTouchEvent);
- else startMovingElement(x, y, evt, isTouchEvent);
+ else clickSelector(x, y, evt, isTouchEvent);
}
function move(x, y, evt, isTouchEvent) {
if (!handTool.secondary.active) moveHand(x, y, evt, isTouchEvent);
- else moveElement(x, y, evt, isTouchEvent);
+ else moveSelector(x, y, evt, isTouchEvent);
}
function release(x, y, evt, isTouchEvent) {
move(x, y, evt, isTouchEvent);
+ if (handTool.secondary.active) releaseSelector(x, y, evt, isTouchEvent);
selected = null;
}
@@ -128,8 +265,8 @@
"release": release,
},
"secondary": {
- "name": "Mover",
- "icon": "tools/hand/mover.svg",
+ "name": "Selector",
+ "icon": "tools/hand/selector.svg",
"active": false,
"switch": switchTool,
},
diff --git a/client-data/tools/hand/selector.svg b/client-data/tools/hand/selector.svg
new file mode 100644
index 00000000..20ac9419
--- /dev/null
+++ b/client-data/tools/hand/selector.svg
@@ -0,0 +1,19 @@
+
+
diff --git a/package.json b/package.json
index 7717f3fa..3a8ae026 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "whitebophir",
"description": "Online collaborative whiteboard",
- "version": "1.10.2",
+ "version": "1.11.0",
"keywords": [
"collaborative",
"whiteboard"
diff --git a/server/boardData.js b/server/boardData.js
index 2c4f27f9..54efb087 100644
--- a/server/boardData.js
+++ b/server/boardData.js
@@ -109,6 +109,44 @@ class BoardData {
this.delaySave();
}
+ /** Process a batch of messages
+ * @typedef {{
+ * id:string,
+ * type: "delete" | "update" | "child",
+ * parent?: string,
+ * _children?: BoardMessage[],
+ * } & BoardElem } BoardMessage
+ * @param {BoardMessage[]} children array of messages to be delegated to the other methods
+ */
+ processMessageBatch(children) {
+ for (const message of children) {
+ this.processMessage(message);
+ }
+ }
+
+ /** Process a single message
+ * @param {BoardMessage} message instruction to apply to the board
+ */
+ processMessage(message) {
+ if (message._children) return this.processMessageBatch(message._children);
+ let id = message.id;
+ switch (message.type) {
+ case "delete":
+ if (id) this.delete(id);
+ break;
+ case "update":
+ if (id) this.update(id, message);
+ break;
+ case "child":
+ this.addChild(message.parent, message);
+ break;
+ default:
+ //Add data
+ if (!id) throw new Error("Invalid message: ", message);
+ this.set(id, message);
+ }
+ }
+
/** Reads data from the board
* @param {string} id - Identifier of the element to get.
* @returns {BoardElem} The element with the given id, or undefined if no element has this id
diff --git a/server/sockets.js b/server/sockets.js
index c524efc6..be12ab85 100644
--- a/server/sockets.js
+++ b/server/sockets.js
@@ -4,7 +4,7 @@ var iolib = require("socket.io"),
config = require("./configuration");
/** Map from name to *promises* of BoardData
- @type {Object>}
+ @type {Object>}
*/
var boards = {};
@@ -159,23 +159,11 @@ function handleMessage(boardName, message, socket) {
}
async function saveHistory(boardName, message) {
- var id = message.id;
- var board = await getBoard(boardName);
- switch (message.type) {
- case "delete":
- if (id) board.delete(id);
- break;
- case "update":
- if (id) board.update(id, message);
- break;
- case "child":
- board.addChild(message.parent, message);
- break;
- default:
- //Add data
- if (!id) throw new Error("Invalid message: ", message);
- board.set(id, message);
+ if (!message.tool && !message._children) {
+ console.error("Received a badly formatted message (no tool). ", message);
}
+ var board = await getBoard(boardName);
+ board.processMessage(message);
}
function generateUID(prefix, suffix) {