Add RichContenteditable component
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
This commit is contained in:
Родитель
8f9bd4fe5e
Коммит
d27ca50317
|
@ -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 ""
|
||||
|
|
|
@ -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(/ /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,
|
||||
|
|
Загрузка…
Ссылка в новой задаче