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 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + 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) {