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.

560 lines
22 KiB

9 months ago
1 year ago
1 year ago
1 year ago
1 year ago
3 years ago
3 years ago
  1. define([
  2. 'jquery',
  3. '/api/config',
  4. '/bower_components/marked/marked.min.js',
  5. '/common/common-hash.js',
  6. '/common/common-util.js',
  7. '/common/hyperscript.js',
  8. '/common/inner/common-mediatag.js',
  9. '/common/media-tag.js',
  10. '/common/highlight/highlight.pack.js',
  11. '/customize/messages.js',
  12. '/bower_components/diff-dom/diffDOM.js',
  13. '/bower_components/tweetnacl/nacl-fast.min.js',
  14. 'css!/common/highlight/styles/github.css'
  15. ],function ($, ApiConfig, Marked, Hash, Util, h, MT, MediaTag, Highlight, Messages) {
  16. var DiffMd = {};
  17. var DiffDOM = window.diffDOM;
  18. var renderer = new Marked.Renderer();
  19. var restrictedRenderer = new Marked.Renderer();
  20. var Mermaid = {
  21. init: function () {}
  22. };
  23. var mermaidThemeCSS = ".node rect { fill: #DDD; stroke: #AAA; } " +
  24. "rect.task, rect.task0, rect.task2 { stroke-width: 1 !important; rx: 0 !important; } " +
  25. "g.grid g.tick line { opacity: 0.25; }" +
  26. "g.today line { stroke: red; stroke-width: 1; stroke-dasharray: 3; opacity: 0.5; }";
  27. require(['mermaid', 'css!/code/mermaid-new.css'], function (_Mermaid) {
  28. Mermaid = _Mermaid;
  29. Mermaid.initialize({
  30. gantt: { axisFormat: '%m-%d', },
  31. "themeCSS": mermaidThemeCSS,
  32. });
  33. });
  34. var highlighter = function () {
  35. return function(code, lang) {
  36. if (lang) {
  37. try {
  38. return Highlight.highlight(lang, code).value;
  39. } catch (e) {
  40. return code;
  41. }
  42. }
  43. return code;
  44. };
  45. };
  46. Marked.setOptions({
  47. //sanitize: true, // Disable HTML
  48. renderer: renderer,
  49. highlight: highlighter(),
  50. });
  51. var toc = [];
  52. var getTOC = function () {
  53. var content = [h('h2', Messages.markdown_toc)];
  54. toc.forEach(function (obj) {
  55. // Only include level 2 headings
  56. var level = obj.level - 1;
  57. if (level < 1) { return; }
  58. var a = h('a.cp-md-toc-link', {
  59. href: '#',
  60. 'data-href': obj.id,
  61. });
  62. a.innerHTML = obj.title;
  63. content.push(h('p.cp-md-toc-'+level, ['• ', a]));
  64. });
  65. return h('div.cp-md-toc', content).outerHTML;
  66. };
  67. DiffMd.render = function (md, sanitize, restrictedMd) {
  68. Marked.setOptions({
  69. renderer: restrictedMd ? restrictedRenderer : renderer,
  70. });
  71. var r = Marked(md, {
  72. sanitize: sanitize
  73. });
  74. // Add Table of Content
  75. if (!restrictedMd) {
  76. r = r.replace(/<div class="cp-md-toc"><\/div>/g, getTOC());
  77. }
  78. toc = [];
  79. return r;
  80. };
  81. var mediaMap = {};
  82. var defaultCode = renderer.code;
  83. renderer.code = function (code, language) {
  84. if (language === 'mermaid' && code.match(/^(graph|pie|gantt|sequenceDiagram|classDiagram|gitGraph)/)) {
  85. return '<pre class="mermaid">'+Util.fixHTML(code)+'</pre>';
  86. } else {
  87. return defaultCode.apply(renderer, arguments);
  88. }
  89. };
  90. restrictedRenderer.code = renderer.code;
  91. renderer.heading = function (text, level) {
  92. var i = 0;
  93. var safeText = text.toLowerCase().replace(/[^\w]+/g, '-');
  94. var getId = function () {
  95. return 'cp-md-' + i + '-' + safeText;
  96. };
  97. var id = getId();
  98. var isAlreadyUsed = function (obj) { return obj.id === id; };
  99. while (toc.some(isAlreadyUsed)) {
  100. i++;
  101. id = getId();
  102. }
  103. toc.push({
  104. level: level,
  105. id: id,
  106. title: Util.stripTags(text)
  107. });
  108. return "<h" + level + " id=\"" + id + "\"><a href=\"#" + id + "\" class=\"anchor\"></a>" + text + "</h" + level + ">";
  109. };
  110. restrictedRenderer.heading = function (text) {
  111. return text;
  112. };
  113. // Tasks list
  114. var checkedTaskItemPtn = /^\s*(<p>)?\[[xX]\](<\/p>)?\s*/;
  115. var uncheckedTaskItemPtn = /^\s*(<p>)?\[ ?\](<\/p>)?\s*/;
  116. var bogusCheckPtn = /<input checked="" disabled="" type="checkbox">/;
  117. var bogusUncheckPtn = /<input disabled="" type="checkbox">/;
  118. renderer.listitem = function (text) {
  119. var isCheckedTaskItem = checkedTaskItemPtn.test(text);
  120. var isUncheckedTaskItem = uncheckedTaskItemPtn.test(text);
  121. var hasBogusCheckedInput = bogusCheckPtn.test(text);
  122. var hasBogusUncheckedInput = bogusUncheckPtn.test(text);
  123. var isCheckbox = true;
  124. if (isCheckedTaskItem) {
  125. text = text.replace(checkedTaskItemPtn,
  126. '<i class="fa fa-check-square" aria-hidden="true"></i>') + '\n';
  127. } else if (isUncheckedTaskItem) {
  128. text = text.replace(uncheckedTaskItemPtn,
  129. '<i class="fa fa-square-o" aria-hidden="true"></i>') + '\n';
  130. } else if (hasBogusCheckedInput) {
  131. text = text.replace(bogusCheckPtn,
  132. '<i class="fa fa-check-square" aria-hidden="true"></i>') + '\n';
  133. } else if (hasBogusUncheckedInput) {
  134. text = text.replace(bogusUncheckPtn,
  135. '<i class="fa fa-square-o" aria-hidden="true"></i>') + '\n';
  136. } else {
  137. isCheckbox = false;
  138. }
  139. var cls = (isCheckbox) ? ' class="todo-list-item"' : '';
  140. return '<li'+ cls + '>' + text + '</li>\n';
  141. };
  142. restrictedRenderer.listitem = function (text) {
  143. if (bogusCheckPtn.test(text)) {
  144. text = text.replace(bogusCheckPtn, '');
  145. }
  146. return '<li>' + text + '</li>\n';
  147. };
  148. renderer.image = function (href, title, text) {
  149. if (href.slice(0,6) === '/file/') {
  150. // DEPRECATED
  151. // Mediatag using markdown syntax should not be used anymore so they don't support
  152. // password-protected files
  153. console.log('DEPRECATED: mediatag using markdown syntax!');
  154. var parsed = Hash.parsePadUrl(href);
  155. var secret = Hash.getSecrets('file', parsed.hash);
  156. var src = (ApiConfig.fileHost || '') +Hash.getBlobPathFromHex(secret.channel);
  157. var key = Hash.encodeBase64(secret.keys.cryptKey);
  158. var mt = '<media-tag src="' + src + '" data-crypto-key="cryptpad:' + key + '"></media-tag>';
  159. if (mediaMap[src]) {
  160. mt += mediaMap[src];
  161. }
  162. mt += '</media-tag>';
  163. return mt;
  164. }
  165. var out = '<img src="' + href + '" alt="' + text + '"';
  166. if (title) {
  167. out += ' title="' + title + '"';
  168. }
  169. out += this.options.xhtml ? '/>' : '>';
  170. return out;
  171. };
  172. restrictedRenderer.image = renderer.image;
  173. var renderParagraph = function (p) {
  174. return /<media\-tag[\s\S]*>/i.test(p)? p + '\n': '<p>' + p + '</p>\n';
  175. };
  176. renderer.paragraph = function (p) {
  177. if (p === '[TOC]') {
  178. return '<p><div class="cp-md-toc"></div></p>';
  179. }
  180. return renderParagraph(p);
  181. };
  182. restrictedRenderer.paragraph = function (p) {
  183. return renderParagraph(p);
  184. };
  185. var MutationObserver = window.MutationObserver;
  186. var forbiddenTags = [
  187. 'SCRIPT',
  188. 'IFRAME',
  189. 'OBJECT',
  190. 'APPLET',
  191. 'VIDEO', // privacy implications of videos are the same as images
  192. 'AUDIO', // same with audio
  193. ];
  194. var unsafeTag = function (info) {
  195. /*if (info.node && $(info.node).parents('media-tag').length) {
  196. // Do not remove elements inside a media-tag
  197. return true;
  198. }*/
  199. if (['addAttribute', 'modifyAttribute'].indexOf(info.diff.action) !== -1) {
  200. if (/^on/i.test(info.diff.name)) {
  201. console.log("Rejecting forbidden element attribute with name", info.diff.name);
  202. return true;
  203. }
  204. }
  205. if (['addElement', 'replaceElement'].indexOf(info.diff.action) !== -1) {
  206. var msg = "Rejecting forbidden tag of type (%s)";
  207. if (info.diff.element && forbiddenTags.indexOf(info.diff.element.nodeName.toUpperCase()) !== -1) {
  208. console.log(msg, info.diff.element.nodeName);
  209. return true;
  210. } else if (info.diff.newValue && forbiddenTags.indexOf(info.diff.newValue.nodeName.toUpperCase()) !== -1) {
  211. console.log("Replacing restricted element type (%s) with PRE", info.diff.newValue.nodeName);
  212. info.diff.newValue.nodeName = 'PRE';
  213. }
  214. }
  215. };
  216. var slice = function (coll) {
  217. return Array.prototype.slice.call(coll);
  218. };
  219. var removeNode = function (node) {
  220. if (!(node && node.parentElement)) { return; }
  221. var parent = node.parentElement;
  222. if (!parent) { return; }
  223. console.log('removing %s tag', node.nodeName);
  224. parent.removeChild(node);
  225. };
  226. var removeForbiddenTags = function (root) {
  227. if (!root) { return; }
  228. if (forbiddenTags.indexOf(root.nodeName.toUpperCase()) !== -1) { removeNode(root); }
  229. slice(root.children).forEach(removeForbiddenTags);
  230. };
  231. /* remove listeners from the DOM */
  232. var removeListeners = function (root) {
  233. if (!root) { return; }
  234. slice(root.attributes).map(function (attr) {
  235. if (/^on/i.test(attr.name)) {
  236. console.log('removing attribute', attr.name, root.attributes[attr.name]);
  237. root.attributes.removeNamedItem(attr.name);
  238. }
  239. });
  240. // all the way down
  241. slice(root.children).forEach(removeListeners);
  242. };
  243. var domFromHTML = function (html) {
  244. var Dom = new DOMParser().parseFromString(html, "text/html");
  245. Dom.normalize();
  246. removeForbiddenTags(Dom.body);
  247. removeListeners(Dom.body);
  248. return Dom;
  249. };
  250. var DD = new DiffDOM({
  251. preDiffApply: function (info) {
  252. if (unsafeTag(info)) { return true; }
  253. },
  254. });
  255. var makeDiff = function (A, B, id) {
  256. var Err;
  257. var Els = [A, B].map(function (frag) {
  258. if (typeof(frag) === 'object') {
  259. if (!frag || (frag && !frag.body)) {
  260. Err = "No body";
  261. return;
  262. }
  263. var els = frag.body.querySelectorAll('#'+id);
  264. if (els.length) {
  265. return els[0];
  266. }
  267. }
  268. Err = 'No candidate found';
  269. });
  270. if (Err) { return Err; }
  271. var patch = DD.diff(Els[0], Els[1]);
  272. return patch;
  273. };
  274. var removeMermaidClickables = function ($el) {
  275. // find all links in the tree and do the following for each one
  276. $el.find('a').each(function (index, a) {
  277. var parent = a.parentElement;
  278. if (!parent) { return; }
  279. // iterate over the links' children and transform them into preceding children
  280. // to preserve their visible ordering
  281. slice(a.children).forEach(function (child) {
  282. parent.insertBefore(child, a);
  283. });
  284. // remove the link once it has been emptied
  285. $(a).remove();
  286. });
  287. // finally, find all 'clickable' items and remove the class
  288. $el.find('.clickable').removeClass('clickable');
  289. };
  290. var renderMermaid = function ($el) {
  291. Mermaid.init(undefined, $el);
  292. // clickable elements in mermaid don't work well with our sandboxing setup
  293. // the function below strips clickable elements but still leaves behind some artifacts
  294. // tippy tooltips might still be useful, so they're not removed. It would be
  295. // preferable to just support links, but this covers up a rough edge in the meantime
  296. removeMermaidClickables($el);
  297. };
  298. DiffMd.apply = function (newHtml, $content, common) {
  299. var contextMenu = common.importMediaTagMenu();
  300. var id = $content.attr('id');
  301. if (!id) { throw new Error("The element must have a valid id"); }
  302. var pattern = /(<media-tag src="([^"]*)" data-crypto-key="([^"]*)">)<\/media-tag>/g;
  303. var unsafe_newHtmlFixed = newHtml.replace(pattern, function (all, tag, src) {
  304. var mt = tag;
  305. if (mediaMap[src]) { mt += mediaMap[src]; }
  306. return mt + '</media-tag>';
  307. });
  308. var newDomFixed = domFromHTML(unsafe_newHtmlFixed);
  309. if (!newDomFixed || !newDomFixed.body) { return; }
  310. var safe_newHtmlFixed = newDomFixed.body.outerHTML;
  311. var $div = $('<div>', {id: id}).append(safe_newHtmlFixed);
  312. var Dom = domFromHTML($('<div>').append($div).html());
  313. $content[0].normalize();
  314. var mermaid_source = [];
  315. var mermaid_cache = {};
  316. var canonicalizeMermaidSource = function (src) {
  317. // ignore changes to empty lines, since that won't affect
  318. // since it will have no effect on the rendered charts
  319. return src.replace(/\n[ \t]*\n*[ \t]*\n/g, '\n');
  320. };
  321. // iterate over the unrendered mermaid inputs, caching their source as you go
  322. $(newDomFixed).find('pre.mermaid').each(function (index, el) {
  323. if (el.childNodes.length === 1 && el.childNodes[0].nodeType === 3) {
  324. var src = canonicalizeMermaidSource(el.childNodes[0].wholeText);
  325. el.setAttribute('mermaid-source', src);
  326. mermaid_source[index] = src;
  327. }
  328. });
  329. // remember the previous scroll position
  330. var $parent = $content.parent();
  331. var scrollTop = $parent.scrollTop();
  332. // iterate over rendered mermaid charts
  333. $content.find('pre.mermaid:not([processed="true"])').each(function (index, el) {
  334. // retrieve the attached source code which it was drawn
  335. var src = el.getAttribute('mermaid-source');
  336. /* The new source might have syntax errors that will prevent rendering.
  337. It might be preferable to keep the existing state instead of removing it
  338. if you don't have something better to display. Ideally we should display
  339. the cause of the syntax error so that the user knows what to correct. */
  340. //if (!Mermaid.parse(src)) { } // TODO
  341. // check if that source exists in the set of charts which are about to be rendered
  342. if (mermaid_source.indexOf(src) === -1) {
  343. // if it's not, then you can remove it
  344. if (el.parentNode && el.parentNode.children.length) {
  345. el.parentNode.removeChild(el);
  346. }
  347. } else if (el.childNodes.length === 1 && el.childNodes[0].nodeType !== 3) {
  348. // otherwise, confirm that the content of the rendered chart is not a text node
  349. // and keep a copy of it
  350. mermaid_cache[src] = el.childNodes[0];
  351. }
  352. });
  353. var oldDom = domFromHTML($content[0].outerHTML);
  354. var onPreview = function ($mt) {
  355. return function () {
  356. var isSvg = $mt.is('pre.mermaid');
  357. var mts = [];
  358. $content.find('media-tag, pre.mermaid').each(function (i, el) {
  359. if (el.nodeName.toLowerCase() === "pre") {
  360. var clone = el.cloneNode();
  361. return void mts.push({
  362. svg: clone,
  363. render: function () {
  364. var $el = $(clone);
  365. $el.text(clone.getAttribute('mermaid-source'));
  366. $el.attr('data-processed', '');
  367. renderMermaid($el);
  368. }
  369. });
  370. }
  371. var $el = $(el);
  372. mts.push({
  373. src: $el.attr('src'),
  374. key: $el.attr('data-crypto-key')
  375. });
  376. });
  377. // Find initial position
  378. var idx = -1;
  379. mts.some(function (obj, i) {
  380. if (isSvg && $mt.attr('mermaid-source') === $(obj.svg).attr('mermaid-source')) {
  381. idx = i;
  382. return true;
  383. }
  384. if (!isSvg && obj.src === $mt.attr('src')) {
  385. idx = i;
  386. return true;
  387. }
  388. });
  389. if (idx === -1) {
  390. if (isSvg) {
  391. var clone = $mt[0].cloneNode();
  392. mts.unshift({
  393. svg: clone,
  394. render: function () {
  395. var $el = $(clone);
  396. $el.text(clone.getAttribute('mermaid-source'));
  397. $el.attr('data-processed', '');
  398. renderMermaid($el);
  399. }
  400. });
  401. } else {
  402. mts.unshift({
  403. src: $mt.attr('src'),
  404. key: $mt.attr('data-crypto-key')
  405. });
  406. }
  407. idx = 0;
  408. }
  409. setTimeout(function () {
  410. common.getMediaTagPreview(mts, idx);
  411. });
  412. };
  413. };
  414. var patch = makeDiff(oldDom, Dom, id);
  415. if (typeof(patch) === 'string') {
  416. throw new Error(patch);
  417. } else {
  418. DD.apply($content[0], patch);
  419. var $mts = $content.find('media-tag');
  420. $mts.each(function (i, el) {
  421. var $mt = $(el).contextmenu(function (e) {
  422. e.preventDefault();
  423. $(contextMenu.menu).data('mediatag', $(el));
  424. $(contextMenu.menu).find('li').show();
  425. contextMenu.show(e);
  426. });
  427. if ($mt.children().length) {
  428. $mt.off('click dblclick preview');
  429. $mt.on('preview', onPreview($mt));
  430. if ($mt.find('img').length) {
  431. $mt.on('click dblclick', function () {
  432. $mt.trigger('preview');
  433. });
  434. }
  435. return;
  436. }
  437. MediaTag(el);
  438. var observer = new MutationObserver(function(mutations) {
  439. mutations.forEach(function(mutation) {
  440. if (mutation.type === 'childList') {
  441. var list_values = slice(mutation.target.children)
  442. .map(function (el) { return el.outerHTML; })
  443. .join('');
  444. mediaMap[mutation.target.getAttribute('src')] = list_values;
  445. observer.disconnect();
  446. }
  447. });
  448. $mt.off('click dblclick preview');
  449. $mt.on('preview', onPreview($mt));
  450. if ($mt.find('img').length) {
  451. $mt.on('click dblclick', function () {
  452. $mt.trigger('preview');
  453. });
  454. }
  455. });
  456. observer.observe(el, {
  457. attributes: false,
  458. childList: true,
  459. characterData: false
  460. });
  461. });
  462. // Fix Table of contents links
  463. $content.find('a.cp-md-toc-link').off('click').click(function (e) {
  464. e.preventDefault();
  465. e.stopPropagation();
  466. var $a = $(this);
  467. if (!$a.attr('data-href')) { return; }
  468. var target = document.getElementById($a.attr('data-href'));
  469. if (target) { target.scrollIntoView(); }
  470. });
  471. // loop over mermaid elements in the rendered content
  472. $content.find('pre.mermaid').each(function (index, el) {
  473. var $el = $(el);
  474. $el.off('contextmenu').on('contextmenu', function (e) {
  475. e.preventDefault();
  476. $(contextMenu.menu).data('mediatag', $el);
  477. $(contextMenu.menu).find('li:not(.cp-svg)').hide();
  478. contextMenu.show(e);
  479. });
  480. $el.off('dblclick click preview');
  481. $el.on('preview', onPreview($el));
  482. $el.on('dblclick click', function () {
  483. $el.trigger('preview');
  484. });
  485. // since you've simply drawn the content that was supplied via markdown
  486. // you can assume that the index of your rendered charts matches that
  487. // of those in the markdown source.
  488. var src = mermaid_source[index];
  489. el.setAttribute('mermaid-source', src);
  490. var cached = mermaid_cache[src];
  491. // check if you had cached a pre-rendered instance of the supplied source
  492. if (typeof(cached) !== 'object') {
  493. try {
  494. renderMermaid($el);
  495. } catch (e) { console.error(e); }
  496. return;
  497. }
  498. // if there's a cached rendering, empty out the contained source code
  499. // which would otherwise be drawn again.
  500. // apparently this is the fastest way to empty out an element
  501. while (el.firstChild) { el.removeChild(el.firstChild); } //el.innerHTML = '';
  502. // insert the cached graph
  503. el.appendChild(cached);
  504. // and set a flag indicating that this graph need not be reprocessed
  505. el.setAttribute('data-processed', true);
  506. });
  507. }
  508. // recover the previous scroll position to avoid jank
  509. $parent.scrollTop(scrollTop);
  510. };
  511. return DiffMd;
  512. });