diff options
author | NicoP-S <paetni1@gmail.com> | 2020-03-26 21:17:17 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-03-26 21:17:17 +0100 |
commit | 7fa9603f9d53594227258d76a78af4023dc325b0 (patch) | |
tree | 8b1a3bb7c8837c709ca21c7d22af59ff7433a83c /client | |
parent | 21b75edcdd8bc2fc79ae0a8c9c3691cc6f61c0ca (diff) | |
parent | 29d62440a5cf82b01de8183a384c6d7811abad81 (diff) | |
download | wekan-7fa9603f9d53594227258d76a78af4023dc325b0.tar.gz wekan-7fa9603f9d53594227258d76a78af4023dc325b0.tar.bz2 wekan-7fa9603f9d53594227258d76a78af4023dc325b0.zip |
Merge pull request #1 from wekan/master
update
Diffstat (limited to 'client')
-rw-r--r-- | client/components/activities/comments.js | 7 | ||||
-rw-r--r-- | client/components/main/editor.jade | 7 | ||||
-rwxr-xr-x | client/components/main/editor.js | 234 | ||||
-rw-r--r-- | client/components/settings/peopleBody.jade | 4 | ||||
-rw-r--r-- | client/components/sidebar/sidebar.jade | 9 | ||||
-rw-r--r-- | client/components/users/userHeader.jade | 12 | ||||
-rw-r--r-- | client/lib/popup.js | 204 |
7 files changed, 330 insertions, 147 deletions
diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js index 50ca019b..e885459e 100644 --- a/client/components/activities/comments.js +++ b/client/components/activities/comments.js @@ -33,6 +33,13 @@ BlazeComponent.extendComponent({ cardId, }); resetCommentInput(input); + // With Richer editor is in use, and comment is submitted, + // clear comment form with JQuery. Id #summernote is defined + // at client/components/main/editor.jade where it previously was + // id=id, now it is id="summernote". + if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') { + $('#summernote').summernote('code', ''); + } Tracker.flush(); autosize.update(input); input.trigger('submitted'); diff --git a/client/components/main/editor.jade b/client/components/main/editor.jade index dbd61715..5c5454ee 100644 --- a/client/components/main/editor.jade +++ b/client/components/main/editor.jade @@ -1,8 +1,13 @@ template(name="editor") + // With Richer editor is in use, and comment is submitted, + // clear comment form with JQuery Comment at + // client/components/activities/comments.js . Id #summernote is defined + // here at client/components/main/editor.jade where it previously was + // id=id, now it is id="summernote". textarea.editor( dir="auto" class="{{class}}" - id=id + id="summernote" autofocus=autofocus placeholder="{{_ 'comment-placeholder'}}") +Template.contentBlock diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 39c03aa9..3f09d284 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,87 +1,3 @@ -import _sanitizeXss from 'xss'; -const ASIS = 'asis'; -const sanitizeXss = (input, options) => { - const defaultAllowedIframeSrc = /^(https:){0,1}\/\/.*?(youtube|vimeo|dailymotion|youku)/i; - const allowedIframeSrcRegex = (function() { - let reg = defaultAllowedIframeSrc; - const SAFE_IFRAME_SRC_PATTERN = - Meteor.settings.public.SAFE_IFRAME_SRC_PATTERN; - try { - if (SAFE_IFRAME_SRC_PATTERN !== undefined) { - reg = new RegExp(SAFE_IFRAME_SRC_PATTERN, 'i'); - } - } catch (e) { - /*eslint no-console: ["error", { allow: ["warn", "error"] }] */ - - console.error('Wrong pattern specified', SAFE_IFRAM_SRC_PATTERN, e); - } - return reg; - })(); - const targetWindow = '_blank'; - const getHtmlDOM = html => { - const i = document.createElement('i'); - i.innerHTML = html; - return i.firstChild; - }; - options = { - onTag(tag, html, options) { - const htmlDOM = getHtmlDOM(html); - const getAttr = attr => { - return htmlDOM && attr && htmlDOM.getAttribute(attr); - }; - if (tag === 'iframe') { - const clipCls = 'note-vide-clip'; - if (!options.isClosing) { - const iframeCls = getAttr('class'); - let safe = iframeCls.indexOf(clipCls) > -1; - const src = getAttr('src'); - if (allowedIframeSrcRegex.exec(src)) { - safe = true; - } - if (safe) - return `<iframe src='${src}' class="${clipCls}" width=100% height=auto allowfullscreen></iframe>`; - } else { - // remove </iframe> tag - return ''; - } - } else if (tag === 'a') { - if (!options.isClosing) { - if (getAttr(ASIS) === 'true') { - // if has a ASIS attribute, don't do anything, it's a member id - return html; - } else { - const href = getAttr('href'); - if (href.match(/^((http(s){0,1}:){0,1}\/\/|\/)/)) { - // a valid url - return `<a href=${href} target=${targetWindow}>`; - } - } - } - } else if (tag === 'img') { - if (!options.isClosing) { - const src = getAttr('src'); - if (src) { - return `<a href='${src}' class='swipebox'><img src='${src}' class="attachment-image-preview mCS_img_loaded"></a>`; - } - } - } - return undefined; - }, - onTagAttr(tag, name, value) { - if (tag === 'img' && name === 'src') { - if (value && value.substr(0, 5) === 'data:') { - // allow image with dataURI src - return `${name}='${value}'`; - } - } else if (tag === 'a' && name === 'target') { - return `${name}='${targetWindow}'`; // always change a href target to a new window - } - return undefined; - }, - ...options, - }; - return _sanitizeXss(input, options); -}; Template.editor.onRendered(() => { const textareaSelector = 'textarea'; const mentions = [ @@ -94,13 +10,7 @@ Template.editor.onRendered(() => { currentBoard .activeMembers() .map(member => { - const user = Users.findOne(member.userId); - if (user._id === Meteor.userId()) { - return null; - } - const value = user.username; - const username = - value && value.match(/\s+/) ? `"${value}"` : value; + const username = Users.findOne(member.userId).username; return username.includes(term) ? username : null; }) .filter(Boolean), @@ -120,16 +30,15 @@ Template.editor.onRendered(() => { autosize($textarea); $textarea.escapeableTextComplete(mentions); }; - if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR !== false) { + if (Meteor.settings.public.RICHER_CARD_COMMENT_EDITOR === 'true') { const isSmall = Utils.isMiniScreen(); const toolbar = isSmall ? [ ['view', ['fullscreen']], ['table', ['table']], - ['font', ['bold']], - ['color', ['color']], - ['insert', ['video']], // iframe tag will be sanitized TODO if iframe[class=note-video-clip] can be added into safe list, insert video can be enabled + ['font', ['bold', 'underline']], //['fontsize', ['fontsize']], + ['color', ['color']], ] : [ ['style', ['style']], @@ -139,11 +48,47 @@ Template.editor.onRendered(() => { ['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', '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', 'help']], ]; - const cleanPastedHTML = sanitizeXss; + 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 editor = '.editor'; const selectors = [ `.js-new-comment-form ${editor}`, @@ -163,11 +108,27 @@ Template.editor.onRendered(() => { } return undefined; }; + // Prevent @member mentions on Add Comment input field + // from closing card, part 1. let popupShown = false; inputs.each(function(idx, input) { mSummernotes[idx] = $(input).summernote({ placeholder, + // Prevent @member mentions on Add Comment input field + // from closing card, part 2. + onKeydown(e) { + if (popupShown) { + e.preventDefault(); + } + }, + onKeyup(e) { + if (popupShown) { + e.preventDefault(); + } + }, callbacks: { + // Prevent @member mentions on Add Comment input field + // from closing card, part 3. onKeydown(e) { if (popupShown) { e.preventDefault(); @@ -180,28 +141,19 @@ Template.editor.onRendered(() => { }, onInit(object) { const originalInput = this; - const setAutocomplete = function(jEditor) { - if (jEditor !== undefined) { - jEditor.escapeableTextComplete(mentions).on({ - 'textComplete:show'() { - popupShown = true; - }, - 'textComplete:hide'() { - popupShown = false; - }, - }); - } - }; - $(originalInput).on('submitted', function() { - // resetCommentInput has been called + $(originalInput).on('input', 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', ''); + sn && sn.summernote('reset'); + object && object.editingArea.find('.note-placeholder').show(); } }); const jEditor = object && object.editable; const toolbar = object && object.toolbar; - setAutocomplete(jEditor); + if (jEditor !== undefined) { + jEditor.escapeableTextComplete(mentions); + } if (toolbar !== undefined) { const fBtn = toolbar.find('.btn-fullscreen'); fBtn.on('click', function() { @@ -289,9 +241,15 @@ Template.editor.onRendered(() => { 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('reset'); //clear original someNote.summernote('pasteHTML', cleaned); //this sets the displayed content editor to the cleaned pasted code. }; setTimeout(function() { @@ -331,6 +289,8 @@ Template.editor.onRendered(() => { } }); +import sanitizeXss from 'xss'; + // XXX I believe we should compute a HTML rendered field on the server that // would handle markdown and user mentions. We can simply have two // fields, one source, and one compiled version (in HTML) and send only the @@ -352,23 +312,28 @@ Blaze.Template.registerHelper( } return member; }); - const mentionRegex = /\B@(?:(?:"([\w.\s]*)")|([\w.]+))/gi; // including space in username + const mentionRegex = /\B@([\w.]*)/gi; let currentMention; while ((currentMention = mentionRegex.exec(content)) !== null) { - const [fullMention, quoteduser, simple] = currentMention; - const username = quoteduser || simple; + const [fullMention, username] = currentMention; const knowedUser = _.findWhere(knowedUsers, { username }); if (!knowedUser) { continue; } const linkValue = [' ', at, knowedUser.username]; - let linkClass = 'atMention js-open-member'; + //let linkClass = 'atMention js-open-member'; + let linkClass = 'atMention'; if (knowedUser.userId === Meteor.userId()) { linkClass += ' me'; } - const link = HTML.A( + // This @user mention link generation did open same Wekan + // window in new tab, so now A is changed to U so it's + // underlined and there is no link popup. This way also + // text can be selected more easily. + //const link = HTML.A( + const link = HTML.U( { class: linkClass, // XXX Hack. Since we stringify this render function result below with @@ -376,42 +341,41 @@ Blaze.Template.registerHelper( // `userId` to the popup as usual, and we need to store it in the DOM // using a data attribute. 'data-userId': knowedUser.userId, - [ASIS]: 'true', }, linkValue, ); content = content.replace(fullMention, Blaze.toHTML(link)); } + return HTML.Raw(sanitizeXss(content)); }), ); + Template.viewer.events({ // Viewer sometimes have click-able wrapper around them (for instance to edit // the corresponding text). Clicking a link shouldn't fire these actions, stop // we stop these event at the viewer component level. 'click a'(event, templateInstance) { - let prevent = true; + event.stopPropagation(); + + // XXX We hijack the build-in browser action because we currently don't have + // `_blank` attributes in viewer links, and the transformer function is + // handled by a third party package that we can't configure easily. Fix that + // by using directly `_blank` attribute in the rendered HTML. + event.preventDefault(); + const userId = event.currentTarget.dataset.userid; if (userId) { - Popup.open('member').call({ userId }, event, templateInstance); + // Prevent @member mentions on Add Comment input field + // from closing card, part 4. + PopupNoClose.open('member').call({ userId }, event, templateInstance); + event.preventDefault(); } else { const href = event.currentTarget.href; - const child = event.currentTarget.firstElementChild; - if (child && child.tagName === 'IMG') { - prevent = false; - } else if (href) { + if (href) { window.open(href, '_blank'); } } - if (prevent) { - event.stopPropagation(); - - // XXX We hijack the build-in browser action because we currently don't have - // `_blank` attributes in viewer links, and the transformer function is - // handled by a third party package that we can't configure easily. Fix that - // by using directly `_blank` attribute in the rendered HTML. - event.preventDefault(); - } }, }); diff --git a/client/components/settings/peopleBody.jade b/client/components/settings/peopleBody.jade index ca4bc382..fef1067e 100644 --- a/client/components/settings/peopleBody.jade +++ b/client/components/settings/peopleBody.jade @@ -110,7 +110,7 @@ template(name="editUserPopup") label.hide.userId(type="text" value=user._id) label | {{_ 'fullname'}} - input.js-profile-fullname(type="text" value=user.profile.fullname autofocus) + input.js-profile-fullname(type="text" value=user.profile.fullname) label | {{_ 'username'}} span.error.hide.username-taken @@ -159,7 +159,7 @@ template(name="newUserPopup") //label.hide.userId(type="text" value=user._id) label | {{_ 'fullname'}} - input.js-profile-fullname(type="text" value="" autofocus) + input.js-profile-fullname(type="text" value="") label | {{_ 'username'}} span.error.hide.username-taken diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index ebcd8486..f0b0e4be 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -245,7 +245,7 @@ template(name="outgoingWebhooksPopup") b .materialCheckBox(class="{{#unless enabled}}is-checked{{/unless}}") input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" value=title) - input.js-outgoing-webhooks-url(type="text" name="url" value=url autofocus) + input.js-outgoing-webhooks-url(type="text" name="url" value=url) input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" value=token name="token") select.js-outgoing-webhooks-type(name="type") each _type in types @@ -257,7 +257,7 @@ template(name="outgoingWebhooksPopup") input(type="hidden" value=_id name="id") input.primary.wide(type="submit" value="{{_ 'save'}}") form.integration-form - input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title" autofocus) + input.js-outgoing-webhooks-title(placeholder="{{_ 'webhook-title'}}" type="text" name="title") input.js-outgoing-webhooks-url(placeholder="{{_ 'URL' }}" type="text" name="url") input.js-outgoing-webhooks-token(placeholder="{{_ 'webhook-token' }}" type="text" name="token") select.js-outgoing-webhooks-type(name="type") @@ -267,7 +267,10 @@ template(name="outgoingWebhooksPopup") template(name="boardMenuPopup") ul.pop-over-list - li: a.js-custom-fields {{_ 'custom-fields'}} + li + a.js-custom-fields + i.fa.fa-list-alt + | {{_ 'custom-fields'}} li a.js-open-archives i.fa.fa-archive diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade index 9306d21d..1cd9da6b 100644 --- a/client/components/users/userHeader.jade +++ b/client/components/users/userHeader.jade @@ -98,12 +98,12 @@ template(name="changeLanguagePopup") template(name="changeSettingsPopup") ul.pop-over-list - li - a.js-toggle-system-messages - i.fa.fa-comments-o - | {{_ 'hide-system-messages'}} - if hiddenSystemMessages - i.fa.fa-check + //li + // a.js-toggle-system-messages + // i.fa.fa-comments-o + // | {{_ 'hide-system-messages'}} + // if hiddenSystemMessages + // i.fa.fa-check li a.js-toggle-desktop-drag-handles i.fa.fa-arrows diff --git a/client/lib/popup.js b/client/lib/popup.js index 8095fbd2..8a55c2df 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -206,3 +206,207 @@ escapeActions.forEach(actionName => { }, ); }); + +// Prevent @member mentions on Add Comment input field +// from closing card, part 5. +// This duplicate below of above popup function is needed, because at +// wekan/components/main/editor.js at bottom is popping up visible +// @member mention, and it seems to trigger closing also card popup, +// so in below closing popup is disabled. +window.PopupNoClose = new (class { + constructor() { + // The template we use to render popups + this.template = Template.popup; + + // We only want to display one popup at a time and we keep the view object + // in this `Popup.current` variable. If there is no popup currently opened + // the value is `null`. + this.current = null; + + // It's possible to open a sub-popup B from a popup A. In that case we keep + // the data of popup A so we can return back to it. Every time we open a new + // popup the stack grows, every time we go back the stack decrease, and if + // we close the popup the stack is reseted to the empty stack []. + this._stack = []; + + // We invalidate this internal dependency every time the top of the stack + // has changed and we want to re-render a popup with the new top-stack data. + this._dep = new Tracker.Dependency(); + } + + /// This function returns a callback that can be used in an event map: + /// Template.tplName.events({ + /// 'click .elementClass': Popup.open("popupName"), + /// }); + /// The popup inherit the data context of its parent. + open(name) { + const self = this; + const popupName = `${name}Popup`; + function clickFromPopup(evt) { + return $(evt.target).closest('.js-pop-over').length !== 0; + } + return function(evt) { + // If a popup is already opened, clicking again on the opener element + // should close it -- and interrupt the current `open` function. + /* + if (self.isOpen()) { + const previousOpenerElement = self._getTopStack().openerElement; + if (previousOpenerElement === evt.currentTarget) { + self.close(); + return; + } else { + $(previousOpenerElement).removeClass('is-active'); + } + } + */ + // We determine the `openerElement` (the DOM element that is being clicked + // and the one we take in reference to position the popup) from the event + // if the popup has no parent, or from the parent `openerElement` if it + // has one. This allows us to position a sub-popup exactly at the same + // position than its parent. + let openerElement; + if (clickFromPopup(evt)) { + openerElement = self._getTopStack().openerElement; + } else { + self._stack = []; + openerElement = evt.currentTarget; + } + $(openerElement).addClass('is-active'); + evt.preventDefault(); + + // We push our popup data to the stack. The top of the stack is always + // used as the data source for our current popup. + self._stack.push({ + popupName, + openerElement, + hasPopupParent: clickFromPopup(evt), + title: self._getTitle(popupName), + depth: self._stack.length, + offset: self._getOffset(openerElement), + dataContext: (this && this.currentData && this.currentData()) || this, + }); + + // If there are no popup currently opened we use the Blaze API to render + // one into the DOM. We use a reactive function as the data parameter that + // return the complete along with its top element and depends on our + // internal dependency that is being invalidated every time the top + // element of the stack has changed and we want to update the popup. + // + // Otherwise if there is already a popup open we just need to invalidate + // our internal dependency, and since we just changed the top element of + // our internal stack, the popup will be updated with the new data. + if (!self.isOpen()) { + self.current = Blaze.renderWithData( + self.template, + () => { + self._dep.depend(); + return { ...self._getTopStack(), stack: self._stack }; + }, + document.body, + ); + } else { + self._dep.changed(); + } + }; + } + + /// This function returns a callback that can be used in an event map: + /// Template.tplName.events({ + /// 'click .elementClass': Popup.afterConfirm("popupName", function() { + /// // What to do after the user has confirmed the action + /// }), + /// }); + afterConfirm(name, action) { + const self = this; + + return function(evt, tpl) { + const context = (this.currentData && this.currentData()) || this; + context.__afterConfirmAction = action; + self.open(name).call(context, evt, tpl); + }; + } + + /// The public reactive state of the popup. + isOpen() { + this._dep.changed(); + return Boolean(this.current); + } + + /// In case the popup was opened from a parent popup we can get back to it + /// with this `Popup.back()` function. You can go back several steps at once + /// by providing a number to this function, e.g. `Popup.back(2)`. In this case + /// intermediate popup won't even be rendered on the DOM. If the number of + /// steps back is greater than the popup stack size, the popup will be closed. + back(n = 1) { + if (this._stack.length > n) { + _.times(n, () => this._stack.pop()); + this._dep.changed(); + } + // else { + // this.close(); + //} + } + + /// Close the current opened popup. + /* + close() { + if (this.isOpen()) { + Blaze.remove(this.current); + this.current = null; + + const openerElement = this._getTopStack().openerElement; + $(openerElement).removeClass('is-active'); + + this._stack = []; + } + } + */ + + getOpenerComponent() { + const { openerElement } = Template.parentData(4); + return BlazeComponent.getComponentForElement(openerElement); + } + + // An utility fonction that returns the top element of the internal stack + _getTopStack() { + return this._stack[this._stack.length - 1]; + } + + // We automatically calculate the popup offset from the reference element + // position and dimensions. We also reactively use the window dimensions to + // ensure that the popup is always visible on the screen. + _getOffset(element) { + const $element = $(element); + return () => { + Utils.windowResizeDep.depend(); + + if (Utils.isMiniScreen()) return { left: 0, top: 0 }; + + const offset = $element.offset(); + const popupWidth = 300 + 15; + return { + left: Math.min(offset.left, $(window).width() - popupWidth), + top: offset.top + $element.outerHeight(), + }; + }; + } + + // We get the title from the translation files. Instead of returning the + // result, we return a function that compute the result and since `TAPi18n.__` + // is a reactive data source, the title will be changed reactively. + _getTitle(popupName) { + return () => { + const translationKey = `${popupName}-title`; + + // XXX There is no public API to check if there is an available + // translation for a given key. So we try to translate the key and if the + // translation output equals the key input we deduce that no translation + // was available and returns `false`. There is a (small) risk a false + // positives. + const title = TAPi18n.__(translationKey); + // when popup showed as full of small screen, we need a default header to clearly see [X] button + const defaultTitle = Utils.isMiniScreen() ? '' : false; + return title !== translationKey ? title : defaultTitle; + }; + } +})(); |