diff --git a/client-data/board.css b/client-data/board.css
index cdab39cb..f83dbb6c 100644
--- a/client-data/board.css
+++ b/client-data/board.css
@@ -276,6 +276,9 @@ circle.opcursor {
transition: 0s;
}
+#board #selectionRect {
+ fill: none;
+}
/* Internet Explorer specific CSS */
@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
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..743d63b9 100644
--- a/client-data/tools/hand/hand.js
+++ b/client-data/tools/hand/hand.js
@@ -25,31 +25,144 @@
*/
(function hand() { //Code isolation
- var selected = null;
+ 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 inRect(x ,y , rect) {
+ return (x>=rect.x && x<=rect.x+rect.width) &&
+ (y>=rect.y && y>=rect.w+rect.height)
+ }
- function startMovingElement(x, y, evt) {
- //Prevent the press from being interpreted by the browser
- 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 };
- }
-
- 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 };
- var now = performance.now();
- if (now - last_sent > 70) {
- last_sent = now;
- Tools.drawAndSend(msg);
- } else {
- draw(msg);
- }
+ function intersectRect(rect1 , rect2) {
+ return !(
+ (rect1.x+rect1.width<=rect2.x) ||
+ (rect2.x+rect2.width<=rect1.x) ||
+ (rect1.y+rect1.height<=rect2.y) ||
+ (rect2.y+rect2.height<=rect1.y)
+ )
+ }
+
+ 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", 3);
+ shape.setAttribute("fill", "none");
+ shape.setAttribute("stroke-dasharray", "5 5");
+ shape.setAttribute("opacity", 1);
+ Tools.svg.appendChild(shape);
+ return shape;
+ }
+
+ function startMovingElements(x, y, evt) {
+ evt.preventDefault();
+ 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 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 = {
+ type: "batch",
+ msgs: 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;
+ Tools.drawAndSend(msg);
+ } else {
+ draw(msg);
+ }
+ }
+
+ 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
@@ -70,21 +183,66 @@
return translate.matrix;
}
- 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;
-
- default:
- throw new Error("Mover: 'move' instruction with unknown type. ", data);
+ function draw(data) {
+ switch (data.type) {
+ case "batch":
+ for ([i,msg] of data.msgs.entries()) {
+ switch (msg.type) {
+ case "update":
+ let tmatrix = get_translate_matrix(Tools.svg.getElementById(msg.id));
+ tmatrix.e = msg.deltax || 0;
+ tmatrix.f = msg.deltay || 0;
+ break;
+ // Eventually also "delete"?
+ }
+ }
+ break;
+ 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;
+ }
+
+ function moveSelector(x, y, evt) {
+ if (selectorState == selectorStates.selecting) {
+ updateRect(x,y, selectionRect);
+ } else if (selectorState == selectorStates.moving) {
+ moveSelection(x,y, selectionRect);
+ }
+ }
+
function startHand(x, y, evt, isTouchEvent) {
if (!isTouchEvent) {
selected = {
@@ -101,17 +259,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 +287,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/server/boardData.js b/server/boardData.js
index 2c4f27f9..f99f4554 100644
--- a/server/boardData.js
+++ b/server/boardData.js
@@ -109,6 +109,32 @@ class BoardData {
this.delaySave();
}
+ /** Process a batch of messages
+ * @param {envelope} array of messages to be delegated to the other methods
+ */
+ batch(envelope) {
+ for (const message of envelope.msgs) {
+ 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;
+ case "batch":
+ throw new Error("Nested batch message: ", message);
+ 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..5fcd7c8d 100644
--- a/server/sockets.js
+++ b/server/sockets.js
@@ -171,6 +171,9 @@ async function saveHistory(boardName, message) {
case "child":
board.addChild(message.parent, message);
break;
+ case "batch":
+ board.batch(message);
+ break;
default:
//Add data
if (!id) throw new Error("Invalid message: ", message);