Add RichContenteditable component

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
John Molakvoæ (skjnldsv) 2020-10-06 12:16:26 +02:00
Родитель 8f9bd4fe5e
Коммит d27ca50317
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 60C25B8C072916CF
11 изменённых файлов: 956 добавлений и 8 удалений

Просмотреть файл

@ -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 ""

30
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",

Просмотреть файл

@ -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",

Просмотреть файл

@ -0,0 +1,181 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @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 <http://www.gnu.org/licenses/>.
-->
<template>
<div class="autocomplete-result">
<!-- Avatar or icon -->
<div :class="[icon, `autocomplete-result__icon--${avatarUrl ? 'with-avatar' : ''}`]"
:style="avatarUrl ? { backgroundImage: `url(${avatarUrl})` } : null "
class="autocomplete-result__icon">
<div v-if="haveStatus"
:class="[`autocomplete-result__status--${status && status.icon ? 'icon' : status.status}`]"
class="autocomplete-result__status">
{{ status && status.icon || '' }}
</div>
</div>
<!-- Title and subtitle -->
<span class="autocomplete-result__content">
<span class="autocomplete-result__title">
{{ label }}
</span>
<span v-if="subline" class="autocomplete-result__subline">
{{ subline }}
</span>
</span>
</div>
</template>
<script>
import { generateUrl } from '@nextcloud/router'
export default {
name: 'AutoCompleteResult',
props: {
label: {
type: String,
required: true,
},
subline: {
type: String,
default: null,
},
id: {
type: String,
default: null,
},
icon: {
type: String,
required: true,
},
source: {
type: String,
required: true,
},
status: {
type: [Object, Array],
default: () => ({}),
},
},
computed: {
avatarUrl() {
return this.id && this.source === 'users'
? this.getAvatarUrl(this.id, 44)
: null
},
haveStatus() {
return this.status?.icon || this.status?.status
},
},
methods: {
getAvatarUrl(user, size) {
return generateUrl('/avatar/{user}/{size}', {
user,
size,
})
},
},
}
</script>
<style lang="scss" scoped>
@import '../../fonts/scss/iconfont-vue';
$autocomplete-padding: 10px;
.autocomplete-result {
display: flex;
height: $clickable-area;
padding: $autocomplete-padding;
.highlight & {
color: var(--color-main-text);
background: var(--color-primary-light);
&, * {
cursor: pointer;
}
}
&__icon {
position: relative;
width: $clickable-area;
height: $clickable-area;
border-radius: $clickable-area;
background-color: var(--color-background-darker);
background-repeat: no-repeat;
background-position: center;
background-size: $clickable-area - 2 * $autocomplete-padding;
&--with-avatar {
color: inherit;
background-size: cover;
}
}
&__status {
position: absolute;
right: -4px;
bottom: -4px;
box-sizing: border-box;
width: 18px;
height: 18px;
border: 2px solid var(--color-main-background);
border-radius: 50%;
background-color: var(--color-main-background);
font-size: 14px;
line-height: 14px;
&--online {
color: #49b382;
@include iconfont('user-status-online');
}
&--dnd {
color: #ed484c;
background-color: #fff;
@include iconfont('user-status-dnd');
}
&--away {
color: #f4a331;
@include iconfont('user-status-away');
}
&--offline,
&--icon {
border: none;
background-color: transparent;
}
}
&__content {
display: flex;
flex: 1 1;
flex-direction: column;
justify-content: center;
padding-left: $autocomplete-padding;
}
&__subline {
color: var(--color-text-lighter);
}
}
</style>

Просмотреть файл

