/** * WHITEBOPHIR ********************************************************* * @licstart The following is the entire license notice for the * JavaScript code in this page. * * Copyright (C) 2013 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 */ var Tools = {}; Tools.i18n = (function i18n() { var translations = JSON.parse(document.getElementById("translations").text); return { t: function translate(s) { var key = s.toLowerCase().replace(/ /g, "_"); return translations[key] || s; }, }; })(); 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.drawingEvent = true; Tools.showMarker = true; Tools.showOtherCursors = true; Tools.showMyCursor = true; Tools.isIE = /MSIE|Trident/.test(window.navigator.userAgent); Tools.socket = null; Tools.connect = function () { var self = this; // Destroy socket if one already exists if (self.socket) { self.socket.destroy(); delete self.socket; self.socket = null; } var url = new URL(window.location); var params = new URLSearchParams(url.search); var socket_params = { path: window.location.pathname.split("/boards/")[0] + "/socket.io", reconnection: true, reconnectionDelay: 100, //Make the xhr connections as fast as possible timeout: 1000 * 60 * 20, // Timeout after 20 minutes }; if (params.has("token")) { socket_params.query = "token=" + params.get("token"); } this.socket = io.connect("", socket_params); //Receive draw instructions from the server this.socket.on("broadcast", function (msg) { handleMessage(msg).finally(function afterload() { var loadingEl = document.getElementById("loadingMessage"); loadingEl.classList.add("hidden"); }); }); this.socket.on("reconnect", function onReconnection() { Tools.socket.emit("joinboard", Tools.boardName); }); }; Tools.connect(); Tools.boardName = (function () { var path = window.location.pathname.split("/"); return decodeURIComponent(path[path.length - 1]); })(); Tools.token = (function () { var url = new URL(window.location); var params = new URLSearchParams(url.search); return params.get("token"); })(); //Get the board as soon as the page is loaded Tools.socket.emit("getboard", Tools.boardName); function saveBoardNametoLocalStorage() { var boardName = Tools.boardName; if (boardName.toLowerCase() === "anonymous") return; var recentBoards, key = "recent-boards"; try { recentBoards = JSON.parse(localStorage.getItem(key)); if (!Array.isArray(recentBoards)) throw new Error("Invalid type"); } catch (e) { // On localstorage or json error, reset board list recentBoards = []; console.log("Board history loading error", e); } recentBoards = recentBoards.filter(function (name) { return name !== boardName; }); recentBoards.unshift(boardName); recentBoards = recentBoards.slice(0, 20); localStorage.setItem(key, JSON.stringify(recentBoards)); } // Refresh recent boards list on each page show window.addEventListener("pageshow", saveBoardNametoLocalStorage); Tools.HTML = { template: new Minitpl("#tools > .tool"), addShortcut: function addShortcut(key, callback) { window.addEventListener("keydown", function (e) { if (e.key === key && !e.target.matches("input[type=text], textarea")) { callback(); } }); }, addTool: function (toolName, toolIcon, toolIconHTML, toolShortcut, oneTouch) { var callback = function () { Tools.change(toolName); }; this.addShortcut(toolShortcut, function () { Tools.change(toolName); document.activeElement.blur && document.activeElement.blur(); }); return this.template.add(function (elem) { elem.addEventListener("click", callback); elem.id = "toolID-" + toolName; elem.getElementsByClassName("tool-name")[0].textContent = Tools.i18n.t(toolName); var toolIconElem = elem.getElementsByClassName("tool-icon")[0]; toolIconElem.src = toolIcon; toolIconElem.alt = toolIcon; if (oneTouch) elem.classList.add("oneTouch"); elem.title = Tools.i18n.t(toolName) + " (" + Tools.i18n.t("keyboard shortcut") + ": " + toolShortcut + ")" + (Tools.list[toolName].secondary ? " [" + Tools.i18n.t("click_to_toggle") + "]" : ""); if (Tools.list[toolName].secondary) { elem.classList.add("hasSecondary"); var secondaryIcon = elem.getElementsByClassName("secondaryIcon")[0]; secondaryIcon.src = Tools.list[toolName].secondary.icon; toolIconElem.classList.add("primaryIcon"); } }); }, changeTool: function (oldToolName, newToolName) { var oldTool = document.getElementById("toolID-" + oldToolName); var newTool = document.getElementById("toolID-" + newToolName); if (oldTool) oldTool.classList.remove("curTool"); if (newTool) newTool.classList.add("curTool"); }, toggle: function (toolName, name, icon) { var elem = document.getElementById("toolID-" + toolName); // Change secondary icon var primaryIcon = elem.getElementsByClassName("primaryIcon")[0]; var secondaryIcon = elem.getElementsByClassName("secondaryIcon")[0]; var primaryIconSrc = primaryIcon.src; var secondaryIconSrc = secondaryIcon.src; primaryIcon.src = secondaryIconSrc; secondaryIcon.src = primaryIconSrc; // Change primary icon elem.getElementsByClassName("tool-icon")[0].src = icon; elem.getElementsByClassName("tool-name")[0].textContent = Tools.i18n.t(name); }, addStylesheet: function (href) { //Adds a css stylesheet to the html or svg document var link = document.createElement("link"); link.href = href; link.rel = "stylesheet"; link.type = "text/css"; document.head.appendChild(link); }, colorPresetTemplate: new Minitpl("#colorPresetSel .colorPresetButton"), addColorButton: function (button) { var setColor = Tools.setColor.bind(Tools, button.color); if (button.key) this.addShortcut(button.key, setColor); return this.colorPresetTemplate.add(function (elem) { elem.addEventListener("click", setColor); elem.id = "color_" + button.color.replace(/^#/, ""); elem.style.backgroundColor = button.color; if (button.key) { elem.title = Tools.i18n.t("keyboard shortcut") + ": " + button.key; } }); }, }; Tools.list = {}; // An array of all known tools. {"toolName" : {toolObject}} Tools.isBlocked = function toolIsBanned(tool) { if (tool.name.includes(",")) throw new Error("Tool Names must not contain a comma"); return Tools.server_config.BLOCKED_TOOLS.includes(tool.name); }; /** * Register a new tool, without touching the User Interface */ Tools.register = function registerTool(newTool) { if (Tools.isBlocked(newTool)) return; if (newTool.name in Tools.list) { console.log( "Tools.add: The tool '" + newTool.name + "' is already" + "in the list. Updating it...", ); } //Format the new tool correctly Tools.applyHooks(Tools.toolHooks, newTool); //Add the tool to the list Tools.list[newTool.name] = newTool; // Register the change handlers if (newTool.onSizeChange) Tools.sizeChangeHandlers.push(newTool.onSizeChange); //There may be pending messages for the tool var pending = Tools.pendingMessages[newTool.name]; if (pending) { console.log("Drawing pending messages for '%s'.", newTool.name); var msg; while ((msg = pending.shift())) { //Transmit the message to the tool (precising that it comes from the network) newTool.draw(msg, false); } } }; /** * Add a new tool to the user interface */ Tools.add = function (newTool) { if (Tools.isBlocked(newTool)) return; 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, newTool.oneTouch, ); }; Tools.change = function (toolName) { var newTool = Tools.list[toolName]; var oldTool = Tools.curTool; if (!newTool) throw new Error("Trying to select a tool that has never been added!"); if (newTool === oldTool) { if (newTool.secondary) { newTool.secondary.active = !newTool.secondary.active; var props = newTool.secondary.active ? newTool.secondary : newTool; Tools.HTML.toggle(newTool.name, props.name, props.icon); if (newTool.secondary.switch) newTool.secondary.switch(); } return; } if (!newTool.oneTouch) { //Update the GUI var curToolName = Tools.curTool ? Tools.curTool.name : ""; try { Tools.HTML.changeTool(curToolName, toolName); } catch (e) { console.error("Unable to update the GUI with the new tool. " + e); } Tools.svg.style.cursor = newTool.mouseCursor || "auto"; Tools.board.title = Tools.i18n.t(newTool.helpText || ""); //There is not necessarily already a curTool if (Tools.curTool !== null) { //It's useless to do anything if the new tool is already selected if (newTool === Tools.curTool) return; //Remove the old event listeners Tools.removeToolListeners(Tools.curTool); //Call the callbacks of the old tool Tools.curTool.onquit(newTool); } //Add the new event listeners Tools.addToolListeners(newTool); Tools.curTool = newTool; } //Call the start callback of the new tool newTool.onstart(oldTool); }; Tools.addToolListeners = function addToolListeners(tool) { for (var event in tool.compiledListeners) { var listener = tool.compiledListeners[event]; var target = listener.target || Tools.board; target.addEventListener(event, listener, { passive: false }); } }; Tools.removeToolListeners = function removeToolListeners(tool) { for (var event in tool.compiledListeners) { var listener = tool.compiledListeners[event]; var target = listener.target || Tools.board; target.removeEventListener(event, listener); // also attempt to remove with capture = true in IE if (Tools.isIE) target.removeEventListener(event, listener, true); } }; (function () { // Handle secondary tool switch with shift (key code 16) function handleShift(active, evt) { if ( evt.keyCode === 16 && Tools.curTool.secondary && Tools.curTool.secondary.active !== active ) { Tools.change(Tools.curTool.name); } } window.addEventListener("keydown", handleShift.bind(null, true)); window.addEventListener("keyup", handleShift.bind(null, false)); })(); Tools.send = function (data, toolName) { toolName = toolName || Tools.curTool.name; data.tool = toolName; Tools.applyHooks(Tools.messageHooks, data); var message = { board: Tools.boardName, data: data, }; Tools.socket.emit("broadcast", message); }; 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 //is loaded. keys : the name of the tool, values : array of messages for this tool Tools.pendingMessages = {}; // Send a message to the corresponding tool function messageForTool(message) { var name = message.tool, tool = Tools.list[name]; if (tool) { Tools.applyHooks(Tools.messageHooks, message); tool.draw(message, false); } else { ///We received a message destinated to a tool that we don't have //So we add it to the pending messages if (!Tools.pendingMessages[name]) Tools.pendingMessages[name] = [message]; else Tools.pendingMessages[name].push(message); } if (message.tool !== "Hand" && message.transform != null) { //this message has special info for the mover messageForTool({ tool: "Hand", type: "update", transform: message.transform, id: message.id, }); } } var BATCH_SIZE = 1024; /** * Apply the function to all arguments by batches * @param {Function} fn - The function to apply to the arguments * @param {Array} args - The arguments to apply the function to * @param {number} [index] - The index to start from * @returns {Promise} */ function batchCall(fn, args, index) { index = index | 0; if (index >= args.length) { return Promise.resolve(); } else { var batch = args.slice(index, index + BATCH_SIZE); return Promise.all(batch.map(fn)) .then(function () { return new Promise(requestAnimationFrame); }) .then(function () { return batchCall(fn, args, index + BATCH_SIZE); }); } } // Call messageForTool recursively on the message and its children function handleMessage(message) { //Check if the message is in the expected format if (!message.tool && !message._children) { console.error("Received a badly formatted message (no tool). ", message); } if (message.tool) messageForTool(message); if (message._children) return batchCall(childMessageHandler(message), message._children); else return Promise.resolve(); } // Takes a parent message, and returns a function that will handle a single child message function childMessageHandler(parent) { if (!parent.id) return handleMessage; return function handleChild(child) { child.parent = parent.id; child.tool = parent.tool; child.type = "child"; return handleMessage(child); }; } Tools.unreadMessagesCount = 0; Tools.newUnreadMessage = function () { Tools.unreadMessagesCount++; updateDocumentTitle(); }; window.addEventListener("focus", function () { Tools.unreadMessagesCount = 0; updateDocumentTitle(); }); function updateDocumentTitle() { document.title = (Tools.unreadMessagesCount ? "(" + Tools.unreadMessagesCount + ") " : "") + Tools.boardName + " | WBO"; } (function () { // Scroll and hash handling var scrollTimeout, lastStateUpdate = Date.now(); window.addEventListener("scroll", function onScroll() { var scale = Tools.getScale(); var x = document.documentElement.scrollLeft / scale, y = document.documentElement.scrollTop / scale; clearTimeout(scrollTimeout); scrollTimeout = setTimeout(function updateHistory() { var hash = "#" + (x | 0) + "," + (y | 0) + "," + Tools.getScale().toFixed(1); if ( Date.now() - lastStateUpdate > 5000 && hash !== window.location.hash ) { window.history.pushState({}, "", hash); lastStateUpdate = Date.now(); } else { window.history.replaceState({}, "", hash); } }, 100); }); function setScrollFromHash() { var coords = window.location.hash.slice(1).split(","); var x = coords[0] | 0; var y = coords[1] | 0; var scale = parseFloat(coords[2]); resizeCanvas({ x: x, y: y }); Tools.setScale(scale); window.scrollTo(x * scale, y * scale); } window.addEventListener("hashchange", setScrollFromHash, false); window.addEventListener("popstate", setScrollFromHash, false); window.addEventListener("DOMContentLoaded", setScrollFromHash, false); })(); function resizeCanvas(m) { //Enlarge the canvas whenever something is drawn near its border var x = m.x | 0, y = m.y | 0; var MAX_BOARD_SIZE = Tools.server_config.MAX_BOARD_SIZE || 65536; // Maximum value for any x or y on the board if (x > Tools.svg.width.baseVal.value - 2000) { Tools.svg.width.baseVal.value = Math.min(x + 2000, MAX_BOARD_SIZE); } if (y > Tools.svg.height.baseVal.value - 2000) { Tools.svg.height.baseVal.value = Math.min(y + 2000, MAX_BOARD_SIZE); } } function updateUnreadCount(m) { if (document.hidden && ["child", "update"].indexOf(m.type) === -1) { Tools.newUnreadMessage(); } } // List of hook functions that will be applied to messages before sending or drawing them Tools.messageHooks = [resizeCanvas, updateUnreadCount]; Tools.scale = 1.0; var scaleTimeout = null; Tools.setScale = function setScale(scale) { var fullScale = Math.max(window.innerWidth, window.innerHeight) / Tools.server_config.MAX_BOARD_SIZE; var minScale = Math.max(0.1, fullScale); var maxScale = 10; if (isNaN(scale)) scale = 1; scale = Math.max(minScale, Math.min(maxScale, scale)); Tools.svg.style.willChange = "transform"; Tools.svg.style.transform = "scale(" + scale + ")"; clearTimeout(scaleTimeout); scaleTimeout = setTimeout(function () { Tools.svg.style.willChange = "auto"; }, 1000); Tools.scale = scale; return scale; }; Tools.getScale = function getScale() { return Tools.scale; }; //List of hook functions that will be applied to tools before adding them Tools.toolHooks = [ function checkToolAttributes(tool) { if (typeof tool.name !== "string") throw "A tool must have a name"; if (typeof tool.listeners !== "object") { tool.listeners = {}; } if (typeof tool.onstart !== "function") { tool.onstart = function () {}; } if (typeof tool.onquit !== "function") { tool.onquit = function () {}; } }, function compileListeners(tool) { //compile listeners into compiledListeners var listeners = tool.listeners; //A tool may provide precompiled listeners var compiled = tool.compiledListeners || {}; tool.compiledListeners = compiled; function compile(listener) { //closure return function listen(evt) { var x = evt.pageX / Tools.getScale(), y = evt.pageY / Tools.getScale(); return listener(x, y, evt, false); }; } function compileTouch(listener) { //closure return function touchListen(evt) { //Currently, we don't handle multitouch if (evt.changedTouches.length === 1) { //evt.preventDefault(); var touch = evt.changedTouches[0]; var x = touch.pageX / Tools.getScale(), y = touch.pageY / Tools.getScale(); return listener(x, y, evt, true); } return true; }; } function wrapUnsetHover(f, toolName) { return function unsetHover(evt) { document.activeElement && document.activeElement.blur && document.activeElement.blur(); return f(evt); }; } if (listeners.press) { compiled["mousedown"] = wrapUnsetHover( compile(listeners.press), tool.name, ); compiled["touchstart"] = wrapUnsetHover( compileTouch(listeners.press), tool.name, ); } if (listeners.move) { compiled["mousemove"] = compile(listeners.move); compiled["touchmove"] = compileTouch(listeners.move); } if (listeners.release) { var release = compile(listeners.release), releaseTouch = compileTouch(listeners.release); compiled["mouseup"] = release; if (!Tools.isIE) compiled["mouseleave"] = release; compiled["touchleave"] = releaseTouch; compiled["touchend"] = releaseTouch; compiled["touchcancel"] = releaseTouch; } }, ]; Tools.applyHooks = function (hooks, object) { //Apply every hooks on the object hooks.forEach(function (hook) { hook(object); }); }; // Utility functions Tools.generateUID = function (prefix, suffix) { var uid = Date.now().toString(36); //Create the uids in chronological order uid += Math.round(Math.random() * 36).toString(36); //Add a random character at the end if (prefix) uid = prefix + uid; if (suffix) uid = uid + suffix; return uid; }; Tools.createSVGElement = function createSVGElement(name, attrs) { var elem = document.createElementNS(Tools.svg.namespaceURI, name); if (typeof attrs !== "object") return elem; Object.keys(attrs).forEach(function (key, i) { elem.setAttributeNS(null, key, attrs[key]); }); return elem; }; Tools.positionElement = function (elem, x, y) { elem.style.top = y + "px"; elem.style.left = x + "px"; }; Tools.colorPresets = [ { color: "#001f3f", key: "1" }, { color: "#FF4136", key: "2" }, { color: "#0074D9", key: "3" }, { color: "#FF851B", key: "4" }, { color: "#FFDC00", key: "5" }, { color: "#3D9970", key: "6" }, { color: "#91E99B", key: "7" }, { color: "#90468b", key: "8" }, { color: "#7FDBFF", key: "9" }, { color: "#AAAAAA", key: "0" }, { color: "#E65194" }, ]; Tools.color_chooser = document.getElementById("chooseColor"); Tools.setColor = function (color) { Tools.color_chooser.value = color; }; Tools.getColor = (function color() { var color_index = (Math.random() * Tools.colorPresets.length) | 0; var initial_color = Tools.colorPresets[color_index].color; Tools.setColor(initial_color); return function () { return Tools.color_chooser.value; }; })(); Tools.colorPresets.forEach(Tools.HTML.addColorButton.bind(Tools.HTML)); Tools.sizeChangeHandlers = []; Tools.setSize = (function size() { var chooser = document.getElementById("chooseSize"); function update() { var size = Math.max(1, Math.min(50, chooser.value | 0)); chooser.value = size; Tools.sizeChangeHandlers.forEach(function (handler) { handler(size); }); } update(); chooser.onchange = chooser.oninput = update; return function (value) { if (value !== null && value !== undefined) { chooser.value = value; update(); } return parseInt(chooser.value); }; })(); Tools.getSize = function () { return Tools.setSize(); }; Tools.getOpacity = (function opacity() { var chooser = document.getElementById("chooseOpacity"); var opacityIndicator = document.getElementById("opacityIndicator"); function update() { opacityIndicator.setAttribute("opacity", chooser.value); } update(); chooser.onchange = chooser.oninput = update; return function () { return Math.max(0.1, Math.min(1, chooser.value)); }; })(); //Scale the canvas on load Tools.svg.width.baseVal.value = document.body.clientWidth; Tools.svg.height.baseVal.value = document.body.clientHeight; /** What does a "tool" object look like? newtool = { "name" : "SuperTool", "listeners" : { "press" : function(x,y,evt){...}, "move" : function(x,y,evt){...}, "release" : function(x,y,evt){...}, }, "draw" : function(data, isLocal){ //Print the data on Tools.svg }, "onstart" : function(oldTool){...}, "onquit" : function(newTool){...}, "stylesheet" : "style.css", } */ (function () { var pos = { top: 0, scroll: 0 }; var menu = document.getElementById("menu"); function menu_mousedown(evt) { pos = { top: menu.scrollTop, scroll: evt.clientY, }; menu.addEventListener("mousemove", menu_mousemove); document.addEventListener("mouseup", menu_mouseup); } function menu_mousemove(evt) { var dy = evt.clientY - pos.scroll; menu.scrollTop = pos.top - dy; } function menu_mouseup(evt) { menu.removeEventListener("mousemove", menu_mousemove); document.removeEventListener("mouseup", menu_mouseup); } menu.addEventListener("mousedown", menu_mousedown); })();