Add a cursor (#46)

* Added cursors

* Prepare cursor code for future settings to toggle cursors on or off.
Let cursor be the color the person has currently selected

* fix cursor on mobile (still won't display it in most cases as there is no hover on mobile but at least it won't throw errors)

* use correct size for cursor

* throttle cursor update rate to dramatically improve performance by eliminating congestion

* fix remote cursor size on desktop

* show own cursor by default and renove offset

* use svg as mouse cursor for pencil to be able to apply a reduced opacity to it and view our cursor

* don't throttle local cursor

* throttle local cursor at an independent higher rate. This could be made user adjustable for low power devices

* remove let and const from client-side code

* get emit count and emit count period from configuration

* reduce network cursor updates a lot to prevent instantly getting banned with the current defaults

* prevent eraser from deleting cursors

* use group inside of svg as drawing area and only delete elements inside it with the eraser

* use transform: translate to move cursors around instead of manipulating x and y directly

* fix: add socket ids to cursor messages

* fix incorrect remote cursor scaling and make local cursor visible again after it has been moved after being hidden due to inactivity

* create cursors in a proper fashion and keep them in a separate group

* scaling has been fixed in a1a5580

* move duplicated cursor creation code to function

* show cursors above content

* pass some of ther server configuration through to the client

* fix bug introduced in a833ce9

* allocate at most half of the allowed traffic to cursor updates

* remove debugging leftover

* use feature detection instead of ua sniffing

Co-Authored-By: Ophir LOJKINE <ophir.lojkine@auto-grid.com>

* fix regression where local cursor color was not updated on color change

* Define the cursor as a tool

* Remove the cursor tool from the UI

* Throttle remote cursor updates, not local ones

* Do not increment notification count on cursor move

* Use only one pencil icon

Use the same image for the pencil icon in the menu
and the pencil cursor that appears while drawing

* Add a test for the new cursor feature

* only stop drawing remote cursor when using some tools and always draw local cursor

* increase idle period before hiding cursor

* change idle duration back and set whether a cursor should be sent when using a tool in the respective tool

Co-authored-by: Robert Beach <rdbeach@gmail.com>
Co-authored-by: Ophir LOJKINE <ophir.lojkine@auto-grid.com>
Co-authored-by: ophir <pere.jobs@gmail.com>
This commit is contained in:
finnboeger 2020-05-02 06:13:48 +02:00 committed by GitHub
parent b3e8e30c16
commit fce694df28
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 269 additions and 61 deletions

View file

@ -237,3 +237,12 @@ text {
user-select:none;
-moz-user-select:none;
}
circle.opcursor {
pointer-events: none;
transition: .1s;
}
#cursor-me {
transition: 0s;
}

View file

@ -27,6 +27,8 @@
<div id="board">
<svg id="canvas" width="500" height="500" version="1.1" xmlns="http://www.w3.org/2000/svg">
<defs id="defs"></defs>
<g id="drawingArea"></g>
<g id="cursors"></g>
</svg>
</div>
@ -64,7 +66,7 @@
<rect x=2 y=0 width=2 height=2 fill=#eeeeee />
<rect x=0 y=2 width=2 height=2 fill=#eeeeee />
</pattern>
<circle cx=4 cy=4 id="opacityIndicator" r=3.5 fill="url(#opacityPattern)" />
<circle cx=4 cy=4 id="opacityIndicator" r=3.5 fill="url(#opacityPattern)" />
</svg>
</span>
<label class="tool-name slider" for="chooseOpacity">
@ -78,11 +80,13 @@
</div>
<script type="application/json" id="translations">{{{json translations}}}</script>
<script type="application/json" id="translations">{{{ json translations }}}</script>
<script type="application/json" id="configuration">{{{ json configuration }}}</script>
<script src="../js/path-data-polyfill.js"></script>
<script src="../js/minitpl.js"></script>
<script src="../js/board.js"></script>
<script src="../tools/pencil/pencil.js"></script>
<script src="../tools/cursor/cursor.js"></script>
<script src="../tools/line/line.js"></script>
<script src="../tools/rect/rect.js"></script>
<script src="../tools/text/text.js"></script>

