浏览代码

Merge pull request #4185 from mfilser/add_copy_text_button_to_most_textarea_fields

Add copy text button to most textarea fields
Lauri Ojansivu 3 年之前
父节点
当前提交
c643cdaad3

+ 0 - 1
client/components/activities/comments.styl

@@ -31,7 +31,6 @@
     background-color: #fff
     border: 0
     box-shadow: 0 1px 2px rgba(0, 0, 0, .23)
-    color: #8c8c8c
     height: 36px
     margin: 4px 4px 6px 0
     padding: 9px 11px

+ 3 - 0
client/components/cards/cardDetails.jade

@@ -22,6 +22,7 @@ template(name="cardDetails")
               title="{{_ 'copy-card-link-to-clipboard'}}"
               href="{{ originRelativeUrl }}"
             )
+            span.copied-tooltip {{_ 'copied'}}
         else
           unless isPopup
             a.fa.fa-times-thin.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
@@ -33,6 +34,7 @@ template(name="cardDetails")
               title="{{_ 'copy-card-link-to-clipboard'}}"
               href="{{ originRelativeUrl }}"
             )
+            span.copied-tooltip {{_ 'copied'}}
         h2.card-details-title.js-card-title(
           class="{{#if canModifyCard}}js-open-inlined-form is-editable{{/if}}")
             +viewer
