editor.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415
  1. import { ReactiveCache } from '/imports/reactiveCache';
  2. const specialHandles = [
  3. {userId: 'board_members', username: 'board_members'},
  4. {userId: 'card_members', username: 'card_members'}
  5. ];
  6. const specialHandleNames = specialHandles.map(m => m.username);
  7. BlazeComponent.extendComponent({
  8. onRendered() {
  9. const textareaSelector = 'textarea';
  10. const mentions = [
  11. // User mentions
  12. {
  13. match: /\B@([\w.-]*)$/,
  14. search(term, callback) {
  15. const currentBoard = Utils.getCurrentBoard();
  16. callback(
  17. _.union(
  18. currentBoard
  19. .activeMembers()
  20. .map(member => {
  21. const user = ReactiveCache.getUser(member.userId);
  22. const username = user.username;
  23. const fullName = user.profile && user.profile !== undefined && user.profile.fullname ? user.profile.fullname : "";
  24. return username.includes(term) || fullName.includes(term) ? user : null;
  25. })
  26. .filter(Boolean), [...specialHandles])
  27. );
  28. },
  29. template(user) {
  30. if (user.profile && user.profile.fullname) {
  31. return (user.profile.fullname + " (" + user.username + ")");
  32. }
  33. return user.username;
  34. },
  35. replace(user) {
  36. if (user.profile && user.profile.fullname) {
  37. return `@${user.username} (${user.profile.fullname}) `;
  38. }
  39. return `@${user.username} `;
  40. },
  41. index: 1,
  42. },
  43. ];
  44. const enableTextarea = function() {
  45. const $textarea = this.$(textareaSelector);
  46. autosize($textarea);
  47. $textarea.escapeableTextComplete(mentions);
  48. };
  49. if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) {
  50. const isSmall = Utils.isMiniScreen();
  51. const toolbar = isSmall
  52. ? [
  53. ['view', ['fullscreen']],
  54. ['table', ['table']],
  55. ['font', ['bold', 'underline']],
  56. //['fontsize', ['fontsize']],
  57. ['color', ['color']],
  58. ]
  59. : [
  60. ['style', ['style']],
  61. ['font', ['bold', 'underline', 'clear']],
  62. ['fontsize', ['fontsize']],
  63. ['fontname', ['fontname']],
  64. ['color', ['color']],
  65. ['para', ['ul', 'ol', 'paragraph']],
  66. ['table', ['table']],
  67. //['insert', ['link', 'picture', 'video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled
  68. ['insert', ['link']], //, 'picture']], // modal popup has issue somehow :(
  69. ['view', ['fullscreen', 'codeview', 'help']],
  70. ];
  71. const cleanPastedHTML = function(input) {
  72. const badTags = [
  73. 'style',
  74. 'script',
  75. 'applet',
  76. 'embed',
  77. 'noframes',
  78. 'noscript',
  79. 'meta',
  80. 'link',
  81. 'button',
  82. 'form',
  83. ].join('|');
  84. const badPatterns = new RegExp(
  85. `(?:${[
  86. `<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`,
  87. `<(${badTags})[^>]*?\\/>`,
  88. ].join('|')})`,
  89. 'gi',
  90. );
  91. let output = input;
  92. // remove bad Tags
  93. output = output.replace(badPatterns, '');
  94. // remove attributes ' style="..."'
  95. const badAttributes = new RegExp(
  96. `(?:${[
  97. 'on\\S+=([\'"]?).*?\\1',
  98. 'href=([\'"]?)javascript:.*?\\2',
  99. 'style=([\'"]?).*?\\3',
  100. 'target=\\S+',
  101. ].join('|')})`,
  102. 'gi',
  103. );
  104. output = output.replace(badAttributes, '');
  105. output = output.replace(/(<a )/gi, '$1target=_ '); // always to new target
  106. return output;
  107. };
  108. const editor = '.editor';
  109. const selectors = [
  110. `.js-new-description-form ${editor}`,
  111. `.js-new-comment-form ${editor}`,
  112. `.js-edit-comment ${editor}`,
  113. ].join(','); // only new comment and edit comment
  114. const inputs = $(selectors);
  115. if (inputs.length === 0) {
  116. // only enable richereditor to new comment or edit comment no others
  117. enableTextarea();
  118. } else {
  119. const placeholder = inputs.attr('placeholder') || '';
  120. const mSummernotes = [];
  121. const getSummernote = function(input) {
  122. const idx = inputs.index(input);
  123. if (idx > -1) {
  124. return mSummernotes[idx];
  125. }
  126. return undefined;
  127. };
  128. inputs.each(function(idx, input) {
  129. mSummernotes[idx] = $(input).summernote({
  130. placeholder,
  131. callbacks: {
  132. onInit(object) {
  133. const originalInput = this;
  134. $(originalInput).on('submitted', function() {
  135. // when comment is submitted, the original textarea will be set to '', so shall we
  136. if (!this.value) {
  137. const sn = getSummernote(this);
  138. sn && sn.summernote('code', '');
  139. }
  140. });
  141. const jEditor = object && object.editable;
  142. const toolbar = object && object.toolbar;
  143. if (jEditor !== undefined) {
  144. jEditor.escapeableTextComplete(mentions);
  145. }
  146. if (toolbar !== undefined) {
  147. const fBtn = toolbar.find('.btn-fullscreen');
  148. fBtn.on('click', function() {
  149. const $this = $(this),
  150. isActive = $this.hasClass('active');
  151. $('.minicards,#header-quick-access').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually
  152. });
  153. }
  154. },
  155. onImageUpload(files) {
  156. const $summernote = getSummernote(this);
  157. if (files && files.length > 0) {
  158. const image = files[0];
  159. const currentCard = Utils.getCurrentCard();
  160. const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
  161. const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
  162. const processUpload = function(file) {
  163. const uploader = Attachments.insert(
  164. {
  165. file,
  166. meta: Utils.getCommonAttachmentMetaFrom(card),
  167. chunkSize: 'dynamic',
  168. },
  169. false,
  170. );
  171. uploader.on('uploaded', (error, fileRef) => {
  172. if (!error) {
  173. if (fileRef.isImage) {
  174. const img = document.createElement('img');
  175. img.src = fileRef.link();
  176. img.setAttribute('width', '100%');
  177. $summernote.summernote('insertNode', img);
  178. }
  179. }
  180. });
  181. uploader.start();
  182. };
  183. if (MAX_IMAGE_PIXEL) {
  184. const reader = new FileReader();
  185. reader.onload = function(e) {
  186. const dataurl = e && e.target && e.target.result;
  187. if (dataurl !== undefined) {
  188. // need to shrink image
  189. Utils.shrinkImage({
  190. dataurl,
  191. maxSize: MAX_IMAGE_PIXEL,
  192. ratio: COMPRESS_RATIO,
  193. toBlob: true,
  194. callback(blob) {
  195. if (blob !== false) {
  196. blob.name = image.name;
  197. processUpload(blob);
  198. }
  199. },
  200. });
  201. }
  202. };
  203. reader.readAsDataURL(image);
  204. } else {
  205. processUpload(image);
  206. }
  207. }
  208. },
  209. onPaste(e) {
  210. var clipboardData = e.clipboardData;
  211. var pastedData = clipboardData.getData('Text');
  212. //if pasted data is an image, exit
  213. if (!pastedData.length) {
  214. e.preventDefault();
  215. return;
  216. }
  217. // clear up unwanted tag info when user pasted in text
  218. const thisNote = this;
  219. const updatePastedText = function(object) {
  220. const someNote = getSummernote(object);
  221. // Fix Pasting text into a card is adding a line before and after
  222. // (and multiplies by pasting more) by changing paste "p" to "br".
  223. // Fixes https://github.com/wekan/wekan/2890 .
  224. // == Fix Start ==
  225. someNote.execCommand('defaultParagraphSeparator', false, 'br');
  226. // == Fix End ==
  227. const original = someNote.summernote('code');
  228. const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML.
  229. someNote.summernote('code', ''); //clear original
  230. someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code.
  231. };
  232. setTimeout(function() {
  233. //this kinda sucks, but if you don't do a setTimeout,
  234. //the function is called before the text is really pasted.
  235. updatePastedText(thisNote);
  236. }, 10);
  237. },
  238. },
  239. dialogsInBody: true,
  240. spellCheck: true,
  241. disableGrammar: false,
  242. disableDragAndDrop: false,
  243. toolbar,
  244. popover: {
  245. image: [
  246. ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']],
  247. ['float', ['floatLeft', 'floatRight', 'floatNone']],
  248. ['remove', ['removeMedia']],
  249. ],
  250. link: [['link', ['linkDialogShow', 'unlink']]],
  251. table: [
  252. ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
  253. ['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
  254. ],
  255. air: [
  256. ['color', ['color']],
  257. ['font', ['bold', 'underline', 'clear']],
  258. ],
  259. },
  260. height: 200,
  261. });
  262. });
  263. }
  264. } else {
  265. enableTextarea();
  266. }
  267. },
  268. events() {
  269. return [
  270. {
  271. 'click a.fa.fa-copy'(event) {
  272. const $editor = this.$('textarea.editor');
  273. const promise = Utils.copyTextToClipboard($editor[0].value);
  274. const $tooltip = this.$('.copied-tooltip');
  275. Utils.showCopied(promise, $tooltip);
  276. },
  277. }
  278. ]
  279. }
  280. }).register('editor');
  281. import DOMPurify from 'dompurify';
  282. // Additional safeAttrValue function to allow for other specific protocols
  283. // See https://github.com/leizongmin/js-xss/issues/52#issuecomment-241354114
  284. /*
  285. function mySafeAttrValue(tag, name, value, cssFilter) {
  286. // only when the tag is 'a' and attribute is 'href'
  287. // then use your custom function
  288. if (tag === 'a' && name === 'href') {
  289. // only filter the value if starts with 'cbthunderlink:' or 'aodroplink'
  290. if (
  291. /^thunderlink:/gi.test(value) ||
  292. /^cbthunderlink:/gi.test(value) ||
  293. /^aodroplink:/gi.test(value) ||
  294. /^onenote:/gi.test(value) ||
  295. /^file:/gi.test(value) ||
  296. /^abasurl:/gi.test(value) ||
  297. /^conisio:/gi.test(value) ||
  298. /^mailspring:/gi.test(value)
  299. ) {
  300. return value;
  301. } else {
  302. // use the default safeAttrValue function to process all non cbthunderlinks
  303. return sanitizeXss.safeAttrValue(tag, name, value, cssFilter);
  304. }
  305. } else {
  306. // use the default safeAttrValue function to process it
  307. return sanitizeXss.safeAttrValue(tag, name, value, cssFilter);
  308. }
  309. }
  310. */
  311. // XXX I believe we should compute a HTML rendered field on the server that
  312. // would handle markdown and user mentions. We can simply have two
  313. // fields, one source, and one compiled version (in HTML) and send only the
  314. // compiled version to most users -- who don't need to edit.
  315. // In the meantime, all the transformation are done on the client using the
  316. // Blaze API.
  317. const at = HTML.CharRef({ html: '&commat;', str: '@' });
  318. Blaze.Template.registerHelper(
  319. 'mentions',
  320. new Template('mentions', function() {
  321. const view = this;
  322. let content = Blaze.toHTML(view.templateContentBlock);
  323. const currentBoard = Utils.getCurrentBoard();
  324. if (!currentBoard)
  325. return HTML.Raw(
  326. DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),
  327. );
  328. const knowedUsers = _.union(currentBoard.members.map(member => {
  329. const u = ReactiveCache.getUser(member.userId);
  330. if (u) {
  331. member.username = u.username;
  332. }
  333. return member;
  334. }), [...specialHandles]);
  335. const mentionRegex = /\B@([\w.-]*)/gi;
  336. let currentMention;
  337. while ((currentMention = mentionRegex.exec(content)) !== null) {
  338. const [fullMention, quoteduser, simple] = currentMention;
  339. const username = quoteduser || simple;
  340. const knowedUser = _.findWhere(knowedUsers, { username });
  341. if (!knowedUser) {
  342. continue;
  343. }
  344. const linkValue = [' ', at, knowedUser.username];
  345. let linkClass = 'atMention js-open-member';
  346. if (knowedUser.userId === Meteor.userId()) {
  347. linkClass += ' me';
  348. }
  349. // This @user mention link generation did open same Wekan
  350. // window in new tab, so now A is changed to U so it's
  351. // underlined and there is no link popup. This way also
  352. // text can be selected more easily.
  353. //const link = HTML.A(
  354. const link = HTML.U(
  355. {
  356. class: linkClass,
  357. // XXX Hack. Since we stringify this render function result below with
  358. // `Blaze.toHTML` we can't rely on blaze data contexts to pass the
  359. // `userId` to the popup as usual, and we need to store it in the DOM
  360. // using a data attribute.
  361. 'data-userId': knowedUser.userId,
  362. },
  363. linkValue,
  364. );
  365. content = content.replace(fullMention, Blaze.toHTML(link));
  366. }
  367. return HTML.Raw(
  368. DOMPurify.sanitize(content, { ALLOW_UNKNOWN_PROTOCOLS: true }),
  369. );
  370. }),
  371. );
  372. Template.viewer.events({
  373. // Viewer sometimes have click-able wrapper around them (for instance to edit
  374. // the corresponding text). Clicking a link shouldn't fire these actions, stop
  375. // we stop these event at the viewer component level.
  376. 'click a'(event, templateInstance) {
  377. const prevent = true;
  378. const userId = event.currentTarget.dataset.userid;
  379. if (userId) {
  380. Popup.open('member').call({ userId }, event, templateInstance);
  381. } else {
  382. const href = event.currentTarget.href;
  383. if (href) {
  384. // Open links in current browser tab, changed from _blank to _self, and back to _blank:
  385. // https://github.com/wekan/wekan/discussions/3534
  386. //window.open(href, '_self');
  387. window.open(href, '_blank');
  388. }
  389. }
  390. if (prevent) {
  391. event.stopPropagation();
  392. // XXX We hijack the build-in browser action because we currently don't have
  393. // `_blank` attributes in viewer links, and the transformer function is
  394. // handled by a third party package that we can't configure easily. Fix that
  395. // by using directly `_blank` attribute in the rendered HTML.
  396. event.preventDefault();
  397. }
  398. },
  399. });