View file

@ -36,14 +36,20 @@ Tools.i18n = (function i18n() {
};
})();
Tools.server_config = JSON.parse(document.getElementById("configuration").text);
Tools.board = document.getElementById("board");
Tools.svg = document.getElementById("canvas");
Tools.drawingArea = Tools.svg.getElementById("drawingArea");
//Initialization
Tools.curTool = null;
Tools.showMarker = true;
Tools.showOtherCursors = true;
Tools.showMyCursor = true;
Tools.socket = null;
Tools.connect = function() {
Tools.connect = function () {
var self = this;
// Destroy socket if one already exists
@ -112,7 +118,7 @@ Tools.HTML = {
Tools.i18n.t(toolName) + " (" +
Tools.i18n.t("keyboard shortcut") + ": " +
toolShortcut + ")" +
(Tools.list[toolName].toggle? " [" + Tools.i18n.t("Click to togle")+ "]":"");
(Tools.list[toolName].toggle ? " [" + Tools.i18n.t("Click to togle") + "]" : "");
});
},
changeTool: function (oldToolName, newToolName) {
@ -146,7 +152,10 @@ Tools.HTML = {
Tools.list = {}; // An array of all known tools. {"toolName" : {toolObject}}
Tools.add = function (newTool) {
/**
* Register a new tool, without touching the User Interface
*/
Tools.register = function registerTool(newTool) {
if (newTool.name in Tools.list) {
console.log("Tools.add: The tool '" + newTool.name + "' is already" +
"in the list. Updating it...");
@ -158,13 +167,6 @@ Tools.add = function (newTool) {
//Add the tool to the list
Tools.list[newTool.name] = newTool;
if (newTool.stylesheet) {
Tools.HTML.addStylesheet(newTool.stylesheet);
}
//Add the tool to the GUI
Tools.HTML.addTool(newTool.name, newTool.icon, newTool.iconHTML, newTool.shortcut);
//There may be pending messages for the tool
var pending = Tools.pendingMessages[newTool.name];
if (pending) {
@ -175,6 +177,20 @@ Tools.add = function (newTool) {
newTool.draw(msg, false);
}
}
}
/**
* Add a new tool to the user interface
*/
Tools.add = function (newTool) {
Tools.register(newTool);
if (newTool.stylesheet) {
Tools.HTML.addStylesheet(newTool.stylesheet);
}
//Add the tool to the GUI
Tools.HTML.addTool(newTool.name, newTool.icon, newTool.iconHTML, newTool.shortcut);
};
Tools.change = function (toolName) {
@ -185,7 +201,7 @@ Tools.change = function (toolName) {
var newtool = Tools.list[toolName];
if (newtool === Tools.curTool) {
if(newtool.toggle){
if (newtool.toggle) {
var elem = document.getElementById("toolID-" + newtool.name);
newtool.toggle(elem);
}
@ -208,26 +224,34 @@ Tools.change = function (toolName) {
if (newtool === Tools.curTool) return;
//Remove the old event listeners
for (var event in Tools.curTool.compiledListeners) {
var listener = Tools.curTool.compiledListeners[event];
Tools.board.removeEventListener(event, listener);
}
Tools.removeToolListeners(Tools.curTool);
//Call the callbacks of the old tool
Tools.curTool.onquit(newtool);
}
//Add the new event listeners
for (var event in newtool.compiledListeners) {
var listener = newtool.compiledListeners[event];
Tools.board.addEventListener(event, listener, { 'passive': false });
}
Tools.addToolListeners(newtool);
//Call the start callback of the new tool
newtool.onstart(Tools.curTool);
Tools.curTool = newtool;
};
Tools.addToolListeners = function addToolListeners(tool) {
for (var event in tool.compiledListeners) {
var listener = tool.compiledListeners[event];
Tools.board.addEventListener(event, listener, { 'passive': false });
}
}
Tools.removeToolListeners = function removeToolListeners(tool) {
for (var event in tool.compiledListeners) {
var listener = tool.compiledListeners[event];
Tools.board.removeEventListener(event, listener);
}
}
Tools.send = function (data, toolName) {
toolName = toolName || Tools.curTool.name;
var d = data;
@ -236,13 +260,14 @@ Tools.send = function (data, toolName) {
var message = {
"board": Tools.boardName,
"data": d
}
};
Tools.socket.emit('broadcast', message);
};
Tools.drawAndSend = function (data) {
Tools.curTool.draw(data, true);
Tools.send(data);
Tools.drawAndSend = function (data, tool) {
if (tool == null) tool = Tools.curTool;
tool.draw(data, true);
Tools.send(data, tool.name);
};
//Object containing the messages that have been received before the corresponding tool
@ -519,12 +544,12 @@ Tools.setSize = (function size() {
chooser.onchange = chooser.oninput = update;
return function (value) {
if (value !== null && value !== undefined) { chooser.value=value; update(); }
if (value !== null && value !== undefined) { chooser.value = value; update(); }
return parseInt(chooser.value);
};
})();
Tools.getSize = (function() {return Tools.setSize()});
Tools.getSize = (function () { return Tools.setSize() });
Tools.getOpacity = (function opacity() {
var chooser = document.getElementById("chooseOpacity");

View file

@ -0,0 +1,99 @@
/**
* WHITEBOPHIR
*********************************************************
* @licstart The following is the entire license notice for the
* JavaScript code in this page.
*
* Copyright (C) 2020 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
*/
(function () { // Code isolation
// Allocate half of the maximum server updates to cursor updates
var MAX_CURSOR_UPDATES_INTERVAL_MS = Tools.server_config.MAX_EMIT_COUNT_PERIOD / Tools.server_config.MAX_EMIT_COUNT * 2;
var CURSOR_DELETE_AFTER_MS = 1000 * 5;
var lastCursorUpdate = 0;
var sending = true;
var cursorTool = {
"name": "Cursor",
"listeners": {
"press": function () { sending = false },
"move": handleMarker,
"release": function () { sending = true },
},
"draw": draw,
"mouseCursor": "crosshair",
"icon": "tools/pencil/icon.svg",
};
Tools.register(cursorTool);
Tools.addToolListeners(cursorTool);
function handleMarker(x, y) {
if (!Tools.showMarker || !Tools.showMyCursor) return;
// throttle local cursor updates
var cur_time = Date.now();
var message = {
type: "update",
x: x,
y: y,
color: Tools.getColor(),
size: Tools.getSize(),
};
if (cur_time - lastCursorUpdate > MAX_CURSOR_UPDATES_INTERVAL_MS &&
(sending || Tools.curTool.showMarker)) {
Tools.drawAndSend(message, cursorTool);
lastCursorUpdate = cur_time;
} else {
draw(message);
}
}
var cursorsElem = Tools.svg.getElementById("cursors");
function createCursor(id) {
var cursor = document.createElementNS("http://www.w3.org/2000/svg", "circle");
cursor.setAttributeNS(null, "class", "opcursor");
cursor.setAttributeNS(null, "id", id);
cursor.setAttributeNS(null, "cx", 0);
cursor.setAttributeNS(null, "cy", 0);
cursor.setAttributeNS(null, "r", 10);
cursorsElem.append(cursor);
setTimeout(function () {
cursorsElem.removeChild(cursor);
}, CURSOR_DELETE_AFTER_MS);
return cursor;
}
function getCursor(id) {
return document.getElementById(id) || createCursor(id);
}
function draw(message) {
var cursor = getCursor("cursor-" + (message.socket || 'me'));
cursor.style.transform = "translate(" + message.x + "px, " + message.y + "px)";
cursor.setAttributeNS(null, "fill", message.color);
cursor.setAttributeNS(null, "r", message.size / 2);
}
})()

View file

@ -39,6 +39,22 @@
"type": "delete",
"id": ""
};
function inDrawingArea(elem) {
if (Tools.drawingArea.contains) {
return Tools.drawingArea.contains(elem);
} else {
var node = elem.parentNode;
while (node != null) {
if (node === Tools.drawingArea) {
return true;
}
node = node.parentNode;
}
return false;
}
}
function erase(x, y, evt) {
// evt.target should be the element over which the mouse is...
var target = evt.target;
@ -48,7 +64,7 @@
var touch = evt.touches[0];
target = document.elementFromPoint(touch.clientX, touch.clientY);
}
if (erasing && target !== Tools.svg) {
if (erasing && target !== Tools.svg && target !== Tools.drawingArea && inDrawingArea(target)) {
msg.id = target.id;
Tools.drawAndSend(msg);
}
@ -65,7 +81,7 @@
case "delete":
elem = svg.getElementById(data.id);
if (elem === null) console.error("Eraser: Tried to delete an element that does not exist.");
else svg.removeChild(elem);
else Tools.drawingArea.removeChild(elem);
break;
default:
console.error("Eraser: 'delete' instruction with unknown type. ", data);
@ -86,6 +102,7 @@
"draw": draw,
"icon": "tools/eraser/icon.svg",
"mouseCursor": "crosshair",
"showMarker": true,
});
})(); //End of code isolation

View file

@ -53,7 +53,8 @@
"release": release
},
"icon": "tools/hand/icon.svg",
"mouseCursor": "move"
"mouseCursor": "move",
"showMarker": true,
});
//The hand tool is selected by default

View file

@ -1,4 +1,4 @@
/**
/**
* WHITEBOPHIR
*********************************************************
* @licstart The following is the entire license notice for the
@ -111,7 +111,7 @@
line.setAttribute("stroke", lineData.color || "black");
line.setAttribute("stroke-width", lineData.size || 10);
line.setAttribute("opacity", Math.max(0.1, Math.min(1, lineData.opacity)) || 1);
svg.appendChild(line);
Tools.drawingArea.appendChild(line);
return line;
}

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 60 60">
<defs/>
<g fill="none" stroke="#000" stroke-width="2">
<path d="M41.06 33.83c.6-2.8-4.52-5.04-4.65-5.1l-21-23.03L3.18 16.83l21 23.04v.02s1.84 5.48 4.65 5.09a17.85 17.85 0 0012.23-11.14z"/>
<path fill="#000" stroke="none" d="M1.18 1.39l.68 5.97 4.97-4.73-5.69-1.26"/>
<path d="M32.5 32.38L11.76 9.48M28.33 36.3L7.35 13.39M15.21 5.26L1.1 1.43l1.68 15.22M36.41 28.74l-11.8 11.62h0"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" height="60" width="60">
<svg xmlns="http://www.w3.org/2000/svg" height="60" width="60" viewBox="0 0 60 60">
<g stroke-width="2" fill="none" stroke="#000">
<path d="M34.26 4.96c-2.85.25-3.5 5.8-3.51 5.94L14.88 37.73l14.23 8.43 15.87-26.83H45s4.7-3.37 3.5-5.94a17.85 17.85 0 00-14.23-8.43z"/>
<path d="M14.93 52.6l5.51-2.4-5.98-3.36.46 5.8" fill="#000" stroke="none"/>

Before

Width:  |  Height:  |  Size: 539 B

After

Width:  |  Height:  |  Size: 559 B

View file

@ -80,7 +80,7 @@
renderingLine = createLine(data);
break;
case "child":
var line = (renderingLine.id == data.parent) ? renderingLine : svg.getElementById(data.parent);
var line = (renderingLine.id === data.parent) ? renderingLine : svg.getElementById(data.parent);
if (!line) {
console.error("Pencil: Hmmm... I received a point of a line that has not been created (%s).", data.parent);
line = renderingLine = createLine({ "id": data.parent }); //create a new line in order not to loose the points
@ -180,7 +180,7 @@
line.setAttribute("stroke", lineData.color || "black");
line.setAttribute("stroke-width", lineData.size || 10);
line.setAttribute("opacity", Math.max(0.1, Math.min(1, lineData.opacity)) || 1);
svg.appendChild(line);
Tools.drawingArea.appendChild(line);
return line;
}
@ -193,7 +193,7 @@
"release": stopLine,
},
"draw": draw,
"mouseCursor": "crosshair",
"mouseCursor": "url('tools/pencil/cursor.svg'), crosshair",
"icon": "tools/pencil/icon.svg",
"stylesheet": "tools/pencil/pencil.css"
});

View file

@ -1,4 +1,4 @@
/**
/**
* WHITEBOPHIR
*********************************************************
* @licstart The following is the entire license notice for the
@ -115,7 +115,7 @@
shape.setAttribute("stroke", data.color || "black");
shape.setAttribute("stroke-width", data.size || 10);
shape.setAttribute("opacity", Math.max(0.1, Math.min(1, data.opacity)) || 1);
svg.appendChild(shape);
Tools.drawingArea.appendChild(shape);
return shape;
}

View file

@ -25,7 +25,7 @@
*/
(function () { //Code isolation
var board = Tools.board, svg = Tools.svg;
var board = Tools.board;
var input = document.createElement("input");
input.id = "textToolInput";
@ -189,7 +189,7 @@
elem.setAttribute("fill", fieldData.color);
elem.setAttribute("opacity", Math.max(0.1, Math.min(1, fieldData.opacity)) || 1);
if (fieldData.txt) elem.textContent = fieldData.txt;
svg.appendChild(elem);
Tools.drawingArea.appendChild(elem);
return elem;
}

View file

@ -169,6 +169,7 @@
"mouseCursor": "zoom-in",
"icon": "tools/zoom/icon.svg",
"helpText": "click_to_zoom",
"showMarker": true,
};
Tools.add(zoomTool);
})(); //End of code isolation

View file

@ -0,0 +1,8 @@
const config = require("./configuration");
/** Settings that should be handed through to the clients */
module.exports = {
"MAX_BOARD_SIZE": config.MAX_BOARD_SIZE,
"MAX_EMIT_COUNT": config.MAX_EMIT_COUNT,
"MAX_EMIT_COUNT_PERIOD": config.MAX_EMIT_COUNT_PERIOD,
};

View file

@ -25,4 +25,10 @@ module.exports = {
/** Maximum value for any x or y on the board */
MAX_BOARD_SIZE: parseInt(process.env['WBO_MAX_BOARD_SIZE']) || 65536,
/** Maximum messages per user over the given time period before banning them */
MAX_EMIT_COUNT: parseInt(process.env['WBO_MAX_EMIT_COUNT']) || 128,
/** Duration after which the emit count is reset in miliseconds */
MAX_EMIT_COUNT_PERIOD: parseInt(process.env['WBO_MAX_EMIT_COUNT_PERIOD']) || 4096,
};

View file

@ -1,9 +1,7 @@
var iolib = require('socket.io')
, log = require("./log.js").log
, BoardData = require("./boardData.js").BoardData;
var MAX_EMIT_COUNT = 64; // Maximum number of draw operations before getting banned
var MAX_EMIT_COUNT_PERIOD = 5000; // Duration (in ms) after which the emit count is reset
, BoardData = require("./boardData.js").BoardData
, config = require("./configuration");
/** Map from name to *promises* of BoardData
@type {Object<string, Promise<BoardData>>}
@ -66,19 +64,21 @@ function socketConnection(socket) {
socket.on("joinboard", noFail(joinBoard));
var lastEmitSecond = Date.now() / MAX_EMIT_COUNT_PERIOD | 0;
var lastEmitSecond = Date.now() / config.MAX_EMIT_COUNT_PERIOD | 0;
var emitCount = 0;
socket.on('broadcast', noFail(function onBroadcast(message) {
var currentSecond = Date.now() / MAX_EMIT_COUNT_PERIOD | 0;
var currentSecond = Date.now() / config.MAX_EMIT_COUNT_PERIOD | 0;
if (currentSecond === lastEmitSecond) {
emitCount++;
if (emitCount > MAX_EMIT_COUNT) {
if (emitCount > config.MAX_EMIT_COUNT) {
var request = socket.client.request;
log('BANNED', {
user_agent: request.headers['user-agent'],
original_ip: request.headers['x-forwarded-for'] || request.headers['forwarded'],
emit_count: emitCount
});
if (emitCount % 100 === 0) {
log('BANNED', {
user_agent: request.headers['user-agent'],
original_ip: request.headers['x-forwarded-for'] || request.headers['forwarded'],
emit_count: emitCount
});
}
return;
}
} else {
@ -96,11 +96,12 @@ function socketConnection(socket) {
return;
}
//Send data to all other users connected on the same board
socket.broadcast.to(boardName).emit('broadcast', data);
// Save the message in the board
saveHistory(boardName, data);
handleMessage(boardName, data, socket);
//Send data to all other users connected on the same board
socket.broadcast.to(boardName).emit('broadcast', data);
}));
socket.on('disconnecting', function onDisconnecting(reason) {
@ -119,6 +120,14 @@ function socketConnection(socket) {
});
}
function handleMessage(boardName, message, socket) {
if (message.tool === "Cursor") {
message.socket = socket.id;
} else {
saveHistory(boardName, message);
}
}
async function saveHistory(boardName, message) {
var id = message.id;
var board = await getBoard(boardName);

View file

@ -3,6 +3,7 @@ const fs = require("fs");
const path = require("path");
const url = require("url");
const accept_language_parser = require('accept-language-parser');
const client_config = require("./client_configuration");
/**
* Associations from language to translation dictionnaries
@ -31,9 +32,10 @@ class Template {
const accept_languages = parsedUrl.query.lang || request.headers['accept-language'];
const language = accept_language_parser.pick(languages, accept_languages) || 'en';
const translations = TRANSLATIONS[language] || {};
const configuration = client_config || {};
const prefix = request.url.split("/boards/")[0].substr(1);
const baseUrl = findBaseUrl(request) + (prefix ? prefix + "/" : "");
return { baseUrl, languages, language, translations };
return { baseUrl, languages, language, translations, configuration };
}
serve(request, response) {
const parsedUrl = url.parse(request.url, true);

View file

@ -18,10 +18,8 @@ async function afterEach(browser, done) {
done();
}
function testBoard(browser) {
browser
.url('http://localhost:8487/boards/anonymous?lang=fr')
.waitForElementVisible('.tool[title ~= Crayon]') // pencil
function testPencil(browser) {
return browser
.assert.titleContains('WBO')
.click('.tool[title ~= Crayon]')
.assert.cssClassPresent('.tool[title ~= Crayon]', ['curTool'])
@ -36,7 +34,28 @@ function testBoard(browser) {
.assert.visible("path[d='M 100 200 C 100 200 300 400 300 400'][stroke='#123456']")
.refresh()
.assert.visible("path[d='M 100 200 C 100 200 300 400 300 400'][stroke='#123456']")
.end();
}
function testCursor(browser) {
return browser
.execute(function (done) {
Tools.setColor('#456123'); // Move the cursor over the board
var e = new Event("mousemove")
e.pageX = 150
e.pageY = 200
Tools.board.dispatchEvent(e)
})
.assert.cssProperty("#cursor-me", "transform", "matrix(1, 0, 0, 1, 150, 200)")
.assert.attributeEquals("#cursor-me", "fill", "#456123")
}
function testBoard(browser) {
var page = browser.url('http://localhost:8487/boards/anonymous?lang=fr')
.waitForElementVisible('.tool[title ~= Crayon]') // pencil
page = testPencil(page);
page = testCursor(page);
page.end();
}
module.exports = { beforeEach, testBoard, afterEach };