and so it begins
commit
1508c7ba71
@ -0,0 +1,2 @@
|
|||||||
|
www/bower/*
|
||||||
|
node_modules
|
@ -0,0 +1,189 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2014 XWiki SAS
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
var WebSocket = require('ws');
|
||||||
|
|
||||||
|
var REGISTER = 0;
|
||||||
|
var REGISTER_ACK = 1;
|
||||||
|
var PATCH = 2;
|
||||||
|
var DISCONNECT = 3;
|
||||||
|
var PING = 4;
|
||||||
|
var PONG = 5;
|
||||||
|
|
||||||
|
var parseMessage = function (msg) {
|
||||||
|
var passLen = msg.substring(0,msg.indexOf(':'));
|
||||||
|
msg = msg.substring(passLen.length+1);
|
||||||
|
var pass = msg.substring(0,Number(passLen));
|
||||||
|
msg = msg.substring(pass.length);
|
||||||
|
|
||||||
|
var unameLen = msg.substring(0,msg.indexOf(':'));
|
||||||
|
msg = msg.substring(unameLen.length+1);
|
||||||
|
var userName = msg.substring(0,Number(unameLen));
|
||||||
|
msg = msg.substring(userName.length);
|
||||||
|
|
||||||
|
var channelIdLen = msg.substring(0,msg.indexOf(':'));
|
||||||
|
msg = msg.substring(channelIdLen.length+1);
|
||||||
|
var channelId = msg.substring(0,Number(channelIdLen));
|
||||||
|
msg = msg.substring(channelId.length);
|
||||||
|
|
||||||
|
var contentStrLen = msg.substring(0,msg.indexOf(':'));
|
||||||
|
msg = msg.substring(contentStrLen.length+1);
|
||||||
|
var contentStr = msg.substring(0,Number(contentStrLen));
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: userName,
|
||||||
|
pass: pass,
|
||||||
|
channelId: channelId,
|
||||||
|
content: JSON.parse(contentStr)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// get the password off the message before sending it to other clients.
|
||||||
|
var popPassword = function (msg) {
|
||||||
|
var passLen = msg.substring(0,msg.indexOf(':'));
|
||||||
|
return msg.substring(passLen.length+1 + Number(passLen));
|
||||||
|
};
|
||||||
|
|
||||||
|
var sendMsg = function (msg, socket) {
|
||||||
|
socket.send(msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
var sendChannelMessage = function (ctx, channel, msg, cb) {
|
||||||
|
ctx.store.message(channel.name, msg, function () {
|
||||||
|
channel.forEach(function (user) {
|
||||||
|
try {
|
||||||
|
sendMsg(msg, user.socket);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.stack);
|
||||||
|
dropClient(ctx, userPass);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cb && cb();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var mkMessage = function (user, channel, content) {
|
||||||
|
content = JSON.stringify(content);
|
||||||
|
return user.length + ':' + user +
|
||||||
|
channel.length + ':' + channel +
|
||||||
|
content.length + ':' + content;
|
||||||
|
};
|
||||||
|
|
||||||
|
var dropClient = function (ctx, userpass) {
|
||||||
|
var client = ctx.registeredClients[userpass];
|
||||||
|
if (client.socket.readyState !== WebSocket.CLOSING
|
||||||
|
&& client.socket.readyState !== WebSocket.CLOSED)
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
client.socket.close();
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Failed to disconnect ["+client.userName+"], attempting to terminate");
|
||||||
|
try {
|
||||||
|
client.socket.terminate();
|
||||||
|
} catch (ee) {
|
||||||
|
console.log("Failed to terminate ["+client.userName+"] *shrug*");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var i = 0; i < client.channels.length; i++) {
|
||||||
|
var chanName = client.channels[i];
|
||||||
|
var chan = ctx.channels[chanName];
|
||||||
|
var idx = chan.indexOf(client);
|
||||||
|
if (idx < 0) { throw new Error(); }
|
||||||
|
console.log("Removing ["+client.userName+"] from channel ["+chanName+"]");
|
||||||
|
chan.splice(idx, 1);
|
||||||
|
if (chan.length === 0) {
|
||||||
|
console.log("Removing empty channel ["+chanName+"]");
|
||||||
|
delete ctx.channels[chanName];
|
||||||
|
} else {
|
||||||
|
sendChannelMessage(ctx, chan, mkMessage(client.userName, chanName, [DISCONNECT,0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete ctx.registeredClients[userpass];
|
||||||
|
};
|
||||||
|
|
||||||
|
var handleMessage = function (ctx, socket, msg) {
|
||||||
|
var parsed = parseMessage(msg);
|
||||||
|
var userPass = parsed.user + ':' + parsed.pass;
|
||||||
|
msg = popPassword(msg);
|
||||||
|
|
||||||
|
if (parsed.content[0] === REGISTER) {
|
||||||
|
console.log("[" + userPass + "] registered");
|
||||||
|
var client = ctx.registeredClients[userPass] = ctx.registeredClients[userPass] || {
|
||||||
|
channels: [parsed.channelId],
|
||||||
|
userName: parsed.user
|
||||||
|
};
|
||||||
|
if (client.socket && client.socket !== socket) { client.socket.close(); }
|
||||||
|
client.socket = socket;
|
||||||
|
|
||||||
|
var chan = ctx.channels[parsed.channelId] = ctx.channels[parsed.channelId] || [];
|
||||||
|
chan.name = parsed.channelId;
|
||||||
|
chan.push(client);
|
||||||
|
|
||||||
|
// we send a register ack right away but then we fallthrough
|
||||||
|
// to let other users know that we were registered.
|
||||||
|
sendMsg(mkMessage('', parsed.channelId, [1,0]), socket);
|
||||||
|
sendChannelMessage(ctx, chan, msg, function () {
|
||||||
|
ctx.store.getMessages(chan.name, function (msg) {
|
||||||
|
sendMsg(msg, socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.content[0] === PING) {
|
||||||
|
// 31:xwiki:XWiki.Admin-141475016907510:RWJ5xF2+SL17:[5,1414752676547]
|
||||||
|
// 1:y31:xwiki:XWiki.Admin-141475016907510:RWJ5xF2+SL17:[4,1414752676547]
|
||||||
|
sendMsg(mkMessage(parsed.user, parsed.channelId, [ PONG, parsed.content[1] ]), socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = ctx.registeredClients[userPass];
|
||||||
|
if (typeof(client) === 'undefined') { throw new Error('unregistered'); }
|
||||||
|
|
||||||
|
var channel = ctx.channels[parsed.channelId];
|
||||||
|
if (typeof(channel) === 'undefined') { throw new Error('no such channel'); }
|
||||||
|
|
||||||
|
if (channel.indexOf(client) === -1) { throw new Error('client not in channel'); }
|
||||||
|
|
||||||
|
sendChannelMessage(ctx, channel, msg);
|
||||||
|
};
|
||||||
|
|
||||||
|
var create = module.exports.create = function (socketServer, store) {
|
||||||
|
var ctx = {
|
||||||
|
registeredClients: {},
|
||||||
|
channels: {},
|
||||||
|
store: store
|
||||||
|
};
|
||||||
|
|
||||||
|
socketServer.on('connection', function(socket) {
|
||||||
|
socket.on('message', function(message) {
|
||||||
|
try {
|
||||||
|
handleMessage(ctx, socket, message);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e.stack);
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('close', function (evt) {
|
||||||
|
for (client in ctx.registeredClients) {
|
||||||
|
if (ctx.registeredClients[client].socket === socket) {
|
||||||
|
dropClient(ctx, client);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2014 XWiki SAS
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
var MongoClient = require('mongodb').MongoClient;
|
||||||
|
|
||||||
|
var MONGO_URI = "mongodb://demo_user:demo_password@ds027769.mongolab.com:27769/demo_database";
|
||||||
|
var COLLECTION_NAME = 'cryptpad';
|
||||||
|
|
||||||
|
var insert = function (coll, channelName, content, cb) {
|
||||||
|
var val = {chan: channelName, msg:content, time: (new Date()).getTime()};
|
||||||
|
coll.insertOne(val, {}, function (err, r) {
|
||||||
|
if (err || (r.insertedCount !== 1)) {
|
||||||
|
console.log('failed to insert ' + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var getMessages = function (coll, channelName, cb) {
|
||||||
|
coll.find({chan:channelName}).forEach(function (doc) {
|
||||||
|
cb(doc.msg);
|
||||||
|
}, function (err) {
|
||||||
|
if (!err) { return; }
|
||||||
|
console.log('error ' + err);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports.create = function (conf, cb) {
|
||||||
|
MongoClient.connect(conf.mongoUri, function(err, db) {
|
||||||
|
var coll = db.collection(conf.mongoCollectionName);
|
||||||
|
if (err) { throw err; }
|
||||||
|
cb({
|
||||||
|
message: function (channelName, content, cb) {
|
||||||
|
insert(coll, channelName, content, cb);
|
||||||
|
},
|
||||||
|
getMessages: function (channelName, msgHandler) {
|
||||||
|
getMessages(coll, channelName, msgHandler);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
Binary file not shown.
After Width: | Height: | Size: 58 KiB |
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "cryptpad",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"authors": [
|
||||||
|
"Caleb James DeLisle <cjd@cjdns.fr>"
|
||||||
|
],
|
||||||
|
"description": "realtime collaborative visual editor with zero knowlege server",
|
||||||
|
"main": "www/index.html",
|
||||||
|
"moduleType": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"license": "AGPLv3",
|
||||||
|
"ignore": [
|
||||||
|
"**/.*",
|
||||||
|
"node_modules",
|
||||||
|
"bower_components",
|
||||||
|
"test",
|
||||||
|
"tests"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"jquery": "~2.1.1",
|
||||||
|
"tweetnacl": "~0.12.2",
|
||||||
|
"ckeditor": "~4.4.5",
|
||||||
|
"requirejs": "~2.1.15"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "cryptpad",
|
||||||
|
"description": "realtime collaborative visual editor with zero knowlege server",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "~4.10.1",
|
||||||
|
"ws": "~0.4.32",
|
||||||
|
"mongodb": "~2.0.5"
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
# CryptPad
|
||||||
|
|
||||||
|
Unity is Strength - Collaboration is Key
|
||||||
|
|
||||||
|
![and_so_it_begins.png](https://github.com/cjdelisle/cryptpad/raw/master/and_so_it_begins.png "We are the 99%")
|
||||||
|
|
||||||
|
CryptPad is a **zero knowledge** realtime collaborative editor.
|
||||||
|
Encryption carried out in your web browser protects the data from the server, the cloud
|
||||||
|
and the NSA. This project uses the [CKEdit] Visual Editor and the [ChainPad] realtime
|
||||||
|
engine. The secret key is stored in the URL [fragment identifier] which is never sent to
|
||||||
|
the server but is available to javascript so by sharing the URL, you give authorization
|
||||||
|
|
||||||
|
|
||||||
|
Realtime Collaboration with
|
||||||
|
|
||||||
|
|
||||||
|
[fragment identifier]: http://en.wikipedia.org/wiki/Fragment_identifier
|
@ -0,0 +1,24 @@
|
|||||||
|
var Express = require('express');
|
||||||
|
var Http = require('http');
|
||||||
|
var WebSocketServer = require('ws').Server;
|
||||||
|
var ChainPadSrv = require('./ChainPadSrv');
|
||||||
|
var Storage = require('./Storage');
|
||||||
|
|
||||||
|
var config = {
|
||||||
|
httpPort: 3000,
|
||||||
|
mongoUri: "mongodb://demo_user:demo_password@ds027769.mongolab.com:27769/demo_database",
|
||||||
|
mongoCollectionName: 'cryptpad'
|
||||||
|
};
|
||||||
|
|
||||||
|
var app = Express();
|
||||||
|
app.use(Express.static(__dirname + '/www'));
|
||||||
|
|
||||||
|
var httpServer = Http.createServer(app);
|
||||||
|
httpServer.listen(config.httpPort);
|
||||||
|
console.log('listening on port ' + config.httpPort);
|
||||||
|
|
||||||
|
var wsSrv = new WebSocketServer({server: httpServer});
|
||||||
|
Storage.create(config, function (store) {
|
||||||
|
console.log('DB connected');
|
||||||
|
ChainPadSrv.create(wsSrv, store);
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,483 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2014 XWiki SAS
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
define([
|
||||||
|
'bower/jquery/dist/jquery.min',
|
||||||
|
'otaml'
|
||||||
|
], function () {
|
||||||
|
|
||||||
|
var $ = jQuery;
|
||||||
|
var Otaml = window.Otaml;
|
||||||
|
var module = { exports: {} };
|
||||||
|
var PARANOIA = true;
|
||||||
|
|
||||||
|
var debug = function (x) { };
|
||||||
|
debug = function (x) { console.log(x); };
|
||||||
|
|
||||||
|
var getNextSiblingDeep = function (node, parent)
|
||||||
|
{
|
||||||
|
if (node.firstChild) { return node.firstChild; }
|
||||||
|
do {
|
||||||
|
if (node.nextSibling) { return node.nextSibling; }
|
||||||
|
node = node.parentNode;
|
||||||
|
} while (node && node !== parent);
|
||||||
|
};
|
||||||
|
|
||||||
|
var getOuterHTML = function (node)
|
||||||
|
{
|
||||||
|
var html = node.outerHTML;
|
||||||
|
if (html) { return html; }
|
||||||
|
if (node.parentNode && node.parentNode.childNodes.length === 1) {
|
||||||
|
return node.parentNode.innerHTML;
|
||||||
|
}
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.appendChild(node.cloneNode(true));
|
||||||
|
return div.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
var nodeFromHTML = function (html)
|
||||||
|
{
|
||||||
|
var e = document.createElement('div');
|
||||||
|
e.innerHTML = html;
|
||||||
|
return e.childNodes[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
var getInnerHTML = function (node)
|
||||||
|
{
|
||||||
|
var html = node.innerHTML;
|
||||||
|
if (html) { return html; }
|
||||||
|
var outerHTML = getOuterHTML(node);
|
||||||
|
var tw = Otaml.tagWidth(outerHTML);
|
||||||
|
if (!tw) { return outerHTML; }
|
||||||
|
return outerHTML.substring(tw, outerHTML.lastIndexOf('</'));
|
||||||
|
};
|
||||||
|
|
||||||
|
var uniqueId = function () { return 'uid-'+(''+Math.random()).slice(2); };
|
||||||
|
|
||||||
|
var offsetOfNodeOuterHTML = function (docText, node, dom, ifrWindow)
|
||||||
|
{
|
||||||
|
if (PARANOIA && getInnerHTML(dom) !== docText) { throw new Error(); }
|
||||||
|
if (PARANOIA && !node) { throw new Error(); }
|
||||||
|
|
||||||
|
// can't get the index of the outerHTML of the dom in a string with only the innerHTML.
|
||||||
|
if (node === dom) { throw new Error(); }
|
||||||
|
|
||||||
|
var content = getOuterHTML(node);
|
||||||
|
var idx = docText.lastIndexOf(content);
|
||||||
|
if (idx === -1) { throw new Error(); }
|
||||||
|
|
||||||
|
if (idx !== docText.indexOf(content)) {
|
||||||
|
var idTag = uniqueId();
|
||||||
|
var span = ifrWindow.document.createElement('span');
|
||||||
|
span.setAttribute('id', idTag);
|
||||||
|
var spanHTML = '<span id="'+idTag+'"></span>';
|
||||||
|
if (PARANOIA && spanHTML !== span.outerHTML) { throw new Error(); }
|
||||||
|
|
||||||
|
node.parentNode.insertBefore(span, node);
|
||||||
|
var newDocText = getInnerHTML(dom);
|
||||||
|
idx = newDocText.lastIndexOf(spanHTML);
|
||||||
|
if (idx === -1 || idx !== newDocText.indexOf(spanHTML)) { throw new Error(); }
|
||||||
|
node.parentNode.removeChild(span);
|
||||||
|
|
||||||
|
if (PARANOIA && getInnerHTML(dom) !== docText) { throw new Error(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PARANOIA && docText.indexOf(content, idx) !== idx) { throw new Error() }
|
||||||
|
return idx;
|
||||||
|
};
|
||||||
|
|
||||||
|
var patchString = module.exports.patchString = function (oldString, offset, toRemove, toInsert)
|
||||||
|
{
|
||||||
|
return oldString.substring(0, offset) + toInsert + oldString.substring(offset + toRemove);
|
||||||
|
};
|
||||||
|
|
||||||
|
var getNodeAtOffset = function (docText, offset, dom)
|
||||||
|
{
|
||||||
|
if (PARANOIA && dom.childNodes.length && docText !== dom.innerHTML) { throw new Error(); }
|
||||||
|
if (offset < 0) { throw new Error(); }
|
||||||
|
|
||||||
|
var idx = 0;
|
||||||
|
for (var i = 0; i < dom.childNodes.length; i++) {
|
||||||
|
var childOuterHTML = getOuterHTML(dom.childNodes[i]);
|
||||||
|
if (PARANOIA && docText.indexOf(childOuterHTML, idx) !== idx) { throw new Error(); }
|
||||||
|
if (i === 0 && idx >= offset) {
|
||||||
|
return { node: dom, pos: 0 };
|
||||||
|
}
|
||||||
|
if (idx + childOuterHTML.length > offset) {
|
||||||
|
var childInnerHTML = childOuterHTML;
|
||||||
|
var tw = Otaml.tagWidth(childOuterHTML);
|
||||||
|
if (tw) {
|
||||||
|
childInnerHTML = childOuterHTML.substring(tw, childOuterHTML.lastIndexOf('</'));
|
||||||
|
}
|
||||||
|
if (offset - idx - tw < 0) {
|
||||||
|
if (offset - idx === 0) {
|
||||||
|
return { node: dom.childNodes[i], pos: 0 };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return getNodeAtOffset(childInnerHTML, offset - idx - tw, dom.childNodes[i]);
|
||||||
|
}
|
||||||
|
idx += childOuterHTML.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dom.nodeName[0] === '#text') {
|
||||||
|
if (offset > docText.length) { throw new Error(); }
|
||||||
|
var beforeOffset = docText.substring(0, offset);
|
||||||
|
if (beforeOffset.indexOf('&') > -1) {
|
||||||
|
var tn = nodeFromHTML(beforeOffset);
|
||||||
|
offset = tn.data.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { node: dom, pos: offset };
|
||||||
|
};
|
||||||
|
|
||||||
|
var relocatedPositionInNode = function (newNode, oldNode, offset)
|
||||||
|
{
|
||||||
|
if (newNode.nodeName !== '#text' || oldNode.nodeName !== '#text' || offset === 0) {
|
||||||
|
offset = 0;
|
||||||
|
} else if (oldNode.data === newNode.data) {
|
||||||
|
// fallthrough
|
||||||
|
} else if (offset > newNode.length) {
|
||||||
|
offset = newNode.length;
|
||||||
|
} else if (oldNode.data.substring(0, offset) === newNode.data.substring(0, offset)) {
|
||||||
|
// keep same offset and fall through
|
||||||
|
} else {
|
||||||
|
var rOffset = oldNode.length - offset;
|
||||||
|
if (oldNode.data.substring(offset) ===
|
||||||
|
newNode.data.substring(newNode.length - rOffset))
|
||||||
|
{
|
||||||
|
offset = newNode.length - rOffset;
|
||||||
|
} else {
|
||||||
|
offset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { node: newNode, pos: offset };
|
||||||
|
};
|
||||||
|
|
||||||
|
var pushNode = function (list, node) {
|
||||||
|
if (node.nodeName === '#text') {
|
||||||
|
list.push.apply(list, node.data.split(''));
|
||||||
|
} else {
|
||||||
|
list.push('#' + node.nodeName);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var getChildPath = function (parent) {
|
||||||
|
var out = [];
|
||||||
|
for (var next = parent; next; next = getNextSiblingDeep(next, parent)) {
|
||||||
|
pushNode(out, next);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
var tryFromBeginning = function (oldPath, newPath) {
|
||||||
|
for (var i = 0; i < oldPath.length; i++) {
|
||||||
|
if (oldPath[i] !== newPath[i]) { return i; }
|
||||||
|
}
|
||||||
|
return oldPath.length;
|
||||||
|
};
|
||||||
|
|
||||||
|
var tryFromEnd = function (oldPath, newPath) {
|
||||||
|
for (var i = 1; i <= oldPath.length; i++) {
|
||||||
|
if (oldPath[oldPath.length - i] !== newPath[newPath.length - i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns 2 arrays (before and after).
|
||||||
|
* before is string representations (see nodeId()) of all nodes before the target
|
||||||
|
* node and after is representations of all nodes which follow.
|
||||||
|
*/
|
||||||
|
var getNodePaths = function (parent, node) {
|
||||||
|
var before = [];
|
||||||
|
var next = parent;
|
||||||
|
for (; next && next !== node; next = getNextSiblingDeep(next, parent)) {
|
||||||
|
pushNode(before, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (next !== node) { throw new Error(); }
|
||||||
|
|
||||||
|
var after = [];
|
||||||
|
next = getNextSiblingDeep(next, parent);
|
||||||
|
for (; next; next = getNextSiblingDeep(next, parent)) {
|
||||||
|
pushNode(after, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { before: before, after: after };
|
||||||
|
};
|
||||||
|
|
||||||
|
var nodeAtIndex = function (parent, idx) {
|
||||||
|
var node = parent;
|
||||||
|
for (var i = 0; i < idx; i++) {
|
||||||
|
if (node.nodeName === '#text') {
|
||||||
|
if (i + node.data.length > idx) { return node; }
|
||||||
|
i += node.data.length - 1;
|
||||||
|
}
|
||||||
|
node = getNextSiblingDeep(node);
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getRelocatedPosition = function (newParent, oldParent, oldNode, oldOffset, origText, op)
|
||||||
|
{
|
||||||
|
var newPath = getChildPath(newParent);
|
||||||
|
if (newPath.length === 1) {
|
||||||
|
return { node: null, pos: 0 };
|
||||||
|
}
|
||||||
|
var oldPaths = getNodePaths(oldParent, oldNode);
|
||||||
|
|
||||||
|
var idx = -1;
|
||||||
|
var fromBeginning = tryFromBeginning(oldPaths.before, newPath);
|
||||||
|
if (fromBeginning === oldPaths.before.length) {
|
||||||
|
idx = oldPaths.before.length;
|
||||||
|
} else if (tryFromEnd(oldPaths.after, newPath)) {
|
||||||
|
idx = (newPath.length - oldPaths.after.length - 1);
|
||||||
|
} else {
|
||||||
|
idx = fromBeginning;
|
||||||
|
var id = 'relocate-' + String(Math.random()).substring(2);
|
||||||
|
$(document.body).append('<textarea id="'+id+'"></textarea>');
|
||||||
|
$('#'+id).val(JSON.stringify([origText, op, newPath, getChildPath(oldParent), oldPaths]));
|
||||||
|
}
|
||||||
|
|
||||||
|
var out = nodeAtIndex(newParent, idx);
|
||||||
|
return relocatedPositionInNode(out, oldNode, oldOffset);
|
||||||
|
};
|
||||||
|
|
||||||
|
// We can't create a real range until the new parent is installed in the document
|
||||||
|
// but we need the old range to be in the document so we can do comparisons
|
||||||
|
// so create a "pseudo" range instead.
|
||||||
|
var getRelocatedPseudoRange = function (newParent, oldParent, range, origText, op)
|
||||||
|
{
|
||||||
|
if (!range.startContainer) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
if (!newParent) { throw new Error(); }
|
||||||
|
|
||||||
|
// Copy because tinkering in the dom messes up the original range.
|
||||||
|
var startContainer = range.startContainer;
|
||||||
|
var startOffset = range.startOffset;
|
||||||
|
var endContainer = range.endContainer;
|
||||||
|
var endOffset = range.endOffset;
|
||||||
|
|
||||||
|
var newStart =
|
||||||
|
getRelocatedPosition(newParent, oldParent, startContainer, startOffset, origText, op);
|
||||||
|
|
||||||
|
if (!newStart.node) {
|
||||||
|
// there is probably nothing left of the document so just clear the selection.
|
||||||
|
endContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newEnd = { node: newStart.node, pos: newStart.pos };
|
||||||
|
if (endContainer) {
|
||||||
|
if (endContainer !== startContainer) {
|
||||||
|
newEnd = getRelocatedPosition(newParent, oldParent, endContainer, endOffset, origText, op);
|
||||||
|
} else if (endOffset !== startOffset) {
|
||||||
|
newEnd = {
|
||||||
|
node: newStart.node,
|
||||||
|
pos: relocatedPositionInNode(newStart.node, endContainer, endOffset).pos
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newEnd = { node: newStart.node, pos: newStart.pos };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start: newStart, end: newEnd };
|
||||||
|
};
|
||||||
|
|
||||||
|
var replaceAllChildren = function (parent, newParent)
|
||||||
|
{
|
||||||
|
var c;
|
||||||
|
while ((c = parent.firstChild)) {
|
||||||
|
parent.removeChild(c);
|
||||||
|
}
|
||||||
|
while ((c = newParent.firstChild)) {
|
||||||
|
newParent.removeChild(c);
|
||||||
|
parent.appendChild(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var isAncestorOf = function (maybeDecendent, maybeAncestor) {
|
||||||
|
while ((maybeDecendent = maybeDecendent.parentNode)) {
|
||||||
|
if (maybeDecendent === maybeAncestor) { return true; }
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
var getSelectedRange = function (rangy, ifrWindow, selection) {
|
||||||
|
selection = selection || rangy.getSelection(ifrWindow);
|
||||||
|
if (selection.rangeCount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var range = selection.getRangeAt(0);
|
||||||
|
range.backward = (selection.rangeCount === 1 && selection.isBackward());
|
||||||
|
if (!range.startContainer) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occasionally, some browsers *cough* firefox *cough* will attach the range to something
|
||||||
|
// which has been used in the past but is nolonger part of the dom...
|
||||||
|
if (range.startContainer &&
|
||||||
|
isAncestorOf(range.startContainer, ifrWindow.document))
|
||||||
|
{
|
||||||
|
return range;
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
var applyHTMLOp = function (docText, op, dom, rangy, ifrWindow)
|
||||||
|
{
|
||||||
|
var parent = getNodeAtOffset(docText, op.offset, dom).node;
|
||||||
|
var htmlToRemove = docText.substring(op.offset, op.offset + op.toRemove);
|
||||||
|
|
||||||
|
var parentInnerHTML;
|
||||||
|
var indexOfInnerHTML;
|
||||||
|
var localOffset;
|
||||||
|
for (;;) {
|
||||||
|
for (;;) {
|
||||||
|
parentInnerHTML = parent.innerHTML;
|
||||||
|
if (typeof(parentInnerHTML) !== 'undefined'
|
||||||
|
&& parentInnerHTML.indexOf(htmlToRemove) !== -1)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (parent === dom || !(parent = parent.parentNode)) { throw new Error(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexOfOuterHTML = 0;
|
||||||
|
var tw = 0;
|
||||||
|
if (parent !== dom) {
|
||||||
|
indexOfOuterHTML = offsetOfNodeOuterHTML(docText, parent, dom, ifrWindow);
|
||||||
|
tw = Otaml.tagWidth(docText.substring(indexOfOuterHTML));
|
||||||
|
}
|
||||||
|
indexOfInnerHTML = indexOfOuterHTML + tw;
|
||||||
|
|
||||||
|
localOffset = op.offset - indexOfInnerHTML;
|
||||||
|
|
||||||
|
if (localOffset >= 0 && localOffset + op.toRemove <= parentInnerHTML.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
parent = parent.parentNode;
|
||||||
|
if (!parent) { throw new Error(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PARANOIA &&
|
||||||
|
docText.substr(indexOfInnerHTML, parentInnerHTML.length) !== parentInnerHTML)
|
||||||
|
{
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
var newParentInnerHTML =
|
||||||
|
patchString(parentInnerHTML, localOffset, op.toRemove, op.toInsert);
|
||||||
|
|
||||||
|
// Create a temp container for holding the children of the parent node.
|
||||||
|
// Once we've identified the new range, we'll return the nodes to the
|
||||||
|
// original parent. This is because parent might be the <body> and we
|
||||||
|
// don't want to destroy all of our event listeners.
|
||||||
|
var babysitter = ifrWindow.document.createElement('div');
|
||||||
|
// give it a uid so that we can prove later that it's not in the document,
|
||||||
|
// see getSelectedRange()
|
||||||
|
babysitter.setAttribute('id', uniqueId());
|
||||||
|
babysitter.innerHTML = newParentInnerHTML;
|
||||||
|
|
||||||
|
var range = getSelectedRange(rangy, ifrWindow);
|
||||||
|
|
||||||
|
// doesn't intersect at all
|
||||||
|
if (!range || !range.containsNode(parent, true)) {
|
||||||
|
replaceAllChildren(parent, babysitter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pseudoRange = getRelocatedPseudoRange(babysitter, parent, range, rangy);
|
||||||
|
range.detach();
|
||||||
|
replaceAllChildren(parent, babysitter);
|
||||||
|
if (pseudoRange.start.node) {
|
||||||
|
var selection = rangy.getSelection(ifrWindow);
|
||||||
|
var newRange = rangy.createRange();
|
||||||
|
newRange.setStart(pseudoRange.start.node, pseudoRange.start.pos);
|
||||||
|
newRange.setEnd(pseudoRange.end.node, pseudoRange.end.pos);
|
||||||
|
selection.setSingleRange(newRange);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
var applyHTMLOpHammer = function (docText, op, dom, rangy, ifrWindow)
|
||||||
|
{
|
||||||
|
var newDocText = patchString(docText, op.offset, op.toRemove, op.toInsert);
|
||||||
|
var babysitter = ifrWindow.document.createElement('body');
|
||||||
|
// give it a uid so that we can prove later that it's not in the document,
|
||||||
|
// see getSelectedRange()
|
||||||
|
babysitter.setAttribute('id', uniqueId());
|
||||||
|
babysitter.innerHTML = newDocText;
|
||||||
|
|
||||||
|
var range = getSelectedRange(rangy, ifrWindow);
|
||||||
|
|
||||||
|
// doesn't intersect at all
|
||||||
|
if (!range) {
|
||||||
|
replaceAllChildren(dom, babysitter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pseudoRange = getRelocatedPseudoRange(babysitter, dom, range, docText, op);
|
||||||
|
range.detach();
|
||||||
|
replaceAllChildren(dom, babysitter);
|
||||||
|
if (pseudoRange.start.node) {
|
||||||
|
var selection = rangy.getSelection(ifrWindow);
|
||||||
|
var newRange = rangy.createRange();
|
||||||
|
newRange.setStart(pseudoRange.start.node, pseudoRange.start.pos);
|
||||||
|
newRange.setEnd(pseudoRange.end.node, pseudoRange.end.pos);
|
||||||
|
selection.setSingleRange(newRange);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Return whether the selection range has been "dirtied" and needs to be reloaded. */
|
||||||
|
var applyOp = module.exports.applyOp = function (docText, op, dom, rangy, ifrWindow)
|
||||||
|
{
|
||||||
|
if (PARANOIA && docText !== getInnerHTML(dom)) { throw new Error(); }
|
||||||
|
|
||||||
|
if (op.offset + op.toRemove > docText.length) {
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
applyHTMLOp(docText, op, dom, rangy, ifrWindow);
|
||||||
|
var result = patchString(docText, op.offset, op.toRemove, op.toInsert);
|
||||||
|
var innerHTML = getInnerHTML(dom);
|
||||||
|
if (result !== innerHTML) {
|
||||||
|
$(document.body).append('<textarea id="statebox"></textarea>');
|
||||||
|
$(document.body).append('<textarea id="errorbox"></textarea>');
|
||||||
|
var SEP = '\n\n\n\n\n\n\n\n\n\n';
|
||||||
|
$('#statebox').val(docText + SEP + result + SEP + innerHTML);
|
||||||
|
var diff = Otaml.makeTextOperation(result, innerHTML);
|
||||||
|
$('#errorbox').val(JSON.stringify(op) + '\n' + JSON.stringify(diff));
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (PARANOIA) { console.log(err.stack); }
|
||||||
|
// The big hammer
|
||||||
|
dom.innerHTML = patchString(docText, op.offset, op.toRemove, op.toInsert);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return module.exports;
|
||||||
|
});
|
@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<!--<title>Sample - CKEditor</title>-->
|
||||||
|
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
|
||||||
|
<script data-main="main" src="bower/requirejs/require.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form action="#" method="post">
|
||||||
|
<textarea cols="80" id="editor1" name="editor1" rows="10">
|
||||||
|
Loading... (or maybe you have Javascript disabled?)
|
||||||
|
</textarea>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,52 @@
|
|||||||
|
define([
|
||||||
|
'realtime-wysiwyg',
|
||||||
|
'bower/jquery/dist/jquery.min',
|
||||||
|
'bower/ckeditor/ckeditor',
|
||||||
|
'bower/tweetnacl/nacl-fast.min'
|
||||||
|
], function (RTWysiwyg) {
|
||||||
|
var Ckeditor = window.CKEDITOR;
|
||||||
|
var Nacl = window.nacl;
|
||||||
|
var $ = jQuery;
|
||||||
|
|
||||||
|
var module = { exports: {} };
|
||||||
|
|
||||||
|
var parseKey = function (str) {
|
||||||
|
var array = Nacl.util.decodeBase64(str);
|
||||||
|
var hash = Nacl.hash(array);
|
||||||
|
return { lookupKey: hash.subarray(32), cryptKey: hash.subarray(0,32) };
|
||||||
|
};
|
||||||
|
|
||||||
|
var genKey = function () {
|
||||||
|
return Nacl.util.encodeBase64(Nacl.randomBytes(18));
|
||||||
|
};
|
||||||
|
|
||||||
|
var userName = function () {
|
||||||
|
return Nacl.util.encodeBase64(Nacl.randomBytes(8));
|
||||||
|
};
|
||||||
|
|
||||||
|
$(function () {
|
||||||
|
if (window.location.href.indexOf('#') === -1) {
|
||||||
|
window.location.href = window.location.href + '#' + genKey();
|
||||||
|
}
|
||||||
|
$(window).on('hashchange', function() {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
var key = parseKey(window.location.hash.substring(1));
|
||||||
|
var editor = Ckeditor.replace('editor1', {
|
||||||
|
removeButtons: 'Source,Maximize',
|
||||||
|
});
|
||||||
|
editor.on('instanceReady', function () {
|
||||||
|
//editor.execCommand('maximize');
|
||||||
|
var ifr = window.ifr = $('iframe')[0];
|
||||||
|
ifr.contentDocument.body.innerHTML = '<p>It works!</p>';
|
||||||
|
|
||||||
|
var rtw =
|
||||||
|
RTWysiwyg.start(window.location.href.replace(/#.*$/, '').replace(/^http/, 'ws'),
|
||||||
|
userName(),
|
||||||
|
{},
|
||||||
|
Nacl.util.encodeBase64(key.lookupKey).substring(0,10),
|
||||||
|
key.cryptKey);
|
||||||
|
editor.on('change', function () { rtw.onEvent(); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,576 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2014 XWiki SAS
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
define([
|
||||||
|
'html-patcher',
|
||||||
|
'rangy',
|
||||||
|
'chainpad',
|
||||||
|
'otaml',
|
||||||
|
'bower/jquery/dist/jquery.min',
|
||||||
|
'bower/tweetnacl/nacl-fast.min'
|
||||||
|
], function (HTMLPatcher) {
|
||||||
|
var $ = window.jQuery;
|
||||||
|
var Rangy = window.rangy;
|
||||||
|
Rangy.init();
|
||||||
|
var ChainPad = window.ChainPad;
|
||||||
|
var Otaml = window.Otaml;
|
||||||
|
var Nacl = window.nacl;
|
||||||
|
|
||||||
|
var ErrorBox = {};
|
||||||
|
|
||||||
|
var PARANOIA = true;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var module = { exports: {} };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If an error is encountered but it is recoverable, do not immediately fail
|
||||||
|
* but if it keeps firing errors over and over, do fail.
|
||||||
|
*/
|
||||||
|
var MAX_RECOVERABLE_ERRORS = 15;
|
||||||
|
|
||||||
|
/** Maximum number of milliseconds of lag before we fail the connection. */
|
||||||
|
var MAX_LAG_BEFORE_DISCONNECT = 20000;
|
||||||
|
|
||||||
|
/** Id of the element for getting debug info. */
|
||||||
|
var DEBUG_LINK_CLS = 'rtwysiwyg-debug-link';
|
||||||
|
|
||||||
|
/** Id of the div containing the user list. */
|
||||||
|
var USER_LIST_CLS = 'rtwysiwyg-user-list';
|
||||||
|
|
||||||
|
/** Id of the div containing the lag info. */
|
||||||
|
var LAG_ELEM_CLS = 'rtwysiwyg-lag';
|
||||||
|
|
||||||
|
/** The toolbar class which contains the user list, debug link and lag. */
|
||||||
|
var TOOLBAR_CLS = 'rtwysiwyg-toolbar';
|
||||||
|
|
||||||
|
/** Key in the localStore which indicates realtime activity should be disallowed. */
|
||||||
|
var LOCALSTORAGE_DISALLOW = 'rtwysiwyg-disallow';
|
||||||
|
|
||||||
|
// ------------------ Trapping Keyboard Events ---------------------- //
|
||||||
|
|
||||||
|
var bindEvents = function (element, events, callback, unbind) {
|
||||||
|
for (var i = 0; i < events.length; i++) {
|
||||||
|
var e = events[i];
|
||||||
|
if (element.addEventListener) {
|
||||||
|
if (unbind) {
|
||||||
|
element.removeEventListener(e, callback, false);
|
||||||
|
} else {
|
||||||
|
element.addEventListener(e, callback, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (unbind) {
|
||||||
|
element.detachEvent('on' + e, callback);
|
||||||
|
} else {
|
||||||
|
element.attachEvent('on' + e, callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var bindAllEvents = function (wysiwygDiv, docBody, onEvent, unbind)
|
||||||
|
{
|
||||||
|
bindEvents(docBody,
|
||||||
|
['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'],
|
||||||
|
onEvent,
|
||||||
|
unbind);
|
||||||
|
bindEvents(wysiwygDiv,
|
||||||
|
['mousedown','mouseup','click'],
|
||||||
|
onEvent,
|
||||||
|
unbind);
|
||||||
|
};
|
||||||
|
|
||||||
|
var checkLag = function (realtime, lagElement) {
|
||||||
|
var lag = realtime.getLag();
|
||||||
|
var lagSec = lag.lag/1000;
|
||||||
|
lagElement.textContent = "Lag: ";
|
||||||
|
if (lag.waiting && lagSec > 1) {
|
||||||
|
lagElement.textContent += "?? " + Math.floor(lagSec);
|
||||||
|
} else {
|
||||||
|
lagElement.textContent += lagSec;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var isSocketDisconnected = function (socket, realtime) {
|
||||||
|
return socket._socket.readyState === socket.CLOSING
|
||||||
|
|| socket._socket.readyState === socket.CLOSED
|
||||||
|
|| (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT);
|
||||||
|
};
|
||||||
|
|
||||||
|
var updateUserList = function (myUserName, listElement, userList, messages) {
|
||||||
|
var meIdx = userList.indexOf(myUserName);
|
||||||
|
if (meIdx === -1) {
|
||||||
|
listElement.text(messages.disconnected);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listElement.text(messages.editingWith + ' ' + (userList.length - 1) + ' people');
|
||||||
|
};
|
||||||
|
|
||||||
|
var createUserList = function (realtime, myUserName, container, messages) {
|
||||||
|
var id = uid();
|
||||||
|
$(container).prepend('<div class="' + USER_LIST_CLS + '" id="'+id+'"></div>');
|
||||||
|
var listElement = $('#'+id);
|
||||||
|
realtime.onUserListChange(function (userList) {
|
||||||
|
updateUserList(myUserName, listElement, userList, messages);
|
||||||
|
});
|
||||||
|
return listElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
var abort = function (socket, realtime) {
|
||||||
|
realtime.abort();
|
||||||
|
try { socket._socket.close(); } catch (e) { }
|
||||||
|
$('.'+USER_LIST_CLS).text("Disconnected");
|
||||||
|
$('.'+LAG_ELEM_CLS).text("");
|
||||||
|
};
|
||||||
|
|
||||||
|
var createDebugInfo = function (cause, realtime, docHTML, allMessages) {
|
||||||
|
return JSON.stringify({
|
||||||
|
cause: cause,
|
||||||
|
realtimeUserDoc: realtime.getUserDoc(),
|
||||||
|
realtimeAuthDoc: realtime.getAuthDoc(),
|
||||||
|
docHTML: docHTML,
|
||||||
|
allMessages: allMessages,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var handleError = function (socket, realtime, err, docHTML, allMessages) {
|
||||||
|
var internalError = createDebugInfo(err, realtime, docHTML, allMessages);
|
||||||
|
abort(socket, realtime);
|
||||||
|
ErrorBox.show('error', docHTML, internalError);
|
||||||
|
};
|
||||||
|
|
||||||
|
var getDocHTML = function (doc) {
|
||||||
|
return doc.body.innerHTML;
|
||||||
|
};
|
||||||
|
|
||||||
|
var makeHTMLOperation = function (oldval, newval) {
|
||||||
|
try {
|
||||||
|
var op = Otaml.makeHTMLOperation(oldval, newval);
|
||||||
|
|
||||||
|
if (PARANOIA && op) {
|
||||||
|
// simulate running the patch.
|
||||||
|
var res = HTMLPatcher.patchString(oldval, op.offset, op.toRemove, op.toInsert);
|
||||||
|
if (res !== newval) {
|
||||||
|
console.log(op);
|
||||||
|
console.log(oldval);
|
||||||
|
console.log(newval);
|
||||||
|
console.log(res);
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
// check matching bracket count
|
||||||
|
// TODO(cjd): this can fail even if the patch is valid because of brackets in
|
||||||
|
// html attributes.
|
||||||
|
var removeText = oldval.substring(op.offset, op.offset + op.toRemove);
|
||||||
|
if (((removeText).match(/</g) || []).length !==
|
||||||
|
((removeText).match(/>/g) || []).length)
|
||||||
|
{
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (((op.toInsert).match(/</g) || []).length !==
|
||||||
|
((op.toInsert).match(/>/g) || []).length)
|
||||||
|
{
|
||||||
|
throw new Error();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return op;
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (PARANOIA) {
|
||||||
|
$(document.body).append('<textarea id="makeOperationErr"></textarea>');
|
||||||
|
$('#makeOperationErr').val(oldval + '\n\n\n\n\n\n\n\n\n\n' + newval);
|
||||||
|
console.log(e.stack);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
offset: 0,
|
||||||
|
toRemove: oldval.length,
|
||||||
|
toInsert: newval
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// chrome sometimes generates invalid html but it corrects it the next time around.
|
||||||
|
var fixChrome = function (docText, doc, contentWindow) {
|
||||||
|
for (var i = 0; i < 10; i++) {
|
||||||
|
var docElem = doc.createElement('div');
|
||||||
|
docElem.innerHTML = docText;
|
||||||
|
var newDocText = docElem.innerHTML;
|
||||||
|
var fixChromeOp = makeHTMLOperation(docText, newDocText);
|
||||||
|
if (!fixChromeOp) { return docText; }
|
||||||
|
HTMLPatcher.applyOp(docText,
|
||||||
|
fixChromeOp,
|
||||||
|
doc.body,
|
||||||
|
Rangy,
|
||||||
|
contentWindow);
|
||||||
|
docText = getDocHTML(doc);
|
||||||
|
if (newDocText === docText) { return docText; }
|
||||||
|
}
|
||||||
|
throw new Error();
|
||||||
|
};
|
||||||
|
|
||||||
|
var fixSafari_STATE_OUTSIDE = 0;
|
||||||
|
var fixSafari_STATE_IN_TAG = 1;
|
||||||
|
var fixSafari_STATE_IN_ATTR = 2;
|
||||||
|
var fixSafari_HTML_ENTITIES_REGEX = /('|"|<|>|<|>)/g;
|
||||||
|
|
||||||
|
var fixSafari = function (html) {
|
||||||
|
var state = fixSafari_STATE_OUTSIDE;
|
||||||
|
return html.replace(fixSafari_HTML_ENTITIES_REGEX, function (x) {
|
||||||
|
switch (state) {
|
||||||
|
case fixSafari_STATE_OUTSIDE: {
|
||||||
|
if (x === '<') { state = fixSafari_STATE_IN_TAG; }
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
case fixSafari_STATE_IN_TAG: {
|
||||||
|
switch (x) {
|
||||||
|
case '"': state = fixSafari_STATE_IN_ATTR; break;
|
||||||
|
case '>': state = fixSafari_STATE_OUTSIDE; break;
|
||||||
|
case "'": throw new Error("single quoted attribute");
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
case fixSafari_STATE_IN_ATTR: {
|
||||||
|
switch (x) {
|
||||||
|
case '<': return '<';
|
||||||
|
case '>': return '>';
|
||||||
|
case '"': state = fixSafari_STATE_IN_TAG; break;
|
||||||
|
}
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
throw new Error();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var getFixedDocText = function (doc, ifrWindow) {
|
||||||
|
var docText = getDocHTML(doc);
|
||||||
|
docText = fixChrome(docText, doc, ifrWindow);
|
||||||
|
docText = fixSafari(docText);
|
||||||
|
return docText;
|
||||||
|
};
|
||||||
|
|
||||||
|
var uid = function () {
|
||||||
|
return 'rtwysiwyg-uid-' + String(Math.random()).substring(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
var checkLag = function (realtime, lagElement, messages) {
|
||||||
|
var lag = realtime.getLag();
|
||||||
|
var lagSec = lag.lag/1000;
|
||||||
|
var lagMsg = messages.lag + ' ';
|
||||||
|
if (lag.waiting && lagSec > 1) {
|
||||||
|
lagMsg += "?? " + Math.floor(lagSec);
|
||||||
|
} else {
|
||||||
|
lagMsg += lagSec;
|
||||||
|
}
|
||||||
|
lagElement.text(lagMsg);
|
||||||
|
};
|
||||||
|
|
||||||
|
var createLagElement = function (socket, realtime, container, messages) {
|
||||||
|
var id = uid();
|
||||||
|
$(container).append('<div class="' + LAG_ELEM_CLS + '" id="'+id+'"></div>');
|
||||||
|
var lagElement = $('#'+id);
|
||||||
|
var intr = setInterval(function () {
|
||||||
|
checkLag(realtime, lagElement, messages);
|
||||||
|
}, 3000);
|
||||||
|
socket.onClose.push(function () { clearTimeout(intr); });
|
||||||
|
return lagElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
var createRealtimeToolbar = function (container) {
|
||||||
|
var id = uid();
|
||||||
|
$(container).prepend(
|
||||||
|
'<div class="' + TOOLBAR_CLS + '" id="' + id + '">' +
|
||||||
|
'<div class="rtwysiwyg-toolbar-leftside"></div>' +
|
||||||
|
'<div class="rtwysiwyg-toolbar-rightside"></div>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
var toolbar = $('#'+id);
|
||||||
|
toolbar.append([
|
||||||
|
'<style>',
|
||||||
|
'.' + TOOLBAR_CLS + ' {',
|
||||||
|
' color: #666;',
|
||||||
|
' font-weight: bold;',
|
||||||
|
' background-color: #f0f0ee;',
|
||||||
|
' border-bottom: 1px solid #DDD;',
|
||||||
|
' border-top: 3px solid #CCC;',
|
||||||
|
' border-right: 2px solid #CCC;',
|
||||||
|
' border-left: 2px solid #CCC;',
|
||||||
|
' height: 26px;',
|
||||||
|
' margin-bottom: -3px;',
|
||||||
|
' display: inline-block;',
|
||||||
|
' width: 100%;',
|
||||||
|
'}',
|
||||||
|
'.' + TOOLBAR_CLS + ' div {',
|
||||||
|
' padding: 0 10px;',
|
||||||
|
' height: 1.5em;',
|
||||||
|
' background: #f0f0ee;',
|
||||||
|
' line-height: 25px;',
|
||||||
|
' height: 22px;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-toolbar-leftside {',
|
||||||
|
' float: left;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-toolbar-rightside {',
|
||||||
|
' float: right;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-lag {',
|
||||||
|
' float: right;',
|
||||||
|
'}',
|
||||||
|
'.gwt-TabBar {',
|
||||||
|
' display:none;',
|
||||||
|
'}',
|
||||||
|
'.' + DEBUG_LINK_CLS + ':link { color:transparent; }',
|
||||||
|
'.' + DEBUG_LINK_CLS + ':link:hover { color:blue; }',
|
||||||
|
'.gwt-TabPanelBottom { border-top: 0 none; }',
|
||||||
|
'</style>'
|
||||||
|
].join('\n'));
|
||||||
|
return toolbar;
|
||||||
|
};
|
||||||
|
|
||||||
|
var makeWebsocket = function (url) {
|
||||||
|
var socket = new WebSocket(url);
|
||||||
|
var out = {
|
||||||
|
onOpen: [],
|
||||||
|
onClose: [],
|
||||||
|
onError: [],
|
||||||
|
onMessage: [],
|
||||||
|
send: function (msg) { socket.send(msg); },
|
||||||
|
close: function () { socket.close(); },
|
||||||
|
_socket: socket
|
||||||
|
};
|
||||||
|
var mkHandler = function (name) {
|
||||||
|
return function (evt) {
|
||||||
|
for (var i = 0; i < out[name].length; i++) {
|
||||||
|
if (out[name][i](evt) === false) { return; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
socket.onopen = mkHandler('onOpen');
|
||||||
|
socket.onclose = mkHandler('onClose');
|
||||||
|
socket.onerror = mkHandler('onError');
|
||||||
|
socket.onmessage = mkHandler('onMessage');
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
var encryptStr = function (str, key) {
|
||||||
|
var array = Nacl.util.decodeUTF8(str);
|
||||||
|
var nonce = Nacl.randomBytes(24);
|
||||||
|
var packed = Nacl.secretbox(array, nonce, key);
|
||||||
|
if (!packed) { throw new Error(); }
|
||||||
|
return Nacl.util.encodeBase64(nonce) + "|" + Nacl.util.encodeBase64(packed);
|
||||||
|
};
|
||||||
|
var decryptStr = function (str, key) {
|
||||||
|
var arr = str.split('|');
|
||||||
|
if (arr.length !== 2) { throw new Error(); }
|
||||||
|
var nonce = Nacl.util.decodeBase64(arr[0]);
|
||||||
|
var packed = Nacl.util.decodeBase64(arr[1]);
|
||||||
|
var unpacked = Nacl.secretbox.open(packed, nonce, key);
|
||||||
|
if (!unpacked) { throw new Error(); }
|
||||||
|
return Nacl.util.encodeUTF8(unpacked);
|
||||||
|
};
|
||||||
|
|
||||||
|
// this is crap because of bencoding messages... it should go away....
|
||||||
|
var splitMessage = function (msg, sending) {
|
||||||
|
var idx = 0;
|
||||||
|
var nl;
|
||||||
|
for (var i = ((sending) ? 0 : 1); i < 3; i++) {
|
||||||
|
nl = msg.indexOf(':',idx);
|
||||||
|
idx = nl + Number(msg.substring(idx,nl)) + 1;
|
||||||
|
}
|
||||||
|
return [ msg.substring(0,idx), msg.substring(msg.indexOf(':',idx) + 1) ];
|
||||||
|
};
|
||||||
|
|
||||||
|
var encrypt = function (msg, key) {
|
||||||
|
var spl = splitMessage(msg, true);
|
||||||
|
var json = JSON.parse(spl[1]);
|
||||||
|
// non-patches are not encrypted.
|
||||||
|
if (json[0] !== 2) { return msg; }
|
||||||
|
json[1] = encryptStr(JSON.stringify(json[1]), key);
|
||||||
|
var res = JSON.stringify(json);
|
||||||
|
return spl[0] + res.length + ':' + res;
|
||||||
|
};
|
||||||
|
|
||||||
|
var decrypt = function (msg, key) {
|
||||||
|
var spl = splitMessage(msg, false);
|
||||||
|
var json = JSON.parse(spl[1]);
|
||||||
|
// non-patches are not encrypted.
|
||||||
|
if (json[0] !== 2) { return msg; }
|
||||||
|
if (typeof(json[1]) !== 'string') { throw new Error(); }
|
||||||
|
json[1] = JSON.parse(decryptStr(json[1], key));
|
||||||
|
var res = JSON.stringify(json);
|
||||||
|
return spl[0] + res.length + ':' + res;
|
||||||
|
};
|
||||||
|
|
||||||
|
var start = module.exports.start =
|
||||||
|
function (websocketUrl, userName, messages, channel, cryptKey)
|
||||||
|
{
|
||||||
|
var passwd = 'y';
|
||||||
|
var wysiwygDiv = document.getElementById('cke_1_contents');
|
||||||
|
var ifr = wysiwygDiv.getElementsByTagName('iframe')[0];
|
||||||
|
var doc = ifr.contentWindow.document;
|
||||||
|
var socket = makeWebsocket(websocketUrl);
|
||||||
|
var onEvent = function () { };
|
||||||
|
|
||||||
|
var toolbar = createRealtimeToolbar('#xwikieditcontent');
|
||||||
|
|
||||||
|
socket.onClose.push(function () {
|
||||||
|
$(toolbar).remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
var allMessages = [];
|
||||||
|
var isErrorState = false;
|
||||||
|
var initializing = true;
|
||||||
|
var recoverableErrorCount = 0;
|
||||||
|
var error = function (recoverable, err) {
|
||||||
|
console.log('error: ' + err.stack);
|
||||||
|
if (recoverable && recoverableErrorCount++ < MAX_RECOVERABLE_ERRORS) { return; }
|
||||||
|
var realtime = socket.realtime;
|
||||||
|
var docHtml = getDocHTML(doc);
|
||||||
|
isErrorState = true;
|
||||||
|
handleError(socket, realtime, err, docHtml, allMessages);
|
||||||
|
};
|
||||||
|
var attempt = function (func) {
|
||||||
|
return function () {
|
||||||
|
var e;
|
||||||
|
try { return func.apply(func, arguments); } catch (ee) { e = ee; }
|
||||||
|
if (e) {
|
||||||
|
console.log(e.stack);
|
||||||
|
error(true, e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
var checkSocket = function () {
|
||||||
|
if (isSocketDisconnected(socket, socket.realtime) && !socket.intentionallyClosing) {
|
||||||
|
isErrorState = true;
|
||||||
|
abort(socket, socket.realtime);
|
||||||
|
ErrorBox.show('disconnected', getDocHTML(doc));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onOpen.push(function (evt) {
|
||||||
|
|
||||||
|
var realtime = socket.realtime =
|
||||||
|
ChainPad.create(userName,
|
||||||
|
passwd,
|
||||||
|
channel,
|
||||||
|
getDocHTML(doc),
|
||||||
|
{ transformFunction: Otaml.transform });
|
||||||
|
|
||||||
|
//createDebugLink(realtime, doc, allMessages, toolbar, messages);
|
||||||
|
|
||||||
|
createLagElement(socket,
|
||||||
|
realtime,
|
||||||
|
toolbar.find('.rtwysiwyg-toolbar-rightside'),
|
||||||
|
messages);
|
||||||
|
|
||||||
|
createUserList(realtime,
|
||||||
|
userName,
|
||||||
|
toolbar.find('.rtwysiwyg-toolbar-leftside'),
|
||||||
|
messages);
|
||||||
|
|
||||||
|
onEvent = function () {
|
||||||
|
if (isErrorState) { return; }
|
||||||
|
if (initializing) { return; }
|
||||||
|
|
||||||
|
var oldDocText = realtime.getUserDoc();
|
||||||
|
var docText = getFixedDocText(doc, ifr.contentWindow);
|
||||||
|
var op = attempt(Otaml.makeTextOperation)(oldDocText, docText);
|
||||||
|
|
||||||
|
if (!op) { return; }
|
||||||
|
|
||||||
|
if (op.toRemove > 0) {
|
||||||
|
attempt(realtime.remove)(op.offset, op.toRemove);
|
||||||
|
}
|
||||||
|
if (op.toInsert.length > 0) {
|
||||||
|
attempt(realtime.insert)(op.offset, op.toInsert);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (realtime.getUserDoc() !== docText) {
|
||||||
|
error(false, 'realtime.getUserDoc() !== docText');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var userDocBeforePatch;
|
||||||
|
var incomingPatch = function () {
|
||||||
|
if (isErrorState || initializing) { return; }
|
||||||
|
userDocBeforePatch = userDocBeforePatch || getFixedDocText(doc, ifr.contentWindow);
|
||||||
|
if (PARANOIA && userDocBeforePatch != getFixedDocText(doc, ifr.contentWindow)) {
|
||||||
|
error(false, "userDocBeforePatch != getFixedDocText(doc, ifr.contentWindow)");
|
||||||
|
}
|
||||||
|
var op = attempt(makeHTMLOperation)(userDocBeforePatch, realtime.getUserDoc());
|
||||||
|
if (!op) { return; }
|
||||||
|
attempt(HTMLPatcher.applyOp)(
|
||||||
|
userDocBeforePatch, op, doc.body, rangy, ifr.contentWindow);
|
||||||
|
};
|
||||||
|
|
||||||
|
realtime.onUserListChange(function (userList) {
|
||||||
|
if (!initializing && userList.indexOf(userName) === -1) { return; }
|
||||||
|
// if we spot ourselves being added to the document, we'll switch
|
||||||
|
// 'initializing' off because it means we're fully synced.
|
||||||
|
initializing = false;
|
||||||
|
userDocBeforePatch = realtime.getUserDoc();
|
||||||
|
incomingPatch();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.onMessage.push(function (evt) {
|
||||||
|
if (isErrorState) { return; }
|
||||||
|
var message = decrypt(evt.data, cryptKey);
|
||||||
|
allMessages.push(message);
|
||||||
|
if (!initializing) {
|
||||||
|
if (PARANOIA) { onEvent(); }
|
||||||
|
userDocBeforePatch = realtime.getUserDoc();
|
||||||
|
}
|
||||||
|
realtime.message(message);
|
||||||
|
});
|
||||||
|
realtime.onMessage(function (message) {
|
||||||
|
if (isErrorState) { return; }
|
||||||
|
message = encrypt(message, cryptKey);
|
||||||
|
try {
|
||||||
|
socket.send(message);
|
||||||
|
} catch (e) {
|
||||||
|
if (!checkSocket()) { error(true, e.stack); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
realtime.onPatch(incomingPatch);
|
||||||
|
|
||||||
|
socket.onError.push(function (err) {
|
||||||
|
if (isErrorState) { return; }
|
||||||
|
if (!checkSocket()) { error(true, err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
bindAllEvents(wysiwygDiv, doc.body, onEvent, false);
|
||||||
|
|
||||||
|
setInterval(function () {
|
||||||
|
if (isErrorState || checkSocket()) { return; }
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
realtime.start();
|
||||||
|
|
||||||
|
//console.log('started');
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
onEvent: function () { onEvent(); }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return module.exports;
|
||||||
|
});
|
Loading…
Reference in New Issue