@ -0,0 +1,158 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @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 <http://www.gnu.org/licenses/>.
-->
<template>
<span :class="{'mention-bubble--primary': primary}"
class="mention-bubble"
contenteditable="false">
<span class="mention-bubble__wrapper">
<span class="mention-bubble__content">
<!-- Avatar or icon -->
<span :class="[icon, `mention-bubble__icon--${avatarUrl ? 'with-avatar' : ''}`]"
:style="avatarUrl ? { backgroundImage: `url(${avatarUrl})` } : null"
class="mention-bubble__icon" />
<!-- Title -->
<span role="heading" class="mention-bubble__title" :title="label" />
</span>
<!-- Selectable text for copy/paste -->
<span role="none" class="mention-bubble__select">{{ mentionText }}</span>
</span>
</span>
</template>
<script>
import { generateUrl } from '@nextcloud/router'
export default {
name: 'MentionBubble',
props: {
id: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
icon: {
type: String,
required: true,
},
source: {
type: String,
required: true,
},
primary: {
type: Boolean,
default: false,
},
},
computed: {
avatarUrl() {
return this.id && this.source === 'users'
? this.getAvatarUrl(this.id, 44)
: null
},
mentionText() {
return this.id.indexOf(' ') === -1
? `@${this.id}`
: `@"${this.id}"`
},
},
methods: {
getAvatarUrl(user, size) {
return generateUrl('/avatar/{user}/{size}', {
user,
size,
})
},
},
}
</script>
<style lang="scss" scoped>
$bubble-size: 20px;
$bubble-padding: 2px;
$bubble-avatar-size: $bubble-size - 2 * $bubble-padding;
.mention-bubble {
&--primary &__content {
background-color: var(--color-primary-element);
color: var(--color-primary-text);
}
&__wrapper {
display: inline-block;
vertical-align: middle;
white-space: nowrap;
// Align vertically with nearby text
line-height: 16px;
}
&__content {
display: inline-flex;
align-items: center;
height: $bubble-size;
padding-right: $bubble-padding * 3;
padding-left: $bubble-padding;
border-radius: $bubble-size / 2;
background-color: var(--color-background-dark);
user-select: none;
-webkit-user-select: none;
}
&__icon {
position: relative;
width: $bubble-avatar-size;
height: $bubble-avatar-size;
border-radius: $bubble-avatar-size / 2;
background-color: var(--color-background-darker);
background-repeat: no-repeat;
background-position: center;
background-size: $bubble-avatar-size - 2 * $bubble-padding;
&--with-avatar {
color: inherit;
background-size: cover;
}
}
&__title {
margin-left: $bubble-padding;
// Put label in ::before so it is not selectable
&::before {
content: attr(title);
}
}
// Hide the mention id so it is selectable
&__select {
position: absolute;
z-index: -1;
left: -1000px;
}
}
</style>

Просмотреть файл