@@ -798,6 +800,7 @@ template(name="cardMorePopup")
       i.fa.colorful(class="{{#if board.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
       input.inline-input(type="text" id="cardURL" readonly value="{{ originRelativeUrl }}" autofocus="autofocus")
       button.js-copy-card-link-to-clipboard(class="btn" id="clipboard") {{_ 'copy-card-link-to-clipboard'}}
+      .copied-tooltip {{_ 'copied'}}
     span.clearfix
     br
     h2 {{_ 'change-card-parent'}}

+ 8 - 2
client/components/cards/cardDetails.js

@@ -325,7 +325,10 @@ BlazeComponent.extendComponent({
         },
         'click .js-copy-link'(event) {
           event.preventDefault();
-          Utils.copyTextToClipboard(event.target.href);
+          const promise = Utils.copyTextToClipboard(event.target.href);
+
+          const $tooltip = this.$('.card-details-header .copied-tooltip');
+          Utils.showCopied(promise, $tooltip);
         },
         'click .js-open-card-details-menu': Popup.open('cardDetailsActions'),
         'submit .js-card-description'(event) {
@@ -1068,7 +1071,10 @@ BlazeComponent.extendComponent({
     return [
       {
         'click .js-copy-card-link-to-clipboard'(event) {
-          Utils.copyTextToClipboard(location.origin + document.getElementById('cardURL').value);
+          const promise = Utils.copyTextToClipboard(location.origin + document.getElementById('cardURL').value);
+
+          const $tooltip = this.$('.copied-tooltip');
+          Utils.showCopied(promise, $tooltip);
         },
         'click .js-delete': Popup.afterConfirm('cardDelete', function () {
           Popup.close();

+ 16 - 1
client/components/cards/cardDetails.styl

@@ -76,6 +76,12 @@ avatar-radius = 50%
       box-shadow: 0 0 0 2px darken(white, 60%) inset
 
 // Other card details
+.copied-tooltip
+  display: none
+  padding: 0px 10px;
+  background-color: #000000df;
+  color: #fff;
+  border-radius: 5px;
 
 .card-details
   padding: 0
@@ -118,7 +124,8 @@ avatar-radius = 50%
     .card-copy-button,
     .card-copy-mobile-button,
     .close-card-details-mobile-web,
-    .card-details-menu-mobile-web
+    .card-details-menu-mobile-web,
+    .copied-tooltip
       float: right
 
     .close-card-details,
@@ -187,6 +194,14 @@ avatar-radius = 50%
           border-radius: 3px
           padding: 0px 5px
 
+    .copied-tooltip
+      display: none
+      margin-right: 10px
+      padding: 10px;
+      background-color: #000000df;
+      color: #fff;
+      border-radius: 5px;
+
   .card-description textarea
     min-height: 100px
 

+ 4 - 0
client/components/cards/checklists.jade

@@ -63,12 +63,16 @@ template(name="checklistDeleteDialog")
       button.toggle-delete-checklist-dialog(type="button") {{_ 'cancel'}}
 
 template(name="addChecklistItemForm")
+  a.fa.fa-copy(title="copy text to clipboard")
+  span.copied-tooltip {{_ 'copied'}}
   textarea.js-add-checklist-item(rows='1' autofocus)
   .edit-controls.clearfix
     button.primary.confirm.js-submit-add-checklist-item-form(type="submit") {{_ 'save'}}
     a.fa.fa-times-thin.js-close-inlined-form
 
 template(name="editChecklistItemForm")
+  a.fa.fa-copy(title="copy text to clipboard")
+  span.copied-tooltip {{_ 'copied'}}
   textarea.js-edit-checklist-item(rows='1' autofocus dir="auto")
     if $eq type 'item'
       = item.title

+ 52 - 6
client/components/cards/checklists.js

@@ -279,13 +279,59 @@ Template.checklists.helpers({
   },
 });
 
-Template.addChecklistItemForm.onRendered(() => {
-  autosize($('textarea.js-add-checklist-item'));
-});
+BlazeComponent.extendComponent({
+  onRendered() {
+    autosize(this.$('textarea.js-add-checklist-item'));
+  },
+  canModifyCard() {
+    return (
+      Meteor.user() &&
+      Meteor.user().isBoardMember() &&
+      !Meteor.user().isCommentOnly() &&
+      !Meteor.user().isWorker()
+    );
+  },
+  events() {
+    return [
+      {
+        'click a.fa.fa-copy'(event) {
+          const $editor = this.$('textarea');
+          const promise = Utils.copyTextToClipboard($editor[0].value);
 
-Template.editChecklistItemForm.onRendered(() => {
-  autosize($('textarea.js-edit-checklist-item'));
-});
+          const $tooltip = this.$('.copied-tooltip');
+          Utils.showCopied(promise, $tooltip);
+        },
+      }
+    ];
+  }
+}).register('addChecklistItemForm');
+
+BlazeComponent.extendComponent({
+  onRendered() {
+    autosize(this.$('textarea.js-edit-checklist-item'));
+  },
+  canModifyCard() {
+    return (
+      Meteor.user() &&
+      Meteor.user().isBoardMember() &&
+      !Meteor.user().isCommentOnly() &&
+      !Meteor.user().isWorker()
+    );
+  },
+  events() {
+    return [
+      {
+        'click a.fa.fa-copy'(event) {
+          const $editor = this.$('textarea');
+          const promise = Utils.copyTextToClipboard($editor[0].value);
+
+          const $tooltip = this.$('.copied-tooltip');
+          Utils.showCopied(promise, $tooltip);
+        },
+      }
+    ];
+  }
+}).register('editChecklistItemForm');
 
 Template.checklistDeleteDialog.onCreated(() => {
   const $cardDetails = this.$('.card-details');

+ 2 - 0
client/components/main/editor.jade

@@ -1,4 +1,6 @@
 template(name="editor")
+  a.fa.fa-copy(title="copy text to clipboard")
+  span.copied-tooltip {{_ 'copied'}}
   textarea.editor(
     dir="auto"
     class="{{class}}"

+ 277 - 261
client/components/main/editor.js

@@ -4,283 +4,299 @@ const specialHandles = [
 ];
 const specialHandleNames = specialHandles.map(m => m.username);
 
-Template.editor.onRendered(() => {
-  const textareaSelector = 'textarea';
-  const mentions = [
-    // User mentions
-    {
-      match: /\B@([\w.]*)$/,
-      search(term, callback) {
-        const currentBoard = Boards.findOne(Session.get('currentBoard'));
-        callback(
-          _.union(
-          currentBoard
-            .activeMembers()
-            .map(member => {
-              const user = Users.findOne(member.userId);
-              const username = user.username;
-              const fullName = user.profile && user.profile !== undefined ?  user.profile.fullname : "";
-              return username.includes(term) || fullName.includes(term) ?  fullName + "(" + username + ")" : null;
-            })
-            .filter(Boolean), [...specialHandleNames])
-        );
-      },
-      template(value) {
-        return value;
-      },
-      replace(username) {
-        return `@${username} `;
+
+BlazeComponent.extendComponent({
+  onRendered() {
+    const textareaSelector = 'textarea';
+    const mentions = [
+      // User mentions
+      {
+        match: /\B@([\w.]*)$/,
+        search(term, callback) {
+          const currentBoard = Boards.findOne(Session.get('currentBoard'));
+          callback(
+            _.union(
+            currentBoard
+              .activeMembers()
+              .map(member => {
+                const user = Users.findOne(member.userId);
+                const username = user.username;
+                const fullName = user.profile && user.profile !== undefined ?  user.profile.fullname : "";
+                return username.includes(term) || fullName.includes(term) ?  fullName + "(" + username + ")" : null;
+              })
+              .filter(Boolean), [...specialHandleNames])
+          );
+        },
+        template(value) {
+          return value;
+        },
+        replace(username) {
+          return `@${username} `;
+        },
+        index: 1,
       },
-      index: 1,
-    },
-  ];
-  const enableTextarea = function() {
-    const $textarea = this.$(textareaSelector);
-    autosize($textarea);
-    $textarea.escapeableTextComplete(mentions);
-  };
-  if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) {
-    const isSmall = Utils.isMiniScreen();
-    const toolbar = isSmall
-      ? [
-          ['view', ['fullscreen']],
-          ['table', ['table']],
-          ['font', ['bold', 'underline']],
-          //['fontsize', ['fontsize']],
-          ['color', ['color']],
-        ]
-      : [
-          ['style', ['style']],
-          ['font', ['bold', 'underline', 'clear']],
-          ['fontsize', ['fontsize']],
-          ['fontname', ['fontname']],
-          ['color', ['color']],
-          ['para', ['ul', 'ol', 'paragraph']],
-          ['table', ['table']],
-          //['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
-          ['insert', ['link']], //, 'picture']], // modal popup has issue somehow :(
-          ['view', ['fullscreen', 'codeview', 'help']],
-        ];
-    const cleanPastedHTML = function(input) {
-      const badTags = [
-        'style',
-        'script',
-        'applet',
-        'embed',
-        'noframes',
-        'noscript',
-        'meta',
-        'link',
-        'button',
-        'form',
-      ].join('|');
-      const badPatterns = new RegExp(
-        `(?:${[
-          `<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`,
-          `<(${badTags})[^>]*?\\/>`,
-        ].join('|')})`,
-        'gi',
-      );
-      let output = input;
-      // remove bad Tags
-      output = output.replace(badPatterns, '');
-      // remove attributes ' style="..."'
-      const badAttributes = new RegExp(
-        `(?:${[
-          'on\\S+=([\'"]?).*?\\1',
-          'href=([\'"]?)javascript:.*?\\2',
-          'style=([\'"]?).*?\\3',
-          'target=\\S+',
-        ].join('|')})`,
-        'gi',
-      );
-      output = output.replace(badAttributes, '');
-      output = output.replace(/(<a )/gi, '$1target=_ '); // always to new target
-      return output;
+    ];
+    const enableTextarea = function() {
+      const $textarea = this.$(textareaSelector);
+      autosize($textarea);
+      $textarea.escapeableTextComplete(mentions);
     };
-    const editor = '.editor';
-    const selectors = [
-      `.js-new-description-form ${editor}`,
-      `.js-new-comment-form ${editor}`,
-      `.js-edit-comment ${editor}`,
-    ].join(','); // only new comment and edit comment
-    const inputs = $(selectors);
-    if (inputs.length === 0) {
-      // only enable richereditor to new comment or edit comment no others
-      enableTextarea();
-    } else {
-      const placeholder = inputs.attr('placeholder') || '';
-      const mSummernotes = [];
-      const getSummernote = function(input) {
-        const idx = inputs.index(input);
-        if (idx > -1) {
-          return mSummernotes[idx];
-        }
-        return undefined;
+    if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) {
+      const isSmall = Utils.isMiniScreen();
+      const toolbar = isSmall
+        ? [
+            ['view', ['fullscreen']],
+            ['table', ['table']],
+            ['font', ['bold', 'underline']],
+            //['fontsize', ['fontsize']],
+            ['color', ['color']],
+          ]
+        : [
+            ['style', ['style']],
+            ['font', ['bold', 'underline', 'clear']],
+            ['fontsize', ['fontsize']],
+            ['fontname', ['fontname']],
+            ['color', ['color']],
+            ['para', ['ul', 'ol', 'paragraph']],
+            ['table', ['table']],
+            //['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
+            ['insert', ['link']], //, 'picture']], // modal popup has issue somehow :(
+            ['view', ['fullscreen', 'codeview', 'help']],
+          ];
+      const cleanPastedHTML = function(input) {
+        const badTags = [
+          'style',
+          'script',
+          'applet',
+          'embed',
+          'noframes',
+          'noscript',
+          'meta',
+          'link',
+          'button',
+          'form',
+        ].join('|');
+        const badPatterns = new RegExp(
+          `(?:${[
+            `<(${badTags})s*[^>][\\s\\S]*?<\\/\\1>`,
+            `<(${badTags})[^>]*?\\/>`,
+          ].join('|')})`,
+          'gi',
+        );
+        let output = input;
+        // remove bad Tags
+        output = output.replace(badPatterns, '');
+        // remove attributes ' style="..."'
+        const badAttributes = new RegExp(
+          `(?:${[
+            'on\\S+=([\'"]?).*?\\1',
+            'href=([\'"]?)javascript:.*?\\2',
+            'style=([\'"]?).*?\\3',
+            'target=\\S+',
+          ].join('|')})`,
+          'gi',
+        );
+        output = output.replace(badAttributes, '');
+        output = output.replace(/(<a )/gi, '$1target=_ '); // always to new target
+        return output;
       };
-      inputs.each(function(idx, input) {
-        mSummernotes[idx] = $(input).summernote({
-          placeholder,
-          callbacks: {
-            onInit(object) {
-              const originalInput = this;
-              $(originalInput).on('submitted', function() {
-                // when comment is submitted, the original textarea will be set to '', so shall we
-                if (!this.value) {
-                  const sn = getSummernote(this);
-                  sn && sn.summernote('code', '');
-                }
-              });
-              const jEditor = object && object.editable;
-              const toolbar = object && object.toolbar;
-              if (jEditor !== undefined) {
-                jEditor.escapeableTextComplete(mentions);
-              }
-              if (toolbar !== undefined) {
-                const fBtn = toolbar.find('.btn-fullscreen');
-                fBtn.on('click', function() {
-                  const $this = $(this),
-                    isActive = $this.hasClass('active');
-                  $('.minicards,#header-quick-access').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually
+      const editor = '.editor';
+      const selectors = [
+        `.js-new-description-form ${editor}`,
+        `.js-new-comment-form ${editor}`,
+        `.js-edit-comment ${editor}`,
+      ].join(','); // only new comment and edit comment
+      const inputs = $(selectors);
+      if (inputs.length === 0) {
+        // only enable richereditor to new comment or edit comment no others
+        enableTextarea();
+      } else {
+        const placeholder = inputs.attr('placeholder') || '';
+        const mSummernotes = [];
+        const getSummernote = function(input) {
+          const idx = inputs.index(input);
+          if (idx > -1) {
+            return mSummernotes[idx];
+          }
+          return undefined;
+        };
+        inputs.each(function(idx, input) {
+          mSummernotes[idx] = $(input).summernote({
+            placeholder,
+            callbacks: {
+              onInit(object) {
+                const originalInput = this;
+                $(originalInput).on('submitted', function() {
+                  // when comment is submitted, the original textarea will be set to '', so shall we
+                  if (!this.value) {
+                    const sn = getSummernote(this);
+                    sn && sn.summernote('code', '');
+                  }
                 });
-              }
-            },
+                const jEditor = object && object.editable;
+                const toolbar = object && object.toolbar;
+                if (jEditor !== undefined) {
+                  jEditor.escapeableTextComplete(mentions);
+                }
+                if (toolbar !== undefined) {
+                  const fBtn = toolbar.find('.btn-fullscreen');
+                  fBtn.on('click', function() {
+                    const $this = $(this),
+                      isActive = $this.hasClass('active');
+                    $('.minicards,#header-quick-access').toggle(!isActive); // mini card is still showing when editor is in fullscreen mode, we hide here manually
+                  });
+                }
+              },
 
-            onImageUpload(files) {
-              const $summernote = getSummernote(this);
-              if (files && files.length > 0) {
-                const image = files[0];
-                const currentCard = Utils.getCurrentCard();
-                const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
-                const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
-                const insertImage = src => {
-                  // process all image upload types to the description/comment window
-                  const img = document.createElement('img');
-                  img.src = src;
-                  img.setAttribute('width', '100%');
-                  $summernote.summernote('insertNode', img);
-                };
-                const processData = function(fileObj) {
-                  Utils.processUploadedAttachment(
-                    currentCard,
-                    fileObj,
-                    attachment => {
-                      if (
-                        attachment &&
-                        attachment._id &&
-                        attachment.isImage()
-                      ) {
-                        attachment.one('uploaded', function() {
-                          const maxTry = 3;
-                          const checkItvl = 500;
-                          let retry = 0;
-                          const checkUrl = function() {
-                            // even though uploaded event fired, attachment.url() is still null somehow //TODO
-                            const url = attachment.url();
-                            if (url) {
-                              insertImage(
-                                `${location.protocol}//${location.host}${url}`,
-                              );
-                            } else {
-                              retry++;
-                              if (retry < maxTry) {
-                                setTimeout(checkUrl, checkItvl);
+              onImageUpload(files) {
+                const $summernote = getSummernote(this);
+                if (files && files.length > 0) {
+                  const image = files[0];
+                  const currentCard = Utils.getCurrentCard();
+                  const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
+                  const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
+                  const insertImage = src => {
+                    // process all image upload types to the description/comment window
+                    const img = document.createElement('img');
+                    img.src = src;
+                    img.setAttribute('width', '100%');
+                    $summernote.summernote('insertNode', img);
+                  };
+                  const processData = function(fileObj) {
+                    Utils.processUploadedAttachment(
+                      currentCard,
+                      fileObj,
+                      attachment => {
+                        if (
+                          attachment &&
+                          attachment._id &&
+                          attachment.isImage()
+                        ) {
+                          attachment.one('uploaded', function() {
+                            const maxTry = 3;
+                            const checkItvl = 500;
+                            let retry = 0;
+                            const checkUrl = function() {
+                              // even though uploaded event fired, attachment.url() is still null somehow //TODO
+                              const url = attachment.url();
+                              if (url) {
+                                insertImage(
+                                  `${location.protocol}//${location.host}${url}`,
+                                );
+                              } else {
+                                retry++;
+                                if (retry < maxTry) {
+                                  setTimeout(checkUrl, checkItvl);
+                                }
                               }
+                            };
+                            checkUrl();
+                          });
+                        }
+                      },
+                    );
+                  };
+                  if (MAX_IMAGE_PIXEL) {
+                    const reader = new FileReader();
+                    reader.onload = function(e) {
+                      const dataurl = e && e.target && e.target.result;
+                      if (dataurl !== undefined) {
+                        // need to shrink image
+                        Utils.shrinkImage({
+                          dataurl,
+                          maxSize: MAX_IMAGE_PIXEL,
+                          ratio: COMPRESS_RATIO,
+                          toBlob: true,
+                          callback(blob) {
+                            if (blob !== false) {
+                              blob.name = image.name;
+                              processData(blob);
                             }
-                          };
-                          checkUrl();
+                          },
                         });
                       }
-                    },
-                  );
-                };
-                if (MAX_IMAGE_PIXEL) {
-                  const reader = new FileReader();
-                  reader.onload = function(e) {
-                    const dataurl = e && e.target && e.target.result;
-                    if (dataurl !== undefined) {
-                      // need to shrink image
-                      Utils.shrinkImage({
-                        dataurl,
-                        maxSize: MAX_IMAGE_PIXEL,
-                        ratio: COMPRESS_RATIO,
-                        toBlob: true,
-                        callback(blob) {
-                          if (blob !== false) {
-                            blob.name = image.name;
-                            processData(blob);
-                          }
-                        },
-                      });
-                    }
-                  };
-                  reader.readAsDataURL(image);
-                } else {
-                  processData(image);
+                    };
+                    reader.readAsDataURL(image);
+                  } else {
+                    processData(image);
+                  }
                 }
-              }
-            },
-            onPaste(e) {
-              var clipboardData = e.clipboardData;
-              var pastedData = clipboardData.getData('Text');
+              },
+              onPaste(e) {
+                var clipboardData = e.clipboardData;
+                var pastedData = clipboardData.getData('Text');
 
-              //if pasted data is an image, exit
-              if (!pastedData.length) {
-                e.preventDefault();
-                return;
-              }
+                //if pasted data is an image, exit
+                if (!pastedData.length) {
+                  e.preventDefault();
+                  return;
+                }
 
-              // clear up unwanted tag info when user pasted in text
-              const thisNote = this;
-              const updatePastedText = function(object) {
-                const someNote = getSummernote(object);
-                // Fix Pasting text into a card is adding a line before and after
-                // (and multiplies by pasting more) by changing paste "p" to "br".
-                // Fixes https://github.com/wekan/wekan/2890 .
-                // == Fix Start ==
-                someNote.execCommand('defaultParagraphSeparator', false, 'br');
-                // == Fix End ==
-                const original = someNote.summernote('code');
-                const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML.
-                someNote.summernote('code', ''); //clear original
-                someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code.
-              };
-              setTimeout(function() {
-                //this kinda sucks, but if you don't do a setTimeout,
-                //the function is called before the text is really pasted.
-                updatePastedText(thisNote);
-              }, 10);
+                // clear up unwanted tag info when user pasted in text
+                const thisNote = this;
+                const updatePastedText = function(object) {
+                  const someNote = getSummernote(object);
+                  // Fix Pasting text into a card is adding a line before and after
+                  // (and multiplies by pasting more) by changing paste "p" to "br".
+                  // Fixes https://github.com/wekan/wekan/2890 .
+                  // == Fix Start ==
+                  someNote.execCommand('defaultParagraphSeparator', false, 'br');
+                  // == Fix End ==
+                  const original = someNote.summernote('code');
+                  const cleaned = cleanPastedHTML(original); //this is where to call whatever clean function you want. I have mine in a different file, called CleanPastedHTML.
+                  someNote.summernote('code', ''); //clear original
+                  someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code.
+                };
+                setTimeout(function() {
+                  //this kinda sucks, but if you don't do a setTimeout,
+                  //the function is called before the text is really pasted.
+                  updatePastedText(thisNote);
+                }, 10);
+              },
             },
-          },
-          dialogsInBody: true,
-          spellCheck: true,
-          disableGrammar: false,
-          disableDragAndDrop: false,
-          toolbar,
-          popover: {
-            image: [
-              ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']],
-              ['float', ['floatLeft', 'floatRight', 'floatNone']],
-              ['remove', ['removeMedia']],
-            ],
-            link: [['link', ['linkDialogShow', 'unlink']]],
-            table: [
-              ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
-              ['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
-            ],
-            air: [
-              ['color', ['color']],
-              ['font', ['bold', 'underline', 'clear']],
-            ],
-          },
-          height: 200,
+            dialogsInBody: true,
+            spellCheck: true,
+            disableGrammar: false,
+            disableDragAndDrop: false,
+            toolbar,
+            popover: {
+              image: [
+                ['imagesize', ['imageSize100', 'imageSize50', 'imageSize25']],
+                ['float', ['floatLeft', 'floatRight', 'floatNone']],
+                ['remove', ['removeMedia']],
+              ],
+              link: [['link', ['linkDialogShow', 'unlink']]],
+              table: [
+                ['add', ['addRowDown', 'addRowUp', 'addColLeft', 'addColRight']],
+                ['delete', ['deleteRow', 'deleteCol', 'deleteTable']],
+              ],
+              air: [
+                ['color', ['color']],
+                ['font', ['bold', 'underline', 'clear']],
+              ],
+            },
+            height: 200,
+          });
         });
-      });
+      }
+    } else {
+      enableTextarea();
     }
-  } else {
-    enableTextarea();
+  },
+  events() {
+    return [
+      {
+        'click a.fa.fa-copy'(event) {
+          const $editor = this.$('textarea.editor');
+          const promise = Utils.copyTextToClipboard($editor[0].value);
+
+          const $tooltip = this.$('.copied-tooltip');
+          Utils.showCopied(promise, $tooltip);
+        },
+      }
+    ]
   }
-});
+}).register('editor');
 
 import DOMPurify from 'dompurify';
 

+ 7 - 0
client/components/main/editor.styl

@@ -0,0 +1,7 @@
+.new-comment,
+.inlined-form
+  a.fa.fa-copy
+    float: right
+    position: relative
+    top: 20px
+    right: 6px

+ 23 - 2
client/lib/utils.js

@@ -481,6 +481,9 @@ Utils = {
 
     try {
       document.execCommand('copy');
+      return Promise.resolve(true);
+    } catch (e) {
+      return Promise.reject(false);
     } finally {
       document.body.removeChild(textArea);
     }
@@ -489,15 +492,33 @@ Utils = {
   /** copy the text to the clipboard
    * @see https://stackoverflow.com/questions/400212/how-do-i-copy-to-the-clipboard-in-javascript/30810322#30810322
    * @param string copy this text to the clipboard
+   * @return Promise
    */
   copyTextToClipboard(text) {
+    let ret;
     if (navigator.clipboard) {
-      navigator.clipboard.writeText(text).then(function() {
+      ret = navigator.clipboard.writeText(text).then(function() {
       }, function(err) {
         console.error('Async: Could not copy text: ', err);
       });
     } else {
-      fallbackCopyTextToClipboard(text);
+      ret = Utils.fallbackCopyTextToClipboard(text);
+    }
+    return ret;
+  },
+
+  /** show the "copied!" message
+   * @param promise the promise of Utils.copyTextToClipboard
+   * @param $tooltip jQuery tooltip element
+   */
+  showCopied(promise, $tooltip) {
+    if (promise) {
+      promise.then(() => {
+        $tooltip.show(100);
+        setTimeout(() => $tooltip.hide(100), 1000);
+      }, (err) => {
+        console.error("error: ", err);
+      });
     }
   },
 };

+ 2 - 1
i18n/en.i18n.json

@@ -1122,5 +1122,6 @@
   "to-create-organizations-contact-admin": "To create organizations, please contact administrator.",
   "custom-legal-notice-link-url": "Custom legal notice page URL",
   "acceptance_of_our_legalNotice": "By continuing, you accept our",
-  "legalNotice": "legal notice"
+  "legalNotice": "legal notice",
+  "copied": "copied!"
 }