You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

483 lines
17 KiB

  1. /*
  2. * Copyright 2014 XWiki SAS
  3. *
  4. * This program is free software: you can redistribute it and/or modify
  5. * it under the terms of the GNU Affero General Public License as published by
  6. * the Free Software Foundation, either version 3 of the License, or
  7. * (at your option) any later version.
  8. *
  9. * This program is distributed in the hope that it will be useful,
  10. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  11. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  12. * GNU Affero General Public License for more details.
  13. *
  14. * You should have received a copy of the GNU Affero General Public License
  15. * along with this program. If not, see <http://www.gnu.org/licenses/>.
  16. */
  17. define([
  18. '/bower_components/jquery/dist/jquery.min.js',
  19. '/common/otaml.js'
  20. ], function () {
  21. var $ = jQuery;
  22. var Otaml = window.Otaml;
  23. var module = { exports: {} };
  24. var PARANOIA = true;
  25. var debug = function (x) { };
  26. debug = function (x) { console.log(x); };
  27. var getNextSiblingDeep = function (node, parent)
  28. {
  29. if (node.firstChild) { return node.firstChild; }
  30. do {
  31. if (node.nextSibling) { return node.nextSibling; }
  32. node = node.parentNode;
  33. } while (node && node !== parent);
  34. };
  35. var getOuterHTML = function (node)
  36. {
  37. var html = node.outerHTML;
  38. if (html) { return html; }
  39. if (node.parentNode && node.parentNode.childNodes.length === 1) {
  40. return node.parentNode.innerHTML;
  41. }
  42. var div = document.createElement('div');
  43. div.appendChild(node.cloneNode(true));
  44. return div.innerHTML;
  45. };
  46. var nodeFromHTML = function (html)
  47. {
  48. var e = document.createElement('div');
  49. e.innerHTML = html;
  50. return e.childNodes[0];
  51. };
  52. var getInnerHTML = function (node)
  53. {
  54. var html = node.innerHTML;
  55. if (html) { return html; }
  56. var outerHTML = getOuterHTML(node);
  57. var tw = Otaml.tagWidth(outerHTML);
  58. if (!tw) { return outerHTML; }
  59. return outerHTML.substring(tw, outerHTML.lastIndexOf('</'));
  60. };
  61. var uniqueId = function () { return 'uid-'+(''+Math.random()).slice(2); };
  62. var offsetOfNodeOuterHTML = function (docText, node, dom, ifrWindow)
  63. {
  64. if (PARANOIA && getInnerHTML(dom) !== docText) { throw new Error(); }
  65. if (PARANOIA && !node) { throw new Error(); }
  66. // can't get the index of the outerHTML of the dom in a string with only the innerHTML.
  67. if (node === dom) { throw new Error(); }
  68. var content = getOuterHTML(node);
  69. var idx = docText.lastIndexOf(content);
  70. if (idx === -1) { throw new Error(); }
  71. if (idx !== docText.indexOf(content)) {
  72. var idTag = uniqueId();
  73. var span = ifrWindow.document.createElement('span');
  74. span.setAttribute('id', idTag);
  75. var spanHTML = '<span id="'+idTag+'"></span>';
  76. if (PARANOIA && spanHTML !== span.outerHTML) { throw new Error(); }
  77. node.parentNode.insertBefore(span, node);
  78. var newDocText = getInnerHTML(dom);
  79. idx = newDocText.lastIndexOf(spanHTML);
  80. if (idx === -1 || idx !== newDocText.indexOf(spanHTML)) { throw new Error(); }
  81. node.parentNode.removeChild(span);
  82. if (PARANOIA && getInnerHTML(dom) !== docText) { throw new Error(); }
  83. }
  84. if (PARANOIA && docText.indexOf(content, idx) !== idx) { throw new Error() }
  85. return idx;
  86. };
  87. var patchString = module.exports.patchString = function (oldString, offset, toRemove, toInsert)
  88. {
  89. return oldString.substring(0, offset) + toInsert + oldString.substring(offset + toRemove);
  90. };
  91. var getNodeAtOffset = function (docText, offset, dom)
  92. {
  93. if (PARANOIA && dom.childNodes.length && docText !== dom.innerHTML) { throw new Error(); }
  94. if (offset < 0) { throw new Error(); }
  95. var idx = 0;
  96. for (var i = 0; i < dom.childNodes.length; i++) {
  97. var childOuterHTML = getOuterHTML(dom.childNodes[i]);
  98. if (PARANOIA && docText.indexOf(childOuterHTML, idx) !== idx) { throw new Error(); }
  99. if (i === 0 && idx >= offset) {
  100. return { node: dom, pos: 0 };
  101. }
  102. if (idx + childOuterHTML.length > offset) {
  103. var childInnerHTML = childOuterHTML;
  104. var tw = Otaml.tagWidth(childOuterHTML);
  105. if (tw) {
  106. childInnerHTML = childOuterHTML.substring(tw, childOuterHTML.lastIndexOf('</'));
  107. }
  108. if (offset - idx - tw < 0) {
  109. if (offset - idx === 0) {
  110. return { node: dom.childNodes[i], pos: 0 };
  111. }
  112. break;
  113. }
  114. return getNodeAtOffset(childInnerHTML, offset - idx - tw, dom.childNodes[i]);
  115. }
  116. idx += childOuterHTML.length;
  117. }
  118. if (dom.nodeName[0] === '#text') {
  119. if (offset > docText.length) { throw new Error(); }
  120. var beforeOffset = docText.substring(0, offset);
  121. if (beforeOffset.indexOf('&') > -1) {
  122. var tn = nodeFromHTML(beforeOffset);
  123. offset = tn.data.length;
  124. }
  125. } else {
  126. offset = 0;
  127. }
  128. return { node: dom, pos: offset };
  129. };
  130. var relocatedPositionInNode = function (newNode, oldNode, offset)
  131. {
  132. if (newNode.nodeName !== '#text' || oldNode.nodeName !== '#text' || offset === 0) {
  133. offset = 0;
  134. } else if (oldNode.data === newNode.data) {
  135. // fallthrough
  136. } else if (offset > newNode.length) {
  137. offset = newNode.length;
  138. } else if (oldNode.data.substring(0, offset) === newNode.data.substring(0, offset)) {
  139. // keep same offset and fall through
  140. } else {
  141. var rOffset = oldNode.length - offset;
  142. if (oldNode.data.substring(offset) ===
  143. newNode.data.substring(newNode.length - rOffset))
  144. {
  145. offset = newNode.length - rOffset;
  146. } else {
  147. offset = 0;
  148. }
  149. }
  150. return { node: newNode, pos: offset };
  151. };
  152. var pushNode = function (list, node) {
  153. if (node.nodeName === '#text') {
  154. list.push.apply(list, node.data.split(''));
  155. } else {
  156. list.push('#' + node.nodeName);
  157. }
  158. };
  159. var getChildPath = function (parent) {
  160. var out = [];
  161. for (var next = parent; next; next = getNextSiblingDeep(next, parent)) {
  162. pushNode(out, next);
  163. }
  164. return out;
  165. };
  166. var tryFromBeginning = function (oldPath, newPath) {
  167. for (var i = 0; i < oldPath.length; i++) {
  168. if (oldPath[i] !== newPath[i]) { return i; }
  169. }
  170. return oldPath.length;
  171. };
  172. var tryFromEnd = function (oldPath, newPath) {
  173. for (var i = 1; i <= oldPath.length; i++) {
  174. if (oldPath[oldPath.length - i] !== newPath[newPath.length - i]) {
  175. return false;
  176. }
  177. }
  178. return true;
  179. };
  180. /**
  181. * returns 2 arrays (before and after).
  182. * before is string representations (see nodeId()) of all nodes before the target
  183. * node and after is representations of all nodes which follow.
  184. */
  185. var getNodePaths = function (parent, node) {
  186. var before = [];
  187. var next = parent;
  188. for (; next && next !== node; next = getNextSiblingDeep(next, parent)) {
  189. pushNode(before, next);
  190. }
  191. if (next !== node) { throw new Error(); }
  192. var after = [];
  193. next = getNextSiblingDeep(next, parent);
  194. for (; next; next = getNextSiblingDeep(next, parent)) {
  195. pushNode(after, next);
  196. }
  197. return { before: before, after: after };
  198. };
  199. var nodeAtIndex = function (parent, idx) {
  200. var node = parent;
  201. for (var i = 0; i < idx; i++) {
  202. if (node.nodeName === '#text') {
  203. if (i + node.data.length > idx) { return node; }
  204. i += node.data.length - 1;
  205. }
  206. node = getNextSiblingDeep(node);
  207. }
  208. return node;
  209. };
  210. var getRelocatedPosition = function (newParent, oldParent, oldNode, oldOffset, origText, op)
  211. {
  212. var newPath = getChildPath(newParent);
  213. if (newPath.length === 1) {
  214. return { node: null, pos: 0 };
  215. }
  216. var oldPaths = getNodePaths(oldParent, oldNode);
  217. var idx = -1;
  218. var fromBeginning = tryFromBeginning(oldPaths.before, newPath);
  219. if (fromBeginning === oldPaths.before.length) {
  220. idx = oldPaths.before.length;
  221. } else if (tryFromEnd(oldPaths.after, newPath)) {
  222. idx = (newPath.length - oldPaths.after.length - 1);
  223. } else {
  224. idx = fromBeginning;
  225. var id = 'relocate-' + String(Math.random()).substring(2);
  226. $(document.body).append('<textarea id="'+id+'"></textarea>');
  227. $('#'+id).val(JSON.stringify([origText, op, newPath, getChildPath(oldParent), oldPaths]));
  228. }
  229. var out = nodeAtIndex(newParent, idx);
  230. return relocatedPositionInNode(out, oldNode, oldOffset);
  231. };
  232. // We can't create a real range until the new parent is installed in the document
  233. // but we need the old range to be in the document so we can do comparisons
  234. // so create a "pseudo" range instead.
  235. var getRelocatedPseudoRange = function (newParent, oldParent, range, origText, op)
  236. {
  237. if (!range.startContainer) {
  238. throw new Error();
  239. }
  240. if (!newParent) { throw new Error(); }
  241. // Copy because tinkering in the dom messes up the original range.
  242. var startContainer = range.startContainer;
  243. var startOffset = range.startOffset;
  244. var endContainer = range.endContainer;
  245. var endOffset = range.endOffset;
  246. var newStart =
  247. getRelocatedPosition(newParent, oldParent, startContainer, startOffset, origText, op);
  248. if (!newStart.node) {
  249. // there is probably nothing left of the document so just clear the selection.
  250. endContainer = null;
  251. }
  252. var newEnd = { node: newStart.node, pos: newStart.pos };
  253. if (endContainer) {
  254. if (endContainer !== startContainer) {
  255. newEnd = getRelocatedPosition(newParent, oldParent, endContainer, endOffset, origText, op);
  256. } else if (endOffset !== startOffset) {
  257. newEnd = {
  258. node: newStart.node,
  259. pos: relocatedPositionInNode(newStart.node, endContainer, endOffset).pos
  260. };
  261. } else {
  262. newEnd = { node: newStart.node, pos: newStart.pos };
  263. }
  264. }
  265. return { start: newStart, end: newEnd };
  266. };
  267. var replaceAllChildren = function (parent, newParent)
  268. {
  269. var c;
  270. while ((c = parent.firstChild)) {
  271. parent.removeChild(c);
  272. }
  273. while ((c = newParent.firstChild)) {
  274. newParent.removeChild(c);
  275. parent.appendChild(c);
  276. }
  277. };
  278. var isAncestorOf = function (maybeDecendent, maybeAncestor) {
  279. while ((maybeDecendent = maybeDecendent.parentNode)) {
  280. if (maybeDecendent === maybeAncestor) { return true; }
  281. }
  282. return false;
  283. };
  284. var getSelectedRange = function (rangy, ifrWindow, selection) {
  285. selection = selection || rangy.getSelection(ifrWindow);
  286. if (selection.rangeCount === 0) {
  287. return;
  288. }
  289. var range = selection.getRangeAt(0);
  290. range.backward = (selection.rangeCount === 1 && selection.isBackward());
  291. if (!range.startContainer) {
  292. throw new Error();
  293. }
  294. // Occasionally, some browsers *cough* firefox *cough* will attach the range to something
  295. // which has been used in the past but is nolonger part of the dom...
  296. if (range.startContainer &&
  297. isAncestorOf(range.startContainer, ifrWindow.document))
  298. {
  299. return range;
  300. }
  301. return;
  302. };
  303. var applyHTMLOp = function (docText, op, dom, rangy, ifrWindow)
  304. {
  305. var parent = getNodeAtOffset(docText, op.offset, dom).node;
  306. var htmlToRemove = docText.substring(op.offset, op.offset + op.toRemove);
  307. var parentInnerHTML;
  308. var indexOfInnerHTML;
  309. var localOffset;
  310. for (;;) {
  311. for (;;) {
  312. parentInnerHTML = parent.innerHTML;
  313. if (typeof(parentInnerHTML) !== 'undefined'
  314. && parentInnerHTML.indexOf(htmlToRemove) !== -1)
  315. {
  316. break;
  317. }
  318. if (parent === dom || !(parent = parent.parentNode)) { throw new Error(); }
  319. }
  320. var indexOfOuterHTML = 0;
  321. var tw = 0;
  322. if (parent !== dom) {
  323. indexOfOuterHTML = offsetOfNodeOuterHTML(docText, parent, dom, ifrWindow);
  324. tw = Otaml.tagWidth(docText.substring(indexOfOuterHTML));
  325. }
  326. indexOfInnerHTML = indexOfOuterHTML + tw;
  327. localOffset = op.offset - indexOfInnerHTML;
  328. if (localOffset >= 0 && localOffset + op.toRemove <= parentInnerHTML.length) {
  329. break;
  330. }
  331. parent = parent.parentNode;
  332. if (!parent) { throw new Error(); }
  333. }
  334. if (PARANOIA &&
  335. docText.substr(indexOfInnerHTML, parentInnerHTML.length) !== parentInnerHTML)
  336. {
  337. throw new Error();
  338. }
  339. var newParentInnerHTML =
  340. patchString(parentInnerHTML, localOffset, op.toRemove, op.toInsert);
  341. // Create a temp container for holding the children of the parent node.
  342. // Once we've identified the new range, we'll return the nodes to the
  343. // original parent. This is because parent might be the <body> and we
  344. // don't want to destroy all of our event listeners.
  345. var babysitter = ifrWindow.document.createElement('div');
  346. // give it a uid so that we can prove later that it's not in the document,
  347. // see getSelectedRange()
  348. babysitter.setAttribute('id', uniqueId());
  349. babysitter.innerHTML = newParentInnerHTML;
  350. var range = getSelectedRange(rangy, ifrWindow);
  351. // doesn't intersect at all
  352. if (!range || !range.containsNode(parent, true)) {
  353. replaceAllChildren(parent, babysitter);
  354. return;
  355. }
  356. var pseudoRange = getRelocatedPseudoRange(babysitter, parent, range, rangy);
  357. range.detach();
  358. replaceAllChildren(parent, babysitter);
  359. if (pseudoRange.start.node) {
  360. var selection = rangy.getSelection(ifrWindow);
  361. var newRange = rangy.createRange();
  362. newRange.setStart(pseudoRange.start.node, pseudoRange.start.pos);
  363. newRange.setEnd(pseudoRange.end.node, pseudoRange.end.pos);
  364. selection.setSingleRange(newRange);
  365. }
  366. return;
  367. };
  368. var applyHTMLOpHammer = function (docText, op, dom, rangy, ifrWindow)
  369. {
  370. var newDocText = patchString(docText, op.offset, op.toRemove, op.toInsert);
  371. var babysitter = ifrWindow.document.createElement('body');
  372. // give it a uid so that we can prove later that it's not in the document,
  373. // see getSelectedRange()
  374. babysitter.setAttribute('id', uniqueId());
  375. babysitter.innerHTML = newDocText;
  376. var range = getSelectedRange(rangy, ifrWindow);
  377. // doesn't intersect at all
  378. if (!range) {
  379. replaceAllChildren(dom, babysitter);
  380. return;
  381. }
  382. var pseudoRange = getRelocatedPseudoRange(babysitter, dom, range, docText, op);
  383. range.detach();
  384. replaceAllChildren(dom, babysitter);
  385. if (pseudoRange.start.node) {
  386. var selection = rangy.getSelection(ifrWindow);
  387. var newRange = rangy.createRange();
  388. newRange.setStart(pseudoRange.start.node, pseudoRange.start.pos);
  389. newRange.setEnd(pseudoRange.end.node, pseudoRange.end.pos);
  390. selection.setSingleRange(newRange);
  391. }
  392. return;
  393. };
  394. /* Return whether the selection range has been "dirtied" and needs to be reloaded. */
  395. var applyOp = module.exports.applyOp = function (docText, op, dom, rangy, ifrWindow)
  396. {
  397. if (PARANOIA && docText !== getInnerHTML(dom)) { throw new Error(); }
  398. if (op.offset + op.toRemove > docText.length) {
  399. throw new Error();
  400. }
  401. try {
  402. applyHTMLOpHammer(docText, op, dom, rangy, ifrWindow);
  403. var result = patchString(docText, op.offset, op.toRemove, op.toInsert);
  404. var innerHTML = getInnerHTML(dom);
  405. if (result !== innerHTML) {
  406. $(document.body).append('<textarea id="statebox"></textarea>');
  407. $(document.body).append('<textarea id="errorbox"></textarea>');
  408. var SEP = '\n\n\n\n\n\n\n\n\n\n';
  409. $('#statebox').val(docText + SEP + result + SEP + innerHTML);
  410. var diff = Otaml.makeTextOperation(result, innerHTML);
  411. $('#errorbox').val(JSON.stringify(op) + '\n' + JSON.stringify(diff));
  412. throw new Error();
  413. }
  414. } catch (err) {
  415. if (PARANOIA) { console.log(err.stack); }
  416. // The big hammer
  417. dom.innerHTML = patchString(docText, op.offset, op.toRemove, op.toInsert);
  418. }
  419. };
  420. return module.exports;
  421. });