@ -0,0 +1,394 @@
<!--
- @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
-
- @author John Molakvoæ <skjnldsv@protonmail.com>
-
- @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 <http://www.gnu.org/licenses/>.
-->
<docs>
### General description
This component displays contenteditable div with automated @ autocompletion [at].
### Examples
```vue
<template>
<div>
<RichContenteditable
v-model="message"
:auto-complete="autoComplete"
:user-data="userData"
placeholder="Try mentioning the user Test01"
style="min-height: 100px;" />
{{ JSON.stringify(message) }}
</div>
</template>
<script>
export default {
data() {
return {
message: '',
// You need to provide this for the inline
// mention to understand what to display or not.
userData: {
Test01: {
icon: 'icon-user',
id: 'Test01',
label: 'Test01',
source: 'users',
primary: true,
},
Test02: {
icon: 'icon-user',
id: 'Test02',
label: 'Test02',
source: 'users',
status: {
clearAt: null,
icon: '🎡',
message: 'Visiting London',
status: 'away',
},
subline: 'Visiting London',
}
}
}
},
methods: {
/**
* Do your own query to the autocompletion api.
* The returned data bellow is a fake data example.
* The callback expects the same format returned by the core/autocomplete/get ocs api endpoint!
* @see userData example above
*
* @param {string} search the query
* @param {Function} callback the callback to process the results with
*/
autoComplete(search, callback) {
callback(Object.values(this.userData))
}
}
}
</script>
```
</docs>
<template>
<div ref="contenteditable"
:class="{'rich-contenteditable__input--empty': isEmptyValue}"
:contenteditable="contenteditable"
:placeholder="placeholder"
aria-multiline="true"
class="rich-contenteditable__input"
role="textbox"
@input="onInput"
@keydown.delete="onDelete"
@paste="onPaste" />
</template>
<script>
import Tribute from 'tributejs/dist/tribute.esm'
import debounce from 'debounce'
import { t } from '../../l10n.js'
import AutoCompleteResult from './AutoCompleteResult'
import richEditor from '../../mixins/richEditor/index'
export default {
name: 'RichContenteditable',
mixins: [richEditor],
props: {
value: {
type: String,
required: true,
},
placeholder: {
type: String,
default: t('Write message, @ to mention someone …'),
},
autoComplete: {
type: Function,
required: true,
},
menuContainer: {
type: Element,
default: () => document.body,
},
/**
* Is the content editable
*/
contenteditable: {
type: Boolean,
default: true,
},
},
data() {
return {
tribute: null,
options: {
fillAttr: 'id',
// Search against id and label (display name)
lookup: result => `${result.id} ${result.label}`,
// Where to inject the menu popup
menuContainer: this.menuContainer,
// Popup mention autocompletion templates
menuItemTemplate: item => this.renderComponentHtml(item.original, AutoCompleteResult),
// Hide if no results
noMatchTemplate: () => '<span class="hidden"></span>',
// Inner display of mentions
selectTemplate: item => this.genSelectTemplate(item.original?.id),
// Autocompletion results
values: this.debouncedAutoComplete,
},
// Represent the raw untrimmed text of the contenteditable
// serves no other purpose than to check whether the
// content is empty or not
localValue: '',
}
},
computed: {
isEmptyValue() {
return !this.localValue || this.localValue.trim() === ''
},
/**
* Is this Firefox? 🙄
* @returns {boolean}
*/
isFF() {
return !!navigator.userAgent.match(/firefox/i)
},
},
watch: {
/**
* If the parent value change, we compare the plain text rendering
* If it's different, we render everything and update the main content
*/
value() {
const html = this.$refs.contenteditable.innerHTML
// Compare trimmed versions to be safe
if (this.value.trim() !== this.parseContent(html).trim()) {
const renderedContent = this.renderContent(this.value)
this.$refs.contenteditable.innerHTML = renderedContent
this.localValue = this.value
}
},
},
mounted() {
this.tribute = new Tribute(this.options)
this.tribute.attach(this.$el)
// Update default value
const renderedContent = this.renderContent(this.value)
this.$refs.contenteditable.innerHTML = renderedContent
},
beforeDestroy() {
if (this.tribute) {
this.tribute.detach(this.$el)
}
},
methods: {
/**
* Re-emit the input event to the parent
* @param {Event} event the input event
*/
onInput(event) {
this.updateValue(event.target.innerHTML)
},
/**
* When pasting, sanitize the content, extract text
* and render it again
* @param {Event} event the paste event
* @emits {Event} paste the original paste event
*/
onPaste(event) {
event.preventDefault()
const clipboardData = event.clipboardData
/** The original paste event */
this.$emit('paste', event)
// If we have other data than text, ignore
if (clipboardData.files.length !== 0
|| !Object.values(clipboardData.items).every(item => item?.type.startsWith('text'))) {
return
}
const html = clipboardData.getData('text')
const selection = window.getSelection()
// If no selection, replace the whole data
if (!selection.rangeCount) {
this.updateValue(html)
}
// Generate text and insert
const text = this.parseContent(html)
const range = selection.getRangeAt(0)
selection.deleteFromDocument()
range.insertNode(document.createTextNode(text))
// Put cursor at the end of the selection
const newRange = document.createRange()
newRange.setStart(event.target, range.endOffset)
newRange.collapse(true)
selection.removeAllRanges()
selection.addRange(newRange)
// Propagate data
this.updateValue(event.target.innerHTML)
},
/**
* Update the value text from the provided html
* @param {string} htmlOrText the html content (or raw text with @mentions)
*/
updateValue(htmlOrText) {
const text = this.parseContent(htmlOrText)
this.localValue = text
this.$emit('input', text)
this.$emit('update:value', text)
},
/**
* Because FF have a decade old bug preventing contenteditable=false
* to properly be deleted on backspace, we have to hack 👀
* https://stackoverflow.com/a/59383394/3885878
* https://stackoverflow.com/a/30574622
*
* @param {Event} event the delete keydown event
*/
onDelete(event) {
if (!this.isFF || !window.getSelection) {
return
}
// fix backspace bug in FF
// https://bugzilla.mozilla.org/show_bug.cgi?id=685445
const selection = window.getSelection()
const node = event.target
if (!selection.isCollapsed || !selection.rangeCount) {
return
}
const curRange = selection.getRangeAt(selection.rangeCount - 1)
if (curRange.commonAncestorContainer.nodeType === 3 && curRange.startOffset > 0) {
// we are in child selection. The characters of the text node is being deleted
return
}
const range = document.createRange()
if (selection.anchorNode !== node) {
// selection is in character mode. expand it to the whole editable field
range.selectNodeContents(node)
range.setEndBefore(selection.anchorNode)
} else if (selection.anchorOffset > 0) {
range.setEnd(node, selection.anchorOffset)
} else {
// reached the beginning of editable field
return
}
range.setStart(node, range.endOffset - 1)
const previousNode = range.cloneContents().lastChild
if (previousNode && previousNode.contentEditable === 'false') {
// this is some rich content, e.g. smile. We should help the user to delete it
range.deleteContents()
event.preventDefault()
}
},
/**
* Debounce the autocomplete function
*/
debouncedAutoComplete: debounce(async function(search, callback) {
this.autoComplete(search, callback)
}, 100),
},
}
</script>
<style lang="scss" scoped>
// Standalone styling, independent from server
.rich-contenteditable__input {
overflow-y: auto;
width: auto;
margin: 0;
padding: 6px;
cursor: text;
white-space: pre-wrap;
word-break: break-word;
color: var(--color-main-text);
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
outline: none;
background-color: var(--color-main-background);
font-family: var(--font-face);
font-size: inherit;
// Cannot use :empty because of firefox bug https://bugzilla.mozilla.org/show_bug.cgi?id=1513303
&--empty:before {
position: absolute;
content: attr(placeholder);
color: var(--color-text-maxcontrast);
}
&[contenteditable='false'] {
cursor: default;
opacity: .5;
color: var(--color-text-lighter);
border: 1px solid var(--color-background-darker);
border-radius: var(--border-radius);
background-color: var(--color-background-dark);
}
}
</style>
<style lang="scss">
@import '../../fonts/scss/iconfont-vue';
.tribute-container {
z-index: 9000;
overflow: auto;
min-width: 250px;
max-width: 300px;
// Show maximum 4 entries and a half to show scroll
max-height: $clickable-area * 4.5;
// Space it out a bit from the text
margin: 5px 0;
color: var(--color-main-text);
border-radius: var(--border-radius);
background: var(--color-main-background);
box-shadow: 0 0 5px var(--color-box-shadow);
}
</style>

Просмотреть файл

@ -0,0 +1,25 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
import RichContenteditable from './RichContenteditable'
export default RichContenteditable

Просмотреть файл

@ -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,

Просмотреть файл

@ -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,
}

Просмотреть файл

@ -0,0 +1,151 @@
/**
* @copyright Copyright (c) 2020 John Molakvoæ <skjnldsv@protonmail.com>
*
* @author John Molakvoæ <skjnldsv@protonmail.com>
*
* @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 <http://www.gnu.org/licenses/>.
*
*/
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, '<br>')
},
/**
* 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(/<br>/g, '\n')
text = text.replace(/&nbsp;/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, '<div>')
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
},
},
}

Просмотреть файл

@ -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,