diff options
Diffstat (limited to 'trunk/etherpad/src/static/js/collab_client.js')
-rw-r--r-- | trunk/etherpad/src/static/js/collab_client.js | 628 |
1 files changed, 628 insertions, 0 deletions
diff --git a/trunk/etherpad/src/static/js/collab_client.js b/trunk/etherpad/src/static/js/collab_client.js new file mode 100644 index 0000000..d8834d7 --- /dev/null +++ b/trunk/etherpad/src/static/js/collab_client.js @@ -0,0 +1,628 @@ +/** + * 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. + */ + +$(window).bind("load", function() { + getCollabClient.windowLoaded = true; +}); + +/** Call this when the document is ready, and a new Ace2Editor() has been created and inited. + ACE's ready callback does not need to have fired yet. + "serverVars" are from calling doc.getCollabClientVars() on the server. */ +function getCollabClient(ace2editor, serverVars, initialUserInfo, options) { + var editor = ace2editor; + + var rev = serverVars.rev; + var padId = serverVars.padId; + var globalPadId = serverVars.globalPadId; + + var state = "IDLE"; + var stateMessage; + var stateMessageSocketId; + var channelState = "CONNECTING"; + var appLevelDisconnectReason = null; + + var lastCommitTime = 0; + var initialStartConnectTime = 0; + + var userId = initialUserInfo.userId; + var socketId; + var socket; + var userSet = {}; // userId -> userInfo + userSet[userId] = initialUserInfo; + + var reconnectTimes = []; + var caughtErrors = []; + var caughtErrorCatchers = []; + var caughtErrorTimes = []; + var debugMessages = []; + + tellAceAboutHistoricalAuthors(serverVars.historicalAuthorData); + tellAceActiveAuthorInfo(initialUserInfo); + + var callbacks = { + onUserJoin: function() {}, + onUserLeave: function() {}, + onUpdateUserInfo: function() {}, + onChannelStateChange: function() {}, + onClientMessage: function() {}, + onInternalAction: function() {}, + onConnectionTrouble: function() {}, + onServerMessage: function() {} + }; + + $(window).bind("unload", function() { + if (socket) { + socket.onclosed = function() {}; + socket.onhiccup = function() {}; + socket.disconnect(true); + } + }); + if ($.browser.mozilla) { + // Prevent "escape" from taking effect and canceling a comet connection; + // doesn't work if focus is on an iframe. + $(window).bind("keydown", function(evt) { if (evt.which == 27) { evt.preventDefault() } }); + } + + editor.setProperty("userAuthor", userId); + editor.setBaseAttributedText(serverVars.initialAttributedText, serverVars.apool); + editor.setUserChangeNotificationCallback(wrapRecordingErrors("handleUserChanges", handleUserChanges)); + + function abandonConnection(reason) { + if (socket) { + socket.onclosed = function() {}; + socket.onhiccup = function() {}; + socket.disconnect(); + } + socket = null; + setChannelState("DISCONNECTED", reason); + } + + function dmesg(str) { + if (typeof window.ajlog == "string") window.ajlog += str+'\n'; + debugMessages.push(str); + } + + function handleUserChanges() { + if ((! socket) || channelState == "CONNECTING") { + if (channelState == "CONNECTING" && (((+new Date()) - initialStartConnectTime) > 20000)) { + abandonConnection("initsocketfail"); // give up + } + else { + // check again in a bit + setTimeout(wrapRecordingErrors("setTimeout(handleUserChanges)", handleUserChanges), + 1000); + } + return; + } + + var t = (+new Date()); + + if (state != "IDLE") { + if (state == "COMMITTING" && (t - lastCommitTime) > 20000) { + // a commit is taking too long + appLevelDisconnectReason = "slowcommit"; + socket.disconnect(); + } + else if (state == "COMMITTING" && (t - lastCommitTime) > 5000) { + callbacks.onConnectionTrouble("SLOW"); + } + else { + // run again in a few seconds, to detect a disconnect + setTimeout(wrapRecordingErrors("setTimeout(handleUserChanges)", handleUserChanges), + 3000); + } + return; + } + + var earliestCommit = lastCommitTime + 500; + if (t < earliestCommit) { + setTimeout(wrapRecordingErrors("setTimeout(handleUserChanges)", handleUserChanges), + earliestCommit - t); + return; + } + + var sentMessage = false; + var userChangesData = editor.prepareUserChangeset(); + if (userChangesData.changeset) { + lastCommitTime = t; + state = "COMMITTING"; + stateMessage = {type:"USER_CHANGES", baseRev:rev, + changeset:userChangesData.changeset, + apool: userChangesData.apool }; + stateMessageSocketId = socketId; + sendMessage(stateMessage); + sentMessage = true; + callbacks.onInternalAction("commitPerformed"); + } + + if (sentMessage) { + // run again in a few seconds, to detect a disconnect + setTimeout(wrapRecordingErrors("setTimeout(handleUserChanges)", handleUserChanges), + 3000); + } + } + + function getStats() { + var stats = {}; + + stats.screen = [$(window).width(), $(window).height(), + window.screen.availWidth, window.screen.availHeight, + window.screen.width, window.screen.height].join(','); + stats.ip = serverVars.clientIp; + stats.useragent = serverVars.clientAgent; + + return stats; + } + + function setUpSocket() { + var success = false; + callCatchingErrors("setUpSocket", function() { + appLevelDisconnectReason = null; + + var oldSocketId = socketId; + socketId = String(Math.floor(Math.random()*1e12)); + socket = new WebSocket(socketId); + socket.onmessage = wrapRecordingErrors("socket.onmessage", handleMessageFromServer); + socket.onclosed = wrapRecordingErrors("socket.onclosed", handleSocketClosed); + socket.onopen = wrapRecordingErrors("socket.onopen", function() { + hiccupCount = 0; + setChannelState("CONNECTED"); + var msg = { type:"CLIENT_READY", roomType:'padpage', + roomName:'padpage/'+globalPadId, + data: { + lastRev:rev, + userInfo:userSet[userId], + stats: getStats() } }; + if (oldSocketId) { + msg.data.isReconnectOf = oldSocketId; + msg.data.isCommitPending = (state == "COMMITTING"); + } + sendMessage(msg); + doDeferredActions(); + }); + socket.onhiccup = wrapRecordingErrors("socket.onhiccup", handleCometHiccup); + socket.onlogmessage = dmesg; + socket.connect(); + success = true; + }); + if (success) { + initialStartConnectTime = +new Date(); + } + else { + abandonConnection("initsocketfail"); + } + } + function setUpSocketWhenWindowLoaded() { + if (getCollabClient.windowLoaded) { + setUpSocket(); + } + else { + setTimeout(setUpSocketWhenWindowLoaded, 200); + } + } + setTimeout(setUpSocketWhenWindowLoaded, 0); + + var hiccupCount = 0; + function handleCometHiccup(params) { + dmesg("HICCUP (connected:"+(!!params.connected)+")"); + var connectedNow = params.connected; + if (! connectedNow) { + hiccupCount++; + // skip first "cut off from server" notification + if (hiccupCount > 1) { + setChannelState("RECONNECTING"); + } + } + else { + hiccupCount = 0; + setChannelState("CONNECTED"); + } + } + + function sendMessage(msg) { + socket.postMessage(JSON.stringify({type: "COLLABROOM", data: msg})); + } + + function wrapRecordingErrors(catcher, func) { + return function() { + try { + return func.apply(this, Array.prototype.slice.call(arguments)); + } + catch (e) { + caughtErrors.push(e); + caughtErrorCatchers.push(catcher); + caughtErrorTimes.push(+new Date()); + //console.dir({catcher: catcher, e: e}); + throw e; + } + }; + } + + function callCatchingErrors(catcher, func) { + try { + wrapRecordingErrors(catcher, func)(); + } + catch (e) { /*absorb*/ } + } + + function handleMessageFromServer(evt) { + if (! socket) return; + if (! evt.data) return; + var wrapper = JSON.parse(evt.data); + if(wrapper.type != "COLLABROOM") return; + var msg = wrapper.data; + if (msg.type == "NEW_CHANGES") { + var newRev = msg.newRev; + var changeset = msg.changeset; + var author = (msg.author || ''); + var apool = msg.apool; + if (newRev != (rev+1)) { + dmesg("bad message revision on NEW_CHANGES: "+newRev+" not "+(rev+1)); + socket.disconnect(); + return; + } + rev = newRev; + editor.applyChangesToBase(changeset, author, apool); + } + else if (msg.type == "ACCEPT_COMMIT") { + var newRev = msg.newRev; + if (newRev != (rev+1)) { + dmesg("bad message revision on ACCEPT_COMMIT: "+newRev+" not "+(rev+1)); + socket.disconnect(); + return; + } + rev = newRev; + editor.applyPreparedChangesetToBase(); + setStateIdle(); + callCatchingErrors("onInternalAction", function() { + callbacks.onInternalAction("commitAcceptedByServer"); + }); + callCatchingErrors("onConnectionTrouble", function() { + callbacks.onConnectionTrouble("OK"); + }); + handleUserChanges(); + } + else if (msg.type == "NO_COMMIT_PENDING") { + if (state == "COMMITTING") { + // server missed our commit message; abort that commit + setStateIdle(); + handleUserChanges(); + } + } + else if (msg.type == "USER_NEWINFO") { + var userInfo = msg.userInfo; + var id = userInfo.userId; + if (userSet[id]) { + userSet[id] = userInfo; + callbacks.onUpdateUserInfo(userInfo); + dmesgUsers(); + } + else { + userSet[id] = userInfo; + callbacks.onUserJoin(userInfo); + dmesgUsers(); + } + tellAceActiveAuthorInfo(userInfo); + } + else if (msg.type == "USER_LEAVE") { + var userInfo = msg.userInfo; + var id = userInfo.userId; + if (userSet[id]) { + delete userSet[userInfo.userId]; + fadeAceAuthorInfo(userInfo); + callbacks.onUserLeave(userInfo); + dmesgUsers(); + } + } + else if (msg.type == "DISCONNECT_REASON") { + appLevelDisconnectReason = msg.reason; + } + else if (msg.type == "CLIENT_MESSAGE") { + callbacks.onClientMessage(msg.payload); + } + else if (msg.type == "SERVER_MESSAGE") { + callbacks.onServerMessage(msg.payload); + } + } + function updateUserInfo(userInfo) { + userInfo.userId = userId; + userSet[userId] = userInfo; + tellAceActiveAuthorInfo(userInfo); + if (! socket) return; + sendMessage({type: "USERINFO_UPDATE", userInfo:userInfo}); + } + + function tellAceActiveAuthorInfo(userInfo) { + tellAceAuthorInfo(userInfo.userId, userInfo.colorId); + } + function tellAceAuthorInfo(userId, colorId, inactive) { + if (colorId || (typeof colorId) == "number") { + colorId = Number(colorId); + if (options && options.colorPalette && options.colorPalette[colorId]) { + var cssColor = options.colorPalette[colorId]; + if (inactive) { + editor.setAuthorInfo(userId, {bgcolor: cssColor, fade: 0.5}); + } + else { + editor.setAuthorInfo(userId, {bgcolor: cssColor}); + } + } + } + } + function fadeAceAuthorInfo(userInfo) { + tellAceAuthorInfo(userInfo.userId, userInfo.colorId, true); + } + + function getConnectedUsers() { + return valuesArray(userSet); + } + + function tellAceAboutHistoricalAuthors(hadata) { + for(var author in hadata) { + var data = hadata[author]; + if (! userSet[author]) { + tellAceAuthorInfo(author, data.colorId, true); + } + } + } + + function dmesgUsers() { + //pad.dmesg($.map(getConnectedUsers(), function(u) { return u.userId.slice(-2); }).join(',')); + } + + function handleSocketClosed(params) { + socket = null; + + $.each(keys(userSet), function() { + var uid = String(this); + if (uid != userId) { + var userInfo = userSet[uid]; + delete userSet[uid]; + callbacks.onUserLeave(userInfo); + dmesgUsers(); + } + }); + + var reason = appLevelDisconnectReason || params.reason; + var shouldReconnect = params.reconnect; + if (shouldReconnect) { + + // determine if this is a tight reconnect loop due to weird connectivity problems + reconnectTimes.push(+new Date()); + var TOO_MANY_RECONNECTS = 8; + var TOO_SHORT_A_TIME_MS = 10000; + if (reconnectTimes.length >= TOO_MANY_RECONNECTS && + ((+new Date()) - reconnectTimes[reconnectTimes.length-TOO_MANY_RECONNECTS]) < + TOO_SHORT_A_TIME_MS) { + setChannelState("DISCONNECTED", "looping"); + } + else { + setChannelState("RECONNECTING", reason); + setUpSocket(); + } + + } + else { + setChannelState("DISCONNECTED", reason); + } + } + + function setChannelState(newChannelState, moreInfo) { + if (newChannelState != channelState) { + channelState = newChannelState; + callbacks.onChannelStateChange(channelState, moreInfo); + } + } + + function keys(obj) { + var array = []; + $.each(obj, function (k, v) { array.push(k); }); + return array; + } + function valuesArray(obj) { + var array = []; + $.each(obj, function (k, v) { array.push(v); }); + return array; + } + + // We need to present a working interface even before the socket + // is connected for the first time. + var deferredActions = []; + function defer(func, tag) { + return function() { + var that = this; + var args = arguments; + function action() { + func.apply(that, args); + } + action.tag = tag; + if (channelState == "CONNECTING") { + deferredActions.push(action); + } + else { + action(); + } + } + } + function doDeferredActions(tag) { + var newArray = []; + for(var i=0;i<deferredActions.length;i++) { + var a = deferredActions[i]; + if ((!tag) || (tag == a.tag)) { + a(); + } + else { + newArray.push(a); + } + } + deferredActions = newArray; + } + + function sendClientMessage(msg) { + sendMessage({ type: "CLIENT_MESSAGE", payload: msg }); + } + + function getCurrentRevisionNumber() { + return rev; + } + + function getDiagnosticInfo() { + var maxCaughtErrors = 3; + var maxAceErrors = 3; + var maxDebugMessages = 50; + var longStringCutoff = 500; + + function trunc(str) { + return String(str).substring(0, longStringCutoff); + } + + var info = { errors: {length: 0} }; + function addError(e, catcher, time) { + var error = {catcher:catcher}; + if (time) error.time = time; + + // a little over-cautious? + try { if (e.description) error.description = e.description; } catch (x) {} + try { if (e.fileName) error.fileName = e.fileName; } catch (x) {} + try { if (e.lineNumber) error.lineNumber = e.lineNumber; } catch (x) {} + try { if (e.message) error.message = e.message; } catch (x) {} + try { if (e.name) error.name = e.name; } catch (x) {} + try { if (e.number) error.number = e.number; } catch (x) {} + try { if (e.stack) error.stack = trunc(e.stack); } catch (x) {} + + info.errors[info.errors.length] = error; + info.errors.length++; + } + for(var i=0; ((i<caughtErrors.length) && (i<maxCaughtErrors)); i++) { + addError(caughtErrors[i], caughtErrorCatchers[i], caughtErrorTimes[i]); + } + if (editor) { + var aceErrors = editor.getUnhandledErrors(); + for(var i=0; ((i<aceErrors.length) && (i<maxAceErrors)) ;i++) { + var errorRecord = aceErrors[i]; + addError(errorRecord.error, "ACE", errorRecord.time); + } + } + + info.time = +new Date(); + info.collabState = state; + info.channelState = channelState; + info.lastCommitTime = lastCommitTime; + info.numSocketReconnects = reconnectTimes.length; + info.userId = userId; + info.currentRev = rev; + info.participants = (function() { + var pp = []; + for(var u in userSet) { + pp.push(u); + } + return pp.join(','); + })(); + + if (debugMessages.length > maxDebugMessages) { + debugMessages = debugMessages.slice(debugMessages.length-maxDebugMessages, + debugMessages.length); + } + + info.debugMessages = {length: 0}; + for(var i=0;i<debugMessages.length;i++) { + info.debugMessages[i] = trunc(debugMessages[i]); + info.debugMessages.length++; + } + + return info; + } + + function getMissedChanges() { + var obj = {}; + obj.userInfo = userSet[userId]; + obj.baseRev = rev; + if (state == "COMMITTING" && stateMessage) { + obj.committedChangeset = stateMessage.changeset; + obj.committedChangesetAPool = stateMessage.apool; + obj.committedChangesetSocketId = stateMessageSocketId; + editor.applyPreparedChangesetToBase(); + } + var userChangesData = editor.prepareUserChangeset(); + if (userChangesData.changeset) { + obj.furtherChangeset = userChangesData.changeset; + obj.furtherChangesetAPool = userChangesData.apool; + } + return obj; + } + + function setStateIdle() { + state = "IDLE"; + callbacks.onInternalAction("newlyIdle"); + schedulePerhapsCallIdleFuncs(); + } + + function callWhenNotCommitting(func) { + idleFuncs.push(func); + schedulePerhapsCallIdleFuncs(); + } + + var idleFuncs = []; + function schedulePerhapsCallIdleFuncs() { + setTimeout(function() { + if (state == "IDLE") { + while (idleFuncs.length > 0) { + var f = idleFuncs.shift(); + f(); + } + } + }, 0); + } + + var self; + return (self = { + setOnUserJoin: function(cb) { callbacks.onUserJoin = cb; }, + setOnUserLeave: function(cb) { callbacks.onUserLeave = cb; }, + setOnUpdateUserInfo: function(cb) { callbacks.onUpdateUserInfo = cb; }, + setOnChannelStateChange: function(cb) { callbacks.onChannelStateChange = cb; }, + setOnClientMessage: function(cb) { callbacks.onClientMessage = cb; }, + setOnInternalAction: function(cb) { callbacks.onInternalAction = cb; }, + setOnConnectionTrouble: function(cb) { callbacks.onConnectionTrouble = cb; }, + setOnServerMessage: function(cb) { callbacks.onServerMessage = cb; }, + updateUserInfo: defer(updateUserInfo), + getConnectedUsers: getConnectedUsers, + sendClientMessage: sendClientMessage, + getCurrentRevisionNumber: getCurrentRevisionNumber, + getDiagnosticInfo: getDiagnosticInfo, + getMissedChanges: getMissedChanges, + callWhenNotCommitting: callWhenNotCommitting, + addHistoricalAuthors: tellAceAboutHistoricalAuthors + }); +} + +function selectElementContents(elem) { + if ($.browser.msie) { + var range = document.body.createTextRange(); + range.moveToElementText(elem); + range.select(); + } + else { + if (window.getSelection) { + var browserSelection = window.getSelection(); + if (browserSelection) { + var range = document.createRange(); + range.selectNodeContents(elem); + browserSelection.removeAllRanges(); + browserSelection.addRange(range); + } + } + } +} |