diff --git a/customize.dist/src/less2/include/cursor.less b/customize.dist/src/less2/include/cursor.less new file mode 100644 index 000000000..e70e877a1 --- /dev/null +++ b/customize.dist/src/less2/include/cursor.less @@ -0,0 +1,34 @@ +.cursor_main() { + // CodeMirror + .cp-codemirror-cursor { + cursor: default; + background-color: red; + background-clip: padding-box; + padding: 0 1px; + border: 2px solid red; + border-right-color: transparent !important; + border-left-color: transparent !important; + } + .cp-codemirror-selection { + background-color: rgba(255,0,0,0.3); + } + + // Tippy + .cp-cursor-avatar { + @size: 32px; + display: flex; + align-items: center; + media-tag { + min-height: @size; + max-height: @size; + min-width: @size; + max-width: @size; + margin-right: 10px; + img { + border-radius: 4px; + max-height: 100%; + max-width: 100%; + } + } + } +} diff --git a/customize.dist/src/less2/include/framework.less b/customize.dist/src/less2/include/framework.less index a273e8f77..ef6cee1f1 100644 --- a/customize.dist/src/less2/include/framework.less +++ b/customize.dist/src/less2/include/framework.less @@ -13,6 +13,7 @@ @import (reference) "./app-print.less"; @import (reference) "./app-noscroll.less"; @import (reference) "./messenger.less"; +@import (reference) "./cursor.less"; .framework_main(@bg-color, @warn-color, @color) { --LessLoader_require: LessLoader_currentFile(); @@ -38,6 +39,7 @@ .checkmark_main(20px); .password_main(); .messenger_main(); + .cursor_main(); .creation_main( @bg-color: @bg-color, @color: @color diff --git a/customize.dist/src/less2/include/toolbar.less b/customize.dist/src/less2/include/toolbar.less index 5651794d8..15ef59d62 100644 --- a/customize.dist/src/less2/include/toolbar.less +++ b/customize.dist/src/less2/include/toolbar.less @@ -235,6 +235,7 @@ padding: 5px; margin: 2px 0; background: rgba(0,0,0,0.1); + border-right: 3px solid transparent; .avatar_main(30px); .cp-avatar-default, media-tag { margin-right: 5px; diff --git a/www/code/app-code.less b/www/code/app-code.less index bb53380c5..b2b3a1a64 100644 --- a/www/code/app-code.less +++ b/www/code/app-code.less @@ -9,13 +9,6 @@ @color: @colortheme_code-color ); - .cp-codemirror-cursor { - border-left: 2px solid red; - } - .cp-codemirror-selection { - background-color: rgba(255,0,0,0.3); - } - display: flex; flex-flow: column; max-height: 100%; diff --git a/www/code/inner.js b/www/code/inner.js index e3885283a..e37d21c72 100644 --- a/www/code/inner.js +++ b/www/code/inner.js @@ -304,7 +304,7 @@ define([ framework.onCursorUpdate(CodeMirror.setRemoteCursor); framework.setCursorGetter(CodeMirror.getCursor); editor.on('cursorActivity', function () { - if (editor._noCursorUpdate) { console.log('ok'); return; } + if (editor._noCursorUpdate) { return; } framework.updateCursor(); }); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 741b71e10..d69658e94 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -1211,6 +1211,13 @@ define([ var emojis = emojiStringToArray(str); return isEmoji(emojis[0])? emojis[0]: str[0]; }; + var avatars = {}; + UIElements.setAvatar = function (hash, data) { + avatars[hash] = data; + }; + UIElements.getAvatar = function (hash) { + return avatars[hash]; + }; UIElements.displayAvatar = function (Common, $container, href, name, cb) { var displayDefault = function () { var text = getFirstEmojiOrCharacter(name); diff --git a/www/common/common-util.js b/www/common/common-util.js index edc7e7074..e9c4c1978 100644 --- a/www/common/common-util.js +++ b/www/common/common-util.js @@ -297,6 +297,15 @@ define([], function () { return false; }; + Util.hexToRGB = function (hex) { + var h = hex.replace(/^#/, ''); + return [ + parseInt(h.slice(0,2), 16), + parseInt(h.slice(2,4), 16), + parseInt(h.slice(4,6), 16), + ]; + }; + return Util; }); }(self)); diff --git a/www/common/outer/async-store.js b/www/common/outer/async-store.js index 3267ee99d..628f2c7c2 100644 --- a/www/common/outer/async-store.js +++ b/www/common/outer/async-store.js @@ -417,6 +417,27 @@ define([ /////////////////////// Store //////////////////////////////////// ////////////////////////////////////////////////////////////////// + // Get or create the user color for the cursor position + var getRandomColor = function () { + var getColor = function () { + return Math.floor(Math.random() * 156) + 70; + }; + return '#' + getColor().toString(16) + + getColor().toString(16) + + getColor().toString(16); + }; + var getUserColor = function () { + var color = Util.find(store.proxy, ['settings', 'general', 'color']); + if (!color) { + color = getRandomColor(); + Store.setAttribute(null, { + attr: ['general', 'color'], + value: color + }, function () {}); + } + return color; + }; + // Get the metadata for sframe-common-outer Store.getMetadata = function (clientId, data, cb) { var disableThumbnails = Util.find(store.proxy, ['settings', 'general', 'disableThumbnails']); @@ -427,6 +448,7 @@ define([ uid: store.proxy.uid, avatar: Util.find(store.proxy, ['profile', 'avatar']), profile: Util.find(store.proxy, ['profile', 'view']), + color: getUserColor(), curvePublic: store.proxy.curvePublic, }, // "priv" is not shared with other users but is needed by the apps diff --git a/www/common/outer/cursor.js b/www/common/outer/cursor.js new file mode 100644 index 000000000..a699f990b --- /dev/null +++ b/www/common/outer/cursor.js @@ -0,0 +1,245 @@ +define([ + '/common/common-util.js', + '/common/common-constants.js', + '/customize/messages.js', + '/bower_components/chainpad-crypto/crypto.js', +], function (Util, Constants, Messages, Crypto) { + var Cursor = {}; + + var convertToUint8 = function (obj) { + var l = Object.keys(obj).length; + var u = new Uint8Array(l); + for (var i = 0; i Set the ID to our client object + if (!c.id) { c.id = chan.wc.myID + '-' + client; } + + // ==> Send the cursor position of the other tabs + chan.clients.forEach(function (cl) { + var clientObj = ctx.clients[cl]; + if (!clientObj) { return; } + ctx.emit('MESSAGE', { + id: clientObj.id, + cursor: clientObj.cursor + }, [client]); + }); + chan.sendMsg(JSON.stringify({join: true, id: c.id})); + + // ==> And push the new tab to the list + chan.clients.push(client); + return void cb(); + } + + var onOpen = function (wc) { + + ctx.channels[channel] = ctx.channels[channel] || {}; + + var chan = ctx.channels[channel]; + if (!c.id) { c.id = wc.myID + '-' + client; } + if (chan.clients) { + // If 2 tabs from the same worker have been opened at the same time, + // we have to fix both of them + chan.clients.forEach(function (cl) { + if (ctx.clients[cl] && !ctx.clients[cl].id) { + ctx.clients[cl].id = wc.myID + '-' + cl; + } + }); + } + + + chan.encryptor = Crypto.createEncryptor(secret.keys); + if (!chan.encryptor) { chan.encryptor = Crypto.createEncryptor(secret.keys); } + + wc.on('join', function () { + sendOurCursors(ctx, chan); + }); + wc.on('leave', function (peer) { + ctx.emit('MESSAGE', {leave: true, id: peer}, chan.clients); + }); + wc.on('message', function (cryptMsg) { + var msg = chan.encryptor.decrypt(cryptMsg, secret.keys.validateKey); + var parsed; + try { + parsed = JSON.parse(msg); + if (parsed && parsed.join) { + return void sendOurCursors(ctx, chan); + } + ctx.emit('MESSAGE', parsed, chan.clients); + } catch (e) { console.error(e); } + }); + + chan.wc = wc; + chan.sendMsg = function (msg, cb) { + cb = cb || function () {}; + var cmsg = chan.encryptor.encrypt(msg); + wc.bcast(cmsg).then(function () { + cb(); + }, function (err) { + cb({error: err}); + }); + }; + + if (!first) { return; } + chan.clients = [client]; + first = false; + cb(); + }; + + network.join(channel).then(onOpen, function (err) { + return void cb({error: err}); + }); + + network.on('reconnect', function () { + if (!ctx.channels[channel]) { console.log("cant reconnect", channel); return; } + network.join(channel).then(onOpen, function (err) { + console.error(err); + }); + }); + }; + + var updateCursor = function (ctx, data, client, cb) { + var c = ctx.clients[client]; + if (!c) { return void cb({error: 'NO_CLIENT'}); } + data.color = Util.find(ctx.store.proxy, ['settings', 'general', 'color']); + data.name = ctx.store.proxy[Constants.displayNameKey] || Messages.anonymous; + data.avatar = Util.find(ctx.store.proxy, ['profile', 'avatar']); + console.log(data.color); + c.cursor = data; + sendMyCursor(ctx, client); + cb(); + }; + + var leaveChannel = function (ctx, padChan) { + // Leave channel and prevent reconnect when we leave a pad + Object.keys(ctx.channels).some(function (cursorChan) { + var channel = ctx.channels[cursorChan]; + if (channel.padChan !== padChan) { return; } + if (channel.wc) { channel.wc.leave(); } + delete ctx.channels[cursorChan]; + return true; + }); + }; + // Remove the client from all its channels when a tab is closed + var removeClient = function (ctx, clientId) { + var filter = function (c) { + return c !== clientId; + }; + + // Remove the client from our channels + var chan; + for (var k in ctx.channels) { + chan = ctx.channels[k]; + chan.clients = chan.clients.filter(filter); + if (chan.clients.length === 0) { + if (chan.wc) { chan.wc.leave(); } + delete ctx.channels[k]; + } + } + + // Send the leave message to the channel we were in + if (ctx.clients[clientId]) { + var leaveMsg = { + leave: true, + id: ctx.clients[clientId].id + }; + chan = ctx.channels[ctx.clients[clientId].channel]; + if (chan) { + chan.sendMsg(JSON.stringify(leaveMsg)); + ctx.emit('MESSAGE', leaveMsg, chan.clients); + } + } + + delete ctx.clients[clientId]; + }; + + Cursor.init = function (store, emit) { + var cursor = {}; + var ctx = { + store: store, + emit: emit, + channels: {}, + clients: {} + }; + + cursor.removeClient = function (clientId) { + removeClient(ctx, clientId); + }; + cursor.leavePad = function (padChan) { + leaveChannel(ctx, padChan); + }; + cursor.execCommand = function (clientId, obj, cb) { + var cmd = obj.cmd; + var data = obj.data; + if (cmd === 'INIT_CURSOR') { + return void initCursor(ctx, data, clientId, cb); + } + if (cmd === 'UPDATE') { + return void updateCursor(ctx, data, clientId, cb); + } + }; + + return cursor; + }; + + return Cursor; +}); diff --git a/www/common/sframe-common-codemirror.js b/www/common/sframe-common-codemirror.js index fdec38729..93d25f7f9 100644 --- a/www/common/sframe-common-codemirror.js +++ b/www/common/sframe-common-codemirror.js @@ -393,6 +393,14 @@ define([ 'class': 'cp-codemirror-cursor' })[0]; }; + var makeTippy = function (cursor) { + var html = ''; + if (cursor.avatar && UIElements.getAvatar(cursor.avatar)) { + html += UIElements.getAvatar(cursor.avatar); + } + html += cursor.name + ''; + return html; + }; var marks = {}; exp.setRemoteCursor = function (data) { if (data.leave) { @@ -415,14 +423,30 @@ define([ delete marks[id]; } + if (!cursor.selectionStart) { return; } + if (cursor.selectionStart === cursor.selectionEnd) { var cursorPosS = posToCursor(cursor.selectionStart, doc); var el = makeCursor(id); + if (cursor.color) { + $(el).css('border-color', cursor.color); + $(el).css('background-color', cursor.color); + } + if (cursor.name) { + $(el).attr('title', cursor.name); + } marks[id] = editor.setBookmark(cursorPosS, { widget: el }); } else { var pos1 = posToCursor(cursor.selectionStart, doc); var pos2 = posToCursor(cursor.selectionEnd, doc); - marks[id] = editor.markText(pos1, pos2, { className: 'cp-codemirror-selection' }); + var css = cursor.color + ? 'background-color: rgba(' + Util.hexToRGB(cursor.color).join(',') + ',0.2)' + : 'background-color: rgba(255,0,0,0.2)'; + marks[id] = editor.markText(pos1, pos2, { + css: css, + title: makeTippy(cursor), + className: 'cp-tippy-html' + }); } }; diff --git a/www/common/sframe-common-cursor.js b/www/common/sframe-common-cursor.js new file mode 100644 index 000000000..9153a8c29 --- /dev/null +++ b/www/common/sframe-common-cursor.js @@ -0,0 +1,53 @@ +define([ +], function () { + var module = {}; + + module.create = function (Common) { + var exp = {}; + var sframeChan = Common.getSframeChannel(); + var metadataMgr = Common.getMetadataMgr(); + + var execCommand = function (cmd, data, cb) { + sframeChan.query('Q_CURSOR_COMMAND', {cmd: cmd, data: data}, function (err, obj) { + if (err || (obj && obj.error)) { return void cb(err || (obj && obj.error)); } + cb(void 0, obj); + }); + }; + + exp.updateCursor = function (obj) { + execCommand('UPDATE', obj, function (err) { + if (err) { console.error(err); } + }); + }; + + var messageHandlers = []; + exp.onCursorUpdate = function (handler) { + messageHandlers.push(handler); + }; + var onMessage = function (data) { + messageHandlers.forEach(function (h) { + try { + h(data); + } catch (e) { + console.error(e); + } + }); + }; + + + sframeChan.on('EV_CURSOR_EVENT', function (obj) { + var cmd = obj.ev; + var data = obj.data; + if (cmd === 'MESSAGE') { + onMessage(data); + return; + } + }); + + return exp; + }; + + return module; +}); + + diff --git a/www/common/toolbar3.js b/www/common/toolbar3.js index 490579a6d..3fcdb8de9 100644 --- a/www/common/toolbar3.js +++ b/www/common/toolbar3.js @@ -232,6 +232,9 @@ MessengerUI, Messages) { editUsersNames.forEach(function (data) { var name = data.name || Messages.anonymous; var $span = $('', {'class': 'cp-avatar'}); + if (data.color) { + $span.css('border-color', data.color); + } var $rightCol = $('', {'class': 'cp-toolbar-userlist-rightcol'}); var $nameSpan = $('', {'class': 'cp-toolbar-userlist-name'}).appendTo($rightCol); var $nameValue = $('', { @@ -322,13 +325,13 @@ MessengerUI, Messages) { window.open(origin+'/profile/#' + data.profile); }); } - if (data.avatar && avatars[data.avatar]) { - $span.append(avatars[data.avatar]); + if (data.avatar && UIElements.getAvatar(data.avatar)) { + $span.append(UIElements.getAvatar(data.avatar)); $span.append($rightCol); } else { Common.displayAvatar($span, data.avatar, name, function ($img) { if (data.avatar && $img && $img.length) { - avatars[data.avatar] = $img[0].outerHTML; + UIElements.setAvatar(data.avatar, $img[0].outerHTML); } $span.append($rightCol); });