Added support of operational transformation
This commit is contained in:
parent
4702b83adc
commit
556338a9c6
23 changed files with 3668 additions and 256 deletions
312
lib/ot/client.js
Executable file
312
lib/ot/client.js
Executable file
|
@ -0,0 +1,312 @@
|
||||||
|
// translation of https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala
|
||||||
|
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.Client = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Client constructor
|
||||||
|
function Client (revision) {
|
||||||
|
this.revision = revision; // the next expected revision number
|
||||||
|
this.setState(synchronized_); // start state
|
||||||
|
}
|
||||||
|
|
||||||
|
Client.prototype.setState = function (state) {
|
||||||
|
this.state = state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call this method when the user changes the document.
|
||||||
|
Client.prototype.applyClient = function (operation) {
|
||||||
|
this.setState(this.state.applyClient(this, operation));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call this method with a new operation from the server
|
||||||
|
Client.prototype.applyServer = function (revision, operation) {
|
||||||
|
this.setState(this.state.applyServer(this, revision, operation));
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.prototype.applyOperations = function (head, operations) {
|
||||||
|
this.setState(this.state.applyOperations(this, head, operations));
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.prototype.serverAck = function (revision) {
|
||||||
|
this.setState(this.state.serverAck(this, revision));
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.prototype.serverReconnect = function () {
|
||||||
|
if (typeof this.state.resend === 'function') { this.state.resend(this); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transforms a selection from the latest known server state to the current
|
||||||
|
// client state. For example, if we get from the server the information that
|
||||||
|
// another user's cursor is at position 3, but the server hasn't yet received
|
||||||
|
// our newest operation, an insertion of 5 characters at the beginning of the
|
||||||
|
// document, the correct position of the other user's cursor in our current
|
||||||
|
// document is 8.
|
||||||
|
Client.prototype.transformSelection = function (selection) {
|
||||||
|
return this.state.transformSelection(selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override this method.
|
||||||
|
Client.prototype.sendOperation = function (revision, operation) {
|
||||||
|
throw new Error("sendOperation must be defined in child class");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override this method.
|
||||||
|
Client.prototype.applyOperation = function (operation) {
|
||||||
|
throw new Error("applyOperation must be defined in child class");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// In the 'Synchronized' state, there is no pending operation that the client
|
||||||
|
// has sent to the server.
|
||||||
|
function Synchronized () {}
|
||||||
|
Client.Synchronized = Synchronized;
|
||||||
|
|
||||||
|
Synchronized.prototype.applyClient = function (client, operation) {
|
||||||
|
// When the user makes an edit, send the operation to the server and
|
||||||
|
// switch to the 'AwaitingConfirm' state
|
||||||
|
client.sendOperation(client.revision, operation);
|
||||||
|
return new AwaitingConfirm(operation);
|
||||||
|
};
|
||||||
|
|
||||||
|
Synchronized.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
throw new Error("Invalid revision.");
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// When we receive a new operation from the server, the operation can be
|
||||||
|
// simply applied to the current document
|
||||||
|
client.applyOperation(operation);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
Synchronized.prototype.serverAck = function (client, revision) {
|
||||||
|
throw new Error("There is no pending operation.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing to do because the latest server state and client state are the same.
|
||||||
|
Synchronized.prototype.transformSelection = function (x) { return x; };
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
var synchronized_ = new Synchronized();
|
||||||
|
|
||||||
|
|
||||||
|
// In the 'AwaitingConfirm' state, there's one operation the client has sent
|
||||||
|
// to the server and is still waiting for an acknowledgement.
|
||||||
|
function AwaitingConfirm (outstanding) {
|
||||||
|
// Save the pending operation
|
||||||
|
this.outstanding = outstanding;
|
||||||
|
}
|
||||||
|
Client.AwaitingConfirm = AwaitingConfirm;
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.applyClient = function (client, operation) {
|
||||||
|
// When the user makes an edit, don't send the operation immediately,
|
||||||
|
// instead switch to 'AwaitingWithBuffer' state
|
||||||
|
return new AwaitingWithBuffer(this.outstanding, operation);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
throw new Error("Invalid revision.");
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// This is another client's operation. Visualization:
|
||||||
|
//
|
||||||
|
// /\
|
||||||
|
// this.outstanding / \ operation
|
||||||
|
// / \
|
||||||
|
// \ /
|
||||||
|
// pair[1] \ / pair[0] (new outstanding)
|
||||||
|
// (can be applied \/
|
||||||
|
// to the client's
|
||||||
|
// current document)
|
||||||
|
var pair = operation.constructor.transform(this.outstanding, operation);
|
||||||
|
client.applyOperation(pair[1]);
|
||||||
|
return new AwaitingConfirm(pair[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.serverAck = function (client, revision) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
return new Stale(this.outstanding, client, revision).getOperations();
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// The client's operation has been acknowledged
|
||||||
|
// => switch to synchronized state
|
||||||
|
return synchronized_;
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.transformSelection = function (selection) {
|
||||||
|
return selection.transform(this.outstanding);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.resend = function (client) {
|
||||||
|
// The confirm didn't come because the client was disconnected.
|
||||||
|
// Now that it has reconnected, we resend the outstanding operation.
|
||||||
|
client.sendOperation(client.revision, this.outstanding);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// In the 'AwaitingWithBuffer' state, the client is waiting for an operation
|
||||||
|
// to be acknowledged by the server while buffering the edits the user makes
|
||||||
|
function AwaitingWithBuffer (outstanding, buffer) {
|
||||||
|
// Save the pending operation and the user's edits since then
|
||||||
|
this.outstanding = outstanding;
|
||||||
|
this.buffer = buffer;
|
||||||
|
}
|
||||||
|
Client.AwaitingWithBuffer = AwaitingWithBuffer;
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.applyClient = function (client, operation) {
|
||||||
|
// Compose the user's changes onto the buffer
|
||||||
|
var newBuffer = this.buffer.compose(operation);
|
||||||
|
return new AwaitingWithBuffer(this.outstanding, newBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
throw new Error("Invalid revision.");
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// Operation comes from another client
|
||||||
|
//
|
||||||
|
// /\
|
||||||
|
// this.outstanding / \ operation
|
||||||
|
// / \
|
||||||
|
// /\ /
|
||||||
|
// this.buffer / \* / pair1[0] (new outstanding)
|
||||||
|
// / \/
|
||||||
|
// \ /
|
||||||
|
// pair2[1] \ / pair2[0] (new buffer)
|
||||||
|
// the transformed \/
|
||||||
|
// operation -- can
|
||||||
|
// be applied to the
|
||||||
|
// client's current
|
||||||
|
// document
|
||||||
|
//
|
||||||
|
// * pair1[1]
|
||||||
|
var transform = operation.constructor.transform;
|
||||||
|
var pair1 = transform(this.outstanding, operation);
|
||||||
|
var pair2 = transform(this.buffer, pair1[1]);
|
||||||
|
client.applyOperation(pair2[1]);
|
||||||
|
return new AwaitingWithBuffer(pair1[0], pair2[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.serverAck = function (client, revision) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
return new StaleWithBuffer(this.outstanding, this.buffer, client, revision).getOperations();
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// The pending operation has been acknowledged
|
||||||
|
// => send buffer
|
||||||
|
client.sendOperation(client.revision, this.buffer);
|
||||||
|
return new AwaitingConfirm(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.transformSelection = function (selection) {
|
||||||
|
return selection.transform(this.outstanding).transform(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.resend = function (client) {
|
||||||
|
// The confirm didn't come because the client was disconnected.
|
||||||
|
// Now that it has reconnected, we resend the outstanding operation.
|
||||||
|
client.sendOperation(client.revision, this.outstanding);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function Stale(acknowlaged, client, revision) {
|
||||||
|
this.acknowlaged = acknowlaged;
|
||||||
|
this.client = client;
|
||||||
|
this.revision = revision;
|
||||||
|
}
|
||||||
|
Client.Stale = Stale;
|
||||||
|
|
||||||
|
Stale.prototype.applyClient = function (client, operation) {
|
||||||
|
return new StaleWithBuffer(this.acknowlaged, operation, client, this.revision);
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
throw new Error("Ignored server-side change.");
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.applyOperations = function (client, head, operations) {
|
||||||
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
for (var i = 0; i < operations.length; i++) {
|
||||||
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
|
var pair = transform(this.acknowlaged, op);
|
||||||
|
client.applyOperation(pair[1]);
|
||||||
|
this.acknowlaged = pair[0];
|
||||||
|
}
|
||||||
|
client.revision = this.revision;
|
||||||
|
return synchronized_;
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.serverAck = function (client, revision) {
|
||||||
|
throw new Error("There is no pending operation.");
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.transformSelection = function (selection) {
|
||||||
|
return selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.getOperations = function () {
|
||||||
|
this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function StaleWithBuffer(acknowlaged, buffer, client, revision) {
|
||||||
|
this.acknowlaged = acknowlaged;
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.client = client;
|
||||||
|
this.revision = revision;
|
||||||
|
}
|
||||||
|
Client.StaleWithBuffer = StaleWithBuffer;
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.applyClient = function (client, operation) {
|
||||||
|
var buffer = this.buffer.compose(operation);
|
||||||
|
return new StaleWithBuffer(this.acknowlaged, buffer, client, this.revision);
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
throw new Error("Ignored server-side change.");
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.applyOperations = function (client, head, operations) {
|
||||||
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
for (var i = 0; i < operations.length; i++) {
|
||||||
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
|
var pair1 = transform(this.acknowlaged, op);
|
||||||
|
var pair2 = transform(this.buffer, pair1[1]);
|
||||||
|
client.applyOperation(pair2[1]);
|
||||||
|
this.acknowlaged = pair1[0];
|
||||||
|
this.buffer = pair2[0];
|
||||||
|
}
|
||||||
|
client.revision = this.revision;
|
||||||
|
client.sendOperation(client.revision, this.buffer);
|
||||||
|
return new AwaitingConfirm(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.serverAck = function (client, revision) {
|
||||||
|
throw new Error("There is no pending operation.");
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.transformSelection = function (selection) {
|
||||||
|
return selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.getOperations = function () {
|
||||||
|
this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return Client;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.Client;
|
||||||
|
}
|
||||||
|
|
146
lib/ot/editor-socketio-server.js
Executable file
146
lib/ot/editor-socketio-server.js
Executable file
|
@ -0,0 +1,146 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var EventEmitter = require('events').EventEmitter;
|
||||||
|
var TextOperation = require('./text-operation');
|
||||||
|
var WrappedOperation = require('./wrapped-operation');
|
||||||
|
var Server = require('./server');
|
||||||
|
var Selection = require('./selection');
|
||||||
|
var util = require('util');
|
||||||
|
|
||||||
|
var LZString = require('lz-string');
|
||||||
|
|
||||||
|
function EditorSocketIOServer(document, operations, docId, mayWrite) {
|
||||||
|
EventEmitter.call(this);
|
||||||
|
Server.call(this, document, operations);
|
||||||
|
this.users = {};
|
||||||
|
this.docId = docId;
|
||||||
|
this.mayWrite = mayWrite || function (_, cb) {
|
||||||
|
cb(true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
util.inherits(EditorSocketIOServer, Server);
|
||||||
|
extend(EditorSocketIOServer.prototype, EventEmitter.prototype);
|
||||||
|
|
||||||
|
function extend(target, source) {
|
||||||
|
for (var key in source) {
|
||||||
|
if (source.hasOwnProperty(key)) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.addClient = function (socket) {
|
||||||
|
var self = this;
|
||||||
|
socket.join(this.docId);
|
||||||
|
var docOut = {
|
||||||
|
str: this.document,
|
||||||
|
revision: this.operations.length,
|
||||||
|
clients: this.users
|
||||||
|
};
|
||||||
|
socket.emit('doc', LZString.compressToUTF16(JSON.stringify(docOut)));
|
||||||
|
socket.on('operation', function (revision, operation, selection) {
|
||||||
|
operation = LZString.decompressFromUTF16(operation);
|
||||||
|
operation = JSON.parse(operation);
|
||||||
|
self.mayWrite(socket, function (mayWrite) {
|
||||||
|
if (!mayWrite) {
|
||||||
|
console.log("User doesn't have the right to edit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.onOperation(socket, revision, operation, selection);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('get_operations', function (base, head) {
|
||||||
|
self.onGetOperations(socket, base, head);
|
||||||
|
});
|
||||||
|
socket.on('selection', function (obj) {
|
||||||
|
self.mayWrite(socket, function (mayWrite) {
|
||||||
|
if (!mayWrite) {
|
||||||
|
console.log("User doesn't have the right to edit.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.updateSelection(socket, obj && Selection.fromJSON(obj));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
socket.on('disconnect', function () {
|
||||||
|
//console.log("Disconnect");
|
||||||
|
socket.leave(self.docId);
|
||||||
|
self.onDisconnect(socket);
|
||||||
|
/*
|
||||||
|
if (socket.manager && socket.manager.sockets.clients(self.docId).length === 0) {
|
||||||
|
self.emit('empty-room');
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.onOperation = function (socket, revision, operation, selection) {
|
||||||
|
var wrapped;
|
||||||
|
try {
|
||||||
|
wrapped = new WrappedOperation(
|
||||||
|
TextOperation.fromJSON(operation),
|
||||||
|
selection && Selection.fromJSON(selection)
|
||||||
|
);
|
||||||
|
} catch (exc) {
|
||||||
|
console.error("Invalid operation received: " + exc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var clientId = socket.id;
|
||||||
|
var wrappedPrime = this.receiveOperation(revision, wrapped);
|
||||||
|
//console.log("new operation: " + JSON.stringify(wrapped));
|
||||||
|
this.getClient(clientId).selection = wrappedPrime.meta;
|
||||||
|
revision = this.operations.length;
|
||||||
|
socket.emit('ack', revision);
|
||||||
|
socket.broadcast.in(this.docId).emit(
|
||||||
|
'operation', clientId, revision,
|
||||||
|
wrappedPrime.wrapped.toJSON(), wrappedPrime.meta
|
||||||
|
);
|
||||||
|
this.isDirty = true;
|
||||||
|
} catch (exc) {
|
||||||
|
console.error(exc);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.onGetOperations = function (socket, base, head) {
|
||||||
|
var operations = this.operations.slice(base, head).map(function (op) {
|
||||||
|
return op.wrapped.toJSON();
|
||||||
|
});
|
||||||
|
operations = LZString.compressToUTF16(JSON.stringify(operations));
|
||||||
|
socket.emit('operations', head, operations);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.updateSelection = function (socket, selection) {
|
||||||
|
var clientId = socket.id;
|
||||||
|
if (selection) {
|
||||||
|
this.getClient(clientId).selection = selection;
|
||||||
|
} else {
|
||||||
|
delete this.getClient(clientId).selection;
|
||||||
|
}
|
||||||
|
socket.broadcast.to(this.docId).emit('selection', clientId, selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.setName = function (socket, name) {
|
||||||
|
var clientId = socket.id;
|
||||||
|
this.getClient(clientId).name = name;
|
||||||
|
socket.broadcast.to(this.docId).emit('set_name', clientId, name);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.setColor = function (socket, color) {
|
||||||
|
var clientId = socket.id;
|
||||||
|
this.getClient(clientId).color = color;
|
||||||
|
socket.broadcast.to(this.docId).emit('set_color', clientId, color);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.getClient = function (clientId) {
|
||||||
|
return this.users[clientId] || (this.users[clientId] = {});
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorSocketIOServer.prototype.onDisconnect = function (socket) {
|
||||||
|
var clientId = socket.id;
|
||||||
|
delete this.users[clientId];
|
||||||
|
socket.broadcast.to(this.docId).emit('client_left', clientId);
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = EditorSocketIOServer;
|
8
lib/ot/index.js
Normal file
8
lib/ot/index.js
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
exports.version = '0.0.15';
|
||||||
|
|
||||||
|
exports.TextOperation = require('./text-operation');
|
||||||
|
exports.SimpleTextOperation = require('./simple-text-operation');
|
||||||
|
exports.Client = require('./client');
|
||||||
|
exports.Server = require('./server');
|
||||||
|
exports.Selection = require('./selection');
|
||||||
|
exports.EditorSocketIOServer = require('./editor-socketio-server');
|
117
lib/ot/selection.js
Normal file
117
lib/ot/selection.js
Normal file
|
@ -0,0 +1,117 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.Selection = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation');
|
||||||
|
|
||||||
|
// Range has `anchor` and `head` properties, which are zero-based indices into
|
||||||
|
// the document. The `anchor` is the side of the selection that stays fixed,
|
||||||
|
// `head` is the side of the selection where the cursor is. When both are
|
||||||
|
// equal, the range represents a cursor.
|
||||||
|
function Range (anchor, head) {
|
||||||
|
this.anchor = anchor;
|
||||||
|
this.head = head;
|
||||||
|
}
|
||||||
|
|
||||||
|
Range.fromJSON = function (obj) {
|
||||||
|
return new Range(obj.anchor, obj.head);
|
||||||
|
};
|
||||||
|
|
||||||
|
Range.prototype.equals = function (other) {
|
||||||
|
return this.anchor === other.anchor && this.head === other.head;
|
||||||
|
};
|
||||||
|
|
||||||
|
Range.prototype.isEmpty = function () {
|
||||||
|
return this.anchor === this.head;
|
||||||
|
};
|
||||||
|
|
||||||
|
Range.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 (TextOperation.isRetain(ops[i])) {
|
||||||
|
index -= ops[i];
|
||||||
|
} else if (TextOperation.isInsert(ops[i])) {
|
||||||
|
newIndex += ops[i].length;
|
||||||
|
} else {
|
||||||
|
newIndex -= Math.min(index, -ops[i]);
|
||||||
|
index += ops[i];
|
||||||
|
}
|
||||||
|
if (index < 0) { break; }
|
||||||
|
}
|
||||||
|
return newIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAnchor = transformIndex(this.anchor);
|
||||||
|
if (this.anchor === this.head) {
|
||||||
|
return new Range(newAnchor, newAnchor);
|
||||||
|
}
|
||||||
|
return new Range(newAnchor, transformIndex(this.head));
|
||||||
|
};
|
||||||
|
|
||||||
|
// A selection is basically an array of ranges. Every range represents a real
|
||||||
|
// selection or a cursor in the document (when the start position equals the
|
||||||
|
// end position of the range). The array must not be empty.
|
||||||
|
function Selection (ranges) {
|
||||||
|
this.ranges = ranges || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Selection.Range = Range;
|
||||||
|
|
||||||
|
// Convenience method for creating selections only containing a single cursor
|
||||||
|
// and no real selection range.
|
||||||
|
Selection.createCursor = function (position) {
|
||||||
|
return new Selection([new Range(position, position)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
Selection.fromJSON = function (obj) {
|
||||||
|
var objRanges = obj.ranges || obj;
|
||||||
|
for (var i = 0, ranges = []; i < objRanges.length; i++) {
|
||||||
|
ranges[i] = Range.fromJSON(objRanges[i]);
|
||||||
|
}
|
||||||
|
return new Selection(ranges);
|
||||||
|
};
|
||||||
|
|
||||||
|
Selection.prototype.equals = function (other) {
|
||||||
|
if (this.position !== other.position) { return false; }
|
||||||
|
if (this.ranges.length !== other.ranges.length) { return false; }
|
||||||
|
// FIXME: Sort ranges before comparing them?
|
||||||
|
for (var i = 0; i < this.ranges.length; i++) {
|
||||||
|
if (!this.ranges[i].equals(other.ranges[i])) { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
Selection.prototype.somethingSelected = function () {
|
||||||
|
for (var i = 0; i < this.ranges.length; i++) {
|
||||||
|
if (!this.ranges[i].isEmpty()) { return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the more current selection information.
|
||||||
|
Selection.prototype.compose = function (other) {
|
||||||
|
return other;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the selection with respect to an operation.
|
||||||
|
Selection.prototype.transform = function (other) {
|
||||||
|
for (var i = 0, newRanges = []; i < this.ranges.length; i++) {
|
||||||
|
newRanges[i] = this.ranges[i].transform(other);
|
||||||
|
}
|
||||||
|
return new Selection(newRanges);
|
||||||
|
};
|
||||||
|
|
||||||
|
return Selection;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.Selection;
|
||||||
|
}
|
46
lib/ot/server.js
Normal file
46
lib/ot/server.js
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.Server = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Constructor. Takes the current document as a string and optionally the array
|
||||||
|
// of all operations.
|
||||||
|
function Server (document, operations) {
|
||||||
|
this.document = document;
|
||||||
|
this.operations = operations || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call this method whenever you receive an operation from a client.
|
||||||
|
Server.prototype.receiveOperation = function (revision, operation) {
|
||||||
|
if (revision < 0 || this.operations.length < revision) {
|
||||||
|
throw new Error("operation revision not in history");
|
||||||
|
}
|
||||||
|
// Find all operations that the client didn't know of when it sent the
|
||||||
|
// operation ...
|
||||||
|
var concurrentOperations = this.operations.slice(revision);
|
||||||
|
|
||||||
|
// ... and transform the operation against all these operations ...
|
||||||
|
var transform = operation.constructor.transform;
|
||||||
|
for (var i = 0; i < concurrentOperations.length; i++) {
|
||||||
|
operation = transform(operation, concurrentOperations[i])[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ... and apply that on the document.
|
||||||
|
this.document = operation.apply(this.document);
|
||||||
|
// Store operation in history.
|
||||||
|
this.operations.push(operation);
|
||||||
|
|
||||||
|
// It's the caller's responsibility to send the operation to all connected
|
||||||
|
// clients and an acknowledgement to the creator.
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
return Server;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.Server;
|
||||||
|
}
|
188
lib/ot/simple-text-operation.js
Normal file
188
lib/ot/simple-text-operation.js
Normal file
|
@ -0,0 +1,188 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.SimpleTextOperation = (function (global) {
|
||||||
|
|
||||||
|
var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation');
|
||||||
|
|
||||||
|
function SimpleTextOperation () {}
|
||||||
|
|
||||||
|
|
||||||
|
// Insert the string `str` at the zero-based `position` in the document.
|
||||||
|
function Insert (str, position) {
|
||||||
|
if (!this || this.constructor !== SimpleTextOperation) {
|
||||||
|
// => function was called without 'new'
|
||||||
|
return new Insert(str, position);
|
||||||
|
}
|
||||||
|
this.str = str;
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
Insert.prototype = new SimpleTextOperation();
|
||||||
|
SimpleTextOperation.Insert = Insert;
|
||||||
|
|
||||||
|
Insert.prototype.toString = function () {
|
||||||
|
return 'Insert(' + JSON.stringify(this.str) + ', ' + this.position + ')';
|
||||||
|
};
|
||||||
|
|
||||||
|
Insert.prototype.equals = function (other) {
|
||||||
|
return other instanceof Insert &&
|
||||||
|
this.str === other.str &&
|
||||||
|
this.position === other.position;
|
||||||
|
};
|
||||||
|
|
||||||
|
Insert.prototype.apply = function (doc) {
|
||||||
|
return doc.slice(0, this.position) + this.str + doc.slice(this.position);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Delete `count` many characters at the zero-based `position` in the document.
|
||||||
|
function Delete (count, position) {
|
||||||
|
if (!this || this.constructor !== SimpleTextOperation) {
|
||||||
|
return new Delete(count, position);
|
||||||
|
}
|
||||||
|
this.count = count;
|
||||||
|
this.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
Delete.prototype = new SimpleTextOperation();
|
||||||
|
SimpleTextOperation.Delete = Delete;
|
||||||
|
|
||||||
|
Delete.prototype.toString = function () {
|
||||||
|
return 'Delete(' + this.count + ', ' + this.position + ')';
|
||||||
|
};
|
||||||
|
|
||||||
|
Delete.prototype.equals = function (other) {
|
||||||
|
return other instanceof Delete &&
|
||||||
|
this.count === other.count &&
|
||||||
|
this.position === other.position;
|
||||||
|
};
|
||||||
|
|
||||||
|
Delete.prototype.apply = function (doc) {
|
||||||
|
return doc.slice(0, this.position) + doc.slice(this.position + this.count);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// An operation that does nothing. This is needed for the result of the
|
||||||
|
// transformation of two deletions of the same character.
|
||||||
|
function Noop () {
|
||||||
|
if (!this || this.constructor !== SimpleTextOperation) { return new Noop(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Noop.prototype = new SimpleTextOperation();
|
||||||
|
SimpleTextOperation.Noop = Noop;
|
||||||
|
|
||||||
|
Noop.prototype.toString = function () {
|
||||||
|
return 'Noop()';
|
||||||
|
};
|
||||||
|
|
||||||
|
Noop.prototype.equals = function (other) { return other instanceof Noop; };
|
||||||
|
|
||||||
|
Noop.prototype.apply = function (doc) { return doc; };
|
||||||
|
|
||||||
|
var noop = new Noop();
|
||||||
|
|
||||||
|
|
||||||
|
SimpleTextOperation.transform = function (a, b) {
|
||||||
|
if (a instanceof Noop || b instanceof Noop) { return [a, b]; }
|
||||||
|
|
||||||
|
if (a instanceof Insert && b instanceof Insert) {
|
||||||
|
if (a.position < b.position || (a.position === b.position && a.str < b.str)) {
|
||||||
|
return [a, new Insert(b.str, b.position + a.str.length)];
|
||||||
|
}
|
||||||
|
if (a.position > b.position || (a.position === b.position && a.str > b.str)) {
|
||||||
|
return [new Insert(a.str, a.position + b.str.length), b];
|
||||||
|
}
|
||||||
|
return [noop, noop];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a instanceof Insert && b instanceof Delete) {
|
||||||
|
if (a.position <= b.position) {
|
||||||
|
return [a, new Delete(b.count, b.position + a.str.length)];
|
||||||
|
}
|
||||||
|
if (a.position >= b.position + b.count) {
|
||||||
|
return [new Insert(a.str, a.position - b.count), b];
|
||||||
|
}
|
||||||
|
// Here, we have to delete the inserted string of operation a.
|
||||||
|
// That doesn't preserve the intention of operation a, but it's the only
|
||||||
|
// thing we can do to get a valid transform function.
|
||||||
|
return [noop, new Delete(b.count + a.str.length, b.position)];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a instanceof Delete && b instanceof Insert) {
|
||||||
|
if (a.position >= b.position) {
|
||||||
|
return [new Delete(a.count, a.position + b.str.length), b];
|
||||||
|
}
|
||||||
|
if (a.position + a.count <= b.position) {
|
||||||
|
return [a, new Insert(b.str, b.position - a.count)];
|
||||||
|
}
|
||||||
|
// Same problem as above. We have to delete the string that was inserted
|
||||||
|
// in operation b.
|
||||||
|
return [new Delete(a.count + b.str.length, a.position), noop];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (a instanceof Delete && b instanceof Delete) {
|
||||||
|
if (a.position === b.position) {
|
||||||
|
if (a.count === b.count) {
|
||||||
|
return [noop, noop];
|
||||||
|
} else if (a.count < b.count) {
|
||||||
|
return [noop, new Delete(b.count - a.count, b.position)];
|
||||||
|
}
|
||||||
|
return [new Delete(a.count - b.count, a.position), noop];
|
||||||
|
}
|
||||||
|
if (a.position < b.position) {
|
||||||
|
if (a.position + a.count <= b.position) {
|
||||||
|
return [a, new Delete(b.count, b.position - a.count)];
|
||||||
|
}
|
||||||
|
if (a.position + a.count >= b.position + b.count) {
|
||||||
|
return [new Delete(a.count - b.count, a.position), noop];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
new Delete(b.position - a.position, a.position),
|
||||||
|
new Delete(b.position + b.count - (a.position + a.count), a.position)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (a.position > b.position) {
|
||||||
|
if (a.position >= b.position + b.count) {
|
||||||
|
return [new Delete(a.count, a.position - b.count), b];
|
||||||
|
}
|
||||||
|
if (a.position + a.count <= b.position + b.count) {
|
||||||
|
return [noop, new Delete(b.count - a.count, b.position)];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
new Delete(a.position + a.count - (b.position + b.count), b.position),
|
||||||
|
new Delete(a.position - b.position, b.position)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert a normal, composable `TextOperation` into an array of
|
||||||
|
// `SimpleTextOperation`s.
|
||||||
|
SimpleTextOperation.fromTextOperation = function (operation) {
|
||||||
|
var simpleOperations = [];
|
||||||
|
var index = 0;
|
||||||
|
for (var i = 0; i < operation.ops.length; i++) {
|
||||||
|
var op = operation.ops[i];
|
||||||
|
if (TextOperation.isRetain(op)) {
|
||||||
|
index += op;
|
||||||
|
} else if (TextOperation.isInsert(op)) {
|
||||||
|
simpleOperations.push(new Insert(op, index));
|
||||||
|
index += op.length;
|
||||||
|
} else {
|
||||||
|
simpleOperations.push(new Delete(Math.abs(op), index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return simpleOperations;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return SimpleTextOperation;
|
||||||
|
})(this);
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.SimpleTextOperation;
|
||||||
|
}
|
530
lib/ot/text-operation.js
Normal file
530
lib/ot/text-operation.js
Normal file
|
@ -0,0 +1,530 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.TextOperation = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 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] !== other.ops[i]) { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
var isRetain = TextOperation.isRetain = function (op) {
|
||||||
|
return typeof op === 'number' && op > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
var isInsert = TextOperation.isInsert = function (op) {
|
||||||
|
return typeof op === 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
var isDelete = TextOperation.isDelete = function (op) {
|
||||||
|
return typeof op === 'number' && op < 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (typeof n !== 'number') {
|
||||||
|
throw new Error("retain expects an integer");
|
||||||
|
}
|
||||||
|
if (n === 0) { return this; }
|
||||||
|
this.baseLength += n;
|
||||||
|
this.targetLength += n;
|
||||||
|
if (isRetain(this.ops[this.ops.length-1])) {
|
||||||
|
// The last op is a retain op => we can merge them into one op.
|
||||||
|
this.ops[this.ops.length-1] += n;
|
||||||
|
} else {
|
||||||
|
// Create a new op.
|
||||||
|
this.ops.push(n);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert a string at the current position.
|
||||||
|
TextOperation.prototype.insert = function (str) {
|
||||||
|
if (typeof str !== 'string') {
|
||||||
|
throw new Error("insert expects a string");
|
||||||
|
}
|
||||||
|
if (str === '') { return this; }
|
||||||
|
this.targetLength += str.length;
|
||||||
|
var ops = this.ops;
|
||||||
|
if (isInsert(ops[ops.length-1])) {
|
||||||
|
// Merge insert op.
|
||||||
|
ops[ops.length-1] += str;
|
||||||
|
} else if (isDelete(ops[ops.length-1])) {
|
||||||
|
// 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 (isInsert(ops[ops.length-2])) {
|
||||||
|
ops[ops.length-2] += str;
|
||||||
|
} else {
|
||||||
|
ops[ops.length] = ops[ops.length-1];
|
||||||
|
ops[ops.length-2] = str;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ops.push(str);
|
||||||
|
}
|
||||||
|
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') {
|
||||||
|
throw new Error("delete expects an integer or a string");
|
||||||
|
}
|
||||||
|
if (n === 0) { return this; }
|
||||||
|
if (n > 0) { n = -n; }
|
||||||
|
this.baseLength -= n;
|
||||||
|
if (isDelete(this.ops[this.ops.length-1])) {
|
||||||
|
this.ops[this.ops.length-1] += n;
|
||||||
|
} else {
|
||||||
|
this.ops.push(n);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tests whether this operation has no effect.
|
||||||
|
TextOperation.prototype.isNoop = function () {
|
||||||
|
return this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (isRetain(op)) {
|
||||||
|
return "retain " + op;
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
return "insert '" + op + "'";
|
||||||
|
} else {
|
||||||
|
return "delete " + (-op);
|
||||||
|
}
|
||||||
|
}).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Converts operation into a JSON value.
|
||||||
|
TextOperation.prototype.toJSON = function () {
|
||||||
|
return this.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];
|
||||||
|
if (isRetain(op)) {
|
||||||
|
o.retain(op);
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
o.insert(op);
|
||||||
|
} else if (isDelete(op)) {
|
||||||
|
o['delete'](op);
|
||||||
|
} else {
|
||||||
|
throw new Error("unknown operation: " + JSON.stringify(op));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
var operation = this;
|
||||||
|
if (str.length !== operation.baseLength) {
|
||||||
|
throw new Error("The operation's base length must be equal to the string's length.");
|
||||||
|
}
|
||||||
|
var newStr = [], j = 0;
|
||||||
|
var strIndex = 0;
|
||||||
|
var ops = this.ops;
|
||||||
|
for (var i = 0, l = ops.length; i < l; i++) {
|
||||||
|
var op = ops[i];
|
||||||
|
if (isRetain(op)) {
|
||||||
|
if (strIndex + op > str.length) {
|
||||||
|
throw new Error("Operation can't retain more characters than are left in the string.");
|
||||||
|
}
|
||||||
|
// Copy skipped part of the old string.
|
||||||
|
newStr[j++] = str.slice(strIndex, strIndex + op);
|
||||||
|
strIndex += op;
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
// Insert string.
|
||||||
|
newStr[j++] = op;
|
||||||
|
} else { // delete op
|
||||||
|
strIndex -= op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (strIndex !== str.length) {
|
||||||
|
throw new Error("The operation didn't operate on the whole string.");
|
||||||
|
}
|
||||||
|
return newStr.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (isRetain(op)) {
|
||||||
|
inverse.retain(op);
|
||||||
|
strIndex += op;
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
inverse['delete'](op.length);
|
||||||
|
} else { // delete op
|
||||||
|
inverse.insert(str.slice(strIndex, strIndex - op));
|
||||||
|
strIndex -= op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
var operation = new TextOperation(); // the combined operation
|
||||||
|
var ops1 = operation1.ops, ops2 = operation2.ops; // for fast access
|
||||||
|
var i1 = 0, i2 = 0; // current index into ops1 respectively ops2
|
||||||
|
var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops
|
||||||
|
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 (isDelete(op1)) {
|
||||||
|
operation['delete'](op1);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isInsert(op2)) {
|
||||||
|
operation.insert(op2);
|
||||||
|
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 (isRetain(op1) && isRetain(op2)) {
|
||||||
|
if (op1 > op2) {
|
||||||
|
operation.retain(op2);
|
||||||
|
op1 = op1 - op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === op2) {
|
||||||
|
operation.retain(op1);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
operation.retain(op1);
|
||||||
|
op2 = op2 - op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else if (isInsert(op1) && isDelete(op2)) {
|
||||||
|
if (op1.length > -op2) {
|
||||||
|
op1 = op1.slice(-op2);
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1.length === -op2) {
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
op2 = op2 + op1.length;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else if (isInsert(op1) && isRetain(op2)) {
|
||||||
|
if (op1.length > op2) {
|
||||||
|
operation.insert(op1.slice(0, op2));
|
||||||
|
op1 = op1.slice(op2);
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1.length === op2) {
|
||||||
|
operation.insert(op1);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
operation.insert(op1);
|
||||||
|
op2 = op2 - op1.length;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else if (isRetain(op1) && isDelete(op2)) {
|
||||||
|
if (op1 > -op2) {
|
||||||
|
operation['delete'](op2);
|
||||||
|
op1 = op1 + op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === -op2) {
|
||||||
|
operation['delete'](op2);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
operation['delete'](op1);
|
||||||
|
op2 = op2 + op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"This shouldn't happen: op1: " +
|
||||||
|
JSON.stringify(op1) + ", op2: " +
|
||||||
|
JSON.stringify(op2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSimpleOp (operation, fn) {
|
||||||
|
var ops = operation.ops;
|
||||||
|
var isRetain = TextOperation.isRetain;
|
||||||
|
switch (ops.length) {
|
||||||
|
case 1:
|
||||||
|
return ops[0];
|
||||||
|
case 2:
|
||||||
|
return isRetain(ops[0]) ? ops[1] : (isRetain(ops[1]) ? ops[0] : null);
|
||||||
|
case 3:
|
||||||
|
if (isRetain(ops[0]) && isRetain(ops[2])) { return ops[1]; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartIndex (operation) {
|
||||||
|
if (isRetain(operation.ops[0])) { return operation.ops[0]; }
|
||||||
|
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 (isInsert(simpleA) && isInsert(simpleB)) {
|
||||||
|
return startA + simpleA.length === startB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDelete(simpleA) && isDelete(simpleB)) {
|
||||||
|
// there are two possibilities to delete: with backspace and with the
|
||||||
|
// delete key.
|
||||||
|
return (startB - simpleB === 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 (isInsert(simpleA) && isInsert(simpleB)) {
|
||||||
|
return startA + simpleA.length === startB || startA === startB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDelete(simpleA) && isDelete(simpleB)) {
|
||||||
|
return startB - simpleB === startA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.ops, ops2 = operation2.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 (isInsert(op1)) {
|
||||||
|
operation1prime.insert(op1);
|
||||||
|
operation2prime.retain(op1.length);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isInsert(op2)) {
|
||||||
|
operation1prime.retain(op2.length);
|
||||||
|
operation2prime.insert(op2);
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var minl;
|
||||||
|
if (isRetain(op1) && isRetain(op2)) {
|
||||||
|
// Simple case: retain/retain
|
||||||
|
if (op1 > op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = op1 - op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
minl = op1;
|
||||||
|
op2 = op2 - op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
operation1prime.retain(minl);
|
||||||
|
operation2prime.retain(minl);
|
||||||
|
} else if (isDelete(op1) && isDelete(op2)) {
|
||||||
|
// 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 > -op2) {
|
||||||
|
op1 = op1 - op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === op2) {
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
op2 = op2 - op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
// next two cases: delete/retain and retain/delete
|
||||||
|
} else if (isDelete(op1) && isRetain(op2)) {
|
||||||
|
if (-op1 > op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = op1 + op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (-op1 === op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
minl = -op1;
|
||||||
|
op2 = op2 + op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
operation1prime['delete'](minl);
|
||||||
|
} else if (isRetain(op1) && isDelete(op2)) {
|
||||||
|
if (op1 > -op2) {
|
||||||
|
minl = -op2;
|
||||||
|
op1 = op1 + op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === -op2) {
|
||||||
|
minl = op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
minl = op1;
|
||||||
|
op2 = op2 + op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
operation2prime['delete'](minl);
|
||||||
|
} else {
|
||||||
|
throw new Error("The two operations aren't compatible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [operation1prime, operation2prime];
|
||||||
|
};
|
||||||
|
|
||||||
|
return TextOperation;
|
||||||
|
|
||||||
|
}());
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.TextOperation;
|
||||||
|
}
|
80
lib/ot/wrapped-operation.js
Normal file
80
lib/ot/wrapped-operation.js
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.WrappedOperation = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// A WrappedOperation contains an operation and corresponing metadata.
|
||||||
|
function WrappedOperation (operation, meta) {
|
||||||
|
this.wrapped = operation;
|
||||||
|
this.meta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedOperation.prototype.apply = function () {
|
||||||
|
return this.wrapped.apply.apply(this.wrapped, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
WrappedOperation.prototype.invert = function () {
|
||||||
|
var meta = this.meta;
|
||||||
|
return new WrappedOperation(
|
||||||
|
this.wrapped.invert.apply(this.wrapped, arguments),
|
||||||
|
meta && typeof meta === 'object' && typeof meta.invert === 'function' ?
|
||||||
|
meta.invert.apply(meta, arguments) : meta
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy all properties from source to target.
|
||||||
|
function copy (source, target) {
|
||||||
|
for (var key in source) {
|
||||||
|
if (source.hasOwnProperty(key)) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeMeta (a, b) {
|
||||||
|
if (a && typeof a === 'object') {
|
||||||
|
if (typeof a.compose === 'function') { return a.compose(b); }
|
||||||
|
var meta = {};
|
||||||
|
copy(a, meta);
|
||||||
|
copy(b, meta);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedOperation.prototype.compose = function (other) {
|
||||||
|
return new WrappedOperation(
|
||||||
|
this.wrapped.compose(other.wrapped),
|
||||||
|
composeMeta(this.meta, other.meta)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformMeta (meta, operation) {
|
||||||
|
if (meta && typeof meta === 'object') {
|
||||||
|
if (typeof meta.transform === 'function') {
|
||||||
|
return meta.transform(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedOperation.transform = function (a, b) {
|
||||||
|
var transform = a.wrapped.constructor.transform;
|
||||||
|
var pair = transform(a.wrapped, b.wrapped);
|
||||||
|
return [
|
||||||
|
new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)),
|
||||||
|
new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped))
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return WrappedOperation;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.WrappedOperation;
|
||||||
|
}
|
175
lib/realtime.js
175
lib/realtime.js
|
@ -16,6 +16,9 @@ var moment = require('moment');
|
||||||
var config = require("../config.js");
|
var config = require("../config.js");
|
||||||
var logger = require("./logger.js");
|
var logger = require("./logger.js");
|
||||||
|
|
||||||
|
//ot
|
||||||
|
var ot = require("./ot/index.js");
|
||||||
|
|
||||||
//others
|
//others
|
||||||
var db = require("./db.js");
|
var db = require("./db.js");
|
||||||
var Note = require("./note.js");
|
var Note = require("./note.js");
|
||||||
|
@ -60,28 +63,41 @@ function secure(socket, next) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitCheck(note) {
|
||||||
|
var out = {
|
||||||
|
updatetime: note.updatetime
|
||||||
|
};
|
||||||
|
for (var i = 0, l = note.socks.length; i < l; i++) {
|
||||||
|
var sock = note.socks[i];
|
||||||
|
sock.emit('check', out);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
//actions
|
//actions
|
||||||
var users = {};
|
var users = {};
|
||||||
var notes = {};
|
var notes = {};
|
||||||
var updater = setInterval(function () {
|
var updater = setInterval(function () {
|
||||||
async.each(Object.keys(notes), function (key, callback) {
|
async.each(Object.keys(notes), function (key, callback) {
|
||||||
var note = notes[key];
|
var note = notes[key];
|
||||||
if (note.isDirty) {
|
if (note.server.isDirty) {
|
||||||
if (config.debug)
|
if (config.debug)
|
||||||
logger.info("updater found dirty note: " + key);
|
logger.info("updater found dirty note: " + key);
|
||||||
var body = LZString.decompressFromUTF16(note.body);
|
var body = note.server.document;
|
||||||
var title = Note.getNoteTitle(body);
|
var title = Note.getNoteTitle(body);
|
||||||
title = LZString.compressToBase64(title);
|
title = LZString.compressToBase64(title);
|
||||||
body = LZString.compressToBase64(body);
|
body = LZString.compressToBase64(body);
|
||||||
db.saveToDB(key, title, body,
|
db.saveToDB(key, title, body, function (err, result) {
|
||||||
function (err, result) {});
|
if (err) return;
|
||||||
note.isDirty = false;
|
note.server.isDirty = false;
|
||||||
|
note.updatetime = Date.now();
|
||||||
|
emitCheck(note);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
callback();
|
callback();
|
||||||
}, function (err) {
|
}, function (err) {
|
||||||
if (err) return logger.error('updater error', err);
|
if (err) return logger.error('updater error', err);
|
||||||
});
|
});
|
||||||
}, 5000);
|
}, 1000);
|
||||||
|
|
||||||
function getStatus(callback) {
|
function getStatus(callback) {
|
||||||
db.countFromDB(function (err, data) {
|
db.countFromDB(function (err, data) {
|
||||||
|
@ -189,9 +205,6 @@ function emitRefresh(socket) {
|
||||||
socket.emit('refresh', {
|
socket.emit('refresh', {
|
||||||
owner: note.owner,
|
owner: note.owner,
|
||||||
permission: note.permission,
|
permission: note.permission,
|
||||||
body: note.body,
|
|
||||||
otk: note.otk,
|
|
||||||
hash: note.hash,
|
|
||||||
updatetime: note.updatetime
|
updatetime: note.updatetime
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -202,8 +215,13 @@ var isDisconnectBusy = false;
|
||||||
var disconnectSocketQueue = [];
|
var disconnectSocketQueue = [];
|
||||||
|
|
||||||
function finishConnection(socket, notename) {
|
function finishConnection(socket, notename) {
|
||||||
notes[notename].users[socket.id] = users[socket.id];
|
var note = notes[notename];
|
||||||
notes[notename].socks.push(socket);
|
note.users[socket.id] = users[socket.id];
|
||||||
|
note.socks.push(socket);
|
||||||
|
note.server.addClient(socket);
|
||||||
|
note.server.setName(socket, users[socket.id].name);
|
||||||
|
note.server.setColor(socket, users[socket.id].color);
|
||||||
|
|
||||||
emitOnlineUsers(socket);
|
emitOnlineUsers(socket);
|
||||||
emitRefresh(socket);
|
emitRefresh(socket);
|
||||||
|
|
||||||
|
@ -260,18 +278,16 @@ function startConnection(socket) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var body = LZString.decompressFromBase64(data.rows[0].content);
|
var body = LZString.decompressFromBase64(data.rows[0].content);
|
||||||
body = LZString.compressToUTF16(body);
|
//body = LZString.compressToUTF16(body);
|
||||||
var updatetime = data.rows[0].update_time;
|
var updatetime = data.rows[0].update_time;
|
||||||
|
var server = new ot.EditorSocketIOServer(body, [], notename, ifMayEdit);
|
||||||
notes[notename] = {
|
notes[notename] = {
|
||||||
owner: owner,
|
owner: owner,
|
||||||
permission: note.permission,
|
permission: note.permission,
|
||||||
socks: [],
|
socks: [],
|
||||||
body: body,
|
|
||||||
isDirty: false,
|
|
||||||
users: {},
|
users: {},
|
||||||
otk: shortId.generate(),
|
updatetime: moment(updatetime).valueOf(),
|
||||||
hash: md5(body),
|
server: server
|
||||||
updatetime: moment(updatetime).valueOf()
|
|
||||||
};
|
};
|
||||||
finishConnection(socket, notename);
|
finishConnection(socket, notename);
|
||||||
});
|
});
|
||||||
|
@ -294,17 +310,18 @@ function disconnect(socket) {
|
||||||
if (users[socket.id]) {
|
if (users[socket.id]) {
|
||||||
delete users[socket.id];
|
delete users[socket.id];
|
||||||
}
|
}
|
||||||
if (notes[notename]) {
|
var note = notes[notename];
|
||||||
delete notes[notename].users[socket.id];
|
if (note) {
|
||||||
|
delete note.users[socket.id];
|
||||||
do {
|
do {
|
||||||
var index = notes[notename].socks.indexOf(socket);
|
var index = note.socks.indexOf(socket);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
notes[notename].socks.splice(index, 1);
|
note.socks.splice(index, 1);
|
||||||
}
|
}
|
||||||
} while (index != -1);
|
} while (index != -1);
|
||||||
if (Object.keys(notes[notename].users).length <= 0) {
|
if (Object.keys(note.users).length <= 0) {
|
||||||
if (notes[notename].isDirty) {
|
if (note.server.isDirty) {
|
||||||
var body = LZString.decompressFromUTF16(notes[notename].body);
|
var body = note.server.document;
|
||||||
var title = Note.getNoteTitle(body);
|
var title = Note.getNoteTitle(body);
|
||||||
title = LZString.compressToBase64(title);
|
title = LZString.compressToBase64(title);
|
||||||
body = LZString.compressToBase64(body);
|
body = LZString.compressToBase64(body);
|
||||||
|
@ -363,7 +380,7 @@ function updateUserData(socket, user) {
|
||||||
if (socket.request.user && socket.request.user.logged_in) {
|
if (socket.request.user && socket.request.user.logged_in) {
|
||||||
var profile = JSON.parse(socket.request.user.profile);
|
var profile = JSON.parse(socket.request.user.profile);
|
||||||
var photo = null;
|
var photo = null;
|
||||||
switch(profile.provider) {
|
switch (profile.provider) {
|
||||||
case "facebook":
|
case "facebook":
|
||||||
photo = 'https://graph.facebook.com/' + profile.id + '/picture';
|
photo = 'https://graph.facebook.com/' + profile.id + '/picture';
|
||||||
break;
|
break;
|
||||||
|
@ -389,6 +406,29 @@ function updateUserData(socket, user) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ifMayEdit(socket, callback) {
|
||||||
|
var notename = getNotenameFromSocket(socket);
|
||||||
|
if (!notename || !notes[notename]) return;
|
||||||
|
var note = notes[notename];
|
||||||
|
var mayEdit = true;
|
||||||
|
switch (note.permission) {
|
||||||
|
case "freely":
|
||||||
|
//not blocking anyone
|
||||||
|
break;
|
||||||
|
case "editable":
|
||||||
|
//only login user can change
|
||||||
|
if (!socket.request.user || !socket.request.user.logged_in)
|
||||||
|
mayEdit = false;
|
||||||
|
break;
|
||||||
|
case "locked":
|
||||||
|
//only owner can change
|
||||||
|
if (note.owner != socket.request.user._id)
|
||||||
|
mayEdit = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
callback(mayEdit);
|
||||||
|
}
|
||||||
|
|
||||||
function connection(socket) {
|
function connection(socket) {
|
||||||
//split notename from socket
|
//split notename from socket
|
||||||
var notename = getNotenameFromSocket(socket);
|
var notename = getNotenameFromSocket(socket);
|
||||||
|
@ -442,30 +482,6 @@ function connection(socket) {
|
||||||
emitRefresh(socket);
|
emitRefresh(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
//received client data updated
|
|
||||||
socket.on('update', function (body_) {
|
|
||||||
var notename = getNotenameFromSocket(socket);
|
|
||||||
if (!notename || !notes[notename]) return;
|
|
||||||
if (config.debug)
|
|
||||||
logger.info('SERVER received [' + notename + '] data updated: ' + socket.id);
|
|
||||||
var note = notes[notename];
|
|
||||||
if (note.body != body_) {
|
|
||||||
note.body = body_;
|
|
||||||
note.hash = md5(body_);
|
|
||||||
note.updatetime = Date.now();
|
|
||||||
note.isDirty = true;
|
|
||||||
}
|
|
||||||
var out = {
|
|
||||||
id: socket.id,
|
|
||||||
hash: note.hash,
|
|
||||||
updatetime: note.updatetime
|
|
||||||
};
|
|
||||||
for (var i = 0, l = note.socks.length; i < l; i++) {
|
|
||||||
var sock = note.socks[i];
|
|
||||||
sock.emit('check', out);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
//received user status
|
//received user status
|
||||||
socket.on('user status', function (data) {
|
socket.on('user status', function (data) {
|
||||||
var notename = getNotenameFromSocket(socket);
|
var notename = getNotenameFromSocket(socket);
|
||||||
|
@ -591,67 +607,6 @@ function connection(socket) {
|
||||||
disconnectSocketQueue.push(socket);
|
disconnectSocketQueue.push(socket);
|
||||||
disconnect(socket);
|
disconnect(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
//when received client change data request
|
|
||||||
socket.on('change', function (op) {
|
|
||||||
var notename = getNotenameFromSocket(socket);
|
|
||||||
if (!notename || !notes[notename]) return;
|
|
||||||
var note = notes[notename];
|
|
||||||
switch (note.permission) {
|
|
||||||
case "freely":
|
|
||||||
//not blocking anyone
|
|
||||||
break;
|
|
||||||
case "editable":
|
|
||||||
//only login user can change
|
|
||||||
if (!socket.request.user || !socket.request.user.logged_in)
|
|
||||||
return;
|
|
||||||
break;
|
|
||||||
case "locked":
|
|
||||||
//only owner can change
|
|
||||||
if (note.owner != socket.request.user._id)
|
|
||||||
return;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
op = LZString.decompressFromUTF16(op);
|
|
||||||
if (op)
|
|
||||||
op = JSON.parse(op);
|
|
||||||
else
|
|
||||||
return;
|
|
||||||
if (config.debug)
|
|
||||||
logger.info('SERVER received [' + notename + '] data changed: ' + socket.id + ', op:' + JSON.stringify(op));
|
|
||||||
switch (op.origin) {
|
|
||||||
case '+input':
|
|
||||||
case '+delete':
|
|
||||||
case '+transpose':
|
|
||||||
case 'paste':
|
|
||||||
case 'cut':
|
|
||||||
case 'undo':
|
|
||||||
case 'redo':
|
|
||||||
case 'drag':
|
|
||||||
case '*compose':
|
|
||||||
case 'case':
|
|
||||||
case '+insert':
|
|
||||||
case '+insertLine':
|
|
||||||
case '+swapLine':
|
|
||||||
case '+joinLines':
|
|
||||||
case '+duplicateLine':
|
|
||||||
case '+sortLines':
|
|
||||||
op.id = socket.id;
|
|
||||||
op.otk = note.otk;
|
|
||||||
op.nextotk = note.otk = shortId.generate();
|
|
||||||
var stringop = JSON.stringify(op);
|
|
||||||
var compressstringop = LZString.compressToUTF16(stringop);
|
|
||||||
for (var i = 0, l = note.socks.length; i < l; i++) {
|
|
||||||
var sock = note.socks[i];
|
|
||||||
if (config.debug)
|
|
||||||
logger.info('SERVER emit sync data out [' + notename + ']: ' + sock.id + ', op:' + stringop);
|
|
||||||
sock.emit('change', compressstringop);
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
logger.info('SERVER received uncaught [' + notename + '] data changed: ' + socket.id + ', op:' + JSON.stringify(op));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = realtime;
|
module.exports = realtime;
|
|
@ -386,7 +386,10 @@ $(window).resize(function () {
|
||||||
});
|
});
|
||||||
//when page unload
|
//when page unload
|
||||||
$(window).unload(function () {
|
$(window).unload(function () {
|
||||||
emitUpdate();
|
//na
|
||||||
|
});
|
||||||
|
$(window).error(function () {
|
||||||
|
setNeedRefresh();
|
||||||
});
|
});
|
||||||
|
|
||||||
//when page hash change
|
//when page hash change
|
||||||
|
@ -898,21 +901,9 @@ socket.on('version', function (data) {
|
||||||
setNeedRefresh();
|
setNeedRefresh();
|
||||||
});
|
});
|
||||||
socket.on('check', function (data) {
|
socket.on('check', function (data) {
|
||||||
if (data.id == socket.id) {
|
|
||||||
lastchangetime = data.updatetime;
|
lastchangetime = data.updatetime;
|
||||||
lastchangeui = ui.infobar.lastchange;
|
lastchangeui = ui.infobar.lastchange;
|
||||||
updateLastChange();
|
updateLastChange();
|
||||||
return;
|
|
||||||
}
|
|
||||||
var currentHash = md5(LZString.compressToUTF16(editor.getValue()));
|
|
||||||
var hashMismatch = (currentHash != data.hash);
|
|
||||||
if (hashMismatch)
|
|
||||||
socket.emit('refresh');
|
|
||||||
else {
|
|
||||||
lastchangetime = data.updatetime;
|
|
||||||
lastchangeui = ui.infobar.lastchange;
|
|
||||||
updateLastChange();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
socket.on('permission', function (data) {
|
socket.on('permission', function (data) {
|
||||||
permission = data.permission;
|
permission = data.permission;
|
||||||
|
@ -922,26 +913,46 @@ var otk = null;
|
||||||
var owner = null;
|
var owner = null;
|
||||||
var permission = null;
|
var permission = null;
|
||||||
socket.on('refresh', function (data) {
|
socket.on('refresh', function (data) {
|
||||||
var currentHash = md5(LZString.compressToUTF16(editor.getValue()));
|
|
||||||
var hashMismatch = (currentHash != data.hash);
|
|
||||||
saveInfo();
|
|
||||||
|
|
||||||
otk = data.otk;
|
otk = data.otk;
|
||||||
owner = data.owner;
|
owner = data.owner;
|
||||||
permission = data.permission;
|
permission = data.permission;
|
||||||
|
lastchangetime = data.updatetime;
|
||||||
|
lastchangeui = ui.infobar.lastchange;
|
||||||
|
updateLastChange();
|
||||||
|
checkPermission();
|
||||||
|
});
|
||||||
|
|
||||||
if (hashMismatch) {
|
var EditorClient = ot.EditorClient;
|
||||||
var body = data.body;
|
var SocketIOAdapter = ot.SocketIOAdapter;
|
||||||
body = LZString.decompressFromUTF16(body);
|
var CodeMirrorAdapter = ot.CodeMirrorAdapter;
|
||||||
|
var cmClient = null;
|
||||||
|
|
||||||
|
socket.on('doc', function (obj) {
|
||||||
|
obj = LZString.decompressFromUTF16(obj);
|
||||||
|
obj = JSON.parse(obj);
|
||||||
|
var body = obj.str;
|
||||||
|
var bodyMismatch = (editor.getValue() != body);
|
||||||
|
|
||||||
|
saveInfo();
|
||||||
|
if (bodyMismatch) {
|
||||||
if (body)
|
if (body)
|
||||||
editor.setValue(body);
|
editor.setValue(body);
|
||||||
else
|
else
|
||||||
editor.setValue("");
|
editor.setValue("");
|
||||||
}
|
}
|
||||||
|
if (!cmClient) {
|
||||||
lastchangetime = data.updatetime;
|
cmClient = window.cmClient = new EditorClient(
|
||||||
lastchangeui = ui.infobar.lastchange;
|
obj.revision, obj.clients,
|
||||||
updateLastChange();
|
new SocketIOAdapter(socket), new CodeMirrorAdapter(editor)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
cmClient.revision = obj.revision;
|
||||||
|
cmClient.initializeClients(obj.clients);
|
||||||
|
if (bodyMismatch) {
|
||||||
|
cmClient.undoManager.undoStack.length = 0;
|
||||||
|
cmClient.undoManager.redoStack.length = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
editor.clearHistory();
|
editor.clearHistory();
|
||||||
|
@ -963,7 +974,7 @@ socket.on('refresh', function (data) {
|
||||||
}, 1);
|
}, 1);
|
||||||
} else {
|
} else {
|
||||||
//if current doc is equal to the doc before disconnect
|
//if current doc is equal to the doc before disconnect
|
||||||
if (hashMismatch)
|
if (bodyMismatch)
|
||||||
editor.clearHistory();
|
editor.clearHistory();
|
||||||
else {
|
else {
|
||||||
if (lastInfo.history)
|
if (lastInfo.history)
|
||||||
|
@ -972,57 +983,26 @@ socket.on('refresh', function (data) {
|
||||||
lastInfo.history = null;
|
lastInfo.history = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hashMismatch)
|
if (bodyMismatch) {
|
||||||
|
isDirty = true;
|
||||||
updateView();
|
updateView();
|
||||||
|
}
|
||||||
|
|
||||||
if (editor.getOption('readOnly'))
|
if (editor.getOption('readOnly'))
|
||||||
editor.setOption('readOnly', false);
|
editor.setOption('readOnly', false);
|
||||||
|
|
||||||
restoreInfo();
|
restoreInfo();
|
||||||
checkPermission();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
var changeStack = [];
|
socket.on('ack', _.debounce(function () {
|
||||||
var changeBusy = false;
|
isDirty = true;
|
||||||
|
updateView();
|
||||||
|
}, finishChangeDelay));
|
||||||
|
|
||||||
socket.on('change', function (data) {
|
socket.on('operation', _.debounce(function () {
|
||||||
data = LZString.decompressFromUTF16(data);
|
isDirty = true;
|
||||||
data = JSON.parse(data);
|
updateView();
|
||||||
changeStack.push(data);
|
}, finishChangeDelay));
|
||||||
if (!changeBusy)
|
|
||||||
executeChange();
|
|
||||||
});
|
|
||||||
|
|
||||||
function executeChange() {
|
|
||||||
if (changeStack.length > 0) {
|
|
||||||
changeBusy = true;
|
|
||||||
var data = changeStack.shift();
|
|
||||||
if (data.otk != otk) {
|
|
||||||
var found = false;
|
|
||||||
for (var i = 0, l = changeStack.length; i < l; i++) {
|
|
||||||
if (changeStack[i].otk == otk) {
|
|
||||||
changeStack.unshift(data);
|
|
||||||
data = changeStack[i];
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!found) {
|
|
||||||
socket.emit('refresh');
|
|
||||||
changeBusy = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
otk = data.nextotk;
|
|
||||||
if (data.id == personalInfo.id)
|
|
||||||
editor.replaceRange(data.text, data.from, data.to, 'self::' + data.origin);
|
|
||||||
else
|
|
||||||
editor.replaceRange(data.text, data.from, data.to, "ignoreHistory");
|
|
||||||
executeChange();
|
|
||||||
} else {
|
|
||||||
changeBusy = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
socket.on('online users', function (data) {
|
socket.on('online users', function (data) {
|
||||||
data = LZString.decompressFromUTF16(data);
|
data = LZString.decompressFromUTF16(data);
|
||||||
|
@ -1214,7 +1194,7 @@ function renderUserStatusList(list) {
|
||||||
var item = items[j];
|
var item = items[j];
|
||||||
var userstatus = $(item.elm).find('.ui-user-status');
|
var userstatus = $(item.elm).find('.ui-user-status');
|
||||||
var usericon = $(item.elm).find('.ui-user-icon');
|
var usericon = $(item.elm).find('.ui-user-icon');
|
||||||
if(item.values().login && item.values().photo) {
|
if (item.values().login && item.values().photo) {
|
||||||
usericon.css('background-image', 'url(' + item.values().photo + ')');
|
usericon.css('background-image', 'url(' + item.values().photo + ')');
|
||||||
usericon.css('box-shadow', '0px 0px 2px ' + item.values().color);
|
usericon.css('box-shadow', '0px 0px 2px ' + item.values().color);
|
||||||
//add 1px more to right, make it feel aligned
|
//add 1px more to right, make it feel aligned
|
||||||
|
@ -1420,6 +1400,7 @@ function buildCursor(user) {
|
||||||
checkCursorTag(coord, cursortag);
|
checkCursorTag(coord, cursortag);
|
||||||
} else {
|
} else {
|
||||||
var cursor = $('#' + user.id);
|
var cursor = $('#' + user.id);
|
||||||
|
var lineDiff = Math.abs(cursor.attr('data-line') - user.cursor.line);
|
||||||
cursor.attr('data-line', user.cursor.line);
|
cursor.attr('data-line', user.cursor.line);
|
||||||
cursor.attr('data-ch', user.cursor.ch);
|
cursor.attr('data-ch', user.cursor.ch);
|
||||||
|
|
||||||
|
@ -1454,19 +1435,10 @@ function buildCursor(user) {
|
||||||
}
|
}
|
||||||
|
|
||||||
//editor actions
|
//editor actions
|
||||||
|
var ignoreEmitEvents = ['setValue', 'ignoreHistory'];
|
||||||
editor.on('beforeChange', function (cm, change) {
|
editor.on('beforeChange', function (cm, change) {
|
||||||
if (debug)
|
if (debug)
|
||||||
console.debug(change);
|
console.debug(change);
|
||||||
var self = change.origin.split('self::');
|
|
||||||
if (self.length == 2) {
|
|
||||||
change.origin = self[1];
|
|
||||||
self = true;
|
|
||||||
} else {
|
|
||||||
self = false;
|
|
||||||
}
|
|
||||||
if (self) {
|
|
||||||
change.canceled = true;
|
|
||||||
} else {
|
|
||||||
var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(change.origin) != -1);
|
var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(change.origin) != -1);
|
||||||
if (!isIgnoreEmitEvent) {
|
if (!isIgnoreEmitEvent) {
|
||||||
switch (permission) {
|
switch (permission) {
|
||||||
|
@ -1487,31 +1459,6 @@ editor.on('beforeChange', function (cm, change) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var ignoreEmitEvents = ['setValue', 'ignoreHistory'];
|
|
||||||
editor.on('change', function (i, op) {
|
|
||||||
if (debug)
|
|
||||||
console.debug(op);
|
|
||||||
var isIgnoreEmitEvent = (ignoreEmitEvents.indexOf(op.origin) != -1);
|
|
||||||
if (!isIgnoreEmitEvent) {
|
|
||||||
var out = {
|
|
||||||
text: op.text,
|
|
||||||
from: op.from,
|
|
||||||
to: op.to,
|
|
||||||
origin: op.origin
|
|
||||||
};
|
|
||||||
socket.emit('change', LZString.compressToUTF16(JSON.stringify(out)));
|
|
||||||
}
|
|
||||||
isDirty = true;
|
|
||||||
clearTimeout(finishChangeTimer);
|
|
||||||
finishChangeTimer = setTimeout(function () {
|
|
||||||
if (!isIgnoreEmitEvent)
|
|
||||||
finishChange(true);
|
|
||||||
else
|
|
||||||
finishChange(false);
|
|
||||||
}, finishChangeDelay);
|
|
||||||
});
|
});
|
||||||
editor.on('focus', function (cm) {
|
editor.on('focus', function (cm) {
|
||||||
for (var i = 0; i < onlineUsers.length; i++) {
|
for (var i = 0; i < onlineUsers.length; i++) {
|
||||||
|
@ -1617,24 +1564,15 @@ function restoreInfo() {
|
||||||
var finishChangeTimer = null;
|
var finishChangeTimer = null;
|
||||||
|
|
||||||
function finishChange(emit) {
|
function finishChange(emit) {
|
||||||
if (emit)
|
|
||||||
emitUpdate();
|
|
||||||
updateView();
|
updateView();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitUpdate() {
|
|
||||||
var value = editor.getValue();
|
|
||||||
socket.emit('update', LZString.compressToUTF16(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastResult = null;
|
var lastResult = null;
|
||||||
|
|
||||||
function updateView() {
|
function updateView() {
|
||||||
if (currentMode == modeType.edit || !isDirty) return;
|
if (currentMode == modeType.edit || !isDirty) return;
|
||||||
var value = editor.getValue();
|
var value = editor.getValue();
|
||||||
var result = postProcess(md.render(value)).children().toArray();
|
var result = postProcess(md.render(value)).children().toArray();
|
||||||
//ui.area.markdown.html(result);
|
|
||||||
//finishView(ui.area.markdown);
|
|
||||||
partialUpdate(result, lastResult, ui.area.markdown.children().toArray());
|
partialUpdate(result, lastResult, ui.area.markdown.children().toArray());
|
||||||
if (result && lastResult && result.length != lastResult.length)
|
if (result && lastResult && result.length != lastResult.length)
|
||||||
updateDataAttrs(result, ui.area.markdown.children().toArray());
|
updateDataAttrs(result, ui.area.markdown.children().toArray());
|
||||||
|
|
116
public/vendor/ot/ajax-adapter.js
vendored
Executable file
116
public/vendor/ot/ajax-adapter.js
vendored
Executable file
|
@ -0,0 +1,116 @@
|
||||||
|
/*global ot, $ */
|
||||||
|
|
||||||
|
ot.AjaxAdapter = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function AjaxAdapter (path, ownUserName, revision) {
|
||||||
|
if (path[path.length - 1] !== '/') { path += '/'; }
|
||||||
|
this.path = path;
|
||||||
|
this.ownUserName = ownUserName;
|
||||||
|
this.majorRevision = revision.major || 0;
|
||||||
|
this.minorRevision = revision.minor || 0;
|
||||||
|
this.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.renderRevisionPath = function () {
|
||||||
|
return 'revision/' + this.majorRevision + '-' + this.minorRevision;
|
||||||
|
};
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.handleResponse = function (data) {
|
||||||
|
var i;
|
||||||
|
var operations = data.operations;
|
||||||
|
for (i = 0; i < operations.length; i++) {
|
||||||
|
if (operations[i].user === this.ownUserName) {
|
||||||
|
this.trigger('ack');
|
||||||
|
} else {
|
||||||
|
this.trigger('operation', operations[i].operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (operations.length > 0) {
|
||||||
|
this.majorRevision += operations.length;
|
||||||
|
this.minorRevision = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var events = data.events;
|
||||||
|
if (events) {
|
||||||
|
for (i = 0; i < events.length; i++) {
|
||||||
|
var user = events[i].user;
|
||||||
|
if (user === this.ownUserName) { continue; }
|
||||||
|
switch (events[i].event) {
|
||||||
|
case 'joined': this.trigger('set_name', user, user); break;
|
||||||
|
case 'left': this.trigger('client_left', user); break;
|
||||||
|
case 'selection': this.trigger('selection', user, events[i].selection); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.minorRevision += events.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
var users = data.users;
|
||||||
|
if (users) {
|
||||||
|
delete users[this.ownUserName];
|
||||||
|
this.trigger('clients', users);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.revision) {
|
||||||
|
this.majorRevision = data.revision.major;
|
||||||
|
this.minorRevision = data.revision.minor;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.poll = function () {
|
||||||
|
var self = this;
|
||||||
|
$.ajax({
|
||||||
|
url: this.path + this.renderRevisionPath(),
|
||||||
|
type: 'GET',
|
||||||
|
dataType: 'json',
|
||||||
|
timeout: 5000,
|
||||||
|
success: function (data) {
|
||||||
|
self.handleResponse(data);
|
||||||
|
self.poll();
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
setTimeout(function () { self.poll(); }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.sendOperation = function (revision, operation, selection) {
|
||||||
|
if (revision !== this.majorRevision) { throw new Error("Revision numbers out of sync"); }
|
||||||
|
var self = this;
|
||||||
|
$.ajax({
|
||||||
|
url: this.path + this.renderRevisionPath(),
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify({ operation: operation, selection: selection }),
|
||||||
|
contentType: 'application/json',
|
||||||
|
processData: false,
|
||||||
|
success: function (data) {},
|
||||||
|
error: function () {
|
||||||
|
setTimeout(function () { self.sendOperation(revision, operation, selection); }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.sendSelection = function (obj) {
|
||||||
|
$.ajax({
|
||||||
|
url: this.path + this.renderRevisionPath() + '/selection',
|
||||||
|
type: 'POST',
|
||||||
|
data: JSON.stringify(obj),
|
||||||
|
contentType: 'application/json',
|
||||||
|
processData: false,
|
||||||
|
timeout: 1000
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.registerCallbacks = function (cb) {
|
||||||
|
this.callbacks = cb;
|
||||||
|
};
|
||||||
|
|
||||||
|
AjaxAdapter.prototype.trigger = function (event) {
|
||||||
|
var args = Array.prototype.slice.call(arguments, 1);
|
||||||
|
var action = this.callbacks && this.callbacks[event];
|
||||||
|
if (action) { action.apply(this, args); }
|
||||||
|
};
|
||||||
|
|
||||||
|
return AjaxAdapter;
|
||||||
|
|
||||||
|
})();
|
312
public/vendor/ot/client.js
vendored
Executable file
312
public/vendor/ot/client.js
vendored
Executable file
|
@ -0,0 +1,312 @@
|
||||||
|
// translation of https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala
|
||||||
|
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.Client = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Client constructor
|
||||||
|
function Client (revision) {
|
||||||
|
this.revision = revision; // the next expected revision number
|
||||||
|
this.setState(synchronized_); // start state
|
||||||
|
}
|
||||||
|
|
||||||
|
Client.prototype.setState = function (state) {
|
||||||
|
this.state = state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call this method when the user changes the document.
|
||||||
|
Client.prototype.applyClient = function (operation) {
|
||||||
|
this.setState(this.state.applyClient(this, operation));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Call this method with a new operation from the server
|
||||||
|
Client.prototype.applyServer = function (revision, operation) {
|
||||||
|
this.setState(this.state.applyServer(this, revision, operation));
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.prototype.applyOperations = function (head, operations) {
|
||||||
|
this.setState(this.state.applyOperations(this, head, operations));
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.prototype.serverAck = function (revision) {
|
||||||
|
this.setState(this.state.serverAck(this, revision));
|
||||||
|
};
|
||||||
|
|
||||||
|
Client.prototype.serverReconnect = function () {
|
||||||
|
if (typeof this.state.resend === 'function') { this.state.resend(this); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transforms a selection from the latest known server state to the current
|
||||||
|
// client state. For example, if we get from the server the information that
|
||||||
|
// another user's cursor is at position 3, but the server hasn't yet received
|
||||||
|
// our newest operation, an insertion of 5 characters at the beginning of the
|
||||||
|
// document, the correct position of the other user's cursor in our current
|
||||||
|
// document is 8.
|
||||||
|
Client.prototype.transformSelection = function (selection) {
|
||||||
|
return this.state.transformSelection(selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override this method.
|
||||||
|
Client.prototype.sendOperation = function (revision, operation) {
|
||||||
|
throw new Error("sendOperation must be defined in child class");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Override this method.
|
||||||
|
Client.prototype.applyOperation = function (operation) {
|
||||||
|
throw new Error("applyOperation must be defined in child class");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// In the 'Synchronized' state, there is no pending operation that the client
|
||||||
|
// has sent to the server.
|
||||||
|
function Synchronized () {}
|
||||||
|
Client.Synchronized = Synchronized;
|
||||||
|
|
||||||
|
Synchronized.prototype.applyClient = function (client, operation) {
|
||||||
|
// When the user makes an edit, send the operation to the server and
|
||||||
|
// switch to the 'AwaitingConfirm' state
|
||||||
|
client.sendOperation(client.revision, operation);
|
||||||
|
return new AwaitingConfirm(operation);
|
||||||
|
};
|
||||||
|
|
||||||
|
Synchronized.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
throw new Error("Invalid revision.");
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// When we receive a new operation from the server, the operation can be
|
||||||
|
// simply applied to the current document
|
||||||
|
client.applyOperation(operation);
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
Synchronized.prototype.serverAck = function (client, revision) {
|
||||||
|
throw new Error("There is no pending operation.");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nothing to do because the latest server state and client state are the same.
|
||||||
|
Synchronized.prototype.transformSelection = function (x) { return x; };
|
||||||
|
|
||||||
|
// Singleton
|
||||||
|
var synchronized_ = new Synchronized();
|
||||||
|
|
||||||
|
|
||||||
|
// In the 'AwaitingConfirm' state, there's one operation the client has sent
|
||||||
|
// to the server and is still waiting for an acknowledgement.
|
||||||
|
function AwaitingConfirm (outstanding) {
|
||||||
|
// Save the pending operation
|
||||||
|
this.outstanding = outstanding;
|
||||||
|
}
|
||||||
|
Client.AwaitingConfirm = AwaitingConfirm;
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.applyClient = function (client, operation) {
|
||||||
|
// When the user makes an edit, don't send the operation immediately,
|
||||||
|
// instead switch to 'AwaitingWithBuffer' state
|
||||||
|
return new AwaitingWithBuffer(this.outstanding, operation);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
throw new Error("Invalid revision.");
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// This is another client's operation. Visualization:
|
||||||
|
//
|
||||||
|
// /\
|
||||||
|
// this.outstanding / \ operation
|
||||||
|
// / \
|
||||||
|
// \ /
|
||||||
|
// pair[1] \ / pair[0] (new outstanding)
|
||||||
|
// (can be applied \/
|
||||||
|
// to the client's
|
||||||
|
// current document)
|
||||||
|
var pair = operation.constructor.transform(this.outstanding, operation);
|
||||||
|
client.applyOperation(pair[1]);
|
||||||
|
return new AwaitingConfirm(pair[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.serverAck = function (client, revision) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
return new Stale(this.outstanding, client, revision).getOperations();
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// The client's operation has been acknowledged
|
||||||
|
// => switch to synchronized state
|
||||||
|
return synchronized_;
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.transformSelection = function (selection) {
|
||||||
|
return selection.transform(this.outstanding);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingConfirm.prototype.resend = function (client) {
|
||||||
|
// The confirm didn't come because the client was disconnected.
|
||||||
|
// Now that it has reconnected, we resend the outstanding operation.
|
||||||
|
client.sendOperation(client.revision, this.outstanding);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// In the 'AwaitingWithBuffer' state, the client is waiting for an operation
|
||||||
|
// to be acknowledged by the server while buffering the edits the user makes
|
||||||
|
function AwaitingWithBuffer (outstanding, buffer) {
|
||||||
|
// Save the pending operation and the user's edits since then
|
||||||
|
this.outstanding = outstanding;
|
||||||
|
this.buffer = buffer;
|
||||||
|
}
|
||||||
|
Client.AwaitingWithBuffer = AwaitingWithBuffer;
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.applyClient = function (client, operation) {
|
||||||
|
// Compose the user's changes onto the buffer
|
||||||
|
var newBuffer = this.buffer.compose(operation);
|
||||||
|
return new AwaitingWithBuffer(this.outstanding, newBuffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
throw new Error("Invalid revision.");
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// Operation comes from another client
|
||||||
|
//
|
||||||
|
// /\
|
||||||
|
// this.outstanding / \ operation
|
||||||
|
// / \
|
||||||
|
// /\ /
|
||||||
|
// this.buffer / \* / pair1[0] (new outstanding)
|
||||||
|
// / \/
|
||||||
|
// \ /
|
||||||
|
// pair2[1] \ / pair2[0] (new buffer)
|
||||||
|
// the transformed \/
|
||||||
|
// operation -- can
|
||||||
|
// be applied to the
|
||||||
|
// client's current
|
||||||
|
// document
|
||||||
|
//
|
||||||
|
// * pair1[1]
|
||||||
|
var transform = operation.constructor.transform;
|
||||||
|
var pair1 = transform(this.outstanding, operation);
|
||||||
|
var pair2 = transform(this.buffer, pair1[1]);
|
||||||
|
client.applyOperation(pair2[1]);
|
||||||
|
return new AwaitingWithBuffer(pair1[0], pair2[0]);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.serverAck = function (client, revision) {
|
||||||
|
if (revision - client.revision > 1) {
|
||||||
|
return new StaleWithBuffer(this.outstanding, this.buffer, client, revision).getOperations();
|
||||||
|
}
|
||||||
|
client.revision = revision;
|
||||||
|
// The pending operation has been acknowledged
|
||||||
|
// => send buffer
|
||||||
|
client.sendOperation(client.revision, this.buffer);
|
||||||
|
return new AwaitingConfirm(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.transformSelection = function (selection) {
|
||||||
|
return selection.transform(this.outstanding).transform(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
AwaitingWithBuffer.prototype.resend = function (client) {
|
||||||
|
// The confirm didn't come because the client was disconnected.
|
||||||
|
// Now that it has reconnected, we resend the outstanding operation.
|
||||||
|
client.sendOperation(client.revision, this.outstanding);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function Stale(acknowlaged, client, revision) {
|
||||||
|
this.acknowlaged = acknowlaged;
|
||||||
|
this.client = client;
|
||||||
|
this.revision = revision;
|
||||||
|
}
|
||||||
|
Client.Stale = Stale;
|
||||||
|
|
||||||
|
Stale.prototype.applyClient = function (client, operation) {
|
||||||
|
return new StaleWithBuffer(this.acknowlaged, operation, client, this.revision);
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
throw new Error("Ignored server-side change.");
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.applyOperations = function (client, head, operations) {
|
||||||
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
for (var i = 0; i < operations.length; i++) {
|
||||||
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
|
var pair = transform(this.acknowlaged, op);
|
||||||
|
client.applyOperation(pair[1]);
|
||||||
|
this.acknowlaged = pair[0];
|
||||||
|
}
|
||||||
|
client.revision = this.revision;
|
||||||
|
return synchronized_;
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.serverAck = function (client, revision) {
|
||||||
|
throw new Error("There is no pending operation.");
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.transformSelection = function (selection) {
|
||||||
|
return selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
Stale.prototype.getOperations = function () {
|
||||||
|
this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function StaleWithBuffer(acknowlaged, buffer, client, revision) {
|
||||||
|
this.acknowlaged = acknowlaged;
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.client = client;
|
||||||
|
this.revision = revision;
|
||||||
|
}
|
||||||
|
Client.StaleWithBuffer = StaleWithBuffer;
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.applyClient = function (client, operation) {
|
||||||
|
var buffer = this.buffer.compose(operation);
|
||||||
|
return new StaleWithBuffer(this.acknowlaged, buffer, client, this.revision);
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.applyServer = function (client, revision, operation) {
|
||||||
|
throw new Error("Ignored server-side change.");
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.applyOperations = function (client, head, operations) {
|
||||||
|
var transform = this.acknowlaged.constructor.transform;
|
||||||
|
for (var i = 0; i < operations.length; i++) {
|
||||||
|
var op = ot.TextOperation.fromJSON(operations[i]);
|
||||||
|
var pair1 = transform(this.acknowlaged, op);
|
||||||
|
var pair2 = transform(this.buffer, pair1[1]);
|
||||||
|
client.applyOperation(pair2[1]);
|
||||||
|
this.acknowlaged = pair1[0];
|
||||||
|
this.buffer = pair2[0];
|
||||||
|
}
|
||||||
|
client.revision = this.revision;
|
||||||
|
client.sendOperation(client.revision, this.buffer);
|
||||||
|
return new AwaitingConfirm(this.buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.serverAck = function (client, revision) {
|
||||||
|
throw new Error("There is no pending operation.");
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.transformSelection = function (selection) {
|
||||||
|
return selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
StaleWithBuffer.prototype.getOperations = function () {
|
||||||
|
this.client.getOperations(this.client.revision, this.revision - 1); // acknowlaged is the one at revision
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return Client;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.Client;
|
||||||
|
}
|
||||||
|
|
393
public/vendor/ot/codemirror-adapter.js
vendored
Executable file
393
public/vendor/ot/codemirror-adapter.js
vendored
Executable file
|
@ -0,0 +1,393 @@
|
||||||
|
/*global ot */
|
||||||
|
|
||||||
|
ot.CodeMirrorAdapter = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var TextOperation = ot.TextOperation;
|
||||||
|
var Selection = ot.Selection;
|
||||||
|
|
||||||
|
function CodeMirrorAdapter(cm) {
|
||||||
|
this.cm = cm;
|
||||||
|
this.ignoreNextChange = false;
|
||||||
|
this.changeInProgress = false;
|
||||||
|
this.selectionChanged = false;
|
||||||
|
|
||||||
|
bind(this, 'onChanges');
|
||||||
|
bind(this, 'onChange');
|
||||||
|
bind(this, 'onCursorActivity');
|
||||||
|
bind(this, 'onFocus');
|
||||||
|
bind(this, 'onBlur');
|
||||||
|
|
||||||
|
cm.on('changes', this.onChanges);
|
||||||
|
cm.on('change', this.onChange);
|
||||||
|
cm.on('cursorActivity', this.onCursorActivity);
|
||||||
|
cm.on('focus', this.onFocus);
|
||||||
|
cm.on('blur', this.onBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removes all event listeners from the CodeMirror instance.
|
||||||
|
CodeMirrorAdapter.prototype.detach = function () {
|
||||||
|
this.cm.off('changes', this.onChanges);
|
||||||
|
this.cm.off('change', this.onChange);
|
||||||
|
this.cm.off('cursorActivity', this.onCursorActivity);
|
||||||
|
this.cm.off('focus', this.onFocus);
|
||||||
|
this.cm.off('blur', this.onBlur);
|
||||||
|
};
|
||||||
|
|
||||||
|
function cmpPos(a, b) {
|
||||||
|
if (a.line < b.line) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.line > b.line) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (a.ch < b.ch) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.ch > b.ch) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posEq(a, b) {
|
||||||
|
return cmpPos(a, b) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posLe(a, b) {
|
||||||
|
return cmpPos(a, b) <= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minPos(a, b) {
|
||||||
|
return posLe(a, b) ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maxPos(a, b) {
|
||||||
|
return posLe(a, b) ? b : a;
|
||||||
|
}
|
||||||
|
|
||||||
|
function codemirrorDocLength(doc) {
|
||||||
|
return doc.indexFromPos({
|
||||||
|
line: doc.lastLine(),
|
||||||
|
ch: 0
|
||||||
|
}) +
|
||||||
|
doc.getLine(doc.lastLine()).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Converts a CodeMirror change array (as obtained from the 'changes' event
|
||||||
|
// in CodeMirror v4) or single change or linked list of changes (as returned
|
||||||
|
// by the 'change' event in CodeMirror prior to version 4) into a
|
||||||
|
// TextOperation and its inverse and returns them as a two-element array.
|
||||||
|
CodeMirrorAdapter.operationFromCodeMirrorChanges = function (changes, doc) {
|
||||||
|
// Approach: Replay the changes, beginning with the most recent one, and
|
||||||
|
// construct the operation and its inverse. We have to convert the position
|
||||||
|
// in the pre-change coordinate system to an index. We have a method to
|
||||||
|
// convert a position in the coordinate system after all changes to an index,
|
||||||
|
// namely CodeMirror's `indexFromPos` method. We can use the information of
|
||||||
|
// a single change object to convert a post-change coordinate system to a
|
||||||
|
// pre-change coordinate system. We can now proceed inductively to get a
|
||||||
|
// pre-change coordinate system for all changes in the linked list.
|
||||||
|
// A disadvantage of this approach is its complexity `O(n^2)` in the length
|
||||||
|
// of the linked list of changes.
|
||||||
|
|
||||||
|
var docEndLength = codemirrorDocLength(doc);
|
||||||
|
var operation = new TextOperation().retain(docEndLength);
|
||||||
|
var inverse = new TextOperation().retain(docEndLength);
|
||||||
|
|
||||||
|
var indexFromPos = function (pos) {
|
||||||
|
return doc.indexFromPos(pos);
|
||||||
|
};
|
||||||
|
|
||||||
|
function last(arr) {
|
||||||
|
return arr[arr.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumLengths(strArr) {
|
||||||
|
if (strArr.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
var sum = 0;
|
||||||
|
for (var i = 0; i < strArr.length; i++) {
|
||||||
|
sum += strArr[i].length;
|
||||||
|
}
|
||||||
|
return sum + strArr.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateIndexFromPos(indexFromPos, change) {
|
||||||
|
return function (pos) {
|
||||||
|
if (posLe(pos, change.from)) {
|
||||||
|
return indexFromPos(pos);
|
||||||
|
}
|
||||||
|
if (posLe(change.to, pos)) {
|
||||||
|
return indexFromPos({
|
||||||
|
line: pos.line + change.text.length - 1 - (change.to.line - change.from.line),
|
||||||
|
ch: (change.to.line < pos.line) ?
|
||||||
|
pos.ch : (change.text.length <= 1) ?
|
||||||
|
pos.ch - (change.to.ch - change.from.ch) + sumLengths(change.text) : pos.ch - change.to.ch + last(change.text).length
|
||||||
|
}) + sumLengths(change.removed) - sumLengths(change.text);
|
||||||
|
}
|
||||||
|
if (change.from.line === pos.line) {
|
||||||
|
return indexFromPos(change.from) + pos.ch - change.from.ch;
|
||||||
|
}
|
||||||
|
return indexFromPos(change.from) +
|
||||||
|
sumLengths(change.removed.slice(0, pos.line - change.from.line)) +
|
||||||
|
1 + pos.ch;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = changes.length - 1; i >= 0; i--) {
|
||||||
|
var change = changes[i];
|
||||||
|
indexFromPos = updateIndexFromPos(indexFromPos, change);
|
||||||
|
|
||||||
|
var fromIndex = indexFromPos(change.from);
|
||||||
|
var restLength = docEndLength - fromIndex - sumLengths(change.text);
|
||||||
|
|
||||||
|
operation = new TextOperation()
|
||||||
|
.retain(fromIndex)['delete'](sumLengths(change.removed))
|
||||||
|
.insert(change.text.join('\n'))
|
||||||
|
.retain(restLength)
|
||||||
|
.compose(operation);
|
||||||
|
|
||||||
|
inverse = inverse.compose(new TextOperation()
|
||||||
|
.retain(fromIndex)['delete'](sumLengths(change.text))
|
||||||
|
.insert(change.removed.join('\n'))
|
||||||
|
.retain(restLength)
|
||||||
|
);
|
||||||
|
|
||||||
|
docEndLength += sumLengths(change.removed) - sumLengths(change.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [operation, inverse];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singular form for backwards compatibility.
|
||||||
|
CodeMirrorAdapter.operationFromCodeMirrorChange =
|
||||||
|
CodeMirrorAdapter.operationFromCodeMirrorChanges;
|
||||||
|
|
||||||
|
// Apply an operation to a CodeMirror instance.
|
||||||
|
CodeMirrorAdapter.applyOperationToCodeMirror = function (operation, cm) {
|
||||||
|
cm.operation(function () {
|
||||||
|
var ops = operation.ops;
|
||||||
|
var index = 0; // holds the current index into CodeMirror's content
|
||||||
|
for (var i = 0, l = ops.length; i < l; i++) {
|
||||||
|
var op = ops[i];
|
||||||
|
if (TextOperation.isRetain(op)) {
|
||||||
|
index += op;
|
||||||
|
} else if (TextOperation.isInsert(op)) {
|
||||||
|
cm.replaceRange(op, cm.posFromIndex(index), null, 'ignoreHistory');
|
||||||
|
index += op.length;
|
||||||
|
} else if (TextOperation.isDelete(op)) {
|
||||||
|
var from = cm.posFromIndex(index);
|
||||||
|
var to = cm.posFromIndex(index - op);
|
||||||
|
cm.replaceRange('', from, to, 'ignoreHistory');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.registerCallbacks = function (cb) {
|
||||||
|
this.callbacks = cb;
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.onChange = function () {
|
||||||
|
// By default, CodeMirror's event order is the following:
|
||||||
|
// 1. 'change', 2. 'cursorActivity', 3. 'changes'.
|
||||||
|
// We want to fire the 'selectionChange' event after the 'change' event,
|
||||||
|
// but need the information from the 'changes' event. Therefore, we detect
|
||||||
|
// when a change is in progress by listening to the change event, setting
|
||||||
|
// a flag that makes this adapter defer all 'cursorActivity' events.
|
||||||
|
this.changeInProgress = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.onChanges = function (_, changes) {
|
||||||
|
if (!this.ignoreNextChange) {
|
||||||
|
var pair = CodeMirrorAdapter.operationFromCodeMirrorChanges(changes, this.cm);
|
||||||
|
this.trigger('change', pair[0], pair[1]);
|
||||||
|
}
|
||||||
|
if (this.selectionChanged) {
|
||||||
|
this.trigger('selectionChange');
|
||||||
|
}
|
||||||
|
this.changeInProgress = false;
|
||||||
|
this.ignoreNextChange = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.onCursorActivity =
|
||||||
|
CodeMirrorAdapter.prototype.onFocus = function () {
|
||||||
|
if (this.changeInProgress) {
|
||||||
|
this.selectionChanged = true;
|
||||||
|
} else {
|
||||||
|
this.trigger('selectionChange');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.onBlur = function () {
|
||||||
|
if (!this.cm.somethingSelected()) {
|
||||||
|
this.trigger('blur');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.getValue = function () {
|
||||||
|
return this.cm.getValue();
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.getSelection = function () {
|
||||||
|
var cm = this.cm;
|
||||||
|
|
||||||
|
var selectionList = cm.listSelections();
|
||||||
|
var ranges = [];
|
||||||
|
for (var i = 0; i < selectionList.length; i++) {
|
||||||
|
ranges[i] = new Selection.Range(
|
||||||
|
cm.indexFromPos(selectionList[i].anchor),
|
||||||
|
cm.indexFromPos(selectionList[i].head)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Selection(ranges);
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.setSelection = function (selection) {
|
||||||
|
var ranges = [];
|
||||||
|
for (var i = 0; i < selection.ranges.length; i++) {
|
||||||
|
var range = selection.ranges[i];
|
||||||
|
ranges[i] = {
|
||||||
|
anchor: this.cm.posFromIndex(range.anchor),
|
||||||
|
head: this.cm.posFromIndex(range.head)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.cm.setSelections(ranges);
|
||||||
|
};
|
||||||
|
|
||||||
|
var addStyleRule = (function () {
|
||||||
|
var added = {};
|
||||||
|
var styleElement = document.createElement('style');
|
||||||
|
document.documentElement.getElementsByTagName('head')[0].appendChild(styleElement);
|
||||||
|
var styleSheet = styleElement.sheet;
|
||||||
|
|
||||||
|
return function (css) {
|
||||||
|
if (added[css]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
added[css] = true;
|
||||||
|
styleSheet.insertRule(css, (styleSheet.cssRules || styleSheet.rules).length);
|
||||||
|
};
|
||||||
|
}());
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.setOtherCursor = function (position, color, clientId) {
|
||||||
|
var cursorPos = this.cm.posFromIndex(position);
|
||||||
|
var cursorCoords = this.cm.cursorCoords(cursorPos);
|
||||||
|
var cursorEl = document.createElement('span');
|
||||||
|
cursorEl.className = 'other-client';
|
||||||
|
cursorEl.style.display = 'none';
|
||||||
|
/*
|
||||||
|
cursorEl.style.padding = '0';
|
||||||
|
cursorEl.style.marginLeft = cursorEl.style.marginRight = '-1px';
|
||||||
|
cursorEl.style.borderLeftWidth = '2px';
|
||||||
|
cursorEl.style.borderLeftStyle = 'solid';
|
||||||
|
cursorEl.style.borderLeftColor = color;
|
||||||
|
cursorEl.style.height = (cursorCoords.bottom - cursorCoords.top) * 0.9 + 'px';
|
||||||
|
cursorEl.style.zIndex = 0;
|
||||||
|
*/
|
||||||
|
cursorEl.setAttribute('data-clientid', clientId);
|
||||||
|
return this.cm.setBookmark(cursorPos, {
|
||||||
|
widget: cursorEl,
|
||||||
|
insertLeft: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.setOtherSelectionRange = function (range, color, clientId) {
|
||||||
|
var match = /^#([0-9a-fA-F]{6})$/.exec(color);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error("only six-digit hex colors are allowed.");
|
||||||
|
}
|
||||||
|
var selectionClassName = 'selection-' + match[1];
|
||||||
|
var rgbcolor = hex2rgb(color);
|
||||||
|
var rule = '.' + selectionClassName + ' { background: rgba(' + rgbcolor.red + ',' + rgbcolor.green + ',' + rgbcolor.blue + ',0.2); }';
|
||||||
|
addStyleRule(rule);
|
||||||
|
|
||||||
|
var anchorPos = this.cm.posFromIndex(range.anchor);
|
||||||
|
var headPos = this.cm.posFromIndex(range.head);
|
||||||
|
|
||||||
|
return this.cm.markText(
|
||||||
|
minPos(anchorPos, headPos),
|
||||||
|
maxPos(anchorPos, headPos), {
|
||||||
|
className: selectionClassName
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.setOtherSelection = function (selection, color, clientId) {
|
||||||
|
var selectionObjects = [];
|
||||||
|
for (var i = 0; i < selection.ranges.length; i++) {
|
||||||
|
var range = selection.ranges[i];
|
||||||
|
if (range.isEmpty()) {
|
||||||
|
selectionObjects[i] = this.setOtherCursor(range.head, color, clientId);
|
||||||
|
} else {
|
||||||
|
selectionObjects[i] = this.setOtherSelectionRange(range, color, clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
clear: function () {
|
||||||
|
for (var i = 0; i < selectionObjects.length; i++) {
|
||||||
|
selectionObjects[i].clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.trigger = function (event) {
|
||||||
|
var args = Array.prototype.slice.call(arguments, 1);
|
||||||
|
var action = this.callbacks && this.callbacks[event];
|
||||||
|
if (action) {
|
||||||
|
action.apply(this, args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.applyOperation = function (operation) {
|
||||||
|
this.ignoreNextChange = true;
|
||||||
|
CodeMirrorAdapter.applyOperationToCodeMirror(operation, this.cm);
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.registerUndo = function (undoFn) {
|
||||||
|
this.cm.undo = undoFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
CodeMirrorAdapter.prototype.registerRedo = function (redoFn) {
|
||||||
|
this.cm.redo = redoFn;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Throws an error if the first argument is falsy. Useful for debugging.
|
||||||
|
function assert(b, msg) {
|
||||||
|
if (!b) {
|
||||||
|
throw new Error(msg || "assertion error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind a method to an object, so it doesn't matter whether you call
|
||||||
|
// object.method() directly or pass object.method as a reference to another
|
||||||
|
// function.
|
||||||
|
function bind(obj, method) {
|
||||||
|
var fn = obj[method];
|
||||||
|
obj[method] = function () {
|
||||||
|
fn.apply(obj, arguments);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return CodeMirrorAdapter;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
function hex2rgb(hex) {
|
||||||
|
if (hex[0] == "#") hex = hex.substr(1);
|
||||||
|
if (hex.length == 3) {
|
||||||
|
var temp = hex;
|
||||||
|
hex = '';
|
||||||
|
temp = /^([a-f0-9])([a-f0-9])([a-f0-9])$/i.exec(temp).slice(1);
|
||||||
|
for (var i = 0; i < 3; i++) hex += temp[i] + temp[i];
|
||||||
|
}
|
||||||
|
var triplets = /^([a-f0-9]{2})([a-f0-9]{2})([a-f0-9]{2})$/i.exec(hex).slice(1);
|
||||||
|
return {
|
||||||
|
red: parseInt(triplets[0], 16),
|
||||||
|
green: parseInt(triplets[1], 16),
|
||||||
|
blue: parseInt(triplets[2], 16)
|
||||||
|
}
|
||||||
|
}
|
10
public/vendor/ot/compress.sh
vendored
Normal file
10
public/vendor/ot/compress.sh
vendored
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
uglifyjs --compress --mangle --output ot.min.js \
|
||||||
|
./text-operation.js \
|
||||||
|
./selection.js \
|
||||||
|
./wrapped-operation.js \
|
||||||
|
./undo-manager.js \
|
||||||
|
./client.js \
|
||||||
|
./codemirror-adapter.js \
|
||||||
|
./socketio-adapter.js \
|
||||||
|
./ajax-adapter.js \
|
||||||
|
./editor-client.js
|
354
public/vendor/ot/editor-client.js
vendored
Executable file
354
public/vendor/ot/editor-client.js
vendored
Executable file
|
@ -0,0 +1,354 @@
|
||||||
|
/*global ot */
|
||||||
|
|
||||||
|
ot.EditorClient = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var Client = ot.Client;
|
||||||
|
var Selection = ot.Selection;
|
||||||
|
var UndoManager = ot.UndoManager;
|
||||||
|
var TextOperation = ot.TextOperation;
|
||||||
|
var WrappedOperation = ot.WrappedOperation;
|
||||||
|
|
||||||
|
|
||||||
|
function SelfMeta (selectionBefore, selectionAfter) {
|
||||||
|
this.selectionBefore = selectionBefore;
|
||||||
|
this.selectionAfter = selectionAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
SelfMeta.prototype.invert = function () {
|
||||||
|
return new SelfMeta(this.selectionAfter, this.selectionBefore);
|
||||||
|
};
|
||||||
|
|
||||||
|
SelfMeta.prototype.compose = function (other) {
|
||||||
|
return new SelfMeta(this.selectionBefore, other.selectionAfter);
|
||||||
|
};
|
||||||
|
|
||||||
|
SelfMeta.prototype.transform = function (operation) {
|
||||||
|
return new SelfMeta(
|
||||||
|
this.selectionBefore.transform(operation),
|
||||||
|
this.selectionAfter.transform(operation)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function OtherMeta (clientId, selection) {
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.selection = selection;
|
||||||
|
}
|
||||||
|
|
||||||
|
OtherMeta.fromJSON = function (obj) {
|
||||||
|
return new OtherMeta(
|
||||||
|
obj.clientId,
|
||||||
|
obj.selection && Selection.fromJSON(obj.selection)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
OtherMeta.prototype.transform = function (operation) {
|
||||||
|
return new OtherMeta(
|
||||||
|
this.clientId,
|
||||||
|
this.selection && this.selection.transform(operation)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function OtherClient (id, listEl, editorAdapter, name, color, selection) {
|
||||||
|
this.id = id;
|
||||||
|
this.listEl = listEl;
|
||||||
|
this.editorAdapter = editorAdapter;
|
||||||
|
this.name = name;
|
||||||
|
this.color = color;
|
||||||
|
|
||||||
|
this.li = document.createElement('li');
|
||||||
|
if (name) {
|
||||||
|
this.li.textContent = name;
|
||||||
|
this.listEl.appendChild(this.li);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!color)
|
||||||
|
this.setColor(name ? hueFromName(name) : Math.random());
|
||||||
|
else
|
||||||
|
this.setForceColor(color);
|
||||||
|
if (selection) { this.updateSelection(selection); }
|
||||||
|
}
|
||||||
|
|
||||||
|
OtherClient.prototype.setColor = function (hue) {
|
||||||
|
this.hue = hue;
|
||||||
|
this.color = hsl2hex(hue, 0.75, 0.5);
|
||||||
|
this.lightColor = hsl2hex(hue, 0.5, 0.9);
|
||||||
|
if (this.li) { this.li.style.color = this.color; }
|
||||||
|
};
|
||||||
|
|
||||||
|
OtherClient.prototype.setForceColor = function (color) {
|
||||||
|
this.hue = null;
|
||||||
|
this.color = color;
|
||||||
|
this.lightColor = color;
|
||||||
|
if (this.li) { this.li.style.color = this.color; }
|
||||||
|
};
|
||||||
|
|
||||||
|
OtherClient.prototype.setName = function (name) {
|
||||||
|
if (this.name === name) { return; }
|
||||||
|
this.name = name;
|
||||||
|
|
||||||
|
this.li.textContent = name;
|
||||||
|
if (!this.li.parentNode) {
|
||||||
|
this.listEl.appendChild(this.li);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setColor(hueFromName(name));
|
||||||
|
};
|
||||||
|
|
||||||
|
OtherClient.prototype.updateSelection = function (selection) {
|
||||||
|
this.removeSelection();
|
||||||
|
this.selection = selection;
|
||||||
|
this.mark = this.editorAdapter.setOtherSelection(
|
||||||
|
selection,
|
||||||
|
selection.position === selection.selectionEnd ? this.color : this.lightColor,
|
||||||
|
this.id
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
OtherClient.prototype.remove = function () {
|
||||||
|
if (this.li) { removeElement(this.li); }
|
||||||
|
this.removeSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
OtherClient.prototype.removeSelection = function () {
|
||||||
|
if (this.mark) {
|
||||||
|
this.mark.clear();
|
||||||
|
this.mark = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
function EditorClient (revision, clients, serverAdapter, editorAdapter) {
|
||||||
|
Client.call(this, revision);
|
||||||
|
this.serverAdapter = serverAdapter;
|
||||||
|
this.editorAdapter = editorAdapter;
|
||||||
|
this.undoManager = new UndoManager();
|
||||||
|
|
||||||
|
this.initializeClientList();
|
||||||
|
this.initializeClients(clients);
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
|
||||||
|
this.editorAdapter.registerCallbacks({
|
||||||
|
change: function (operation, inverse) { self.onChange(operation, inverse); },
|
||||||
|
selectionChange: function () { self.onSelectionChange(); },
|
||||||
|
blur: function () { self.onBlur(); }
|
||||||
|
});
|
||||||
|
this.editorAdapter.registerUndo(function () { self.undo(); });
|
||||||
|
this.editorAdapter.registerRedo(function () { self.redo(); });
|
||||||
|
|
||||||
|
this.serverAdapter.registerCallbacks({
|
||||||
|
client_left: function (clientId) { self.onClientLeft(clientId); },
|
||||||
|
set_name: function (clientId, name) { self.getClientObject(clientId).setName(name); },
|
||||||
|
set_color: function (clientId, color) { self.getClientObject(clientId).setForceColor(color); },
|
||||||
|
ack: function (revision) { self.serverAck(revision); },
|
||||||
|
operation: function (revision, operation) {
|
||||||
|
self.applyServer(revision, TextOperation.fromJSON(operation));
|
||||||
|
},
|
||||||
|
operations: function (head, operations) {
|
||||||
|
self.applyOperations(head, operations);
|
||||||
|
},
|
||||||
|
selection: function (clientId, selection) {
|
||||||
|
if (selection) {
|
||||||
|
self.getClientObject(clientId).updateSelection(
|
||||||
|
self.transformSelection(Selection.fromJSON(selection))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.getClientObject(clientId).removeSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
clients: function (clients) {
|
||||||
|
var clientId;
|
||||||
|
for (clientId in self.clients) {
|
||||||
|
if (self.clients.hasOwnProperty(clientId) && !clients.hasOwnProperty(clientId)) {
|
||||||
|
self.onClientLeft(clientId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (clientId in clients) {
|
||||||
|
if (clients.hasOwnProperty(clientId)) {
|
||||||
|
var clientObject = self.getClientObject(clientId);
|
||||||
|
|
||||||
|
if (clients[clientId].name) {
|
||||||
|
clientObject.setName(clients[clientId].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
var selection = clients[clientId].selection;
|
||||||
|
if (selection) {
|
||||||
|
self.clients[clientId].updateSelection(
|
||||||
|
self.transformSelection(Selection.fromJSON(selection))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self.clients[clientId].removeSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reconnect: function () { self.serverReconnect(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
inherit(EditorClient, Client);
|
||||||
|
|
||||||
|
EditorClient.prototype.addClient = function (clientId, clientObj) {
|
||||||
|
this.clients[clientId] = new OtherClient(
|
||||||
|
clientId,
|
||||||
|
this.clientListEl,
|
||||||
|
this.editorAdapter,
|
||||||
|
clientObj.name || clientId,
|
||||||
|
clientObj.color || null,
|
||||||
|
clientObj.selection ? Selection.fromJSON(clientObj.selection) : null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.initializeClients = function (clients) {
|
||||||
|
this.clients = {};
|
||||||
|
for (var clientId in clients) {
|
||||||
|
if (clients.hasOwnProperty(clientId)) {
|
||||||
|
this.addClient(clientId, clients[clientId]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.getClientObject = function (clientId) {
|
||||||
|
var client = this.clients[clientId];
|
||||||
|
if (client) { return client; }
|
||||||
|
return this.clients[clientId] = new OtherClient(
|
||||||
|
clientId,
|
||||||
|
this.clientListEl,
|
||||||
|
this.editorAdapter
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.onClientLeft = function (clientId) {
|
||||||
|
//console.log("User disconnected: " + clientId);
|
||||||
|
var client = this.clients[clientId];
|
||||||
|
if (!client) { return; }
|
||||||
|
client.remove();
|
||||||
|
delete this.clients[clientId];
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.initializeClientList = function () {
|
||||||
|
this.clientListEl = document.createElement('ul');
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.applyUnredo = function (operation) {
|
||||||
|
this.undoManager.add(operation.invert(this.editorAdapter.getValue()));
|
||||||
|
this.editorAdapter.applyOperation(operation.wrapped);
|
||||||
|
this.selection = operation.meta.selectionAfter;
|
||||||
|
this.editorAdapter.setSelection(this.selection);
|
||||||
|
this.applyClient(operation.wrapped);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.undo = function () {
|
||||||
|
var self = this;
|
||||||
|
if (!this.undoManager.canUndo()) { return; }
|
||||||
|
this.undoManager.performUndo(function (o) { self.applyUnredo(o); });
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.redo = function () {
|
||||||
|
var self = this;
|
||||||
|
if (!this.undoManager.canRedo()) { return; }
|
||||||
|
this.undoManager.performRedo(function (o) { self.applyUnredo(o); });
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.onChange = function (textOperation, inverse) {
|
||||||
|
var selectionBefore = this.selection;
|
||||||
|
this.updateSelection();
|
||||||
|
var meta = new SelfMeta(selectionBefore, this.selection);
|
||||||
|
var operation = new WrappedOperation(textOperation, meta);
|
||||||
|
|
||||||
|
var compose = this.undoManager.undoStack.length > 0 &&
|
||||||
|
inverse.shouldBeComposedWithInverted(last(this.undoManager.undoStack).wrapped);
|
||||||
|
var inverseMeta = new SelfMeta(this.selection, selectionBefore);
|
||||||
|
this.undoManager.add(new WrappedOperation(inverse, inverseMeta), compose);
|
||||||
|
this.applyClient(textOperation);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.updateSelection = function () {
|
||||||
|
this.selection = this.editorAdapter.getSelection();
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.onSelectionChange = function () {
|
||||||
|
var oldSelection = this.selection;
|
||||||
|
this.updateSelection();
|
||||||
|
if (oldSelection && this.selection.equals(oldSelection)) { return; }
|
||||||
|
this.sendSelection(this.selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.onBlur = function () {
|
||||||
|
this.selection = null;
|
||||||
|
this.sendSelection(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.sendSelection = function (selection) {
|
||||||
|
if (this.state instanceof Client.AwaitingWithBuffer) { return; }
|
||||||
|
this.serverAdapter.sendSelection(selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.sendOperation = function (revision, operation) {
|
||||||
|
this.serverAdapter.sendOperation(revision, operation.toJSON(), this.selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.getOperations = function (base, head) {
|
||||||
|
this.serverAdapter.getOperations(base, head);
|
||||||
|
};
|
||||||
|
|
||||||
|
EditorClient.prototype.applyOperation = function (operation) {
|
||||||
|
this.editorAdapter.applyOperation(operation);
|
||||||
|
this.updateSelection();
|
||||||
|
this.undoManager.transform(new WrappedOperation(operation, null));
|
||||||
|
};
|
||||||
|
|
||||||
|
function rgb2hex (r, g, b) {
|
||||||
|
function digits (n) {
|
||||||
|
var m = Math.round(255*n).toString(16);
|
||||||
|
return m.length === 1 ? '0'+m : m;
|
||||||
|
}
|
||||||
|
return '#' + digits(r) + digits(g) + digits(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hsl2hex (h, s, l) {
|
||||||
|
if (s === 0) { return rgb2hex(l, l, l); }
|
||||||
|
var var2 = l < 0.5 ? l * (1+s) : (l+s) - (s*l);
|
||||||
|
var var1 = 2 * l - var2;
|
||||||
|
var hue2rgb = function (hue) {
|
||||||
|
if (hue < 0) { hue += 1; }
|
||||||
|
if (hue > 1) { hue -= 1; }
|
||||||
|
if (6*hue < 1) { return var1 + (var2-var1)*6*hue; }
|
||||||
|
if (2*hue < 1) { return var2; }
|
||||||
|
if (3*hue < 2) { return var1 + (var2-var1)*6*(2/3 - hue); }
|
||||||
|
return var1;
|
||||||
|
};
|
||||||
|
return rgb2hex(hue2rgb(h+1/3), hue2rgb(h), hue2rgb(h-1/3));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hueFromName (name) {
|
||||||
|
var a = 1;
|
||||||
|
for (var i = 0; i < name.length; i++) {
|
||||||
|
a = 17 * (a+name.charCodeAt(i)) % 360;
|
||||||
|
}
|
||||||
|
return a/360;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set Const.prototype.__proto__ to Super.prototype
|
||||||
|
function inherit (Const, Super) {
|
||||||
|
function F () {}
|
||||||
|
F.prototype = Super.prototype;
|
||||||
|
Const.prototype = new F();
|
||||||
|
Const.prototype.constructor = Const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function last (arr) { return arr[arr.length - 1]; }
|
||||||
|
|
||||||
|
// Remove an element from the DOM.
|
||||||
|
function removeElement (el) {
|
||||||
|
if (el.parentNode) {
|
||||||
|
el.parentNode.removeChild(el);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return EditorClient;
|
||||||
|
}());
|
1
public/vendor/ot/ot.min.js
vendored
Normal file
1
public/vendor/ot/ot.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
117
public/vendor/ot/selection.js
vendored
Executable file
117
public/vendor/ot/selection.js
vendored
Executable file
|
@ -0,0 +1,117 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.Selection = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var TextOperation = global.ot ? global.ot.TextOperation : require('./text-operation');
|
||||||
|
|
||||||
|
// Range has `anchor` and `head` properties, which are zero-based indices into
|
||||||
|
// the document. The `anchor` is the side of the selection that stays fixed,
|
||||||
|
// `head` is the side of the selection where the cursor is. When both are
|
||||||
|
// equal, the range represents a cursor.
|
||||||
|
function Range (anchor, head) {
|
||||||
|
this.anchor = anchor;
|
||||||
|
this.head = head;
|
||||||
|
}
|
||||||
|
|
||||||
|
Range.fromJSON = function (obj) {
|
||||||
|
return new Range(obj.anchor, obj.head);
|
||||||
|
};
|
||||||
|
|
||||||
|
Range.prototype.equals = function (other) {
|
||||||
|
return this.anchor === other.anchor && this.head === other.head;
|
||||||
|
};
|
||||||
|
|
||||||
|
Range.prototype.isEmpty = function () {
|
||||||
|
return this.anchor === this.head;
|
||||||
|
};
|
||||||
|
|
||||||
|
Range.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 (TextOperation.isRetain(ops[i])) {
|
||||||
|
index -= ops[i];
|
||||||
|
} else if (TextOperation.isInsert(ops[i])) {
|
||||||
|
newIndex += ops[i].length;
|
||||||
|
} else {
|
||||||
|
newIndex -= Math.min(index, -ops[i]);
|
||||||
|
index += ops[i];
|
||||||
|
}
|
||||||
|
if (index < 0) { break; }
|
||||||
|
}
|
||||||
|
return newIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newAnchor = transformIndex(this.anchor);
|
||||||
|
if (this.anchor === this.head) {
|
||||||
|
return new Range(newAnchor, newAnchor);
|
||||||
|
}
|
||||||
|
return new Range(newAnchor, transformIndex(this.head));
|
||||||
|
};
|
||||||
|
|
||||||
|
// A selection is basically an array of ranges. Every range represents a real
|
||||||
|
// selection or a cursor in the document (when the start position equals the
|
||||||
|
// end position of the range). The array must not be empty.
|
||||||
|
function Selection (ranges) {
|
||||||
|
this.ranges = ranges || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Selection.Range = Range;
|
||||||
|
|
||||||
|
// Convenience method for creating selections only containing a single cursor
|
||||||
|
// and no real selection range.
|
||||||
|
Selection.createCursor = function (position) {
|
||||||
|
return new Selection([new Range(position, position)]);
|
||||||
|
};
|
||||||
|
|
||||||
|
Selection.fromJSON = function (obj) {
|
||||||
|
var objRanges = obj.ranges || obj;
|
||||||
|
for (var i = 0, ranges = []; i < objRanges.length; i++) {
|
||||||
|
ranges[i] = Range.fromJSON(objRanges[i]);
|
||||||
|
}
|
||||||
|
return new Selection(ranges);
|
||||||
|
};
|
||||||
|
|
||||||
|
Selection.prototype.equals = function (other) {
|
||||||
|
if (this.position !== other.position) { return false; }
|
||||||
|
if (this.ranges.length !== other.ranges.length) { return false; }
|
||||||
|
// FIXME: Sort ranges before comparing them?
|
||||||
|
for (var i = 0; i < this.ranges.length; i++) {
|
||||||
|
if (!this.ranges[i].equals(other.ranges[i])) { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
Selection.prototype.somethingSelected = function () {
|
||||||
|
for (var i = 0; i < this.ranges.length; i++) {
|
||||||
|
if (!this.ranges[i].isEmpty()) { return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Return the more current selection information.
|
||||||
|
Selection.prototype.compose = function (other) {
|
||||||
|
return other;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the selection with respect to an operation.
|
||||||
|
Selection.prototype.transform = function (other) {
|
||||||
|
for (var i = 0, newRanges = []; i < this.ranges.length; i++) {
|
||||||
|
newRanges[i] = this.ranges[i].transform(other);
|
||||||
|
}
|
||||||
|
return new Selection(newRanges);
|
||||||
|
};
|
||||||
|
|
||||||
|
return Selection;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.Selection;
|
||||||
|
}
|
66
public/vendor/ot/socketio-adapter.js
vendored
Executable file
66
public/vendor/ot/socketio-adapter.js
vendored
Executable file
|
@ -0,0 +1,66 @@
|
||||||
|
/*global ot */
|
||||||
|
|
||||||
|
ot.SocketIOAdapter = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function SocketIOAdapter(socket) {
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
var self = this;
|
||||||
|
socket.on('client_left', function (clientId) {
|
||||||
|
self.trigger('client_left', clientId);
|
||||||
|
});
|
||||||
|
socket.on('set_name', function (clientId, name) {
|
||||||
|
self.trigger('set_name', clientId, name);
|
||||||
|
});
|
||||||
|
socket.on('set_color', function (clientId, color) {
|
||||||
|
self.trigger('set_color', clientId, color);
|
||||||
|
});
|
||||||
|
socket.on('ack', function (revision) {
|
||||||
|
self.trigger('ack', revision);
|
||||||
|
});
|
||||||
|
socket.on('operation', function (clientId, revision, operation, selection) {
|
||||||
|
self.trigger('operation', revision, operation);
|
||||||
|
self.trigger('selection', clientId, selection);
|
||||||
|
});
|
||||||
|
socket.on('operations', function (head, operations) {
|
||||||
|
operations = LZString.decompressFromUTF16(operations);
|
||||||
|
operations = JSON.parse(operations);
|
||||||
|
self.trigger('operations', head, operations);
|
||||||
|
});
|
||||||
|
socket.on('selection', function (clientId, selection) {
|
||||||
|
self.trigger('selection', clientId, selection);
|
||||||
|
});
|
||||||
|
socket.on('reconnect', function () {
|
||||||
|
self.trigger('reconnect');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
SocketIOAdapter.prototype.sendOperation = function (revision, operation, selection) {
|
||||||
|
operation = LZString.compressToUTF16(JSON.stringify(operation));
|
||||||
|
this.socket.emit('operation', revision, operation, selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
SocketIOAdapter.prototype.sendSelection = function (selection) {
|
||||||
|
this.socket.emit('selection', selection);
|
||||||
|
};
|
||||||
|
|
||||||
|
SocketIOAdapter.prototype.getOperations = function (base, head) {
|
||||||
|
this.socket.emit('get_operations', base, head);
|
||||||
|
};
|
||||||
|
|
||||||
|
SocketIOAdapter.prototype.registerCallbacks = function (cb) {
|
||||||
|
this.callbacks = cb;
|
||||||
|
};
|
||||||
|
|
||||||
|
SocketIOAdapter.prototype.trigger = function (event) {
|
||||||
|
var args = Array.prototype.slice.call(arguments, 1);
|
||||||
|
var action = this.callbacks && this.callbacks[event];
|
||||||
|
if (action) {
|
||||||
|
action.apply(this, args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return SocketIOAdapter;
|
||||||
|
|
||||||
|
}());
|
530
public/vendor/ot/text-operation.js
vendored
Executable file
530
public/vendor/ot/text-operation.js
vendored
Executable file
|
@ -0,0 +1,530 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.TextOperation = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 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] !== other.ops[i]) { return false; }
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
|
||||||
|
var isRetain = TextOperation.isRetain = function (op) {
|
||||||
|
return typeof op === 'number' && op > 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
var isInsert = TextOperation.isInsert = function (op) {
|
||||||
|
return typeof op === 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
var isDelete = TextOperation.isDelete = function (op) {
|
||||||
|
return typeof op === 'number' && op < 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
if (typeof n !== 'number') {
|
||||||
|
throw new Error("retain expects an integer");
|
||||||
|
}
|
||||||
|
if (n === 0) { return this; }
|
||||||
|
this.baseLength += n;
|
||||||
|
this.targetLength += n;
|
||||||
|
if (isRetain(this.ops[this.ops.length-1])) {
|
||||||
|
// The last op is a retain op => we can merge them into one op.
|
||||||
|
this.ops[this.ops.length-1] += n;
|
||||||
|
} else {
|
||||||
|
// Create a new op.
|
||||||
|
this.ops.push(n);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Insert a string at the current position.
|
||||||
|
TextOperation.prototype.insert = function (str) {
|
||||||
|
if (typeof str !== 'string') {
|
||||||
|
throw new Error("insert expects a string");
|
||||||
|
}
|
||||||
|
if (str === '') { return this; }
|
||||||
|
this.targetLength += str.length;
|
||||||
|
var ops = this.ops;
|
||||||
|
if (isInsert(ops[ops.length-1])) {
|
||||||
|
// Merge insert op.
|
||||||
|
ops[ops.length-1] += str;
|
||||||
|
} else if (isDelete(ops[ops.length-1])) {
|
||||||
|
// 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 (isInsert(ops[ops.length-2])) {
|
||||||
|
ops[ops.length-2] += str;
|
||||||
|
} else {
|
||||||
|
ops[ops.length] = ops[ops.length-1];
|
||||||
|
ops[ops.length-2] = str;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ops.push(str);
|
||||||
|
}
|
||||||
|
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') {
|
||||||
|
throw new Error("delete expects an integer or a string");
|
||||||
|
}
|
||||||
|
if (n === 0) { return this; }
|
||||||
|
if (n > 0) { n = -n; }
|
||||||
|
this.baseLength -= n;
|
||||||
|
if (isDelete(this.ops[this.ops.length-1])) {
|
||||||
|
this.ops[this.ops.length-1] += n;
|
||||||
|
} else {
|
||||||
|
this.ops.push(n);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tests whether this operation has no effect.
|
||||||
|
TextOperation.prototype.isNoop = function () {
|
||||||
|
return this.ops.length === 0 || (this.ops.length === 1 && isRetain(this.ops[0]));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (isRetain(op)) {
|
||||||
|
return "retain " + op;
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
return "insert '" + op + "'";
|
||||||
|
} else {
|
||||||
|
return "delete " + (-op);
|
||||||
|
}
|
||||||
|
}).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Converts operation into a JSON value.
|
||||||
|
TextOperation.prototype.toJSON = function () {
|
||||||
|
return this.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];
|
||||||
|
if (isRetain(op)) {
|
||||||
|
o.retain(op);
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
o.insert(op);
|
||||||
|
} else if (isDelete(op)) {
|
||||||
|
o['delete'](op);
|
||||||
|
} else {
|
||||||
|
throw new Error("unknown operation: " + JSON.stringify(op));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
var operation = this;
|
||||||
|
if (str.length !== operation.baseLength) {
|
||||||
|
throw new Error("The operation's base length must be equal to the string's length.");
|
||||||
|
}
|
||||||
|
var newStr = [], j = 0;
|
||||||
|
var strIndex = 0;
|
||||||
|
var ops = this.ops;
|
||||||
|
for (var i = 0, l = ops.length; i < l; i++) {
|
||||||
|
var op = ops[i];
|
||||||
|
if (isRetain(op)) {
|
||||||
|
if (strIndex + op > str.length) {
|
||||||
|
throw new Error("Operation can't retain more characters than are left in the string.");
|
||||||
|
}
|
||||||
|
// Copy skipped part of the old string.
|
||||||
|
newStr[j++] = str.slice(strIndex, strIndex + op);
|
||||||
|
strIndex += op;
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
// Insert string.
|
||||||
|
newStr[j++] = op;
|
||||||
|
} else { // delete op
|
||||||
|
strIndex -= op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (strIndex !== str.length) {
|
||||||
|
throw new Error("The operation didn't operate on the whole string.");
|
||||||
|
}
|
||||||
|
return newStr.join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 (isRetain(op)) {
|
||||||
|
inverse.retain(op);
|
||||||
|
strIndex += op;
|
||||||
|
} else if (isInsert(op)) {
|
||||||
|
inverse['delete'](op.length);
|
||||||
|
} else { // delete op
|
||||||
|
inverse.insert(str.slice(strIndex, strIndex - op));
|
||||||
|
strIndex -= op;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
|
||||||
|
var operation = new TextOperation(); // the combined operation
|
||||||
|
var ops1 = operation1.ops, ops2 = operation2.ops; // for fast access
|
||||||
|
var i1 = 0, i2 = 0; // current index into ops1 respectively ops2
|
||||||
|
var op1 = ops1[i1++], op2 = ops2[i2++]; // current ops
|
||||||
|
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 (isDelete(op1)) {
|
||||||
|
operation['delete'](op1);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isInsert(op2)) {
|
||||||
|
operation.insert(op2);
|
||||||
|
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 (isRetain(op1) && isRetain(op2)) {
|
||||||
|
if (op1 > op2) {
|
||||||
|
operation.retain(op2);
|
||||||
|
op1 = op1 - op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === op2) {
|
||||||
|
operation.retain(op1);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
operation.retain(op1);
|
||||||
|
op2 = op2 - op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else if (isInsert(op1) && isDelete(op2)) {
|
||||||
|
if (op1.length > -op2) {
|
||||||
|
op1 = op1.slice(-op2);
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1.length === -op2) {
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
op2 = op2 + op1.length;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else if (isInsert(op1) && isRetain(op2)) {
|
||||||
|
if (op1.length > op2) {
|
||||||
|
operation.insert(op1.slice(0, op2));
|
||||||
|
op1 = op1.slice(op2);
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1.length === op2) {
|
||||||
|
operation.insert(op1);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
operation.insert(op1);
|
||||||
|
op2 = op2 - op1.length;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else if (isRetain(op1) && isDelete(op2)) {
|
||||||
|
if (op1 > -op2) {
|
||||||
|
operation['delete'](op2);
|
||||||
|
op1 = op1 + op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === -op2) {
|
||||||
|
operation['delete'](op2);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
operation['delete'](op1);
|
||||||
|
op2 = op2 + op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
"This shouldn't happen: op1: " +
|
||||||
|
JSON.stringify(op1) + ", op2: " +
|
||||||
|
JSON.stringify(op2)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return operation;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getSimpleOp (operation, fn) {
|
||||||
|
var ops = operation.ops;
|
||||||
|
var isRetain = TextOperation.isRetain;
|
||||||
|
switch (ops.length) {
|
||||||
|
case 1:
|
||||||
|
return ops[0];
|
||||||
|
case 2:
|
||||||
|
return isRetain(ops[0]) ? ops[1] : (isRetain(ops[1]) ? ops[0] : null);
|
||||||
|
case 3:
|
||||||
|
if (isRetain(ops[0]) && isRetain(ops[2])) { return ops[1]; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStartIndex (operation) {
|
||||||
|
if (isRetain(operation.ops[0])) { return operation.ops[0]; }
|
||||||
|
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 (isInsert(simpleA) && isInsert(simpleB)) {
|
||||||
|
return startA + simpleA.length === startB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDelete(simpleA) && isDelete(simpleB)) {
|
||||||
|
// there are two possibilities to delete: with backspace and with the
|
||||||
|
// delete key.
|
||||||
|
return (startB - simpleB === 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 (isInsert(simpleA) && isInsert(simpleB)) {
|
||||||
|
return startA + simpleA.length === startB || startA === startB;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDelete(simpleA) && isDelete(simpleB)) {
|
||||||
|
return startB - simpleB === startA;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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.ops, ops2 = operation2.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 (isInsert(op1)) {
|
||||||
|
operation1prime.insert(op1);
|
||||||
|
operation2prime.retain(op1.length);
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isInsert(op2)) {
|
||||||
|
operation1prime.retain(op2.length);
|
||||||
|
operation2prime.insert(op2);
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var minl;
|
||||||
|
if (isRetain(op1) && isRetain(op2)) {
|
||||||
|
// Simple case: retain/retain
|
||||||
|
if (op1 > op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = op1 - op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
minl = op1;
|
||||||
|
op2 = op2 - op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
operation1prime.retain(minl);
|
||||||
|
operation2prime.retain(minl);
|
||||||
|
} else if (isDelete(op1) && isDelete(op2)) {
|
||||||
|
// 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 > -op2) {
|
||||||
|
op1 = op1 - op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === op2) {
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
op2 = op2 - op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
// next two cases: delete/retain and retain/delete
|
||||||
|
} else if (isDelete(op1) && isRetain(op2)) {
|
||||||
|
if (-op1 > op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = op1 + op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (-op1 === op2) {
|
||||||
|
minl = op2;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
minl = -op1;
|
||||||
|
op2 = op2 + op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
operation1prime['delete'](minl);
|
||||||
|
} else if (isRetain(op1) && isDelete(op2)) {
|
||||||
|
if (op1 > -op2) {
|
||||||
|
minl = -op2;
|
||||||
|
op1 = op1 + op2;
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else if (op1 === -op2) {
|
||||||
|
minl = op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
op2 = ops2[i2++];
|
||||||
|
} else {
|
||||||
|
minl = op1;
|
||||||
|
op2 = op2 + op1;
|
||||||
|
op1 = ops1[i1++];
|
||||||
|
}
|
||||||
|
operation2prime['delete'](minl);
|
||||||
|
} else {
|
||||||
|
throw new Error("The two operations aren't compatible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [operation1prime, operation2prime];
|
||||||
|
};
|
||||||
|
|
||||||
|
return TextOperation;
|
||||||
|
|
||||||
|
}());
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.TextOperation;
|
||||||
|
}
|
111
public/vendor/ot/undo-manager.js
vendored
Executable file
111
public/vendor/ot/undo-manager.js
vendored
Executable file
|
@ -0,0 +1,111 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.UndoManager = (function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var NORMAL_STATE = 'normal';
|
||||||
|
var UNDOING_STATE = 'undoing';
|
||||||
|
var REDOING_STATE = 'redoing';
|
||||||
|
|
||||||
|
// Create a new UndoManager with an optional maximum history size.
|
||||||
|
function UndoManager (maxItems) {
|
||||||
|
this.maxItems = maxItems || 50;
|
||||||
|
this.state = NORMAL_STATE;
|
||||||
|
this.dontCompose = false;
|
||||||
|
this.undoStack = [];
|
||||||
|
this.redoStack = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add an operation to the undo or redo stack, depending on the current state
|
||||||
|
// of the UndoManager. The operation added must be the inverse of the last
|
||||||
|
// edit. When `compose` is true, compose the operation with the last operation
|
||||||
|
// unless the last operation was alread pushed on the redo stack or was hidden
|
||||||
|
// by a newer operation on the undo stack.
|
||||||
|
UndoManager.prototype.add = function (operation, compose) {
|
||||||
|
if (this.state === UNDOING_STATE) {
|
||||||
|
this.redoStack.push(operation);
|
||||||
|
this.dontCompose = true;
|
||||||
|
} else if (this.state === REDOING_STATE) {
|
||||||
|
this.undoStack.push(operation);
|
||||||
|
this.dontCompose = true;
|
||||||
|
} else {
|
||||||
|
var undoStack = this.undoStack;
|
||||||
|
if (!this.dontCompose && compose && undoStack.length > 0) {
|
||||||
|
undoStack.push(operation.compose(undoStack.pop()));
|
||||||
|
} else {
|
||||||
|
undoStack.push(operation);
|
||||||
|
if (undoStack.length > this.maxItems) { undoStack.shift(); }
|
||||||
|
}
|
||||||
|
this.dontCompose = false;
|
||||||
|
this.redoStack = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformStack (stack, operation) {
|
||||||
|
var newStack = [];
|
||||||
|
var Operation = operation.constructor;
|
||||||
|
for (var i = stack.length - 1; i >= 0; i--) {
|
||||||
|
var pair = Operation.transform(stack[i], operation);
|
||||||
|
if (typeof pair[0].isNoop !== 'function' || !pair[0].isNoop()) {
|
||||||
|
newStack.push(pair[0]);
|
||||||
|
}
|
||||||
|
operation = pair[1];
|
||||||
|
}
|
||||||
|
return newStack.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform the undo and redo stacks against a operation by another client.
|
||||||
|
UndoManager.prototype.transform = function (operation) {
|
||||||
|
this.undoStack = transformStack(this.undoStack, operation);
|
||||||
|
this.redoStack = transformStack(this.redoStack, operation);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Perform an undo by calling a function with the latest operation on the undo
|
||||||
|
// stack. The function is expected to call the `add` method with the inverse
|
||||||
|
// of the operation, which pushes the inverse on the redo stack.
|
||||||
|
UndoManager.prototype.performUndo = function (fn) {
|
||||||
|
this.state = UNDOING_STATE;
|
||||||
|
if (this.undoStack.length === 0) { throw new Error("undo not possible"); }
|
||||||
|
fn(this.undoStack.pop());
|
||||||
|
this.state = NORMAL_STATE;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The inverse of `performUndo`.
|
||||||
|
UndoManager.prototype.performRedo = function (fn) {
|
||||||
|
this.state = REDOING_STATE;
|
||||||
|
if (this.redoStack.length === 0) { throw new Error("redo not possible"); }
|
||||||
|
fn(this.redoStack.pop());
|
||||||
|
this.state = NORMAL_STATE;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Is the undo stack not empty?
|
||||||
|
UndoManager.prototype.canUndo = function () {
|
||||||
|
return this.undoStack.length !== 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Is the redo stack not empty?
|
||||||
|
UndoManager.prototype.canRedo = function () {
|
||||||
|
return this.redoStack.length !== 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whether the UndoManager is currently performing an undo.
|
||||||
|
UndoManager.prototype.isUndoing = function () {
|
||||||
|
return this.state === UNDOING_STATE;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Whether the UndoManager is currently performing a redo.
|
||||||
|
UndoManager.prototype.isRedoing = function () {
|
||||||
|
return this.state === REDOING_STATE;
|
||||||
|
};
|
||||||
|
|
||||||
|
return UndoManager;
|
||||||
|
|
||||||
|
}());
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.UndoManager;
|
||||||
|
}
|
80
public/vendor/ot/wrapped-operation.js
vendored
Executable file
80
public/vendor/ot/wrapped-operation.js
vendored
Executable file
|
@ -0,0 +1,80 @@
|
||||||
|
if (typeof ot === 'undefined') {
|
||||||
|
// Export for browsers
|
||||||
|
var ot = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
ot.WrappedOperation = (function (global) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// A WrappedOperation contains an operation and corresponing metadata.
|
||||||
|
function WrappedOperation (operation, meta) {
|
||||||
|
this.wrapped = operation;
|
||||||
|
this.meta = meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedOperation.prototype.apply = function () {
|
||||||
|
return this.wrapped.apply.apply(this.wrapped, arguments);
|
||||||
|
};
|
||||||
|
|
||||||
|
WrappedOperation.prototype.invert = function () {
|
||||||
|
var meta = this.meta;
|
||||||
|
return new WrappedOperation(
|
||||||
|
this.wrapped.invert.apply(this.wrapped, arguments),
|
||||||
|
meta && typeof meta === 'object' && typeof meta.invert === 'function' ?
|
||||||
|
meta.invert.apply(meta, arguments) : meta
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Copy all properties from source to target.
|
||||||
|
function copy (source, target) {
|
||||||
|
for (var key in source) {
|
||||||
|
if (source.hasOwnProperty(key)) {
|
||||||
|
target[key] = source[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeMeta (a, b) {
|
||||||
|
if (a && typeof a === 'object') {
|
||||||
|
if (typeof a.compose === 'function') { return a.compose(b); }
|
||||||
|
var meta = {};
|
||||||
|
copy(a, meta);
|
||||||
|
copy(b, meta);
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedOperation.prototype.compose = function (other) {
|
||||||
|
return new WrappedOperation(
|
||||||
|
this.wrapped.compose(other.wrapped),
|
||||||
|
composeMeta(this.meta, other.meta)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function transformMeta (meta, operation) {
|
||||||
|
if (meta && typeof meta === 'object') {
|
||||||
|
if (typeof meta.transform === 'function') {
|
||||||
|
return meta.transform(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
WrappedOperation.transform = function (a, b) {
|
||||||
|
var transform = a.wrapped.constructor.transform;
|
||||||
|
var pair = transform(a.wrapped, b.wrapped);
|
||||||
|
return [
|
||||||
|
new WrappedOperation(pair[0], transformMeta(a.meta, b.wrapped)),
|
||||||
|
new WrappedOperation(pair[1], transformMeta(b.meta, a.wrapped))
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
return WrappedOperation;
|
||||||
|
|
||||||
|
}(this));
|
||||||
|
|
||||||
|
// Export for CommonJS
|
||||||
|
if (typeof module === 'object') {
|
||||||
|
module.exports = ot.WrappedOperation;
|
||||||
|
}
|
|
@ -58,7 +58,7 @@
|
||||||
<h4 class="modal-title" id="myModalLabel">This page need refresh</h4>
|
<h4 class="modal-title" id="myModalLabel">This page need refresh</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<h5>This page have a mismatch client version or incorrect user state.</h5>
|
<h5>This page have a mismatch client version or incorrect user state or errors.</h5>
|
||||||
<strong>Please refresh this page.</strong>
|
<strong>Please refresh this page.</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
<script src="/vendor/codemirror/codemirror.min.js" defer></script>
|
<script src="/vendor/codemirror/codemirror.min.js" defer></script>
|
||||||
<script src="/vendor/inlineAttachment/inline-attachment.js" defer></script>
|
<script src="/vendor/inlineAttachment/inline-attachment.js" defer></script>
|
||||||
<script src="/vendor/inlineAttachment/codemirror.inline-attachment.js" defer></script>
|
<script src="/vendor/inlineAttachment/codemirror.inline-attachment.js" defer></script>
|
||||||
|
<!--ot-->
|
||||||
|
<script src="/vendor/ot/ot.min.js" defer></script>
|
||||||
<!--others-->
|
<!--others-->
|
||||||
<script src="/vendor/socket.io-1.3.5.js" defer></script>
|
<script src="/vendor/socket.io-1.3.5.js" defer></script>
|
||||||
<script src="/vendor/remarkable.min.js" defer></script>
|
<script src="/vendor/remarkable.min.js" defer></script>
|
||||||
|
|
Loading…
Reference in a new issue