Switch to vue-at for comment mentions
Signed-off-by: Julius Härtl <jus@bitgrid.net>
This commit is contained in:
5
package-lock.json
generated
5
package-lock.json
generated
@@ -18978,6 +18978,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz",
|
"resolved": "https://registry.npmjs.org/vue/-/vue-2.6.11.tgz",
|
||||||
"integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ=="
|
"integrity": "sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ=="
|
||||||
},
|
},
|
||||||
|
"vue-at": {
|
||||||
|
"version": "2.5.0-beta.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-at/-/vue-at-2.5.0-beta.2.tgz",
|
||||||
|
"integrity": "sha512-WXjngEaNyNWFU9unUUdK5kGolCHgG3jmlUIgeRnKlHpskbgGjIE/HGTOWnMfLEqjYJl9DTzt/SKPWDoFVaND/A=="
|
||||||
|
},
|
||||||
"vue-click-outside": {
|
"vue-click-outside": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/vue-click-outside/-/vue-click-outside-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/vue-click-outside/-/vue-click-outside-1.0.7.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"@nextcloud/l10n": "^1.0.1",
|
"@nextcloud/l10n": "^1.0.1",
|
||||||
"@nextcloud/router": "^1.0.0",
|
"@nextcloud/router": "^1.0.0",
|
||||||
"@nextcloud/vue": "^1.3.0",
|
"@nextcloud/vue": "^1.3.0",
|
||||||
|
"escape-html": "^1.0.3",
|
||||||
"fuse.js": "^3.4.6",
|
"fuse.js": "^3.4.6",
|
||||||
"nextcloud-server": "^0.15.10",
|
"nextcloud-server": "^0.15.10",
|
||||||
"nextcloud-vue-collections": "^0.7.1",
|
"nextcloud-vue-collections": "^0.7.1",
|
||||||
@@ -41,6 +42,7 @@
|
|||||||
"tiptap-extensions": "^1.28.6",
|
"tiptap-extensions": "^1.28.6",
|
||||||
"url-search-params-polyfill": "^7.0.1",
|
"url-search-params-polyfill": "^7.0.1",
|
||||||
"vue": "^2.6.11",
|
"vue": "^2.6.11",
|
||||||
|
"vue-at": "^2.5.0-beta.2",
|
||||||
"vue-click-outside": "^1.0.7",
|
"vue-click-outside": "^1.0.7",
|
||||||
"vue-color": "^2.7.0",
|
"vue-color": "^2.7.0",
|
||||||
"vue-easymde": "^1.0.1",
|
"vue-easymde": "^1.0.1",
|
||||||
|
|||||||
@@ -8,11 +8,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="comment-form">
|
<div class="comment-form">
|
||||||
<form @submit.prevent="createComment()">
|
<form @submit.prevent="createComment">
|
||||||
<EditorContent :editor="editor"
|
<At ref="at"
|
||||||
:placeholder="t('deck', 'New comment') + ' ...'"
|
v-model="newComment"
|
||||||
class="editor__content"
|
:members="members"
|
||||||
required />
|
name-key="primaryKey"
|
||||||
|
:tab-select="true">
|
||||||
|
<template v-slot:item="s">
|
||||||
|
<Avatar :user="s.item.uid" />
|
||||||
|
<span v-text="s.item.displayname" />
|
||||||
|
</template>
|
||||||
|
<template v-slot:embeddedItem="scope">
|
||||||
|
<span>
|
||||||
|
<UserBubble v-if="scope.current.primaryKey"
|
||||||
|
:data-mention-id="scope.current.primaryKey"
|
||||||
|
:user="scope.current.primaryKey"
|
||||||
|
:display-name="scope.current.displayname" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<div ref="contentEditable" contenteditable />
|
||||||
|
</At>
|
||||||
<input v-tooltip="t('deck', 'Save')"
|
<input v-tooltip="t('deck', 'Save')"
|
||||||
class="icon-confirm"
|
class="icon-confirm"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -20,22 +35,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="showSuggestions" ref="suggestions" class="suggestion-list">
|
|
||||||
<template v-if="hasResults">
|
|
||||||
<div
|
|
||||||
v-for="(user, index) in filteredUsers"
|
|
||||||
:key="user.uid"
|
|
||||||
:class="{ 'is-selected': navigatedUserIndex === index }"
|
|
||||||
class="suggestion-list__item"
|
|
||||||
@click="selectUser(user)">
|
|
||||||
{{ user.displayname }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div v-else class="suggestion-list__item is-empty">
|
|
||||||
{{ t('deck', 'No users found') }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul v-if="getCommentsForCard(card.id).length > 0" id="commentsFeed">
|
<ul v-if="getCommentsForCard(card.id).length > 0" id="commentsFeed">
|
||||||
<CommentItem v-for="comment in getCommentsForCard(card.id)"
|
<CommentItem v-for="comment in getCommentsForCard(card.id)"
|
||||||
:key="comment.id"
|
:key="comment.id"
|
||||||
@@ -56,23 +55,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Fuse from 'fuse.js'
|
|
||||||
import tippy from 'tippy.js'
|
|
||||||
import { Editor, EditorContent } from 'tiptap'
|
|
||||||
import { Mention } from 'tiptap-extensions'
|
|
||||||
|
|
||||||
import { mapState, mapGetters } from 'vuex'
|
import { mapState, mapGetters } from 'vuex'
|
||||||
import { Avatar } from '@nextcloud/vue'
|
import { Avatar, UserBubble } from '@nextcloud/vue'
|
||||||
import CommentItem from './CommentItem'
|
import CommentItem from './CommentItem'
|
||||||
import InfiniteLoading from 'vue-infinite-loading'
|
import InfiniteLoading from 'vue-infinite-loading'
|
||||||
|
import At from 'vue-at'
|
||||||
|
import { rawToParsed } from '../../helpers/mentions'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CardSidebarTabComments',
|
name: 'CardSidebarTabComments',
|
||||||
components: {
|
components: {
|
||||||
Avatar,
|
Avatar,
|
||||||
CommentItem,
|
CommentItem,
|
||||||
EditorContent,
|
|
||||||
InfiniteLoading,
|
InfiniteLoading,
|
||||||
|
At,
|
||||||
|
UserBubble,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
card: {
|
card: {
|
||||||
@@ -84,107 +81,19 @@ export default {
|
|||||||
return {
|
return {
|
||||||
newComment: '',
|
newComment: '',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
editor: new Editor({
|
|
||||||
extensions: [
|
|
||||||
new Mention({
|
|
||||||
// a list of all suggested items
|
|
||||||
items: () => {
|
|
||||||
return this.currentBoard.users
|
|
||||||
},
|
|
||||||
// is called when a suggestion starts
|
|
||||||
onEnter: ({
|
|
||||||
items, query, range, command, virtualNode,
|
|
||||||
}) => {
|
|
||||||
this.query = query
|
|
||||||
this.filteredUsers = items
|
|
||||||
this.suggestionRange = range
|
|
||||||
this.renderPopup(virtualNode)
|
|
||||||
// we save the command for inserting a selected mention
|
|
||||||
// this allows us to call it inside of our custom popup
|
|
||||||
// via keyboard navigation and on click
|
|
||||||
this.insertMention = command
|
|
||||||
},
|
|
||||||
// is called when a suggestion has changed
|
|
||||||
onChange: ({
|
|
||||||
items, query, range, virtualNode,
|
|
||||||
}) => {
|
|
||||||
this.query = query
|
|
||||||
this.filteredUsers = items
|
|
||||||
this.suggestionRange = range
|
|
||||||
this.navigatedUserIndex = 0
|
|
||||||
this.renderPopup(virtualNode)
|
|
||||||
},
|
|
||||||
// is called when a suggestion is cancelled
|
|
||||||
onExit: () => {
|
|
||||||
// reset all saved values
|
|
||||||
this.query = null
|
|
||||||
this.filteredUsers = []
|
|
||||||
this.suggestionRange = null
|
|
||||||
this.navigatedUserIndex = 0
|
|
||||||
this.destroyPopup()
|
|
||||||
},
|
|
||||||
// is called on every keyDown event while a suggestion is active
|
|
||||||
onKeyDown: ({ event }) => {
|
|
||||||
// pressing up arrow
|
|
||||||
if (event.keyCode === 38) {
|
|
||||||
this.upHandler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// pressing down arrow
|
|
||||||
if (event.keyCode === 40) {
|
|
||||||
this.downHandler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// pressing enter
|
|
||||||
if (event.keyCode === 13) {
|
|
||||||
this.enterHandler()
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
},
|
|
||||||
// is called when a suggestion has changed
|
|
||||||
// this function is optional because there is basic filtering built-in
|
|
||||||
// you can overwrite it if you prefer your own filtering
|
|
||||||
// in this example we use fuse.js with support for fuzzy search
|
|
||||||
onFilter: (items, query) => {
|
|
||||||
if (!query) {
|
|
||||||
return items
|
|
||||||
}
|
|
||||||
const fuse = new Fuse(items, {
|
|
||||||
threshold: 0.2,
|
|
||||||
keys: ['uid', 'displayname'],
|
|
||||||
})
|
|
||||||
return fuse.search(query)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
content: '',
|
|
||||||
onUpdate: ({ getHTML }) => {
|
|
||||||
this.newComment = getHTML().replace(/(<p>|<\/p>)/g, '')
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
query: null,
|
|
||||||
suggestionRange: null,
|
|
||||||
filteredUsers: [],
|
|
||||||
navigatedUserIndex: 0,
|
|
||||||
insertMention: () => {},
|
|
||||||
observer: null,
|
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
comments: state => state.comment.comments,
|
|
||||||
currentBoard: state => state.currentBoard,
|
currentBoard: state => state.currentBoard,
|
||||||
}),
|
}),
|
||||||
...mapGetters(['getCommentsForCard', 'hasMoreComments']),
|
...mapGetters([
|
||||||
hasResults() {
|
'getCommentsForCard',
|
||||||
return this.filteredUsers.length
|
'hasMoreComments',
|
||||||
|
]),
|
||||||
|
members() {
|
||||||
|
return this.currentBoard.users
|
||||||
},
|
},
|
||||||
showSuggestions() {
|
|
||||||
return this.query || this.hasResults
|
|
||||||
},
|
|
||||||
|
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
'card': {
|
'card': {
|
||||||
@@ -208,15 +117,15 @@ export default {
|
|||||||
await this.$store.dispatch('fetchComments', { cardId: this.card.id })
|
await this.$store.dispatch('fetchComments', { cardId: this.card.id })
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
},
|
},
|
||||||
createComment() {
|
async createComment() {
|
||||||
|
const content = this.contentEditableToParsed()
|
||||||
const commentObj = {
|
const commentObj = {
|
||||||
cardId: this.card.id,
|
cardId: this.card.id,
|
||||||
comment: this.newComment,
|
comment: content,
|
||||||
}
|
}
|
||||||
this.$store.dispatch('createComment', commentObj)
|
await this.$store.dispatch('createComment', commentObj)
|
||||||
this.loadComments()
|
|
||||||
this.newComment = ''
|
this.newComment = ''
|
||||||
this.editor.setContent('')
|
await this.loadComments()
|
||||||
},
|
},
|
||||||
async loadMore() {
|
async loadMore() {
|
||||||
this.isLoading = true
|
this.isLoading = true
|
||||||
@@ -224,69 +133,20 @@ export default {
|
|||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
},
|
},
|
||||||
|
|
||||||
// navigate to the previous item
|
/**
|
||||||
// if it's the first item, navigate to the last one
|
* All credits for this go to the talk app
|
||||||
upHandler() {
|
* https://github.com/nextcloud/spreed/blob/e69740b372e17eec4541337b47baa262a5766510/src/components/NewMessageForm/NewMessageForm.vue#L100-L143
|
||||||
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredUsers.length) - 1) % this.filteredUsers.length
|
*/
|
||||||
},
|
contentEditableToParsed() {
|
||||||
// navigate to the next item
|
const mentions = this.$refs.contentEditable.querySelectorAll('span[data-at-embedded]')
|
||||||
// if it's the last item, navigate to the first one
|
mentions.forEach(mention => {
|
||||||
downHandler() {
|
// FIXME Adding a space after the mention should be improved to
|
||||||
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredUsers.length
|
// do it or not based on the next element instead of always
|
||||||
},
|
// adding it.
|
||||||
enterHandler() {
|
mention.replaceWith('@' + mention.firstElementChild.attributes['data-mention-id'].value + ' ')
|
||||||
const user = this.filteredUsers[this.navigatedUserIndex]
|
|
||||||
if (user) {
|
|
||||||
this.selectUser(user)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// we have to replace our suggestion text with a mention
|
|
||||||
// so it's important to pass also the position of your suggestion text
|
|
||||||
selectUser(user) {
|
|
||||||
this.insertMention({
|
|
||||||
range: this.suggestionRange,
|
|
||||||
attrs: {
|
|
||||||
id: user.uid,
|
|
||||||
label: user.displayname,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
this.editor.focus()
|
|
||||||
},
|
return rawToParsed(this.$refs.contentEditable.innerHTML)
|
||||||
// renders a popup with suggestions
|
|
||||||
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
|
|
||||||
renderPopup(node) {
|
|
||||||
if (this.popup) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.popup = tippy(node, {
|
|
||||||
content: this.$refs.suggestions,
|
|
||||||
trigger: 'mouseenter',
|
|
||||||
interactive: true,
|
|
||||||
placement: 'bottom-start',
|
|
||||||
inertia: true,
|
|
||||||
duration: [400, 200],
|
|
||||||
showOnInit: true,
|
|
||||||
})
|
|
||||||
// we have to update tippy whenever the DOM is updated
|
|
||||||
if (MutationObserver) {
|
|
||||||
this.observer = new MutationObserver(() => {
|
|
||||||
this.popup.popperInstance.scheduleUpdate()
|
|
||||||
})
|
|
||||||
this.observer.observe(this.$refs.suggestions, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true,
|
|
||||||
characterData: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
destroyPopup() {
|
|
||||||
if (this.popup) {
|
|
||||||
this.popup.destroy()
|
|
||||||
this.popup = null
|
|
||||||
}
|
|
||||||
if (this.observer) {
|
|
||||||
this.observer.disconnect()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -294,4 +154,15 @@ export default {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@import "../../css/comments";
|
@import "../../css/comments";
|
||||||
|
|
||||||
|
.atwho-wrap {
|
||||||
|
width: 100%;
|
||||||
|
& > div[contenteditable] {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::v-deep > span > div {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</Actions>
|
</Actions>
|
||||||
</div>
|
</div>
|
||||||
<!-- FIXME: Check if input is sanitized -->
|
<!-- FIXME: Check if input is sanitized -->
|
||||||
<p class="comment--content" v-html="comment.message" /><p />
|
<p class="comment--content" v-html="parsedMessage" /><p />
|
||||||
</template>
|
</template>
|
||||||
<form v-else @submit.prevent="updateComment">
|
<form v-else @submit.prevent="updateComment">
|
||||||
<input v-model="commentMsg"
|
<input v-model="commentMsg"
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Avatar, Actions, ActionButton } from '@nextcloud/vue'
|
import { Avatar, Actions, ActionButton } from '@nextcloud/vue'
|
||||||
|
import escapeHtml from 'escape-html'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'CommentItem',
|
name: 'CommentItem',
|
||||||
@@ -58,6 +59,21 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
parsedMessage() {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = this.comment.message
|
||||||
|
let message = escapeHtml(div.textContent || div.innerText || '')
|
||||||
|
// FIXME: We need a proper way to show user bubbles in the comment content
|
||||||
|
// Either we split the text and render components for each part or we could try
|
||||||
|
// to manually mount a component into the current components dom
|
||||||
|
this.comment.mentions.forEach((mention) => {
|
||||||
|
message = message.replace('@' + mention.mentionId + ' ', '<strong data-mention-id="' + mention.mentionId + '">@' + mention.mentionDisplayName + '</strong> ')
|
||||||
|
})
|
||||||
|
return message
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
|
||||||
showUpdateForm() {
|
showUpdateForm() {
|
||||||
|
|||||||
23
src/helpers/mentions.js
Normal file
23
src/helpers/mentions.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
const rawToParsed = (text) => {
|
||||||
|
text = text.replace(/<br>/g, '\n')
|
||||||
|
text = text.replace(/ /g, ' ')
|
||||||
|
|
||||||
|
// Since we used innerHTML to get the content of the div.contenteditable
|
||||||
|
// it is escaped. With this little trick from https://stackoverflow.com/a/7394787
|
||||||
|
// We unescape the code again, so if you write `<strong>` we can display
|
||||||
|
// it again instead of `<strong>`
|
||||||
|
const temp = document.createElement('textarea')
|
||||||
|
temp.innerHTML = text
|
||||||
|
text = temp.value
|
||||||
|
|
||||||
|
// Although the text is fully trimmed, at the very least the last
|
||||||
|
// "\n" occurrence should be always removed, as browsers add a
|
||||||
|
// "<br>" element as soon as some rich text is written in a content
|
||||||
|
// editable div (for example, if a new line is added the div content
|
||||||
|
// will be "<br><br>").
|
||||||
|
return text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
rawToParsed,
|
||||||
|
}
|
||||||
@@ -40,6 +40,11 @@ const parseXml = (xml) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const commentToObject = (tag) => {
|
const commentToObject = (tag) => {
|
||||||
|
let mentions = tag['d:prop']['oc:mentions']['oc:mention'] ?? []
|
||||||
|
if (mentions && !Array.isArray(mentions)) {
|
||||||
|
mentions = [mentions]
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
cardId: tag['d:prop']['oc:objectId']['#text'],
|
cardId: tag['d:prop']['oc:objectId']['#text'],
|
||||||
id: tag['d:prop']['oc:id']['#text'],
|
id: tag['d:prop']['oc:id']['#text'],
|
||||||
@@ -47,6 +52,13 @@ const commentToObject = (tag) => {
|
|||||||
actorDisplayName: tag['d:prop']['oc:actorDisplayName']['#text'],
|
actorDisplayName: tag['d:prop']['oc:actorDisplayName']['#text'],
|
||||||
creationDateTime: tag['d:prop']['oc:creationDateTime']['#text'],
|
creationDateTime: tag['d:prop']['oc:creationDateTime']['#text'],
|
||||||
message: tag['d:prop']['oc:message']['#text'],
|
message: tag['d:prop']['oc:message']['#text'],
|
||||||
|
mentions: mentions.map((mention) => {
|
||||||
|
return {
|
||||||
|
mentionType: mention['oc:mentionType']['#text'],
|
||||||
|
mentionId: mention['oc:mentionId']['#text'],
|
||||||
|
mentionDisplayName: mention['oc:mentionDisplayName']['#text'],
|
||||||
|
}
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user