'use strict'; /** * Copyright 2009 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS-IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const padutils = require('./pad_utils').padutils; const hooks = require('./pluginfw/hooks'); let myUserInfo = {}; let colorPickerOpen = false; let colorPickerSetup = false; const paduserlist = (() => { const rowManager = (() => { // The row manager handles rendering rows of the user list and animating // their insertion, removal, and reordering. It manipulates TD height // and TD opacity. const nextRowId = () => `usertr${nextRowId.counter++}`; nextRowId.counter = 1; // objects are shared; fields are "domId","data","animationStep" const rowsFadingOut = []; // unordered set const rowsFadingIn = []; // unordered set const rowsPresent = []; // in order const ANIMATION_START = -12; // just starting to fade in const ANIMATION_END = 12; // just finishing fading out const animateStep = () => { // animation must be symmetrical for (let i = rowsFadingIn.length - 1; i >= 0; i--) { // backwards to allow removal const row = rowsFadingIn[i]; const step = ++row.animationStep; const animHeight = getAnimationHeight(step, row.animationPower); const node = rowNode(row); const baseOpacity = (row.opacity === undefined ? 1 : row.opacity); if (step <= -OPACITY_STEPS) { node.find('td').height(animHeight); } else if (step === -OPACITY_STEPS + 1) { node.empty().append(createUserRowTds(animHeight, row.data)) .find('td').css('opacity', baseOpacity * 1 / OPACITY_STEPS); } else if (step < 0) { node.find('td').css('opacity', baseOpacity * (OPACITY_STEPS - (-step)) / OPACITY_STEPS) .height(animHeight); } else if (step === 0) { // set HTML in case modified during animation node.empty().append(createUserRowTds(animHeight, row.data)) .find('td').css('opacity', baseOpacity * 1).height(animHeight); rowsFadingIn.splice(i, 1); // remove from set } } for (let i = rowsFadingOut.length - 1; i >= 0; i--) { // backwards to allow removal const row = rowsFadingOut[i]; const step = ++row.animationStep; const node = rowNode(row); const animHeight = getAnimationHeight(step, row.animationPower); const baseOpacity = (row.opacity === undefined ? 1 : row.opacity); if (step < OPACITY_STEPS) { node.find('td').css('opacity', baseOpacity * (OPACITY_STEPS - step) / OPACITY_STEPS) .height(animHeight); } else if (step === OPACITY_STEPS) { node.empty().append(createEmptyRowTds(animHeight)); } else if (step <= ANIMATION_END) { node.find('td').height(animHeight); } else { rowsFadingOut.splice(i, 1); // remove from set node.remove(); } } handleOtherUserInputs(); return (rowsFadingIn.length > 0) || (rowsFadingOut.length > 0); // is more to do }; const getAnimationHeight = (step, power) => { let a = Math.abs(step / 12); if (power === 2) a **= 2; else if (power === 3) a **= 3; else if (power === 4) a **= 4; else if (power >= 5) a **= 5; return Math.round(26 * (1 - a)); }; const OPACITY_STEPS = 6; const ANIMATION_STEP_TIME = 20; const LOWER_FRAMERATE_FACTOR = 2; const {scheduleAnimation} = padutils.makeAnimationScheduler(animateStep, ANIMATION_STEP_TIME, LOWER_FRAMERATE_FACTOR); const NUMCOLS = 4; // we do lots of manipulation of table rows and stuff that JQuery makes ok, despite // IE's poor handling when manipulating the DOM directly. const createEmptyRowTds = (height) => $('') .attr('colspan', NUMCOLS) .css('border', 0) .css('height', `${height}px`); const isNameEditable = (data) => (!data.name) && (data.status !== 'Disconnected'); const replaceUserRowContents = (tr, height, data) => { const tds = createUserRowTds(height, data); if (isNameEditable(data) && tr.find('td.usertdname input:enabled').length > 0) { // preserve input field node tds.each((i, td) => { const oldTd = $(tr.find('td').get(i)); if (!oldTd.hasClass('usertdname')) { oldTd.replaceWith(td); } else { // Prevent leak. I'm not 100% confident that this is necessary, but it shouldn't hurt. $(td).remove(); } }); } else { tr.empty().append(tds); } return tr; }; const createUserRowTds = (height, data) => { let name; if (data.name) { name = document.createTextNode(data.name); } else { name = $('') .attr('data-l10n-id', 'pad.userlist.unnamed') .attr('type', 'text') .addClass('editempty') .addClass('newinput') .attr('value', html10n.get('pad.userlist.unnamed')); if (isNameEditable(data)) name.attr('disabled', 'disabled'); } return $() .add($('') .css('height', `${height}px`) .addClass('usertdswatch') .append($('
') .addClass('swatch') .css('background', padutils.escapeHtml(data.color)) .html(' '))) .add($('') .css('height', `${height}px`) .addClass('usertdname') .append(name)) .add($('') .css('height', `${height}px`) .addClass('activity') .text(data.activity)); }; const createRow = (id, contents, authorId) => $('') .attr('data-authorId', authorId) .attr('id', id) .append(contents); const rowNode = (row) => $(`#${row.domId}`); const handleRowData = (row) => { if (row.data && row.data.status === 'Disconnected') { row.opacity = 0.5; } else { delete row.opacity; } }; const handleOtherUserInputs = () => { // handle 'INPUT' elements for naming other unnamed users $('#otheruserstable input.newinput').each(function () { const input = $(this); const tr = input.closest('tr'); if (tr.length > 0) { const index = tr.parent().children().index(tr); if (index >= 0) { const userId = rowsPresent[index].data.id; rowManagerMakeNameEditor($(this), userId); } } }).removeClass('newinput'); }; // animationPower is 0 to skip animation, 1 for linear, 2 for quadratic, etc. const insertRow = (position, data, animationPower) => { position = Math.max(0, Math.min(rowsPresent.length, position)); animationPower = (animationPower === undefined ? 4 : animationPower); const domId = nextRowId(); const row = { data, animationStep: ANIMATION_START, domId, animationPower, }; const authorId = data.id; handleRowData(row); rowsPresent.splice(position, 0, row); let tr; if (animationPower === 0) { tr = createRow(domId, createUserRowTds(getAnimationHeight(0), data), authorId); row.animationStep = 0; } else { rowsFadingIn.push(row); tr = createRow(domId, createEmptyRowTds(getAnimationHeight(ANIMATION_START)), authorId); } $('table#otheruserstable').show(); if (position === 0) { $('table#otheruserstable').prepend(tr); } else { rowNode(rowsPresent[position - 1]).after(tr); } if (animationPower !== 0) { scheduleAnimation(); } handleOtherUserInputs(); return row; }; const updateRow = (position, data) => { const row = rowsPresent[position]; if (row) { row.data = data; handleRowData(row); if (row.animationStep === 0) { // not currently animating const tr = rowNode(row); replaceUserRowContents(tr, getAnimationHeight(0), row.data) .find('td') .css('opacity', (row.opacity === undefined ? 1 : row.opacity)); handleOtherUserInputs(); } } }; const removeRow = (position, animationPower) => { animationPower = (animationPower === undefined ? 4 : animationPower); const row = rowsPresent[position]; if (row) { rowsPresent.splice(position, 1); // remove if (animationPower === 0) { rowNode(row).remove(); } else { row.animationStep = -row.animationStep; // use symmetry row.animationPower = animationPower; rowsFadingOut.push(row); scheduleAnimation(); } } if (rowsPresent.length === 0) { $('table#otheruserstable').hide(); } }; // newPosition is position after the row has been removed const moveRow = (oldPosition, newPosition, animationPower) => { animationPower = (animationPower === undefined ? 1 : animationPower); // linear is best const row = rowsPresent[oldPosition]; if (row && oldPosition !== newPosition) { const rowData = row.data; removeRow(oldPosition, animationPower); insertRow(newPosition, rowData, animationPower); } }; const self = { insertRow, removeRow, moveRow, updateRow, }; return self; })(); // //////// rowManager const otherUsersInfo = []; const otherUsersData = []; const rowManagerMakeNameEditor = (jnode, userId) => { setUpEditable(jnode, () => { const existingIndex = findExistingIndex(userId); if (existingIndex >= 0) { return otherUsersInfo[existingIndex].name || ''; } else { return ''; } }, (newName) => { if (!newName) { jnode.addClass('editempty'); jnode.val(html10n.get('pad.userlist.unnamed')); } else { jnode.attr('disabled', 'disabled'); pad.suggestUserName(userId, newName); } }); }; const findExistingIndex = (userId) => { let existingIndex = -1; for (let i = 0; i < otherUsersInfo.length; i++) { if (otherUsersInfo[i].userId === userId) { existingIndex = i; break; } } return existingIndex; }; const setUpEditable = (jqueryNode, valueGetter, valueSetter) => { jqueryNode.bind('focus', (evt) => { const oldValue = valueGetter(); if (jqueryNode.val() !== oldValue) { jqueryNode.val(oldValue); } jqueryNode.addClass('editactive').removeClass('editempty'); }); jqueryNode.bind('blur', (evt) => { const newValue = jqueryNode.removeClass('editactive').val(); valueSetter(newValue); }); padutils.bindEnterAndEscape(jqueryNode, () => { jqueryNode.blur(); }, () => { jqueryNode.val(valueGetter()).blur(); }); jqueryNode.removeAttr('disabled').addClass('editable'); }; let pad = undefined; const self = { init: (myInitialUserInfo, _pad) => { pad = _pad; self.setMyUserInfo(myInitialUserInfo); if ($('#online_count').length === 0) { $('#editbar [data-key=showusers] > a').append('1'); } $('#otheruserstable tr').remove(); $('#myusernameedit').addClass('myusernameedithoverable'); setUpEditable($('#myusernameedit'), () => myUserInfo.name || '', (newValue) => { myUserInfo.name = newValue; pad.notifyChangeName(newValue); // wrap with setTimeout to do later because we get // a double "blur" fire in IE... window.setTimeout(() => { self.renderMyUserInfo(); }, 0); }); // color picker $('#myswatchbox').click(showColorPicker); $('#mycolorpicker .pickerswatchouter').click(function () { $('#mycolorpicker .pickerswatchouter').removeClass('picked'); $(this).addClass('picked'); }); $('#mycolorpickersave').click(() => { closeColorPicker(true); }); $('#mycolorpickercancel').click(() => { closeColorPicker(false); }); // }, usersOnline: () => { // Returns an object of users who are currently online on this pad // Make a copy of the otherUsersInfo, otherwise every call to users // modifies the referenced array const userList = [].concat(otherUsersInfo); // Now we need to add ourselves.. userList.push(myUserInfo); return userList; }, users: () => { // Returns an object of users who have been on this pad const userList = self.usersOnline(); // Now we add historical authors const historical = clientVars.collab_client_vars.historicalAuthorData; for (const [key, {userId}] of Object.entries(historical)) { // Check we don't already have this author in our array let exists = false; userList.forEach((user) => { if (user.userId === userId) exists = true; }); if (exists === false) { userList.push(historical[key]); } } return userList; }, setMyUserInfo: (info) => { // translate the colorId if (typeof info.colorId === 'number') { info.colorId = clientVars.colorPalette[info.colorId]; } myUserInfo = $.extend( {}, info); self.renderMyUserInfo(); }, userJoinOrUpdate: (info) => { if ((!info.userId) || (info.userId === myUserInfo.userId)) { // not sure how this would happen return; } hooks.callAll('userJoinOrUpdate', { userInfo: info, }); const userData = {}; userData.color = typeof info.colorId === 'number' ? clientVars.colorPalette[info.colorId] : info.colorId; userData.name = info.name; userData.status = ''; userData.activity = ''; userData.id = info.userId; const existingIndex = findExistingIndex(info.userId); let numUsersBesides = otherUsersInfo.length; if (existingIndex >= 0) { numUsersBesides--; } const newIndex = padutils.binarySearch(numUsersBesides, (n) => { if (existingIndex >= 0 && n >= existingIndex) { // pretend existingIndex isn't there n++; } const infoN = otherUsersInfo[n]; const nameN = (infoN.name || '').toLowerCase(); const nameThis = (info.name || '').toLowerCase(); const idN = infoN.userId; const idThis = info.userId; return (nameN > nameThis) || (nameN === nameThis && idN > idThis); }); if (existingIndex >= 0) { // update if (existingIndex === newIndex) { otherUsersInfo[existingIndex] = info; otherUsersData[existingIndex] = userData; rowManager.updateRow(existingIndex, userData); } else { otherUsersInfo.splice(existingIndex, 1); otherUsersData.splice(existingIndex, 1); otherUsersInfo.splice(newIndex, 0, info); otherUsersData.splice(newIndex, 0, userData); rowManager.updateRow(existingIndex, userData); rowManager.moveRow(existingIndex, newIndex); } } else { otherUsersInfo.splice(newIndex, 0, info); otherUsersData.splice(newIndex, 0, userData); rowManager.insertRow(newIndex, userData); } self.updateNumberOfOnlineUsers(); }, updateNumberOfOnlineUsers: () => { let online = 1; // you are always online! for (let i = 0; i < otherUsersData.length; i++) { if (otherUsersData[i].status === '') { online++; } } $('#online_count').text(online); return online; }, userLeave: (info) => { const existingIndex = findExistingIndex(info.userId); if (existingIndex >= 0) { const userData = otherUsersData[existingIndex]; userData.status = 'Disconnected'; rowManager.updateRow(existingIndex, userData); if (userData.leaveTimer) { window.clearTimeout(userData.leaveTimer); } // set up a timer that will only fire if no leaves, // joins, or updates happen for this user in the // next N seconds, to remove the user from the list. const thisUserId = info.userId; const thisLeaveTimer = window.setTimeout(() => { const newExistingIndex = findExistingIndex(thisUserId); if (newExistingIndex >= 0) { const newUserData = otherUsersData[newExistingIndex]; if (newUserData.status === 'Disconnected' && newUserData.leaveTimer === thisLeaveTimer) { otherUsersInfo.splice(newExistingIndex, 1); otherUsersData.splice(newExistingIndex, 1); rowManager.removeRow(newExistingIndex); hooks.callAll('userLeave', { userInfo: info, }); } } }, 8000); // how long to wait userData.leaveTimer = thisLeaveTimer; } self.updateNumberOfOnlineUsers(); }, renderMyUserInfo: () => { if (myUserInfo.name) { $('#myusernameedit').removeClass('editempty').val(myUserInfo.name); } else { $('#myusernameedit').attr('placeholder', html10n.get('pad.userlist.entername')); } if (colorPickerOpen) { $('#myswatchbox').addClass('myswatchboxunhoverable').removeClass('myswatchboxhoverable'); } else { $('#myswatchbox').addClass('myswatchboxhoverable').removeClass('myswatchboxunhoverable'); } $('#myswatch').css({'background-color': myUserInfo.colorId}); $('li[data-key=showusers] > a').css({'box-shadow': `inset 0 0 30px ${myUserInfo.colorId}`}); }, }; return self; })(); const getColorPickerSwatchIndex = (jnode) => $('#colorpickerswatches li').index(jnode); const closeColorPicker = (accept) => { if (accept) { let newColor = $('#mycolorpickerpreview').css('background-color'); const parts = newColor.match(/^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/); // parts now should be ["rgb(0, 70, 255", "0", "70", "255"] if (parts) { delete (parts[0]); for (let i = 1; i <= 3; ++i) { parts[i] = parseInt(parts[i]).toString(16); if (parts[i].length === 1) parts[i] = `0${parts[i]}`; } newColor = `#${parts.join('')}`; // "0070ff" } myUserInfo.colorId = newColor; pad.notifyChangeColor(newColor); paduserlist.renderMyUserInfo(); } else { // pad.notifyChangeColor(previousColorId); // paduserlist.renderMyUserInfo(); } colorPickerOpen = false; $('#mycolorpicker').removeClass('popup-show'); }; const showColorPicker = () => { $.farbtastic('#colorpicker').setColor(myUserInfo.colorId); if (!colorPickerOpen) { const palette = pad.getColorPalette(); if (!colorPickerSetup) { const colorsList = $('#colorpickerswatches'); for (let i = 0; i < palette.length; i++) { const li = $('
  • ', { style: `background: ${palette[i]};`, }); li.appendTo(colorsList); li.bind('click', (event) => { $('#colorpickerswatches li').removeClass('picked'); $(event.target).addClass('picked'); const newColorId = getColorPickerSwatchIndex($('#colorpickerswatches .picked')); pad.notifyChangeColor(newColorId); }); } colorPickerSetup = true; } $('#mycolorpicker').addClass('popup-show'); colorPickerOpen = true; $('#colorpickerswatches li').removeClass('picked'); $($('#colorpickerswatches li')[myUserInfo.colorId]).addClass('picked'); // seems weird } }; exports.paduserlist = paduserlist;