diff options
Diffstat (limited to 'trunk/etherpad/src/static/js/broadcast.js')
-rw-r--r-- | trunk/etherpad/src/static/js/broadcast.js | 607 |
1 files changed, 607 insertions, 0 deletions
diff --git a/trunk/etherpad/src/static/js/broadcast.js b/trunk/etherpad/src/static/js/broadcast.js new file mode 100644 index 0000000..9fa8141 --- /dev/null +++ b/trunk/etherpad/src/static/js/broadcast.js @@ -0,0 +1,607 @@ +/** + * 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. + */ + +// just in case... (todo: this must be somewhere else in the client code.) +if (!Array.prototype.map) +{ + Array.prototype.map = function(fun /*, thisp*/) + { + var len = this.length >>> 0; + if (typeof fun != "function") + throw new TypeError(); + + var res = new Array(len); + var thisp = arguments[1]; + for (var i = 0; i < len; i++) + { + if (i in this) + res[i] = fun.call(thisp, this[i], i, this); + } + + return res; + }; +} + +if (!Array.prototype.forEach) +{ + Array.prototype.forEach = function(fun /*, thisp*/) + { + var len = this.length >>> 0; + if (typeof fun != "function") + throw new TypeError(); + + var thisp = arguments[1]; + for (var i = 0; i < len; i++) + { + if (i in this) + fun.call(thisp, this[i], i, this); + } + }; +} + +if (!Array.prototype.indexOf) +{ + Array.prototype.indexOf = function(elt /*, from*/) + { + var len = this.length >>> 0; + + var from = Number(arguments[1]) || 0; + from = (from < 0) + ? Math.ceil(from) + : Math.floor(from); + if (from < 0) + from += len; + + for (; from < len; from++) + { + if (from in this && + this[from] === elt) + return from; + } + return -1; + }; +} + +function debugLog() { + try { + // console.log.apply(console, arguments); + } catch (e) {console.log("error printing: ",e);} +} + +function randomString() { + return "_"+Math.floor(Math.random() * 1000000); +} + +// for IE +if ($.browser.msie) { + try { + document.execCommand("BackgroundImageCache", false, true); + } catch (e) {} +} + +var userId = "hiddenUser" + randomString(); +var socketId; +var socket; + +var channelState = "DISCONNECTED"; + +var appLevelDisconnectReason = null; + +var padContents = { + currentRevision: clientVars.revNum, + currentTime : clientVars.currentTime, + currentLines: Changeset.splitTextLines(clientVars.initialStyledContents.atext.text), + currentDivs : null, // to be filled in once the dom loads + apool: (new AttribPool()).fromJsonable(clientVars.initialStyledContents.apool), + alines: Changeset.splitAttributionLines( + clientVars.initialStyledContents.atext.attribs, + clientVars.initialStyledContents.atext.text), + + // generates a jquery element containing HTML for a line + lineToElement: function(line, aline) { + var element = document.createElement("div"); + var emptyLine = (line == '\n'); + var domInfo = domline.createDomLine(! emptyLine, true); + linestylefilter.populateDomLine(line, aline, this.apool, + domInfo); + domInfo.prepareForAdd(); + element.className = domInfo.node.className; + element.innerHTML = domInfo.node.innerHTML; + element.id = Math.random(); + return $(element); + }, + + applySpliceToDivs: function(start, numRemoved, newLines) { + // remove spliced-out lines from DOM + for(var i=start; i<start+numRemoved && i<this.currentDivs.length; i++) { + debugLog("removing", this.currentDivs[i].attr('id')); + this.currentDivs[i].remove(); + } + + // remove spliced-out line divs from currentDivs array + this.currentDivs.splice(start, numRemoved); + + var newDivs = []; + for(var i=0;i<newLines.length;i++) { + newDivs.push(this.lineToElement(newLines[i], + this.alines[start+i])); + } + + // grab the div just before the first one + var startDiv = this.currentDivs[start-1] || null; + + // insert the div elements into the correct place, in the correct order + for(var i=0; i<newDivs.length; i++) { + if (startDiv) { + startDiv.after(newDivs[i]); + } + else { + $("#padcontent").prepend(newDivs[i]); + } + startDiv = newDivs[i]; + } + + // insert new divs into currentDivs array + newDivs.unshift(0); // remove 0 elements + newDivs.unshift(start); + this.currentDivs.splice.apply(this.currentDivs, newDivs); + return this; + }, + + // splice the lines + splice: function(start, numRemoved, newLinesVA) { + var newLines = Array.prototype.slice.call(arguments, 2).map( + function(s) { return s; }); + + // apply this splice to the divs + this.applySpliceToDivs(start, numRemoved, newLines); + + // call currentLines.splice, to keep the currentLines array up to date + newLines.unshift(numRemoved); + newLines.unshift(start); + this.currentLines.splice.apply(this.currentLines, arguments); + }, + // returns the contents of the specified line I + get: function(i) { + return this.currentLines[i]; + }, + // returns the number of lines in the document + length: function() { + return this.currentLines.length; + }, + + getActiveAuthors: function() { + var self = this; + var authors = []; + var seenNums = {}; + var alines = self.alines; + for(var i=0;i<alines.length;i++) { + Changeset.eachAttribNumber(alines[i], function(n) { + if (! seenNums[n]) { + seenNums[n] = true; + if (self.apool.getAttribKey(n) == 'author') { + var a = self.apool.getAttribValue(n); + if (a) { + authors.push(a); + } + } + } + }); + } + authors.sort(); + return authors; + } +}; + +function callCatchingErrors(catcher, func) { + try { + wrapRecordingErrors(catcher, func)(); + } + catch (e) { /*absorb*/ } +} + +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}); + debugLog(e); // TODO(kroo): added temporary, to catch errors + throw e; + } + }; +} + +function loadedNewChangeset(changesetForward, changesetBackward, revision, timeDelta) { + var broadcasting = (BroadcastSlider.getSliderPosition() == revisionInfo.latest); + debugLog("broadcasting:", broadcasting, BroadcastSlider.getSliderPosition(), revisionInfo.latest, revision); + revisionInfo.addChangeset(revision, revision+1, changesetForward, changesetBackward, timeDelta); + BroadcastSlider.setSliderLength(revisionInfo.latest); + if(broadcasting) + applyChangeset(changesetForward, revision+1, false, timeDelta); +} + +/* + At this point, we must be certain that the changeset really does map from + the current revision to the specified revision. Any mistakes here will + cause the whole slider to get out of sync. + */ +function applyChangeset(changeset, revision, preventSliderMovement, timeDelta) { + // disable the next 'gotorevision' call handled by a timeslider update + if(!preventSliderMovement) { + goToRevisionIfEnabledCount ++; + BroadcastSlider.setSliderPosition(revision); + } + + try { + // must mutate attribution lines before text lines + Changeset.mutateAttributionLines(changeset, padContents.alines, padContents.apool); + }catch(e) { debugLog(e); } + + Changeset.mutateTextLines(changeset, padContents); + padContents.currentRevision = revision; + padContents.currentTime += timeDelta * 1000; + debugLog('Time Delta: ',timeDelta) + updateTimer(); + BroadcastSlider.setAuthors(padContents.getActiveAuthors().map(function(name) {return authorData[name];})); +} + +function updateTimer() { + var zpad = function(str, length) { + str = str+""; + while(str.length < length) + str = '0'+str; + return str; + } + var date = new Date(padContents.currentTime); + var dateFormat = function() { + var month = zpad(date.getMonth()+1,2); + var day = zpad(date.getDate(),2); + var year = (date.getFullYear()); + var hours = zpad(date.getHours(),2); + var minutes = zpad(date.getMinutes(),2); + var seconds = zpad(date.getSeconds(),2); + return ([month,'/',day,'/',year,' ',hours,':',minutes,':',seconds].join("")); + } + + $('#timer').html(dateFormat()); + + var revisionDate = [ + "Saved", + [ + "Jan", "Feb", "March", "April", "May", "June", + "July", "Aug", "Sept", "Oct", "Nov", "Dec" + ][date.getMonth()], + date.getDate()+",", + date.getFullYear() + ].join(" ") + $('#revision_date').html(revisionDate) + +} + +function goToRevision(newRevision) { + padContents.targetRevision = newRevision; + var self = this; + var path = revisionInfo.getPath(padContents.currentRevision, newRevision); + debugLog('newRev: ', padContents.currentRevision, path); + if( path.status == 'complete') { + var cs = path.changesets; + debugLog("status: complete, changesets: ",cs, "path:", path); + var changeset = cs[0]; + var timeDelta = path.times[0]; + for(var i=1; i<cs.length; i++) { + changeset = Changeset.compose(changeset, cs[i], padContents.apool); + timeDelta += path.times[i]; + } + if(changeset) + applyChangeset(changeset, path.rev, true, timeDelta); + } else if(path.status == "partial") { + debugLog('partial'); + var sliderLocation = padContents.currentRevision; + // callback is called after changeset information is pulled from server + // this may never get called, if the changeset has already been loaded + var update = function(start, end) { + // if we've called goToRevision in the time since, don't goToRevision + goToRevision(padContents.targetRevision); + }; + + // do our best with what we have... + var cs = path.changesets; + + var changeset = cs[0]; + var timeDelta = path.times[0]; + for(var i=1; i<cs.length; i++) { + changeset = Changeset.compose(changeset, cs[i], padContents.apool); + timeDelta += path.times[i]; + } + if(changeset) + applyChangeset(changeset, path.rev, true, timeDelta); + + + if(BroadcastSlider.getSliderLength() > 10000) { + var start = (Math.floor((newRevision) / 10000) * 10000); // revision 0 to 10 + changesetLoader.queueUp(start, 100); + } + + if(BroadcastSlider.getSliderLength() > 1000) { + var start = (Math.floor((newRevision) / 1000) * 1000); // (start from -1, go to 19) + 1 + changesetLoader.queueUp(start, 10); + } + + start = (Math.floor((newRevision) / 100) * 100); + + changesetLoader.queueUp(start, 1, update); + } + BroadcastSlider.setAuthors(padContents.getActiveAuthors().map(function(name) {return authorData[name];})); +} + +var changesetLoader = { + running: false, + resolved: [], + requestQueue1: [], + requestQueue2: [], + requestQueue3: [], + queueUp: function(revision, width, callback) { + if(revision < 0) revision = 0; + // if(changesetLoader.requestQueue.indexOf(revision) != -1) + // return; // already in the queue. + if(changesetLoader.resolved.indexOf(revision+"_"+width) != -1) + return; // already loaded from the server + changesetLoader.resolved.push(revision+"_"+width); + + var requestQueue = width == 1 ? changesetLoader.requestQueue3 : + width == 10 ? changesetLoader.requestQueue2 : + changesetLoader.requestQueue1; + requestQueue.push({'rev': revision, 'res': width, 'callback': callback}); + if(!changesetLoader.running) { + changesetLoader.running = true; + setTimeout(changesetLoader.loadFromQueue, 10); + } + }, + loadFromQueue: function() { + var self = changesetLoader; + var requestQueue = self.requestQueue1.length > 0 ? self.requestQueue1 : + self.requestQueue2.length > 0 ? self.requestQueue2 : + self.requestQueue3.length > 0 ? self.requestQueue3 : null; + + if(!requestQueue) { + self.running = false; + return; + } + + var request = requestQueue.pop(); + var granularity = request.res; + var callback = request.callback; + var start = request.rev; + debugLog("loadinging revision", start, "through ajax"); + $.getJSON( + "/ep/pad/changes/"+clientVars.padIdForUrl+"?s="+start + "&g="+granularity, + function(data, textStatus) { + if(textStatus !== "success") { + console.log(textStatus); + BroadcastSlider.showReconnectUI(); + } + self.handleResponse(data, start, granularity, callback); + + setTimeout(self.loadFromQueue, 10); // load the next ajax function + } + ); + }, + handleResponse: function(data, start, granularity, callback) { + debugLog("response: ", data); + var pool = (new AttribPool()).fromJsonable(data.apool); + for(var i=0; i<data.forwardsChangesets.length; i++) { + var astart = start + i * granularity - 1; // rev -1 is a blank single line + var aend = start + (i+1) * granularity - 1;// totalRevs is the most recent revision + if(aend > data.actualEndNum - 1) aend = data.actualEndNum - 1; + debugLog("adding changeset:", astart, aend); + var forwardcs = Changeset.moveOpsToNewPool(data.forwardsChangesets[i], pool, padContents.apool); + var backwardcs = Changeset.moveOpsToNewPool(data.backwardsChangesets[i], pool, padContents.apool); + revisionInfo.addChangeset(astart, aend, forwardcs, backwardcs, data.timeDeltas[i]); + } + if(callback)callback(start - 1, start + data.forwardsChangesets.length * granularity - 1); + } +}; + +function handleMessageFromServer() { + debugLog("handleMessage:", arguments); + var obj = arguments[0]['data']; + var expectedType = "COLLABROOM"; + + obj = JSON.parse(obj); + if (obj['type'] == expectedType) { + obj = obj['data']; + + if (obj['type'] == "NEW_CHANGES") { + debugLog(obj); + var changeset = Changeset.moveOpsToNewPool( + obj.changeset, (new AttribPool()).fromJsonable(obj.apool), + padContents.apool); + + var changesetBack = Changeset.moveOpsToNewPool( + obj.changesetBack, (new AttribPool()).fromJsonable(obj.apool), + padContents.apool); + + loadedNewChangeset(changeset, changesetBack, obj.newRev-1, obj.timeDelta); + } + else if (obj['type'] == "NEW_AUTHORDATA") { + var authorMap = {}; + authorMap[obj.author] = obj.data; + receiveAuthorData(authorMap); + BroadcastSlider.setAuthors(padContents.getActiveAuthors().map(function(name) {return authorData[name];})); + } else if (obj['type'] == "NEW_SAVEDREV") { + var savedRev = obj.savedRev; + BroadcastSlider.addSavedRevision(savedRev.revNum, savedRev); + } + } else { + debugLog("incorrect message type: " + obj['type'] + ", expected " + expectedType); + } +} + +function handleSocketClosed(params) { + debugLog("socket closed!", params); + socket = null; + + BroadcastSlider.showReconnectUI(); + // 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 { + // BroadcastSlider.showReconnectUI(); + // setChannelState("DISCONNECTED", reason); + // } +} + +function sendMessage(msg) { + socket.postMessage(JSON.stringify({type: "COLLABROOM", data: msg})); +} + +function setUpSocket() { + // required for Comet + if ((! $.browser.msie) && + (! ($.browser.mozilla && $.browser.version.indexOf("1.8.") == 0))) { + document.domain = document.domain; // for comet + } + + var success = false; + callCatchingErrors("setUpSocket", function() { + appLevelDisconnectReason = null; + + 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() { + setChannelState("CONNECTED"); + var msg = { type:"CLIENT_READY", roomType:'padview', + roomName:'padview/'+clientVars.viewId, + data: { lastRev:clientVars.revNum, + userInfo:{userId: userId} } }; + sendMessage(msg); + }); + // socket.onhiccup = wrapRecordingErrors("socket.onhiccup", handleCometHiccup); + // socket.onlogmessage = function(x) {debugLog(x); }; + socket.connect(); + success = true; + }); + if (success) { + //initialStartConnectTime = +new Date(); + } + else { + abandonConnection("initsocketfail"); + } +} + +function setChannelState(newChannelState, moreInfo) { + if (newChannelState != channelState) { + channelState = newChannelState; + // callbacks.onChannelStateChange(channelState, moreInfo); + } +} + +function abandonConnection(reason) { + if (socket) { + socket.onclosed = function() {}; + socket.onhiccup = function() {}; + socket.disconnect(); + } + socket = null; + setChannelState("DISCONNECTED", reason); +} + +window['onloadFuncts'] = []; +window.onload = function() { + window['isloaded'] = true; + window['onloadFuncts'].forEach(function(funct) { + funct(); + }); +}; + +// to start upon window load, just push a function onto this array +window['onloadFuncts'].push(setUpSocket); +window['onloadFuncts'].push(function() { + // set up the currentDivs and DOM + padContents.currentDivs = []; + $("#padcontent").html(""); + for(var i=0; i<padContents.currentLines.length; i++) { + var div = padContents.lineToElement(padContents.currentLines[i], + padContents.alines[i]); + padContents.currentDivs.push(div); + $("#padcontent").append(div); + } + debugLog(padContents.currentDivs); +}); + +// this is necessary to keep infinite loops of events firing, +// since goToRevision changes the slider position +var goToRevisionIfEnabledCount = 0; +var goToRevisionIfEnabled = function() { + if(goToRevisionIfEnabledCount > 0) { + goToRevisionIfEnabledCount --; + } else { + goToRevision.apply(goToRevision, arguments); + } +} + +BroadcastSlider.onSlider(goToRevisionIfEnabled); + +(function() { + for(var i=0; i<clientVars.initialChangesets.length; i++) { + var csgroup = clientVars.initialChangesets[i]; + var start = clientVars.initialChangesets[i].start; + var granularity = clientVars.initialChangesets[i].granularity; + debugLog("loading changest on startup: ", start, granularity, csgroup); + changesetLoader.handleResponse(csgroup, start, granularity, null); + } +})(); + +var dynamicCSS = makeCSSManager('dynamicsyntax'); +var authorData = {}; + +function receiveAuthorData(newAuthorData) { + for(var author in newAuthorData) { + var data = newAuthorData[author]; + if ((typeof data.colorId) == 'number') { + var bgcolor = clientVars.colorPalette[data.colorId]; + if (bgcolor && dynamicCSS) { + dynamicCSS.selectorStyle( + '.'+linestylefilter.getAuthorClassName(author)).backgroundColor = + bgcolor; + } + } + authorData[author] = data; + } +} + +receiveAuthorData(clientVars.historicalAuthorData); |