diff options
Diffstat (limited to 'trunk/etherpad/src/static/js/pad_userlist.js')
-rw-r--r-- | trunk/etherpad/src/static/js/pad_userlist.js | 604 |
1 files changed, 604 insertions, 0 deletions
diff --git a/trunk/etherpad/src/static/js/pad_userlist.js b/trunk/etherpad/src/static/js/pad_userlist.js new file mode 100644 index 0000000..13bfd6a --- /dev/null +++ b/trunk/etherpad/src/static/js/pad_userlist.js @@ -0,0 +1,604 @@ +/** + * 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. + */ + + +var paduserlist = (function() { + + var rowManager = (function() { + // The row manager handles rendering rows of the user list and animating + // their insertion, removal, and reordering. It manipulates TD height + // and TD opacity. + + function nextRowId() { + return "usertr"+(nextRowId.counter++); + } + nextRowId.counter = 1; + // objects are shared; fields are "domId","data","animationStep" + var rowsFadingOut = []; // unordered set + var rowsFadingIn = []; // unordered set + var rowsPresent = []; // in order + + var ANIMATION_START = -12; // just starting to fade in + var ANIMATION_END = 12; // just finishing fading out + function getAnimationHeight(step, power) { + var a = Math.abs(step/12); + if (power == 2) a = a*a; + else if (power == 3) a = a*a*a; + else if (power == 4) a = a*a*a*a; + else if (power >= 5) a = a*a*a*a*a; + return Math.round(26*(1-a)); + } + var OPACITY_STEPS = 6; + + var ANIMATION_STEP_TIME = 20; + var LOWER_FRAMERATE_FACTOR = 2; + var scheduleAnimation = padutils.makeAnimationScheduler(animateStep, ANIMATION_STEP_TIME, + LOWER_FRAMERATE_FACTOR).scheduleAnimation; + + var 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. + + function getEmptyRowHtml(height) { + return '<td colspan="'+NUMCOLS+'" style="border:0;height:'+height+'px"><!-- --></td>'; + } + function isNameEditable(data) { + return (! data.name) && (data.status != 'Disconnected'); + } + function replaceUserRowContents(tr, height, data) { + var tds = getUserRowHtml(height, data).match(/<td.*?<\/td>/gi); + if (isNameEditable(data) && tr.find("td.usertdname input:enabled").length > 0) { + // preserve input field node + for(var i=0; i<tds.length; i++) { + var oldTd = $(tr.find("td").get(i)); + if (! oldTd.hasClass('usertdname')) { + oldTd.replaceWith(tds[i]); + } + } + } + else { + tr.html(tds.join('')); + } + return tr; + } + function getUserRowHtml(height, data) { + var nameHtml; + var isGuest = (data.id.charAt(0) != 'p'); + if (data.name) { + nameHtml = padutils.escapeHtml(data.name); + if (isGuest && pad.getIsProPad()) { + nameHtml += ' (Guest)'; + } + } + else { + nameHtml = '<input type="text" class="editempty newinput" value="unnamed" '+ + (isNameEditable(data) ? '' : 'disabled="disabled" ')+ + '/>'; + } + + return ['<td style="height:',height,'px" class="usertdswatch"><div class="swatch" style="background:'+data.color+'"> </div></td>', + '<td style="height:',height,'px" class="usertdname">',nameHtml,'</td>', + '<td style="height:',height,'px" class="usertdstatus">',padutils.escapeHtml(data.status),'</td>', + '<td style="height:',height,'px" class="activity">',padutils.escapeHtml(data.activity),'</td>'].join(''); + } + function getRowHtml(id, innerHtml) { + return '<tr id="'+id+'">'+innerHtml+'</tr>'; + } + function rowNode(row) { + return $("#"+row.domId); + } + function handleRowData(row) { + if (row.data && row.data.status == 'Disconnected') { + row.opacity = 0.5; + } + else { + delete row.opacity; + } + } + function handleRowNode(tr, data) { + if (data.titleText) { + tr.attr('title', data.titleText); + } + else { + tr.removeAttr('title'); + } + } + function handleOtherUserInputs() { + // handle 'INPUT' elements for naming other unnamed users + $("#otheruserstable input.newinput").each(function() { + var input = $(this); + var tr = input.closest("tr"); + if (tr.length > 0) { + var index = tr.parent().children().index(tr); + if (index >= 0) { + var userId = rowsPresent[index].data.id; + rowManagerMakeNameEditor($(this), userId); + } + } + }).removeClass('newinput'); + } + + // animationPower is 0 to skip animation, 1 for linear, 2 for quadratic, etc. + function insertRow(position, data, animationPower) { + position = Math.max(0, Math.min(rowsPresent.length, position)); + animationPower = (animationPower === undefined ? 4 : animationPower); + + var domId = nextRowId(); + var row = {data: data, animationStep: ANIMATION_START, domId: domId, + animationPower: animationPower}; + handleRowData(row); + rowsPresent.splice(position, 0, row); + var tr; + if (animationPower == 0) { + tr = $(getRowHtml(domId, getUserRowHtml(getAnimationHeight(0), data))); + row.animationStep = 0; + } + else { + rowsFadingIn.push(row); + tr = $(getRowHtml(domId, getEmptyRowHtml(getAnimationHeight(ANIMATION_START)))); + } + handleRowNode(tr, data); + if (position == 0) { + $("table#otheruserstable").prepend(tr); + } + else { + rowNode(rowsPresent[position-1]).after(tr); + } + + if (animationPower != 0) { + scheduleAnimation(); + } + + handleOtherUserInputs(); + + return row; + } + + function updateRow(position, data) { + var row = rowsPresent[position]; + if (row) { + row.data = data; + handleRowData(row); + if (row.animationStep == 0) { + // not currently animating + var tr = rowNode(row); + replaceUserRowContents(tr, getAnimationHeight(0), row.data).find( + "td").css('opacity', (row.opacity === undefined ? 1 : row.opacity)); + handleRowNode(tr, data); + handleOtherUserInputs(); + } + } + } + + function removeRow(position, animationPower) { + animationPower = (animationPower === undefined ? 4 : animationPower); + var 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(); + } + } + } + + // newPosition is position after the row has been removed + function moveRow(oldPosition, newPosition, animationPower) { + animationPower = (animationPower === undefined ? 1 : animationPower); // linear is best + var row = rowsPresent[oldPosition]; + if (row && oldPosition != newPosition) { + var rowData = row.data; + removeRow(oldPosition, animationPower); + insertRow(newPosition, rowData, animationPower); + } + } + + function animateStep() { + // animation must be symmetrical + for(var i=rowsFadingIn.length-1;i>=0;i--) { // backwards to allow removal + var row = rowsFadingIn[i]; + var step = ++row.animationStep; + var animHeight = getAnimationHeight(step, row.animationPower); + var node = rowNode(row); + var baseOpacity = (row.opacity === undefined ? 1 : row.opacity); + if (step <= -OPACITY_STEPS) { + node.find("td").height(animHeight); + } + else if (step == -OPACITY_STEPS+1) { + node.html(getUserRowHtml(animHeight, row.data)).find("td").css( + 'opacity', baseOpacity*1/OPACITY_STEPS); + handleRowNode(node, row.data); + } + 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.html(getUserRowHtml(animHeight, row.data)).find("td").css( + 'opacity', baseOpacity*1).height(animHeight); + handleRowNode(node, row.data); + rowsFadingIn.splice(i, 1); // remove from set + } + } + for(var i=rowsFadingOut.length-1;i>=0;i--) { // backwards to allow removal + var row = rowsFadingOut[i]; + var step = ++row.animationStep; + var node = rowNode(row); + var animHeight = getAnimationHeight(step, row.animationPower); + var 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.html(getEmptyRowHtml(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 + } + + var self = { + insertRow: insertRow, + removeRow: removeRow, + moveRow: moveRow, + updateRow: updateRow + }; + return self; + }()); ////////// rowManager + + var myUserInfo = {}; + var otherUsersInfo = []; + var otherUsersData = []; + var colorPickerOpen = false; + + function rowManagerMakeNameEditor(jnode, userId) { + setUpEditable(jnode, function() { + var existingIndex = findExistingIndex(userId); + if (existingIndex >= 0) { + return otherUsersInfo[existingIndex].name || ''; + } + else { + return ''; + } + }, function(newName) { + if (! newName) { + jnode.addClass("editempty"); + jnode.val("unnamed"); + } + else { + jnode.attr('disabled', 'disabled'); + pad.suggestUserName(userId, newName); + } + }); + } + + function renderMyUserInfo() { + if (myUserInfo.name) { + $("#myusernameedit").removeClass("editempty").val( + myUserInfo.name); + } + else { + $("#myusernameedit").addClass("editempty").val( + "< enter your name >"); + } + if (colorPickerOpen) { + $("#myswatchbox").addClass('myswatchboxunhoverable').removeClass( + 'myswatchboxhoverable'); + } + else { + $("#myswatchbox").addClass('myswatchboxhoverable').removeClass( + 'myswatchboxunhoverable'); + } + $("#myswatch").css('background', pad.getColorPalette()[myUserInfo.colorId]); + } + + function findExistingIndex(userId) { + var existingIndex = -1; + for(var i=0;i<otherUsersInfo.length;i++) { + if (otherUsersInfo[i].userId == userId) { + existingIndex = i; + break; + } + } + return existingIndex; + } + + function setUpEditable(jqueryNode, valueGetter, valueSetter) { + jqueryNode.bind('focus', function(evt) { + var oldValue = valueGetter(); + if (jqueryNode.val() !== oldValue) { + jqueryNode.val(oldValue); + } + jqueryNode.addClass("editactive").removeClass("editempty"); + }); + jqueryNode.bind('blur', function(evt) { + var newValue = jqueryNode.removeClass("editactive").val(); + valueSetter(newValue); + }); + padutils.bindEnterAndEscape(jqueryNode, function onEnter() { + jqueryNode.blur(); + }, function onEscape() { + jqueryNode.val(valueGetter()).blur(); + }); + jqueryNode.removeAttr('disabled').addClass('editable'); + } + + function showColorPicker() { + if (! colorPickerOpen) { + var palette = pad.getColorPalette(); + for(var i=0;i<palette.length;i++) { + $("#mycolorpicker .n"+(i+1)+" .pickerswatch").css( + 'background', palette[i]); + } + $("#mycolorpicker").css('display', 'block'); + colorPickerOpen = true; + renderMyUserInfo(); + } + // this part happens even if color picker is already open + $("#mycolorpicker .pickerswatchouter").removeClass('picked'); + $("#mycolorpicker .pickerswatchouter:eq("+(myUserInfo.colorId||0)+")"). + addClass('picked'); + } + function getColorPickerSwatchIndex(jnode) { + return Number(jnode.get(0).className.match(/\bn([0-9]+)\b/)[1])-1; + } + function closeColorPicker(accept) { + if (accept) { + var newColorId = getColorPickerSwatchIndex($("#mycolorpicker .picked")); + if (newColorId >= 0) { // fails on NaN + myUserInfo.colorId = newColorId; + pad.notifyChangeColor(newColorId); + } + } + colorPickerOpen = false; + $("#mycolorpicker").css('display', 'none'); + renderMyUserInfo(); + } + + function updateInviteNotice() { + if (otherUsersInfo.length == 0) { + $("#otheruserstable").hide(); + $("#nootherusers").show(); + } + else { + $("#nootherusers").hide(); + $("#otheruserstable").show(); + } + } + + var knocksToIgnore = {}; + var guestPromptFlashState = 0; + var guestPromptFlash = padutils.makeAnimationScheduler( + function () { + var prompts = $("#guestprompts .guestprompt"); + if (prompts.length == 0) { + return false; // no more to do + } + + guestPromptFlashState = 1 - guestPromptFlashState; + if (guestPromptFlashState) { + prompts.css('background', '#ffa'); + } + else { + prompts.css('background', '#ffe'); + } + + return true; + }, 1000); + + var self = { + init: function(myInitialUserInfo) { + self.setMyUserInfo(myInitialUserInfo); + + $("#otheruserstable tr").remove(); + + if (pad.getUserIsGuest()) { + $("#myusernameedit").addClass('myusernameedithoverable'); + setUpEditable($("#myusernameedit"), + function() { + return myUserInfo.name || ''; + }, + function(newValue) { + myUserInfo.name = newValue; + pad.notifyChangeName(newValue); + // wrap with setTimeout to do later because we get + // a double "blur" fire in IE... + window.setTimeout(function() { + renderMyUserInfo(); + }, 0); + }); + } + + // color picker + $("#myswatchbox").click(showColorPicker); + $("#mycolorpicker .pickerswatchouter").click(function() { + $("#mycolorpicker .pickerswatchouter").removeClass('picked'); + $(this).addClass('picked'); + }); + $("#mycolorpickersave").click(function() { + closeColorPicker(true); + }); + $("#mycolorpickercancel").click(function() { + closeColorPicker(false); + }); + // + + }, + setMyUserInfo: function(info) { + myUserInfo = $.extend({}, info); + + renderMyUserInfo(); + }, + userJoinOrUpdate: function(info) { + if ((! info.userId) || (info.userId == myUserInfo.userId)) { + // not sure how this would happen + return; + } + + var userData = {}; + userData.color = pad.getColorPalette()[info.colorId]; + userData.name = info.name; + userData.status = ''; + userData.activity = ''; + userData.id = info.userId; + // Firefox ignores \n in title text; Safari does a linebreak + userData.titleText = [info.userAgent||'', info.ip||''].join(' \n'); + + var existingIndex = findExistingIndex(info.userId); + + var numUsersBesides = otherUsersInfo.length; + if (existingIndex >= 0) { + numUsersBesides--; + } + var newIndex = padutils.binarySearch(numUsersBesides, function(n) { + if (existingIndex >= 0 && n >= existingIndex) { + // pretend existingIndex isn't there + n++; + } + var infoN = otherUsersInfo[n]; + var nameN = (infoN.name||'').toLowerCase(); + var nameThis = (info.name||'').toLowerCase(); + var idN = infoN.userId; + var 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); + } + + updateInviteNotice(); + }, + userLeave: function(info) { + var existingIndex = findExistingIndex(info.userId); + if (existingIndex >= 0) { + var 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. + var thisUserId = info.userId; + var thisLeaveTimer = window.setTimeout(function() { + var newExistingIndex = findExistingIndex(thisUserId); + if (newExistingIndex >= 0) { + var newUserData = otherUsersData[newExistingIndex]; + if (newUserData.status == 'Disconnected' && + newUserData.leaveTimer == thisLeaveTimer) { + otherUsersInfo.splice(newExistingIndex, 1); + otherUsersData.splice(newExistingIndex, 1); + rowManager.removeRow(newExistingIndex); + updateInviteNotice(); + } + } + }, 8000); // how long to wait + userData.leaveTimer = thisLeaveTimer; + } + updateInviteNotice(); + }, + showGuestPrompt: function(userId, displayName) { + if (knocksToIgnore[userId]) { + return; + } + + var encodedUserId = padutils.encodeUserId(userId); + + var actionName = 'hide-guest-prompt-'+encodedUserId; + padutils.cancelActions(actionName); + + var box = $("#guestprompt-"+encodedUserId); + if (box.length == 0) { + // make guest prompt box + box = $('<div id="guestprompt-'+encodedUserId+'" class="guestprompt"><div class="choices"><a href="javascript:void(paduserlist.answerGuestPrompt(\''+encodedUserId+'\',false))">Deny</a> <a href="javascript:void(paduserlist.answerGuestPrompt(\''+encodedUserId+'\',true))">Approve</a></div><div class="guestname"><strong>Guest:</strong> '+padutils.escapeHtml(displayName)+'</div></div>'); + $("#guestprompts").append(box); + } + else { + // update display name + box.find(".guestname").html('<strong>Guest:</strong> '+padutils.escapeHtml(displayName)); + } + var hideLater = padutils.getCancellableAction(actionName, function() { + self.removeGuestPrompt(userId); + }); + window.setTimeout(hideLater, 15000); // time-out with no knock + + guestPromptFlash.scheduleAnimation(); + }, + removeGuestPrompt: function(userId) { + var box = $("#guestprompt-"+padutils.encodeUserId(userId)); + // remove ID now so a new knock by same user gets new, unfaded box + box.removeAttr('id').fadeOut("fast", function() { + box.remove(); + }); + + knocksToIgnore[userId] = true; + window.setTimeout(function() { + delete knocksToIgnore[userId]; + }, 5000); + }, + answerGuestPrompt: function(encodedUserId, approve) { + var guestId = padutils.decodeUserId(encodedUserId); + + var msg = { + type: 'guestanswer', + authId: pad.getUserId(), + guestId: guestId, + answer: (approve ? "approved" : "denied") + }; + pad.sendClientMessage(msg); + + self.removeGuestPrompt(guestId); + } + }; + return self; +}()); + |