diff --git a/l10n/messages.pot b/l10n/messages.pot index 1801febb33..8a1368838b 100644 --- a/l10n/messages.pot +++ b/l10n/messages.pot @@ -119,3 +119,7 @@ msgstr "" #: src/components/SettingsSelectGroup/SettingsSelectGroup.vue:143 msgid "Unable to search the group" msgstr "" + +#: src/components/RichContenteditable/RichContenteditable.vue:126 +msgid "Write message, @ to mention someone …" +msgstr "" diff --git a/package-lock.json b/package-lock.json index 477ce7e9bf..26748d12ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7587,6 +7587,23 @@ } } }, + "babel-loader-exclude-node-modules-except": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/babel-loader-exclude-node-modules-except/-/babel-loader-exclude-node-modules-except-1.0.3.tgz", + "integrity": "sha512-UEHoSzivswrRu3bb7UIVpY0g1kBnZvuU/2oXrRBhggjQOqW2HBCp6FsvuNc8ZupBUh+zClKxw8jxwuqZTSapwQ==", + "dev": true, + "requires": { + "escape-string-regexp": "2.0.0" + }, + "dependencies": { + "escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true + } + } + }, "babel-messages": { "version": "6.23.0", "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", @@ -10935,8 +10952,7 @@ "escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=", - "dev": true + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" }, "escape-string-regexp": { "version": "1.0.5", @@ -26203,6 +26219,11 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=" }, + "striptags": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/striptags/-/striptags-3.1.1.tgz", + "integrity": "sha1-yMPn/db7S7OjKjt1LltePjgJPr0=" + }, "style-loader": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-1.2.1.tgz", @@ -27751,6 +27772,11 @@ "integrity": "sha512-1CxDIZmCQ3vA0GGnkdMQqxUXVm3xXAFmglPYRS1hr37LzSg22TC7QAWOT38OmdUvMEs/rqcnkFoAsqvzdiluDg==", "dev": true }, + "tributejs": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/tributejs/-/tributejs-5.1.3.tgz", + "integrity": "sha512-B5CXihaVzXw+1UHhNFyAwUTMDk1EfoLP5Tj1VhD9yybZ1I8DZJEv8tZ1l0RJo0t0tk9ZhR8eG5tEsaCvRigmdQ==" + }, "trim": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/trim/-/trim-0.0.1.tgz", diff --git a/package.json b/package.json index efc6886262..3652d0a38a 100644 --- a/package.json +++ b/package.json @@ -46,10 +46,13 @@ "core-js": "^3.6.5", "debounce": "1.2.0", "emoji-mart-vue-fast": "^7.0.4", + "escape-html": "^1.0.3", "hammerjs": "^2.0.8", "linkifyjs": "~2.1.9", "md5": "^2.2.1", "regenerator-runtime": "^0.13.5", + "striptags": "^3.1.1", + "tributejs": "^5.1.3", "v-click-outside": "^3.0.1", "v-tooltip": "^2.0.3", "vue": "^2.6.11", @@ -72,6 +75,7 @@ "babel-eslint": "^10.1.0", "babel-jest": "^26.3.0", "babel-loader": "^8.1.0", + "babel-loader-exclude-node-modules-except": "^1.0.3", "css-loader": "^3.5.2", "cypress": "^5.0.0", "cypress-visual-regression": "^1.5.0", diff --git a/src/components/RichContenteditable/AutoCompleteResult.vue b/src/components/RichContenteditable/AutoCompleteResult.vue new file mode 100644 index 0000000000..9de8f9c458 --- /dev/null +++ b/src/components/RichContenteditable/AutoCompleteResult.vue @@ -0,0 +1,181 @@ + + + + + + + diff --git a/src/components/RichContenteditable/MentionBubble.vue b/src/components/RichContenteditable/MentionBubble.vue new file mode 100644 index 0000000000..f415975dbd --- /dev/null +++ b/src/components/RichContenteditable/MentionBubble.vue @@ -0,0 +1,158 @@ + + + + + + + diff --git a/src/components/RichContenteditable/RichContenteditable.vue b/src/components/RichContenteditable/RichContenteditable.vue new file mode 100644 index 0000000000..37cc5b03dc --- /dev/null +++ b/src/components/RichContenteditable/RichContenteditable.vue @@ -0,0 +1,394 @@ + + + + +### General description + +This component displays contenteditable div with automated @ autocompletion [at]. + +### Examples + +```vue + + +``` + + + + + + + + + + diff --git a/src/components/RichContenteditable/index.js b/src/components/RichContenteditable/index.js new file mode 100644 index 0000000000..5b3e36ebae --- /dev/null +++ b/src/components/RichContenteditable/index.js @@ -0,0 +1,25 @@ +/** + * @copyright Copyright (c) 2020 John Molakvoæ + * + * @author John Molakvoæ + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import RichContenteditable from './RichContenteditable' + +export default RichContenteditable diff --git a/src/components/index.js b/src/components/index.js index a29029b784..a76e1391de 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -56,6 +56,7 @@ import Multiselect from './Multiselect' import MultiselectTags from './MultiselectTags' import Popover from './Popover' import PopoverMenu from './PopoverMenu' +import RichContenteditable from './RichContenteditable' import SettingsSection from './SettingsSection' import UserBubble from './UserBubble' import AppSettingsDialog from './AppSettingsDialog' @@ -97,6 +98,7 @@ export { MultiselectTags, Popover, PopoverMenu, + RichContenteditable, SettingsSection, UserBubble, AppSettingsDialog, diff --git a/src/mixins/index.js b/src/mixins/index.js index cadc2cdb91..3d68c2a391 100644 --- a/src/mixins/index.js +++ b/src/mixins/index.js @@ -23,11 +23,13 @@ import excludeClickOutsideClasses from './excludeClickOutsideClasses' import isFullscreen from './isFullscreen' import isMobile from './isMobile' +import richEditor from './richEditor' import userStatus from './userStatus' export { excludeClickOutsideClasses, isFullscreen, isMobile, + richEditor, userStatus, } diff --git a/src/mixins/richEditor/index.js b/src/mixins/richEditor/index.js new file mode 100644 index 0000000000..311f72c213 --- /dev/null +++ b/src/mixins/richEditor/index.js @@ -0,0 +1,151 @@ +/** + * @copyright Copyright (c) 2020 John Molakvoæ + * + * @author John Molakvoæ + * + * @license GNU AGPL version 3 or any later version + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + */ + +import Vue from 'vue' +import stripTags from 'striptags' +import escapeHtml from 'escape-html' +import MentionBubble from '../../components/RichContenteditable/MentionBubble.vue' + +// Beginning or whitespace. Non-capturing group +const MENTION_START = '(?:^|\\s)' +// Anything that is not text or end-of-line. Non-capturing group +const MENTION_END = '(?:[^a-z]|$)' +export const USERID_REGEX = new RegExp(`${MENTION_START}(@[a-zA-Z0-9_.@\\-']+)(${MENTION_END})`, 'gi') +export const USERID_REGEX_WITH_SPACE = new RegExp(`${MENTION_START}(@"[a-zA-Z0-9 _.@\\-']+")(${MENTION_END})`, 'gi') + +export default { + props: { + userData: { + type: Object, + default: () => ({}), + }, + }, + methods: { + /** + * Convert the value string to html for the inner content + * + * @param {string} value the content without html + * @returns {string} rendered html + */ + renderContent(value) { + // Sanitize the value prop + const sanitizedValue = escapeHtml(value) + + // Extract all the userIds + const splitValue = sanitizedValue.split(USERID_REGEX) + .map(part => part.split(USERID_REGEX_WITH_SPACE)).flat() + + // Replace userIds by html + return splitValue + .map(part => { + // When splitting, the string is always putting the userIds + // on the the uneven indexes. We only want to generate the mentions html + if (!part.startsWith('@')) { + return part + } + + // Extracting the id, nuking the " and @ + const id = part.replace(/[@"]/gi, '') + + // Compiling template and prepend with the space we removed during the split + return ' ' + this.genSelectTemplate(id) + }) + .join('') + .replace(/\n/gm, '
') + }, + + /** + * Convert the innerHtml content to a string with mentions as text + * + * @param {string} content the content without html + * @returns {string} + */ + parseContent(content) { + let text = content.replace(/
/g, '\n') + text = text.replace(/ /g, ' ') + + // Convert the mentions to text only + // first we replace divs with new lines + text = text.replace(/<\/div>/gim, '\n') + // then we remove all leftover html + text = stripTags(text, '
') + text = stripTags(text) + + return text + }, + + /** + * Generate an autocompletion popup entry template + * + * @param {string} value the value to match against the userData + * @returns {string} + */ + genSelectTemplate(value) { + let data = this.userData[value] + + // Fallback to @mention in case no data matches + if (!data) { + // return `@${value}` + data = { + id: value, + label: value, + icon: 'icon-user', + source: 'users', + } + } + + // Return template and make sure we strip of new lines and tabs + return this.renderComponentHtml(data, MentionBubble).replace(/[\n\t]/g, '') + }, + + /** + * Render a component and return its html content + * + * @param {Object} propsData the props to pass to the component + * @param {Object} component the component to render + * @returns {string} the rendered html + */ + renderComponentHtml(propsData, component) { + const View = Vue.extend(component) + const Item = new View({ + propsData, + }) + + // Prepare mountpoint + const wrapper = document.createElement('div') + const mount = document.createElement('div') + wrapper.style.display = 'none' + wrapper.appendChild(mount) + document.body.appendChild(wrapper) + + // Mount and get raw html + Item.$mount(mount) + const renderedHtml = wrapper.innerHTML + + // Destroy + Item.$destroy() + wrapper.remove() + + return renderedHtml + }, + }, +} diff --git a/webpack.common.js b/webpack.common.js index 611ebf04e6..51668b2d11 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -3,14 +3,13 @@ const gettextParser = require('gettext-parser') const glob = require('glob') const md5 = require('md5') const path = require('path') -const StyleLintPlugin = require('stylelint-webpack-plugin') -const { VueLoaderPlugin } = require('vue-loader') - -const IconfontPlugin = require('iconfont-plugin-webpack') const { DefinePlugin } = require('webpack') - +const { VueLoaderPlugin } = require('vue-loader') +const BabelLoaderExcludeNodeModulesExcept = require('babel-loader-exclude-node-modules-except') +const IconfontPlugin = require('iconfont-plugin-webpack') const nodeExternals = require('webpack-node-externals') +const StyleLintPlugin = require('stylelint-webpack-plugin') // scope variable // fallback for cypress testing @@ -121,7 +120,9 @@ module.exports = { { test: /\.js$/, loader: 'babel-loader', - exclude: /node_modules/, + exclude: BabelLoaderExcludeNodeModulesExcept([ + 'tributejs', + ]), }, { test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/i,