diff --git a/client/components/activities/comments.styl b/client/components/activities/comments.styl index ccf24b726..d7492b767 100644 --- a/client/components/activities/comments.styl +++ b/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 diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade index 659f70918..cf049625c 100644 --- a/client/components/cards/cardDetails.jade +++ b/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'}} diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index b967ca087..adf5a9a5f 100644 --- a/client/components/cards/cardDetails.js +++ b/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(); diff --git a/client/components/cards/cardDetails.styl b/client/components/cards/cardDetails.styl index 202600e2e..ac007b169 100644 --- a/client/components/cards/cardDetails.styl +++ b/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 diff --git a/client/components/cards/checklists.jade b/client/components/cards/checklists.jade index 06f419282..b27a771db 100644 --- a/client/components/cards/checklists.jade +++ b/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 diff --git a/client/components/cards/checklists.js b/client/components/cards/checklists.js index feec56d37..0d9521635 100644 --- a/client/components/cards/checklists.js +++ b/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'); diff --git a/client/components/main/editor.jade b/client/components/main/editor.jade index dbd617154..6e7702ff3 100644 --- a/client/components/main/editor.jade +++ b/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}}" diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 34ada0e24..fec040a23 100644 --- a/client/components/main/editor.js +++ b/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} `; - }, - 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(/( -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); +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, + }, + ]; + 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(/( -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); + } } + }; + 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'; diff --git a/client/components/main/editor.styl b/client/components/main/editor.styl new file mode 100644 index 000000000..07e1c627f --- /dev/null +++ b/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 diff --git a/client/lib/utils.js b/client/lib/utils.js index ebc76f22f..eb53a6a4c 100644 --- a/client/lib/utils.js +++ b/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); + }); } }, }; diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 8e6170397..24a6a1b1f 100644 --- a/i18n/en.i18n.json +++ b/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!" }