Update Codepad with the latest improvements
parent
692fe24b32
commit
c53baab99d
@ -1,483 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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_components/jquery/dist/jquery.min.js',
|
|
||||||
'/common/otaml.js'
|
|
||||||
], function () {
|
|
||||||
|
|
||||||
var $ = window.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 {
|
|
||||||
applyHTMLOpHammer(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;
|
|
||||||
});
|
|
File diff suppressed because it is too large
Load Diff
@ -1,358 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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([
|
|
||||||
'/code/html-patcher.js',
|
|
||||||
'/code/errorbox.js',
|
|
||||||
'/common/messages.js',
|
|
||||||
'/bower_components/reconnectingWebsocket/reconnecting-websocket.js',
|
|
||||||
'/common/crypto.js',
|
|
||||||
'/common/toolbar.js',
|
|
||||||
'/code/rangy.js',
|
|
||||||
'/common/chainpad.js',
|
|
||||||
'/common/otaml.js',
|
|
||||||
'/bower_components/jquery/dist/jquery.min.js',
|
|
||||||
], function (HTMLPatcher, ErrorBox, Messages, ReconnectingWebSocket, Crypto, Toolbar) {
|
|
||||||
|
|
||||||
window.ErrorBox = ErrorBox;
|
|
||||||
|
|
||||||
var $ = window.jQuery;
|
|
||||||
var Rangy = window.rangy;
|
|
||||||
Rangy.init();
|
|
||||||
var ChainPad = window.ChainPad;
|
|
||||||
var Otaml = window.Otaml;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
// ------------------ 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 (cmDiv, onEvent, unbind)
|
|
||||||
{
|
|
||||||
bindEvents(cmDiv,
|
|
||||||
['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste', 'mousedown','mouseup','click'],
|
|
||||||
onEvent,
|
|
||||||
unbind);
|
|
||||||
};
|
|
||||||
|
|
||||||
var isSocketDisconnected = function (socket, realtime) {
|
|
||||||
var sock = socket._socket;
|
|
||||||
return sock.readyState === sock.CLOSING
|
|
||||||
|| sock.readyState === sock.CLOSED
|
|
||||||
|| (realtime.getLag().waiting && realtime.getLag().lag > MAX_LAG_BEFORE_DISCONNECT);
|
|
||||||
};
|
|
||||||
|
|
||||||
var abort = function (socket, realtime) {
|
|
||||||
realtime.abort();
|
|
||||||
realtime.toolbar.failed();
|
|
||||||
try { socket._socket.close(); } catch (e) { }
|
|
||||||
};
|
|
||||||
|
|
||||||
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).val();
|
|
||||||
};
|
|
||||||
|
|
||||||
var transformCursorCMRemove = function(text, cursor, pos, length) {
|
|
||||||
var newCursor = cursor;
|
|
||||||
var textLines = text.substr(0, pos).split("\n");
|
|
||||||
var removedTextLineNumber = textLines.length-1;
|
|
||||||
var removedTextColumnIndex = textLines[textLines.length-1].length;
|
|
||||||
var removedLines = text.substr(pos, length).split("\n").length - 1;
|
|
||||||
if(cursor.line > (removedTextLineNumber + removedLines)) {
|
|
||||||
newCursor.line -= removedLines;
|
|
||||||
}
|
|
||||||
else if(removedLines > 0 && cursor.line === (removedTextLineNumber+removedLines)) {
|
|
||||||
var lastLineCharsRemoved = text.substr(pos, length).split("\n")[removedLines].length;
|
|
||||||
if(cursor.ch >= lastLineCharsRemoved) {
|
|
||||||
newCursor.line = removedTextLineNumber;
|
|
||||||
newCursor.ch = removedTextColumnIndex + cursor.ch - lastLineCharsRemoved;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
newCursor.line -= removedLines;
|
|
||||||
newCursor.ch = removedTextColumnIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if(cursor.line === removedTextLineNumber && cursor.ch > removedTextLineNumber) {
|
|
||||||
newCursor.ch -= Math.min(length, cursor.ch-removedTextLineNumber);
|
|
||||||
}
|
|
||||||
return newCursor;
|
|
||||||
};
|
|
||||||
var transformCursorCMInsert = function(oldtext, cursor, pos, text) {
|
|
||||||
var newCursor = cursor;
|
|
||||||
var textLines = oldtext.substr(0, pos).split("\n");
|
|
||||||
var addedTextLineNumber = textLines.length-1;
|
|
||||||
var addedTextColumnIndex = textLines[textLines.length-1].length;
|
|
||||||
var addedLines = text.split("\n").length - 1;
|
|
||||||
if(cursor.line > addedTextLineNumber) {
|
|
||||||
newCursor.line += addedLines;
|
|
||||||
}
|
|
||||||
else if(cursor.line === addedTextLineNumber && cursor.ch > addedTextColumnIndex) {
|
|
||||||
newCursor.line += addedLines;
|
|
||||||
if(addedLines > 0) {
|
|
||||||
newCursor.ch = newCursor.ch - addedTextColumnIndex + text.split("\n")[addedLines].length;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
newCursor.ch += text.split("\n")[addedLines].length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newCursor;
|
|
||||||
};
|
|
||||||
|
|
||||||
var makeWebsocket = function (url) {
|
|
||||||
var socket = new ReconnectingWebSocket(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 start = module.exports.start =
|
|
||||||
function (window, websocketUrl, userName, channel, cryptKey)
|
|
||||||
{
|
|
||||||
var passwd = 'y';
|
|
||||||
//var wysiwygDiv = window.document.getElementById('cke_1_contents');
|
|
||||||
var doc = window.document.getElementById('editor1');
|
|
||||||
var cmDiv = window.document.getElementsByClassName('CodeMirror')[0];
|
|
||||||
var cmEditor = cmDiv.CodeMirror;
|
|
||||||
//var ifr = wysiwygDiv.getElementsByTagName('iframe')[0];
|
|
||||||
var socket = makeWebsocket(websocketUrl);
|
|
||||||
var onEvent = function () { };
|
|
||||||
|
|
||||||
var allMessages = [];
|
|
||||||
var isErrorState = false;
|
|
||||||
var initializing = true;
|
|
||||||
var recoverableErrorCount = 0;
|
|
||||||
var error = function (recoverable, err) {
|
|
||||||
console.log(new Error().stack);
|
|
||||||
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) {
|
|
||||||
if (!initializing) {
|
|
||||||
socket.realtime.start();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var realtime = socket.realtime =
|
|
||||||
ChainPad.create(userName,
|
|
||||||
passwd,
|
|
||||||
channel,
|
|
||||||
getDocHTML(doc),
|
|
||||||
{ transformFunction: Otaml.transform });
|
|
||||||
|
|
||||||
var toolbar = realtime.toolbar =
|
|
||||||
Toolbar.create(window.$('#cme_toolbox'), userName, realtime);
|
|
||||||
|
|
||||||
onEvent = function () {
|
|
||||||
if (isErrorState) { return; }
|
|
||||||
if (initializing) { return; }
|
|
||||||
|
|
||||||
var oldDocText = realtime.getUserDoc();
|
|
||||||
var docText = getDocHTML(doc);
|
|
||||||
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 || getDocHTML(doc);
|
|
||||||
if (PARANOIA && userDocBeforePatch !== getDocHTML(doc)) {
|
|
||||||
error(false, "userDocBeforePatch != getDocHTML(doc)");
|
|
||||||
}
|
|
||||||
var op = attempt(Otaml.makeTextOperation)(userDocBeforePatch, realtime.getUserDoc());
|
|
||||||
var oldValue = getDocHTML(doc);
|
|
||||||
var newValue = realtime.getUserDoc();
|
|
||||||
// Fix cursor and/or selection
|
|
||||||
var oldCursor = cmEditor.getCursor();
|
|
||||||
var oldCursorCMStart = cmEditor.getCursor('from');
|
|
||||||
var oldCursorCMEnd = cmEditor.getCursor('to');
|
|
||||||
var newCursor;
|
|
||||||
var newSelection;
|
|
||||||
if(oldCursorCMStart !== oldCursorCMEnd) { // Selection
|
|
||||||
if (op.toRemove > 0) {
|
|
||||||
newSelection = [transformCursorCMRemove(oldValue, oldCursorCMStart, op.offset, op.toRemove), transformCursorCMRemove(oldValue, oldCursorCMEnd, op.offset, op.toRemove)];
|
|
||||||
}
|
|
||||||
if (op.toInsert.length > 0) {
|
|
||||||
newSelection = [transformCursorCMInsert(oldValue, oldCursorCMStart, op.offset, op.toInsert), transformCursorCMInsert(oldValue, oldCursorCMEnd, op.offset, op.toInsert)];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else { // Cursor
|
|
||||||
if (op.toRemove > 0) {
|
|
||||||
newCursor = transformCursorCMRemove(oldValue, oldCursor, op.offset, op.toRemove);
|
|
||||||
}
|
|
||||||
if (op.toInsert.length > 0) {
|
|
||||||
newCursor = transformCursorCMInsert(oldValue, oldCursor, op.offset, op.toInsert);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$(doc).val(newValue);
|
|
||||||
cmEditor.setValue(newValue);
|
|
||||||
if(newCursor) {
|
|
||||||
cmEditor.setCursor(newCursor);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
cmEditor.setSelection(newSelection[0], newSelection[1]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
incomingPatch();
|
|
||||||
});
|
|
||||||
|
|
||||||
socket.onMessage.push(function (evt) {
|
|
||||||
if (isErrorState) { return; }
|
|
||||||
var message = Crypto.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 = Crypto.encrypt(message, cryptKey);
|
|
||||||
try {
|
|
||||||
socket.send(message);
|
|
||||||
} catch (e) {
|
|
||||||
error(true, e.stack);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
realtime.onPatch(incomingPatch);
|
|
||||||
|
|
||||||
bindAllEvents(cmDiv, onEvent, false);
|
|
||||||
|
|
||||||
setInterval(function () {
|
|
||||||
if (isErrorState || checkSocket()) {
|
|
||||||
toolbar.reconnecting();
|
|
||||||
}
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
realtime.start();
|
|
||||||
toolbar.connected();
|
|
||||||
|
|
||||||
//console.log('started');
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
onEvent: function () { onEvent(); }
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return module.exports;
|
|
||||||
});
|
|
@ -1,263 +0,0 @@
|
|||||||
define(function () {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Licensed under the standard MIT license:
|
|
||||||
*
|
|
||||||
* Copyright 2011 Joseph Gentle.
|
|
||||||
*
|
|
||||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
* of this software and associated documentation files (the "Software"), to deal
|
|
||||||
* in the Software without restriction, including without limitation the rights
|
|
||||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
* copies of the Software, and to permit persons to whom the Software is
|
|
||||||
* furnished to do so, subject to the following conditions:
|
|
||||||
*
|
|
||||||
* The above copyright notice and this permission notice shall be included in
|
|
||||||
* all copies or substantial portions of the Software.
|
|
||||||
*
|
|
||||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
||||||
* THE SOFTWARE.
|
|
||||||
*
|
|
||||||
* See: https://github.com/share/ShareJS/blob/master/LICENSE
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* This contains the textarea binding for ShareJS. This binding is really
|
|
||||||
* simple, and a bit slow on big documents (Its O(N). However, it requires no
|
|
||||||
* changes to the DOM and no heavy libraries like ace. It works for any kind of
|
|
||||||
* text input field.
|
|
||||||
*
|
|
||||||
* You probably want to use this binding for small fields on forms and such.
|
|
||||||
* For code editors or rich text editors or whatever, I recommend something
|
|
||||||
* heavier.
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
/* applyChange creates the edits to convert oldval -> newval.
|
|
||||||
*
|
|
||||||
* This function should be called every time the text element is changed.
|
|
||||||
* Because changes are always localised, the diffing is quite easy. We simply
|
|
||||||
* scan in from the start and scan in from the end to isolate the edited range,
|
|
||||||
* then delete everything that was removed & add everything that was added.
|
|
||||||
* This wouldn't work for complex changes, but this function should be called
|
|
||||||
* on keystroke - so the edits will mostly just be single character changes.
|
|
||||||
* Sometimes they'll paste text over other text, but even then the diff
|
|
||||||
* generated by this algorithm is correct.
|
|
||||||
*
|
|
||||||
* This algorithm is O(N). I suspect you could speed it up somehow using regular expressions.
|
|
||||||
*/
|
|
||||||
var applyChange = function(ctx, oldval, newval) {
|
|
||||||
// Strings are immutable and have reference equality. I think this test is O(1), so its worth doing.
|
|
||||||
if (oldval === newval) { return; }
|
|
||||||
|
|
||||||
var commonStart = 0;
|
|
||||||
while (oldval.charAt(commonStart) === newval.charAt(commonStart)) {
|
|
||||||
commonStart++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var commonEnd = 0;
|
|
||||||
while (oldval.charAt(oldval.length - 1 - commonEnd) === newval.charAt(newval.length - 1 - commonEnd) &&
|
|
||||||
commonEnd + commonStart < oldval.length && commonEnd + commonStart < newval.length) {
|
|
||||||
commonEnd++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldval.length !== commonStart + commonEnd) {
|
|
||||||
ctx.remove(commonStart, oldval.length - commonStart - commonEnd);
|
|
||||||
}
|
|
||||||
if (newval.length !== commonStart + commonEnd) {
|
|
||||||
ctx.insert(commonStart, newval.slice(commonStart, newval.length - commonEnd));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fix issues with textarea content which is different per-browser.
|
|
||||||
*/
|
|
||||||
var cannonicalize = function (content) {
|
|
||||||
|
|
||||||
return content.replace(/\r\n/g, '\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attach a textarea to a document's editing context.
|
|
||||||
//
|
|
||||||
// The context is optional, and will be created from the document if its not
|
|
||||||
// specified.
|
|
||||||
var attachTextarea = function(elem, ctx, cmElem) {
|
|
||||||
|
|
||||||
// initial state will always fail the !== check in genop.
|
|
||||||
var content = {};
|
|
||||||
|
|
||||||
// Replace the content of the text area with newText, and transform the
|
|
||||||
// current cursor by the specified function.
|
|
||||||
var replaceText = function(newText, transformCursor, transformCursorCM) {
|
|
||||||
var newCursor;
|
|
||||||
var newSelection;
|
|
||||||
|
|
||||||
if(cmElem) {
|
|
||||||
// Fix cursor here?
|
|
||||||
var cursorCM = cmElem.getCursor();
|
|
||||||
var cursorCMStart = cmElem.getCursor('from');
|
|
||||||
var cursorCMEnd = cmElem.getCursor('to');
|
|
||||||
if(cursorCMStart !== cursorCMEnd) {
|
|
||||||
newSelection = [transformCursorCM(elem.value, cursorCMStart), transformCursorCM(elem.value, cursorCMEnd)];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
newCursor = transformCursorCM(elem.value, cursorCM);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (transformCursor && !cmElem) {
|
|
||||||
newSelection = [transformCursor(elem.selectionStart), transformCursor(elem.selectionEnd)];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fixate the window's scroll while we set the element's value. Otherwise
|
|
||||||
// the browser scrolls to the element.
|
|
||||||
var scrollTop = elem.scrollTop;
|
|
||||||
elem.value = newText;
|
|
||||||
if(cmElem) {
|
|
||||||
// Fix cursor here?
|
|
||||||
cmElem.setValue(newText);
|
|
||||||
if(newCursor) {
|
|
||||||
cmElem.setCursor(newCursor);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
cmElem.setSelection(newSelection[0], newSelection[1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
content = elem.value; // Not done on one line so the browser can do newline conversion.
|
|
||||||
|
|
||||||
if(!cmElem) {
|
|
||||||
if (elem.scrollTop !== scrollTop) { elem.scrollTop = scrollTop; }
|
|
||||||
|
|
||||||
// Setting the selection moves the cursor. We'll just have to let your
|
|
||||||
// cursor drift if the element isn't active, though usually users don't
|
|
||||||
// care.
|
|
||||||
if (newSelection && window.document.activeElement === elem) {
|
|
||||||
elem.selectionStart = newSelection[0];
|
|
||||||
elem.selectionEnd = newSelection[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//replaceText(ctx.get());
|
|
||||||
|
|
||||||
|
|
||||||
// *** remote -> local changes
|
|
||||||
|
|
||||||
ctx.onRemove(function(pos, length) {
|
|
||||||
var transformCursor = function(cursor) {
|
|
||||||
// If the cursor is inside the deleted region, we only want to move back to the start
|
|
||||||
// of the region. Hence the Math.min.
|
|
||||||
return pos < cursor ? cursor - Math.min(length, cursor - pos) : cursor;
|
|
||||||
};
|
|
||||||
var transformCursorCM = function(text, cursor) {
|
|
||||||
var newCursor = cursor;
|
|
||||||
var textLines = text.substr(0, pos).split("\n");
|
|
||||||
var removedTextLineNumber = textLines.length-1;
|
|
||||||
var removedTextColumnIndex = textLines[textLines.length-1].length;
|
|
||||||
var removedLines = text.substr(pos, length).split("\n").length - 1;
|
|
||||||
if(cursor.line > (removedTextLineNumber + removedLines)) {
|
|
||||||
newCursor.line -= removedLines;
|
|
||||||
}
|
|
||||||
else if(removedLines > 0 && cursor.line === (removedTextLineNumber+removedLines)) {
|
|
||||||
var lastLineCharsRemoved = text.substr(pos, length).split("\n")[removedLines].length;
|
|
||||||
if(cursor.ch >= lastLineCharsRemoved) {
|
|
||||||
newCursor.line = removedTextLineNumber;
|
|
||||||
newCursor.ch = removedTextColumnIndex + cursor.ch - lastLineCharsRemoved;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
newCursor.line -= removedLines;
|
|
||||||
newCursor.ch = removedTextColumnIndex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if(cursor.line === removedTextLineNumber && cursor.ch > removedTextLineNumber) {
|
|
||||||
newCursor.ch -= Math.min(length, cursor.ch-removedTextLineNumber);
|
|
||||||
}
|
|
||||||
return newCursor;
|
|
||||||
};
|
|
||||||
replaceText(ctx.getUserDoc(), transformCursor, transformCursorCM);
|
|
||||||
});
|
|
||||||
|
|
||||||
ctx.onInsert(function(pos, text) {
|
|
||||||
var transformCursor = function(cursor) {
|
|
||||||
return pos < cursor ? cursor + text.length : cursor;
|
|
||||||
};
|
|
||||||
var transformCursorCM = function(oldtext, cursor) {
|
|
||||||
var newCursor = cursor;
|
|
||||||
var textLines = oldtext.substr(0, pos).split("\n");
|
|
||||||
var addedTextLineNumber = textLines.length-1;
|
|
||||||
var addedTextColumnIndex = textLines[textLines.length-1].length;
|
|
||||||
var addedLines = text.split("\n").length - 1;
|
|
||||||
if(cursor.line > addedTextLineNumber) {
|
|
||||||
newCursor.line += addedLines;
|
|
||||||
}
|
|
||||||
else if(cursor.line === addedTextLineNumber && cursor.ch > addedTextColumnIndex) {
|
|
||||||
newCursor.line += addedLines;
|
|
||||||
if(addedLines > 0) {
|
|
||||||
newCursor.ch = newCursor.ch - addedTextColumnIndex + text.split("\n")[addedLines].length;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
newCursor.ch += text.split("\n")[addedLines].length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return newCursor;
|
|
||||||
};
|
|
||||||
replaceText(ctx.getUserDoc(), transformCursor, transformCursorCM);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
// *** local -> remote changes
|
|
||||||
|
|
||||||
// This function generates operations from the changed content in the textarea.
|
|
||||||
var genOp = function() {
|
|
||||||
// In a timeout so the browser has time to propogate the event's changes to the DOM.
|
|
||||||
setTimeout(function() {
|
|
||||||
var val = elem.value;
|
|
||||||
if (val !== content) {
|
|
||||||
applyChange(ctx, ctx.getUserDoc(), cannonicalize(val));
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
var eventNames = ['textInput', 'keydown', 'keyup', 'select', 'cut', 'paste'];
|
|
||||||
for (var i = 0; i < eventNames.length; i++) {
|
|
||||||
var e = eventNames[i];
|
|
||||||
if (elem.addEventListener) {
|
|
||||||
elem.addEventListener(e, genOp, false);
|
|
||||||
} else {
|
|
||||||
elem.attachEvent('on' + e, genOp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.setTimeout(function() {
|
|
||||||
if(cmElem) {
|
|
||||||
var elem2 = cmElem;
|
|
||||||
elem2.on('change', function() {
|
|
||||||
elem2.save();
|
|
||||||
genOp();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
console.log('CM inexistant');
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
|
|
||||||
ctx.detach = function() {
|
|
||||||
for (var i = 0; i < eventNames.length; i++) {
|
|
||||||
var e = eventNames[i];
|
|
||||||
if (elem.removeEventListener) {
|
|
||||||
elem.removeEventListener(e, genOp, false);
|
|
||||||
} else {
|
|
||||||
elem.detachEvent('on' + e, genOp);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return ctx;
|
|
||||||
};
|
|
||||||
|
|
||||||
return { attach: attachTextarea };
|
|
||||||
});
|
|
@ -0,0 +1,246 @@
|
|||||||
|
define([
|
||||||
|
'/common/messages.js'
|
||||||
|
], function (Messages) {
|
||||||
|
|
||||||
|
/** 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';
|
||||||
|
|
||||||
|
var SPINNER_DISAPPEAR_TIME = 3000;
|
||||||
|
var SPINNER = [ '-', '\\', '|', '/' ];
|
||||||
|
|
||||||
|
var uid = function () {
|
||||||
|
return 'rtwysiwyg-uid-' + String(Math.random()).substring(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = $container.find('#'+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 + ' a {',
|
||||||
|
' float: right;',
|
||||||
|
'}',
|
||||||
|
'.' + TOOLBAR_CLS + ' div {',
|
||||||
|
' padding: 0 10px;',
|
||||||
|
' height: 1.5em;',
|
||||||
|
// ' background: #f0f0ee;',
|
||||||
|
' line-height: 25px;',
|
||||||
|
' height: 22px;',
|
||||||
|
'}',
|
||||||
|
'.' + TOOLBAR_CLS + ' div.rtwysiwyg-back {',
|
||||||
|
' padding: 0;',
|
||||||
|
' font-weight: bold;',
|
||||||
|
' cursor: pointer;',
|
||||||
|
' color: #000;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-toolbar-leftside div {',
|
||||||
|
' float: left;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-toolbar-leftside {',
|
||||||
|
' float: left;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-toolbar-rightside {',
|
||||||
|
' float: right;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-lag {',
|
||||||
|
' float: right;',
|
||||||
|
'}',
|
||||||
|
'.rtwysiwyg-spinner {',
|
||||||
|
' float: left;',
|
||||||
|
'}',
|
||||||
|
'.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 createEscape = function ($container) {
|
||||||
|
var id = uid();
|
||||||
|
$container.append('<div class="rtwysiwyg-back" id="' + id + '">⇐ Back</div>');
|
||||||
|
var $ret = $container.find('#'+id);
|
||||||
|
$ret.on('click', function () {
|
||||||
|
window.location.href = '/';
|
||||||
|
});
|
||||||
|
return $ret[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
var createSpinner = function ($container) {
|
||||||
|
var id = uid();
|
||||||
|
$container.append('<div class="rtwysiwyg-spinner" id="'+id+'"></div>');
|
||||||
|
return $container.find('#'+id)[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
var kickSpinner = function (spinnerElement, reversed) {
|
||||||
|
var txt = spinnerElement.textContent || '-';
|
||||||
|
var inc = (reversed) ? -1 : 1;
|
||||||
|
spinnerElement.textContent = SPINNER[(SPINNER.indexOf(txt) + inc) % SPINNER.length];
|
||||||
|
if (spinnerElement.timeout) { clearTimeout(spinnerElement.timeout); }
|
||||||
|
spinnerElement.timeout = setTimeout(function () {
|
||||||
|
spinnerElement.textContent = '';
|
||||||
|
}, SPINNER_DISAPPEAR_TIME);
|
||||||
|
};
|
||||||
|
|
||||||
|
var createUserList = function ($container) {
|
||||||
|
var id = uid();
|
||||||
|
$container.append('<div class="' + USER_LIST_CLS + '" id="'+id+'"></div>');
|
||||||
|
return $container.find('#'+id)[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
var getOtherUsers = function(myUserName, userList) {
|
||||||
|
var i = 0;
|
||||||
|
var list = '';
|
||||||
|
userList.forEach(function(user) {
|
||||||
|
if(user !== myUserName) {
|
||||||
|
if(i === 0) list = ' : ';
|
||||||
|
list += user + ', ';
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (i > 0) ? list.slice(0, -2) : list;
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateUserList = function (myUserName, listElement, userList) {
|
||||||
|
var meIdx = userList.indexOf(myUserName);
|
||||||
|
if (meIdx === -1) {
|
||||||
|
listElement.textContent = Messages.synchronizing;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (userList.length === 1) {
|
||||||
|
listElement.innerHTML = Messages.editingAlone;
|
||||||
|
} else if (userList.length === 2) {
|
||||||
|
listElement.innerHTML = Messages.editingWithOneOtherPerson + getOtherUsers(myUserName, userList);
|
||||||
|
} else {
|
||||||
|
listElement.innerHTML = Messages.editingWith + ' ' + (userList.length - 1) + ' ' + Messages.otherPeople + getOtherUsers(myUserName, userList);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var createLagElement = function ($container) {
|
||||||
|
var id = uid();
|
||||||
|
$container.append('<div class="' + LAG_ELEM_CLS + '" id="'+id+'"></div>');
|
||||||
|
return $container.find('#'+id)[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
var checkLag = function (realtime, lagElement) {
|
||||||
|
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.textContent = lagMsg;
|
||||||
|
};
|
||||||
|
|
||||||
|
// this is a little hack, it should go in it's own file.
|
||||||
|
// FIXME ok, so let's put it in its own file then
|
||||||
|
// TODO there should also be a 'clear recent pads' button
|
||||||
|
var rememberPad = function () {
|
||||||
|
// FIXME, this is overly complicated, use array methods
|
||||||
|
var recentPadsStr = localStorage['CryptPad_RECENTPADS'];
|
||||||
|
var recentPads = [];
|
||||||
|
if (recentPadsStr) { recentPads = JSON.parse(recentPadsStr); }
|
||||||
|
// TODO use window.location.hash or something like that
|
||||||
|
if (window.location.href.indexOf('#') === -1) { return; }
|
||||||
|
var now = new Date();
|
||||||
|
var out = [];
|
||||||
|
for (var i = recentPads.length; i >= 0; i--) {
|
||||||
|
if (recentPads[i] &&
|
||||||
|
// TODO precompute this time value, maybe make it configurable?
|
||||||
|
// FIXME precompute the date too, why getTime every time?
|
||||||
|
now.getTime() - recentPads[i][1] < (1000*60*60*24*30) &&
|
||||||
|
recentPads[i][0] !== window.location.href)
|
||||||
|
{
|
||||||
|
out.push(recentPads[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push([window.location.href, now.getTime()]);
|
||||||
|
localStorage['CryptPad_RECENTPADS'] = JSON.stringify(out);
|
||||||
|
};
|
||||||
|
|
||||||
|
var create = function ($container, myUserName, realtime) {
|
||||||
|
var toolbar = createRealtimeToolbar($container);
|
||||||
|
createEscape(toolbar.find('.rtwysiwyg-toolbar-leftside'));
|
||||||
|
var userListElement = createUserList(toolbar.find('.rtwysiwyg-toolbar-leftside'));
|
||||||
|
var spinner = createSpinner(toolbar.find('.rtwysiwyg-toolbar-rightside'));
|
||||||
|
var lagElement = createLagElement(toolbar.find('.rtwysiwyg-toolbar-rightside'));
|
||||||
|
|
||||||
|
rememberPad();
|
||||||
|
|
||||||
|
var connected = false;
|
||||||
|
|
||||||
|
realtime.onUserListChange(function (userList) {
|
||||||
|
if (userList.indexOf(myUserName) !== -1) { connected = true; }
|
||||||
|
if (!connected) { return; }
|
||||||
|
updateUserList(myUserName, userListElement, userList);
|
||||||
|
});
|
||||||
|
|
||||||
|
var ks = function () {
|
||||||
|
if (connected) { kickSpinner(spinner, false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
realtime.onPatch(ks);
|
||||||
|
// Try to filter out non-patch messages, doesn't have to be perfect this is just the spinner
|
||||||
|
realtime.onMessage(function (msg) { if (msg.indexOf(':[2,') > -1) { ks(); } });
|
||||||
|
|
||||||
|
setInterval(function () {
|
||||||
|
if (!connected) { return; }
|
||||||
|
checkLag(realtime, lagElement);
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
failed: function () {
|
||||||
|
connected = false;
|
||||||
|
userListElement.textContent = '';
|
||||||
|
lagElement.textContent = '';
|
||||||
|
},
|
||||||
|
reconnecting: function () {
|
||||||
|
connected = false;
|
||||||
|
userListElement.textContent = Messages.reconnecting;
|
||||||
|
lagElement.textContent = '';
|
||||||
|
},
|
||||||
|
connected: function () {
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return { create: create };
|
||||||
|
});
|
Loading…
Reference in New Issue