UNPKG

firepad

Version:

Collaborative text editing powered by Firebase

1,512 lines (1,319 loc) 220 kB
/*! * Firepad is an open-source, collaborative code and text editor. It was designed * to be embedded inside larger applications. Since it uses Firebase as a backend, * it requires no server-side code and can be added to any web app simply by * including a couple JavaScript files. * * Firepad 0.0.0 * http://www.firepad.io/ * License: MIT * Copyright: 2014 Firebase * With code from ot.js (Copyright 2012-2013 Tim Baumann) */ (function (name, definition, context) { //try CommonJS, then AMD (require.js), then use global. if (typeof module != 'undefined' && module.exports) module.exports = definition(); else if (typeof context['define'] == 'function' && context['define']['amd']) define(definition); else context[name] = definition(); })('Firepad', function () {var firepad = firepad || { }; firepad.utils = { }; firepad.utils.makeEventEmitter = function(clazz, opt_allowedEVents) { clazz.prototype.allowedEvents_ = opt_allowedEVents; clazz.prototype.on = function(eventType, callback, context) { this.validateEventType_(eventType); this.eventListeners_ = this.eventListeners_ || { }; this.eventListeners_[eventType] = this.eventListeners_[eventType] || []; this.eventListeners_[eventType].push({ callback: callback, context: context }); }; clazz.prototype.off = function(eventType, callback) { this.validateEventType_(eventType); this.eventListeners_ = this.eventListeners_ || { }; var listeners = this.eventListeners_[eventType] || []; for(var i = 0; i < listeners.length; i++) { if (listeners[i].callback === callback) { listeners.splice(i, 1); return; } } }; clazz.prototype.trigger = function(eventType /*, args ... */) { this.eventListeners_ = this.eventListeners_ || { }; var listeners = this.eventListeners_[eventType] || []; for(var i = 0; i < listeners.length; i++) { listeners[i].callback.apply(listeners[i].context, Array.prototype.slice.call(arguments, 1)); } }; clazz.prototype.validateEventType_ = function(eventType) { if (this.allowedEvents_) { var allowed = false; for(var i = 0; i < this.allowedEvents_.length; i++) { if (this.allowedEvents_[i] === eventType) { allowed = true; break; } } if (!allowed) { throw new Error('Unknown event "' + eventType + '"'); } } }; }; firepad.utils.elt = function(tag, content, attrs) { var e = document.createElement(tag); if (typeof content === "string") { firepad.utils.setTextContent(e, content); } else if (content) { for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } } for(var attr in (attrs || { })) { e.setAttribute(attr, attrs[attr]); } return e; }; firepad.utils.setTextContent = function(e, str) { e.innerHTML = ""; e.appendChild(document.createTextNode(str)); }; firepad.utils.on = function(emitter, type, f, capture) { if (emitter.addEventListener) { emitter.addEventListener(type, f, capture || false); } else if (emitter.attachEvent) { emitter.attachEvent("on" + type, f); } }; firepad.utils.off = function(emitter, type, f, capture) { if (emitter.removeEventListener) { emitter.removeEventListener(type, f, capture || false); } else if (emitter.detachEvent) { emitter.detachEvent("on" + type, f); } }; firepad.utils.preventDefault = function(e) { if (e.preventDefault) { e.preventDefault(); } else { e.returnValue = false; } }; firepad.utils.stopPropagation = function(e) { if (e.stopPropagation) { e.stopPropagation(); } else { e.cancelBubble = true; } }; firepad.utils.stopEvent = function(e) { firepad.utils.preventDefault(e); firepad.utils.stopPropagation(e); }; firepad.utils.stopEventAnd = function(fn) { return function(e) { fn(e); firepad.utils.stopEvent(e); return false; }; }; firepad.utils.trim = function(str) { return str.replace(/^\s+/g, '').replace(/\s+$/g, ''); }; firepad.utils.stringEndsWith = function(str, suffix) { var list = (typeof suffix == 'string') ? [suffix] : suffix; for (var i = 0; i < list.length; i++) { var suffix = list[i]; if (str.indexOf(suffix, str.length - suffix.length) !== -1) return true; } return false; }; firepad.utils.assert = function assert (b, msg) { if (!b) { throw new Error(msg || "assertion error"); } }; firepad.utils.log = function() { if (typeof console !== 'undefined' && typeof console.log !== 'undefined') { var args = ['Firepad:']; for(var i = 0; i < arguments.length; i++) { args.push(arguments[i]); } console.log.apply(console, args); } }; var firepad = firepad || { }; firepad.Span = (function () { function Span(pos, length) { this.pos = pos; this.length = length; } Span.prototype.end = function() { return this.pos + this.length; }; return Span; }()); var firepad = firepad || { }; firepad.TextOp = (function() { var utils = firepad.utils; // Operation are essentially lists of ops. There are three types of ops: // // * Retain ops: Advance the cursor position by a given number of characters. // Represented by positive ints. // * Insert ops: Insert a given string at the current cursor position. // Represented by strings. // * Delete ops: Delete the next n characters. Represented by negative ints. function TextOp(type) { this.type = type; this.chars = null; this.text = null; this.attributes = null; if (type === 'insert') { this.text = arguments[1]; utils.assert(typeof this.text === 'string'); this.attributes = arguments[2] || { }; utils.assert (typeof this.attributes === 'object'); } else if (type === 'delete') { this.chars = arguments[1]; utils.assert(typeof this.chars === 'number'); } else if (type === 'retain') { this.chars = arguments[1]; utils.assert(typeof this.chars === 'number'); this.attributes = arguments[2] || { }; utils.assert (typeof this.attributes === 'object'); } } TextOp.prototype.isInsert = function() { return this.type === 'insert'; }; TextOp.prototype.isDelete = function() { return this.type === 'delete'; }; TextOp.prototype.isRetain = function() { return this.type === 'retain'; }; TextOp.prototype.equals = function(other) { return (this.type === other.type && this.text === other.text && this.chars === other.chars && this.attributesEqual(other.attributes)); }; TextOp.prototype.attributesEqual = function(otherAttributes) { for (var attr in this.attributes) { if (this.attributes[attr] !== otherAttributes[attr]) { return false; } } for (attr in otherAttributes) { if (this.attributes[attr] !== otherAttributes[attr]) { return false; } } return true; }; TextOp.prototype.hasEmptyAttributes = function() { var empty = true; for (var attr in this.attributes) { empty = false; break; } return empty; }; return TextOp; })(); var firepad = firepad || { }; firepad.TextOperation = (function () { 'use strict'; var TextOp = firepad.TextOp; var utils = firepad.utils; // Constructor for new operations. function TextOperation () { if (!this || this.constructor !== TextOperation) { // => function was called without 'new' return new TextOperation(); } // When an operation is applied to an input string, you can think of this as // if an imaginary cursor runs over the entire string and skips over some // parts, deletes some parts and inserts characters at some positions. These // actions (skip/delete/insert) are stored as an array in the "ops" property. this.ops = []; // An operation's baseLength is the length of every string the operation // can be applied to. this.baseLength = 0; // The targetLength is the length of every string that results from applying // the operation on a valid input string. this.targetLength = 0; } TextOperation.prototype.equals = function (other) { if (this.baseLength !== other.baseLength) { return false; } if (this.targetLength !== other.targetLength) { return false; } if (this.ops.length !== other.ops.length) { return false; } for (var i = 0; i < this.ops.length; i++) { if (!this.ops[i].equals(other.ops[i])) { return false; } } return true; }; // After an operation is constructed, the user of the library can specify the // actions of an operation (skip/insert/delete) with these three builder // methods. They all return the operation for convenient chaining. // Skip over a given number of characters. TextOperation.prototype.retain = function (n, attributes) { if (typeof n !== 'number' || n < 0) { throw new Error("retain expects a positive integer."); } if (n === 0) { return this; } this.baseLength += n; this.targetLength += n; attributes = attributes || { }; var prevOp = (this.ops.length > 0) ? this.ops[this.ops.length - 1] : null; if (prevOp && prevOp.isRetain() && prevOp.attributesEqual(attributes)) { // The last op is a retain op with the same attributes => we can merge them into one op. prevOp.chars += n; } else { // Create a new op. this.ops.push(new TextOp('retain', n, attributes)); } return this; }; // Insert a string at the current position. TextOperation.prototype.insert = function (str, attributes) { if (typeof str !== 'string') { throw new Error("insert expects a string"); } if (str === '') { return this; } attributes = attributes || { }; this.targetLength += str.length; var prevOp = (this.ops.length > 0) ? this.ops[this.ops.length - 1] : null; var prevPrevOp = (this.ops.length > 1) ? this.ops[this.ops.length - 2] : null; if (prevOp && prevOp.isInsert() && prevOp.attributesEqual(attributes)) { // Merge insert op. prevOp.text += str; } else if (prevOp && prevOp.isDelete()) { // It doesn't matter when an operation is applied whether the operation // is delete(3), insert("something") or insert("something"), delete(3). // Here we enforce that in this case, the insert op always comes first. // This makes all operations that have the same effect when applied to // a document of the right length equal in respect to the `equals` method. if (prevPrevOp && prevPrevOp.isInsert() && prevPrevOp.attributesEqual(attributes)) { prevPrevOp.text += str; } else { this.ops[this.ops.length - 1] = new TextOp('insert', str, attributes); this.ops.push(prevOp); } } else { this.ops.push(new TextOp('insert', str, attributes)); } return this; }; // Delete a string at the current position. TextOperation.prototype['delete'] = function (n) { if (typeof n === 'string') { n = n.length; } if (typeof n !== 'number' || n < 0) { throw new Error("delete expects a positive integer or a string"); } if (n === 0) { return this; } this.baseLength += n; var prevOp = (this.ops.length > 0) ? this.ops[this.ops.length - 1] : null; if (prevOp && prevOp.isDelete()) { prevOp.chars += n; } else { this.ops.push(new TextOp('delete', n)); } return this; }; // Tests whether this operation has no effect. TextOperation.prototype.isNoop = function () { return this.ops.length === 0 || (this.ops.length === 1 && (this.ops[0].isRetain() && this.ops[0].hasEmptyAttributes())); }; TextOperation.prototype.clone = function() { var clone = new TextOperation(); for(var i = 0; i < this.ops.length; i++) { if (this.ops[i].isRetain()) { clone.retain(this.ops[i].chars, this.ops[i].attributes); } else if (this.ops[i].isInsert()) { clone.insert(this.ops[i].text, this.ops[i].attributes); } else { clone['delete'](this.ops[i].chars); } } return clone; }; // Pretty printing. TextOperation.prototype.toString = function () { // map: build a new array by applying a function to every element in an old // array. var map = Array.prototype.map || function (fn) { var arr = this; var newArr = []; for (var i = 0, l = arr.length; i < l; i++) { newArr[i] = fn(arr[i]); } return newArr; }; return map.call(this.ops, function (op) { if (op.isRetain()) { return "retain " + op.chars; } else if (op.isInsert()) { return "insert '" + op.text + "'"; } else { return "delete " + (op.chars); } }).join(', '); }; // Converts operation into a JSON value. TextOperation.prototype.toJSON = function () { var ops = []; for(var i = 0; i < this.ops.length; i++) { // We prefix ops with their attributes if non-empty. if (!this.ops[i].hasEmptyAttributes()) { ops.push(this.ops[i].attributes); } if (this.ops[i].type === 'retain') { ops.push(this.ops[i].chars); } else if (this.ops[i].type === 'insert') { ops.push(this.ops[i].text); } else if (this.ops[i].type === 'delete') { ops.push(-this.ops[i].chars); } } // Return an array with /something/ in it, since an empty array will be treated as null by Firebase. if (ops.length === 0) { ops.push(0); } return ops; }; // Converts a plain JS object into an operation and validates it. TextOperation.fromJSON = function (ops) { var o = new TextOperation(); for (var i = 0, l = ops.length; i < l; i++) { var op = ops[i]; var attributes = { }; if (typeof op === 'object') { attributes = op; i++; op = ops[i]; } if (typeof op === 'number') { if (op > 0) { o.retain(op, attributes); } else { o['delete'](-op); } } else { utils.assert(typeof op === 'string'); o.insert(op, attributes); } } return o; }; // Apply an operation to a string, returning a new string. Throws an error if // there's a mismatch between the input string and the operation. TextOperation.prototype.apply = function (str, oldAttributes, newAttributes) { var operation = this; oldAttributes = oldAttributes || []; newAttributes = newAttributes || []; if (str.length !== operation.baseLength) { throw new Error("The operation's base length must be equal to the string's length."); } var newStringParts = [], j = 0, k, attr; var oldIndex = 0; var ops = this.ops; for (var i = 0, l = ops.length; i < l; i++) { var op = ops[i]; if (op.isRetain()) { if (oldIndex + op.chars > str.length) { throw new Error("Operation can't retain more characters than are left in the string."); } // Copy skipped part of the retained string. newStringParts[j++] = str.slice(oldIndex, oldIndex + op.chars); // Copy (and potentially update) attributes for each char in retained string. for(k = 0; k < op.chars; k++) { var currAttributes = oldAttributes[oldIndex + k] || { }, updatedAttributes = { }; for(attr in currAttributes) { updatedAttributes[attr] = currAttributes[attr]; utils.assert(updatedAttributes[attr] !== false); } for(attr in op.attributes) { if (op.attributes[attr] === false) { delete updatedAttributes[attr]; } else { updatedAttributes[attr] = op.attributes[attr]; } utils.assert(updatedAttributes[attr] !== false); } newAttributes.push(updatedAttributes); } oldIndex += op.chars; } else if (op.isInsert()) { // Insert string. newStringParts[j++] = op.text; // Insert attributes for each char. for(k = 0; k < op.text.length; k++) { var insertedAttributes = { }; for(attr in op.attributes) { insertedAttributes[attr] = op.attributes[attr]; utils.assert(insertedAttributes[attr] !== false); } newAttributes.push(insertedAttributes); } } else { // delete op oldIndex += op.chars; } } if (oldIndex !== str.length) { throw new Error("The operation didn't operate on the whole string."); } var newString = newStringParts.join(''); utils.assert(newString.length === newAttributes.length); return newString; }; // Computes the inverse of an operation. The inverse of an operation is the // operation that reverts the effects of the operation, e.g. when you have an // operation 'insert("hello "); skip(6);' then the inverse is 'delete("hello "); // skip(6);'. The inverse should be used for implementing undo. TextOperation.prototype.invert = function (str) { var strIndex = 0; var inverse = new TextOperation(); var ops = this.ops; for (var i = 0, l = ops.length; i < l; i++) { var op = ops[i]; if (op.isRetain()) { inverse.retain(op.chars); strIndex += op.chars; } else if (op.isInsert()) { inverse['delete'](op.text.length); } else { // delete op inverse.insert(str.slice(strIndex, strIndex + op.chars)); strIndex += op.chars; } } return inverse; }; // Compose merges two consecutive operations into one operation, that // preserves the changes of both. Or, in other words, for each input string S // and a pair of consecutive operations A and B, // apply(apply(S, A), B) = apply(S, compose(A, B)) must hold. TextOperation.prototype.compose = function (operation2) { var operation1 = this; if (operation1.targetLength !== operation2.baseLength) { throw new Error("The base length of the second operation has to be the target length of the first operation"); } function composeAttributes(first, second, firstOpIsInsert) { var merged = { }, attr; for(attr in first) { merged[attr] = first[attr]; } for(attr in second) { if (firstOpIsInsert && second[attr] === false) { delete merged[attr]; } else { merged[attr] = second[attr]; } } return merged; } var operation = new TextOperation(); // the combined operation var ops1 = operation1.clone().ops, ops2 = operation2.clone().ops; var i1 = 0, i2 = 0; // current index into ops1 respectively ops2 var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops var attributes; while (true) { // Dispatch on the type of op1 and op2 if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { // end condition: both ops1 and ops2 have been processed break; } if (op1 && op1.isDelete()) { operation['delete'](op1.chars); op1 = ops1[i1++]; continue; } if (op2 && op2.isInsert()) { operation.insert(op2.text, op2.attributes); op2 = ops2[i2++]; continue; } if (typeof op1 === 'undefined') { throw new Error("Cannot compose operations: first operation is too short."); } if (typeof op2 === 'undefined') { throw new Error("Cannot compose operations: first operation is too long."); } if (op1.isRetain() && op2.isRetain()) { attributes = composeAttributes(op1.attributes, op2.attributes); if (op1.chars > op2.chars) { operation.retain(op2.chars, attributes); op1.chars -= op2.chars; op2 = ops2[i2++]; } else if (op1.chars === op2.chars) { operation.retain(op1.chars, attributes); op1 = ops1[i1++]; op2 = ops2[i2++]; } else { operation.retain(op1.chars, attributes); op2.chars -= op1.chars; op1 = ops1[i1++]; } } else if (op1.isInsert() && op2.isDelete()) { if (op1.text.length > op2.chars) { op1.text = op1.text.slice(op2.chars); op2 = ops2[i2++]; } else if (op1.text.length === op2.chars) { op1 = ops1[i1++]; op2 = ops2[i2++]; } else { op2.chars -= op1.text.length; op1 = ops1[i1++]; } } else if (op1.isInsert() && op2.isRetain()) { attributes = composeAttributes(op1.attributes, op2.attributes, /*firstOpIsInsert=*/true); if (op1.text.length > op2.chars) { operation.insert(op1.text.slice(0, op2.chars), attributes); op1.text = op1.text.slice(op2.chars); op2 = ops2[i2++]; } else if (op1.text.length === op2.chars) { operation.insert(op1.text, attributes); op1 = ops1[i1++]; op2 = ops2[i2++]; } else { operation.insert(op1.text, attributes); op2.chars -= op1.text.length; op1 = ops1[i1++]; } } else if (op1.isRetain() && op2.isDelete()) { if (op1.chars > op2.chars) { operation['delete'](op2.chars); op1.chars -= op2.chars; op2 = ops2[i2++]; } else if (op1.chars === op2.chars) { operation['delete'](op2.chars); op1 = ops1[i1++]; op2 = ops2[i2++]; } else { operation['delete'](op1.chars); op2.chars -= op1.chars; op1 = ops1[i1++]; } } else { throw new Error( "This shouldn't happen: op1: " + JSON.stringify(op1) + ", op2: " + JSON.stringify(op2) ); } } return operation; }; function getSimpleOp (operation) { var ops = operation.ops; switch (ops.length) { case 1: return ops[0]; case 2: return ops[0].isRetain() ? ops[1] : (ops[1].isRetain() ? ops[0] : null); case 3: if (ops[0].isRetain() && ops[2].isRetain()) { return ops[1]; } } return null; } function getStartIndex (operation) { if (operation.ops[0].isRetain()) { return operation.ops[0].chars; } return 0; } // When you use ctrl-z to undo your latest changes, you expect the program not // to undo every single keystroke but to undo your last sentence you wrote at // a stretch or the deletion you did by holding the backspace key down. This // This can be implemented by composing operations on the undo stack. This // method can help decide whether two operations should be composed. It // returns true if the operations are consecutive insert operations or both // operations delete text at the same position. You may want to include other // factors like the time since the last change in your decision. TextOperation.prototype.shouldBeComposedWith = function (other) { if (this.isNoop() || other.isNoop()) { return true; } var startA = getStartIndex(this), startB = getStartIndex(other); var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); if (!simpleA || !simpleB) { return false; } if (simpleA.isInsert() && simpleB.isInsert()) { return startA + simpleA.text.length === startB; } if (simpleA.isDelete() && simpleB.isDelete()) { // there are two possibilities to delete: with backspace and with the // delete key. return (startB + simpleB.chars === startA) || startA === startB; } return false; }; // Decides whether two operations should be composed with each other // if they were inverted, that is // `shouldBeComposedWith(a, b) = shouldBeComposedWithInverted(b^{-1}, a^{-1})`. TextOperation.prototype.shouldBeComposedWithInverted = function (other) { if (this.isNoop() || other.isNoop()) { return true; } var startA = getStartIndex(this), startB = getStartIndex(other); var simpleA = getSimpleOp(this), simpleB = getSimpleOp(other); if (!simpleA || !simpleB) { return false; } if (simpleA.isInsert() && simpleB.isInsert()) { return startA + simpleA.text.length === startB || startA === startB; } if (simpleA.isDelete() && simpleB.isDelete()) { return startB + simpleB.chars === startA; } return false; }; TextOperation.transformAttributes = function(attributes1, attributes2) { var attributes1prime = { }, attributes2prime = { }; var attr, allAttrs = { }; for(attr in attributes1) { allAttrs[attr] = true; } for(attr in attributes2) { allAttrs[attr] = true; } for (attr in allAttrs) { var attr1 = attributes1[attr], attr2 = attributes2[attr]; utils.assert(attr1 != null || attr2 != null); if (attr1 == null) { // Only modified by attributes2; keep it. attributes2prime[attr] = attr2; } else if (attr2 == null) { // only modified by attributes1; keep it attributes1prime[attr] = attr1; } else if (attr1 === attr2) { // Both set it to the same value. Nothing to do. } else { // attr1 and attr2 are different. Prefer attr1. attributes1prime[attr] = attr1; } } return [attributes1prime, attributes2prime]; }; // Transform takes two operations A and B that happened concurrently and // produces two operations A' and B' (in an array) such that // `apply(apply(S, A), B') = apply(apply(S, B), A')`. This function is the // heart of OT. TextOperation.transform = function (operation1, operation2) { if (operation1.baseLength !== operation2.baseLength) { throw new Error("Both operations have to have the same base length"); } var operation1prime = new TextOperation(); var operation2prime = new TextOperation(); var ops1 = operation1.clone().ops, ops2 = operation2.clone().ops; var i1 = 0, i2 = 0; var op1 = ops1[i1++], op2 = ops2[i2++]; while (true) { // At every iteration of the loop, the imaginary cursor that both // operation1 and operation2 have that operates on the input string must // have the same position in the input string. if (typeof op1 === 'undefined' && typeof op2 === 'undefined') { // end condition: both ops1 and ops2 have been processed break; } // next two cases: one or both ops are insert ops // => insert the string in the corresponding prime operation, skip it in // the other one. If both op1 and op2 are insert ops, prefer op1. if (op1 && op1.isInsert()) { operation1prime.insert(op1.text, op1.attributes); operation2prime.retain(op1.text.length); op1 = ops1[i1++]; continue; } if (op2 && op2.isInsert()) { operation1prime.retain(op2.text.length); operation2prime.insert(op2.text, op2.attributes); op2 = ops2[i2++]; continue; } if (typeof op1 === 'undefined') { throw new Error("Cannot transform operations: first operation is too short."); } if (typeof op2 === 'undefined') { throw new Error("Cannot transform operations: first operation is too long."); } var minl; if (op1.isRetain() && op2.isRetain()) { // Simple case: retain/retain var attributesPrime = TextOperation.transformAttributes(op1.attributes, op2.attributes); if (op1.chars > op2.chars) { minl = op2.chars; op1.chars -= op2.chars; op2 = ops2[i2++]; } else if (op1.chars === op2.chars) { minl = op2.chars; op1 = ops1[i1++]; op2 = ops2[i2++]; } else { minl = op1.chars; op2.chars -= op1.chars; op1 = ops1[i1++]; } operation1prime.retain(minl, attributesPrime[0]); operation2prime.retain(minl, attributesPrime[1]); } else if (op1.isDelete() && op2.isDelete()) { // Both operations delete the same string at the same position. We don't // need to produce any operations, we just skip over the delete ops and // handle the case that one operation deletes more than the other. if (op1.chars > op2.chars) { op1.chars -= op2.chars; op2 = ops2[i2++]; } else if (op1.chars === op2.chars) { op1 = ops1[i1++]; op2 = ops2[i2++]; } else { op2.chars -= op1.chars; op1 = ops1[i1++]; } // next two cases: delete/retain and retain/delete } else if (op1.isDelete() && op2.isRetain()) { if (op1.chars > op2.chars) { minl = op2.chars; op1.chars -= op2.chars; op2 = ops2[i2++]; } else if (op1.chars === op2.chars) { minl = op2.chars; op1 = ops1[i1++]; op2 = ops2[i2++]; } else { minl = op1.chars; op2.chars -= op1.chars; op1 = ops1[i1++]; } operation1prime['delete'](minl); } else if (op1.isRetain() && op2.isDelete()) { if (op1.chars > op2.chars) { minl = op2.chars; op1.chars -= op2.chars; op2 = ops2[i2++]; } else if (op1.chars === op2.chars) { minl = op1.chars; op1 = ops1[i1++]; op2 = ops2[i2++]; } else { minl = op1.chars; op2.chars -= op1.chars; op1 = ops1[i1++]; } operation2prime['delete'](minl); } else { throw new Error("The two operations aren't compatible"); } } return [operation1prime, operation2prime]; }; // convenience method to write transform(a, b) as a.transform(b) TextOperation.prototype.transform = function(other) { return TextOperation.transform(this, other); }; return TextOperation; }()); var firepad = firepad || { }; // TODO: Rewrite this (probably using a splay tree) to be efficient. Right now it's based on a linked list // so all operations are O(n), where n is the number of spans in the list. firepad.AnnotationList = (function () { var Span = firepad.Span; function assert(bool, text) { if (!bool) { throw new Error('AnnotationList assertion failed' + (text ? (': ' + text) : '')); } } function OldAnnotatedSpan(pos, node) { this.pos = pos; this.length = node.length; this.annotation = node.annotation; this.attachedObject_ = node.attachedObject; } OldAnnotatedSpan.prototype.getAttachedObject = function() { return this.attachedObject_; }; function NewAnnotatedSpan(pos, node) { this.pos = pos; this.length = node.length; this.annotation = node.annotation; this.node_ = node; } NewAnnotatedSpan.prototype.attachObject = function(object) { this.node_.attachedObject = object; }; var NullAnnotation = { equals: function() { return false; } }; function AnnotationList(changeHandler) { // There's always a head node; to avoid special cases. this.head_ = new Node(0, NullAnnotation); this.changeHandler_ = changeHandler; } AnnotationList.prototype.insertAnnotatedSpan = function(span, annotation) { this.wrapOperation_(new Span(span.pos, 0), function(oldPos, old) { assert(!old || old.next === null); // should be 0 or 1 nodes. var toInsert = new Node(span.length, annotation); if (!old) { return toInsert; } else { assert (span.pos > oldPos && span.pos < oldPos + old.length); var newNodes = new Node(0, NullAnnotation); // Insert part of old before insertion point. newNodes.next = new Node(span.pos - oldPos, old.annotation); // Insert new node. newNodes.next.next = toInsert; // Insert part of old after insertion point. toInsert.next = new Node(oldPos + old.length - span.pos, old.annotation); return newNodes.next; } }); }; AnnotationList.prototype.removeSpan = function(removeSpan) { if (removeSpan.length === 0) { return; } this.wrapOperation_(removeSpan, function(oldPos, old) { assert (old !== null); var newNodes = new Node(0, NullAnnotation), current = newNodes; // Add new node for part before the removed span (if any). if (removeSpan.pos > oldPos) { current.next = new Node(removeSpan.pos - oldPos, old.annotation); current = current.next; } // Skip over removed nodes. while (removeSpan.end() > oldPos + old.length) { oldPos += old.length; old = old.next; } // Add new node for part after the removed span (if any). var afterChars = oldPos + old.length - removeSpan.end(); if (afterChars > 0) { current.next = new Node(afterChars, old.annotation); } return newNodes.next; }); }; AnnotationList.prototype.updateSpan = function (span, updateFn) { if (span.length === 0) { return; } this.wrapOperation_(span, function(oldPos, old) { assert (old !== null); var newNodes = new Node(0, NullAnnotation), current = newNodes, currentPos = oldPos; // Add node for any characters before the span we're updating. var beforeChars = span.pos - currentPos; assert(beforeChars < old.length); if (beforeChars > 0) { current.next = new Node(beforeChars, old.annotation); current = current.next; currentPos += current.length; } // Add updated nodes for entirely updated nodes. while (old !== null && span.end() >= oldPos + old.length) { var length = oldPos + old.length - currentPos; current.next = new Node(length, updateFn(old.annotation, length)); current = current.next; oldPos += old.length; old = old.next; currentPos = oldPos; } // Add updated nodes for last node. var updateChars = span.end() - currentPos; if (updateChars > 0) { assert(updateChars < old.length); current.next = new Node(updateChars, updateFn(old.annotation, updateChars)); current = current.next; currentPos += current.length; // Add non-updated remaining part of node. current.next = new Node(oldPos + old.length - currentPos, old.annotation); } return newNodes.next; }); }; AnnotationList.prototype.wrapOperation_ = function(span, operationFn) { if (span.pos < 0) { throw new Error('Span start cannot be negative.'); } var oldNodes = [], newNodes = []; var res = this.getAffectedNodes_(span); var tail; if (res.start !== null) { tail = res.end.next; // Temporarily truncate list so we can pass it to operationFn. We'll splice it back in later. res.end.next = null; } else { // start and end are null, because span is empty and lies on the border of two nodes. tail = res.succ; } // Create a new segment to replace the affected nodes. var newSegment = operationFn(res.startPos, res.start); var includePredInOldNodes = false, includeSuccInOldNodes = false; if (newSegment) { this.mergeNodesWithSameAnnotations_(newSegment); var newPos; if (res.pred && res.pred.annotation.equals(newSegment.annotation)) { // We can merge the pred node with newSegment's first node. includePredInOldNodes = true; newSegment.length += res.pred.length; // Splice newSegment in after beforePred. res.beforePred.next = newSegment; newPos = res.predPos; } else { // Splice newSegment in after beforeStart. res.beforeStart.next = newSegment; newPos = res.startPos; } // Generate newNodes, but not the last one (since we may be able to merge it with succ). while(newSegment.next) { newNodes.push(new NewAnnotatedSpan(newPos, newSegment)); newPos += newSegment.length; newSegment = newSegment.next; } if (res.succ && res.succ.annotation.equals(newSegment.annotation)) { // We can merge newSegment's last node with the succ node. newSegment.length += res.succ.length; includeSuccInOldNodes = true; // Splice rest of list after succ after newSegment. newSegment.next = res.succ.next; } else { // Splice tail after newSegment. newSegment.next = tail; } // Add last newSegment node to newNodes. newNodes.push(new NewAnnotatedSpan(newPos, newSegment)); } else { // newList is empty. Try to merge pred and succ. if (res.pred && res.succ && res.pred.annotation.equals(res.succ.annotation)) { includePredInOldNodes = true; includeSuccInOldNodes = true; // Create succ + pred merged node and splice list together. newSegment = new Node(res.pred.length + res.succ.length, res.pred.annotation); res.beforePred.next = newSegment; newSegment.next = res.succ.next; newNodes.push(new NewAnnotatedSpan(res.startPos - res.pred.length, newSegment)); } else { // Just splice list back together. res.beforeStart.next = tail; } } // Build list of oldNodes. if (includePredInOldNodes) { oldNodes.push(new OldAnnotatedSpan(res.predPos, res.pred)); } var oldPos = res.startPos, oldSegment = res.start; while (oldSegment !== null) { oldNodes.push(new OldAnnotatedSpan(oldPos, oldSegment)); oldPos += oldSegment.length; oldSegment = oldSegment.next; } if (includeSuccInOldNodes) { oldNodes.push(new OldAnnotatedSpan(oldPos, res.succ)); } this.changeHandler_(oldNodes, newNodes); }; AnnotationList.prototype.getAffectedNodes_ = function(span) { // We want to find nodes 'start', 'end', 'beforeStart', 'pred', and 'succ' where: // - 'start' contains the first character in span. // - 'end' contains the last character in span. // - 'beforeStart' is the node before 'start'. // - 'beforePred' is the node before 'pred'. // - 'succ' contains the node after 'end' if span.end() was on a node boundary, else null. // - 'pred' contains the node before 'start' if span.pos was on a node boundary, else null. var result = {}; var prevprev = null, prev = this.head_, current = prev.next, currentPos = 0; while (current !== null && span.pos >= currentPos + current.length) { currentPos += current.length; prevprev = prev; prev = current; current = current.next; } if (current === null && !(span.length === 0 && span.pos === currentPos)) { throw new Error('Span start exceeds the bounds of the AnnotationList.'); } result.startPos = currentPos; // Special case if span is empty and on the border of two nodes if (span.length === 0 && span.pos === currentPos) { result.start = null; } else { result.start = current; } result.beforeStart = prev; if (currentPos === span.pos && currentPos > 0) { result.pred = prev; result.predPos = currentPos - prev.length; result.beforePred = prevprev; } else { result.pred = null; } while (current !== null && span.end() > currentPos) { currentPos += current.length; prev = current; current = current.next; } if (span.end() > currentPos) { throw new Error('Span end exceeds the bounds of the AnnotationList.'); } // Special case if span is empty and on the border of two nodes. if (span.length === 0 && span.end() === currentPos) { result.end = null; } else { result.end = prev; } result.succ = (currentPos === span.end()) ? current : null; return result; }; AnnotationList.prototype.mergeNodesWithSameAnnotations_ = function(list) { if (!list) { return; } var prev = null, curr = list; while (curr) { if (prev && prev.annotation.equals(curr.annotation)) { prev.length += curr.length; prev.next = curr.next; } else { prev = curr; } curr = curr.next; } }; AnnotationList.prototype.forEach = function(callback) { var current = this.head_.next; while (current !== null) { callback(current.length, current.annotation, current.attachedObject); current = current.next; } }; AnnotationList.prototype.getAnnotatedSpansForPos = function(pos) { var currentPos = 0; var current = this.head_.next, prev = null; while (current !== null && currentPos + current.length <= pos) { currentPos += current.length; prev = current; current = current.next; } if (current === null && currentPos !== pos) { throw new Error('pos exceeds the bounds of the AnnotationList'); } var res = []; if (currentPos === pos && prev) { res.push(new OldAnnotatedSpan(currentPos - prev.length, prev)); } if (current) { res.push(new OldAnnotatedSpan(currentPos, current)); } return res; }; AnnotationList.prototype.getAnnotatedSpansForSpan = function(span) { if (span.length === 0) { return []; } var oldSpans = []; var res = this.getAffectedNodes_(span); var currentPos = res.startPos, current = res.start; while (current !== null && currentPos < span.end()) { var start = Math.max(currentPos, span.pos), end = Math.min(currentPos + current.length, span.end()); var oldSpan = new Span(start, end - start); oldSpan.annotation = current.annotation; oldSpans.push(oldSpan); currentPos += current.length; current = current.next; } return oldSpans; }; // For testing. AnnotationList.prototype.count = function() { var count = 0; var current = this.head_.next, prev = null; while(current !== null) { if (prev) { assert(!prev.annotation.equals(current.annotation)); } prev = current; current = current.next; count++; } return count; }; function Node(length, annotation) { this.length = length; this.annotation = annotation; this.attachedObject = null; this.next = null; } Node.prototype.clone = function() { var node = new Node(this.spanLength, this.annotation); node.next = this.next; return node; }; return AnnotationList; }()); var firepad = firepad || { }; firepad.Cursor = (function () { 'use strict'; // A cursor has a `position` and a `selectionEnd`. Both are zero-based indexes // into the document. When nothing is selected, `selectionEnd` is equal to // `position`. When there is a selection, `position` is always the side of the // selection that would move if you pressed an arrow key. function Cursor (position, selectionEnd) { this.position = position; this.selectionEnd = selectionEnd; } Cursor.fromJSON = function (obj) { return new Cursor(obj.position, obj.selectionEnd); }; Cursor.prototype.equals = function (other) { return this.position === other.position && this.selectionEnd === other.selectionEnd; }; // Return the more current cursor information. Cursor.prototype.compose = function (other) { return other; }; // Update the cursor with respect to an operation. Cursor.prototype.transform = function (other) { function transformIndex (index) { var newIndex = index; var ops = other.ops; for (var i = 0, l = other.ops.length; i < l; i++) { if (ops[i].isRetain()) { index -= ops[i].chars; } else if (ops[i].isInsert()) { newIndex += ops[i].text.length; } else { newIndex -= Math.min(index, ops[i].chars); index -= ops[i].chars; } if (index < 0) { break; } } return newIndex; } var newPosition = transformIndex(this.position); if (this.position === this.selectionEnd) { return new Cursor(newPosition, newPosition); } return new Cursor(newPosition, transformIndex(this.selectionEnd)); }; return Cursor; }()); var firepad = firepad || { }; firepad.FirebaseAdapter = (function (global) { if (typeof firebase === "undefined" && typeof require === 'function' && typeof Firebase !== 'function') { firebase = require('firebase/app'); require('firebase/database'); } var TextOperation = firepad.TextOperation; var utils = firepad.utils; // Save a checkpoint every 100 edits. var CHECKPOINT_FREQUENCY = 100; function FirebaseAdapter (ref, userId, userColor) { this.ref_ = ref; this.ready_ = false; this.firebaseCallbacks_ = []; this.zombie_ = false; // We store the current document state as a TextOperation so we can write checkpoints to Firebase occasionally. // TODO: Consider more efficient ways to do this. (composing text operations is ~linear in the length of the document). this.document_ = new TextOperation(); // The next expected revision. this.revision_ = 0; // This is used for two purposes: // 1) On initialization, we fill this with the latest checkpoint and any subsequent operations and then // process them all together. // 2) If we ever receive revisions out-of-order (e.g. rev 5 before rev 4), we queue them here until it's time // for them to be handled. [this should never happen with well-behaved clients; but if it /does/ happen we want // to handle it gracefully.] this.pendingReceivedRevisions_ = { }; var self = this; if (userId) { this.setUserId(userId); this.setColor(userColor); var connectedRef = ref.root.child('.info/connected') this.firebaseOn_(connectedRef, 'value', function(snapshot) { if (snapshot.val() === true) { self.initializeUserData_(); } }, this); // Once we're initialized, start tracking users' cursors. this.on('ready', function() { self.monitorCursors_(); }); } else { this.userId_ = ref.push().key; } // Avoid triggering any events until our callers have had a chance to attach their listeners. setTimeout(function() { self.monitorHistory_(); }, 0); } utils.makeEventEmitter(FirebaseAdapter, ['ready', 'cursor', 'operation', 'ack', 'retry']); FirebaseAdapter.prototype.dispose = function() { var self = this; this.removeFirebaseCallbacks_(); this.handleInitialRevisions_ = () => {}; if (!this.ready_) { this.on('ready', function() { self.dispose(); }); return; } if (this.userRef_) { this.userRef_.child('cursor').remove(); this.userRef_.child('color').remove(); } this.ref_ = null; this.document_ = null; this.zombie_ = true; }; FirebaseAdapter.prototype.setUserId = function(userId) { if (this.userRef_) { // Clean up existing data. Avoid nuking another user's data // (if a future user takes our old name). this.userRef_.child('cursor').remove(); this.userRef_.child('cursor').onDisconnect().cancel(); this.userRef_.child('color').remove(); this.userRef_.child('color').onDisconnect().cancel(); } this.userId_ = userId; this.userRef_ = this.ref_.child('users').child(userId); this.initializeUserData_(); }; FirebaseAdapter.prototype.isHistoryEmpty = function() { assert(this.ready_, "Not ready yet."); return this.revision_ === 0; }; /* * Send operation, retrying on connection failure. Takes an optional callback with signature: * function(error, committed). * An exception will be thrown on transaction failure, which should only happen on * catastrophic failure like a security rule violation. */ FirebaseAdapter.prototype.sendOperation = function (operation, callback) { var self = this; // If we're not ready yet, do nothing right now, and trigger a retry when we're ready. if (!this.ready_) { this.on('ready', function() { self.trigger('retry'); }); return; } // Sanity check that this operation is valid. assert(this.document_.targetLength === operation.baseLength, "sendOperation() called with invalid operation."); // Convert revision into an id that will sort properly lexicographically. var revisionId = revisionToId(this.revision_); function doTransaction(revisionId, revisionData) { self.ref_.child('history').child(revisionId).transaction(function(current) { if (current === null) { return revisionData; } }, function(error, committed, snapshot) { if (error) { if (error.message === 'disconnect') { if (self.sent_ && self.sent_.id === revisionId) { // We haven't seen our transaction succeed or fail. Send it again. setTimeout(function() { doTransaction(revisionId, revisionData); }, 0); } else if (callback) { callback(error, false); } } else { utils.log('Transaction failure!', error); throw error; } } else { if (callback) callback(null, committed); } }, /*applyLocally=*/false); } this.sent_ = { id: revisionId, op: operation }; doTransaction(revisionId, { a: self.userId_, o: operation.toJSON(), t: firebase.database.ServerValue.TIMESTAMP }); }; FirebaseAdapter.prototype.sendCursor = function (obj) { this.userRef_.child('cursor').set(obj); this.cursor_ = obj; }; FirebaseAdapter.prototype.setColor = function(color) { this.userRef_.child('color').set(color); this.color_ = color; }; FirebaseAdapter.prototype.getDocument = function() { return this.document_; }; FirebaseAdapter.prototype.registerCallbacks = function(callbacks) { for (var eventType in callbacks) { this.on(eventType, callbacks[eventType]); } }; FirebaseAdapter.prototype.initializeUserData_ = function() { this.userRef_.child('cursor').onDisconnect().remove(); this.userRef_.child('color').onDisconnect().remove(); this.sendCursor(this.cursor_ || null); this.setColor(this.color_ || null); };