diff --git a/server/components/adaptor/room.js b/server/components/adaptor/room.js index 67ab019..fea8417 100644 --- a/server/components/adaptor/room.js +++ b/server/components/adaptor/room.js @@ -1,361 +1,370 @@ /* * Copyright (C) 2015 KO GmbH * * @licstart * This file is part of Kotype. * * Kotype is free software: you can redistribute it and/or modify it * under the terms of the GNU Affero General Public License (GNU AGPL) * as published by the Free Software Foundation, either version 3 of * the License, or (at your option) any later version. * * Kotype is distributed in the hope that it will be useful, but * WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Kotype. If not, see . * @licend * * @source: https://github.com/kogmbh/Kotype/ */ var async = require("async"); var _ = require("lodash"); +var RColor = require('../colors'); var DocumentChunk = require("../../api/document/document.model").DocumentChunk; var Room = function (doc, objectCache, cb) { var document, chunk, hasCursor = {}, sockets = [], + userColorMap = {}, + randomColor = new RColor(), serverSeq = 0; function trackTitle(ops) { var newTitle, i; for (i = 0; i < ops.length; i += 1) { if (ops[i].optype === "UpdateMetadata" && ops[i].setProperties["dc:title"] !== undefined) { newTitle = ops[i].setProperties["dc:title"]; } } if (newTitle !== undefined) { if (newTitle.length === 0) { newTitle = "Untitled Document"; } } if (newTitle) { document.title = newTitle; } } function trackEditors() { // TODO: rather track by ops, to decouple from socket implementation sockets.forEach(function (socket) { var _id = socket.user._id; if (document.editors.indexOf(_id) === -1) { document.editors.push(_id); } }); } function trackCursors(ops) { var i; for (i = 0; i < ops.length; i += 1) { if (ops[i].optype === "AddCursor") { hasCursor[ops[i].memberid] = true; } if (ops[i].optype === "RemoveCursor") { hasCursor[ops[i].memberid] = false; } } } function sanitizeDocument() { var ops = chunk.operations, unbalancedCursors = {}, unbalancedMembers = {}, lastAccessDate = document.date, newOps = [], i; for (i = 0; i < ops.length; i += 1) { if (ops[i].optype === "AddCursor") { unbalancedCursors[ops[i].memberid] = true; } else if (ops[i].optype === "RemoveCursor") { unbalancedCursors[ops[i].memberid] = false; } else if (ops[i].optype === "AddMember") { unbalancedMembers[ops[i].memberid] = true; } else if (ops[i].optype === "RemoveMember") { unbalancedMembers[ops[i].memberid] = false; } } Object.keys(unbalancedCursors).forEach(function (memberId) { if (unbalancedCursors[memberId]) { newOps.push({ optype: "RemoveCursor", memberid: memberId, timestamp: lastAccessDate }); } }); Object.keys(unbalancedMembers).forEach(function (memberId) { if (unbalancedMembers[memberId]) { newOps.push({ optype: "RemoveMember", memberid: memberId, timestamp: lastAccessDate }); } }); if (newOps.length) { // Update op stack chunk.operations = chunk.operations.concat(newOps); serverSeq = chunk.operations.length; } } function broadcastMessage(message, data) { sockets.forEach(function (peerSocket) { peerSocket.emit(message, data) }); } function sendOpsToMember(socket, ops) { socket.emit("new_ops", { head: serverSeq, ops: ops }); } function replayOpsToMember(socket) { socket.emit("replay", { head: serverSeq, ops: chunk.operations }); } function broadcastOpsByMember(socket, ops) { if (!ops.length) { return; } sockets.forEach(function (peerSocket) { if (peerSocket !== socket) { sendOpsToMember(peerSocket, ops); } }); } function writeOpsToDocument(ops, cb) { if (!ops.length) { cb(); } trackTitle(ops); trackEditors(); // Update op stack chunk.operations = chunk.operations.concat(ops); serverSeq = chunk.operations.length; // Update modified date document.date = new Date(); cb(); } function addMember(user, cb) { var memberId, op, - timestamp = Date.now(); + timestamp = Date.now(), + color = userColorMap[user._id]; memberId = user.name + "_" + timestamp.toString(); + // Let user colors persist in a Room even after they've + // left and joined. + if (!color) { + userColorMap[user._id] = color = randomColor.get(true, 0.7); + } op = { optype: "AddMember", memberid: memberId, timestamp: timestamp, setProperties: { fullName: user.name, - color: "red" + color: color } }; writeOpsToDocument([op], function () { cb(memberId, [op]); }); } function removeMember(memberId, cb) { var ops = [], timestamp = Date.now(); if (hasCursor[memberId]) { ops.push({ optype: "RemoveCursor", memberid: memberId, timestamp: timestamp }); } ops.push({ optype: "RemoveMember", memberid: memberId, timestamp: timestamp }); writeOpsToDocument(ops, function () { cb(ops); }); } function getOpsAfter(basedOn) { return chunk.operations.slice(basedOn, serverSeq); } this.socketCount = function () { return sockets.length; }; this.attachSocket = function (socket) { // Add the socket to the room and give the // client it's unique memberId addMember(socket.user, function (memberId, ops) { socket.memberId = memberId; sockets.push(socket); broadcastOpsByMember(socket, ops); socket.emit("join_success", { memberId: memberId }); // Service replay requests socket.on("replay", function () { replayOpsToMember(socket); }); // Store, analyze, and broadcast incoming commits socket.on("commit_ops", function (data, cb) { var clientSeq = data.head, ops = data.ops; if (clientSeq === serverSeq) { writeOpsToDocument(ops, function () { cb({ conflict: false, head: serverSeq }); trackCursors(ops); broadcastOpsByMember(socket, data.ops); }); } else { cb({ conflict: true }); } }); // Service various requests socket.on("access_get", function (data, cb) { cb({ access: document.isPublic ? "public" : "normal" }); }); if (socket.user.identity !== "guest") { socket.on("access_change", function (data) { document.isPublic = data.access === "public"; broadcastMessage("access_changed", { access: data.access === "public" ? "public" : "normal" }); if (data.access !== "public") { sockets.forEach(function (peerSocket) { if (peerSocket.user.identity === "guest") { console.log(peerSocket.user.name); removeSocket(peerSocket); } }); } }); } // Handle dropout events socket.on("leave", function () { removeSocket(socket); }); socket.on("disconnect", function () { removeSocket(socket); }); }); }; function detachSocket(socket, callback) { removeMember(socket.memberId, function (ops) { broadcastOpsByMember(socket, ops); socket.removeAllListeners(); function lastCB() { socket.removeAllListeners(); if (callback) { callback(); } } // If a socket that is already connected is being // removed, this means that this is a deliberate // kicking-out, and not a natural event that could // result in a reconnection later. Therefore, clean // up. if (socket.connected) { console.log(socket.user.name + " is connected, removing"); socket.on('disconnect', lastCB); socket.emit("kick"); socket.emit("disconnect"); } else { console.log(socket.user.name + " is not connected, removing"); lastCB(); } }); } function removeSocket(socket) { var index = sockets.indexOf(socket); detachSocket(socket); if (index !== -1) { sockets.splice(index, 1); } } this.getDocument = function () { return document; }; this.destroy = function (callback) { async.each(sockets, function (socket, cb) { detachSocket(socket, cb); }, function () { sockets.length = 0; callback(); }); }; function init() { // Setup caching document = objectCache.getTrackedObject(doc); DocumentChunk.findById(_.last(document.chunks), function (err, lastChunk) { chunk = objectCache.getTrackedObject(lastChunk); console.log(chunk); // Sanitize leftovers from previous session, if any sanitizeDocument(); cb(); }); } init(); }; module.exports = Room; diff --git a/server/components/colors/index.js b/server/components/colors/index.js new file mode 100644 index 0000000..4a93c00 --- /dev/null +++ b/server/components/colors/index.js @@ -0,0 +1,80 @@ +// Free to use & distribute under the MIT license +// Wes Johnson (@SterlingWes) +// +// inspired by http://martin.ankerl.com/2009/12/09/how-to-create-random-colors-programmatically/ + +/*global module*/ + +var RColor = function () { + "use strict"; + this.hue = Math.random(); + this.goldenRatio = 0.618033988749895; + this.hexwidth = 2; +}; + +RColor.prototype.hsvToRgb = function (h, s, v) { + "use strict"; + var h_i = Math.floor(h * 6), + f = h * 6 - h_i, + p = v * (1 - s), + q = v * (1 - f * s), + t = v * (1 - (1 - f) * s), + r = 255, + g = 255, + b = 255; + function rgb(rr, gg, bb) { + r = rr; + g = gg; + b = bb; + } + switch (h_i) { + case 0: + rgb(v, t, p); + break; + case 1: + rgb(q, v, p); + break; + case 2: + rgb(p, v, t); + break; + case 3: + rgb(p, q, v); + break; + case 4: + rgb(t, p, v); + break; + case 5: + rgb(v, p, q); + break; + } + return [Math.floor(r * 256), Math.floor(g * 256), Math.floor(b * 256)]; +}; + +RColor.prototype.padHex = function (str) { + "use strict"; + if (str.length > this.hexwidth) { + return str; + } + return new Array(this.hexwidth - str.length + 1).join('0') + str; +}; + +RColor.prototype.get = function (hex, saturation, value) { + "use strict"; + this.hue += this.goldenRatio; + this.hue %= 1; + if (typeof saturation !== "number") { + saturation = 0.5; + } + if (typeof value !== "number") { + value = 0.95; + } + var rgb = this.hsvToRgb(this.hue, saturation, value); + if (hex) { + return "#" + this.padHex(rgb[0].toString(16)) + + this.padHex(rgb[1].toString(16)) + + this.padHex(rgb[2].toString(16)); + } + return rgb; +}; + +module.exports = RColor;