firepad
Version:
Collaborative text editing powered by Firebase
1,512 lines (1,319 loc) • 220 kB
JavaScript
/*!
* 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);
};