Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
202 changes: 201 additions & 1 deletion client/components/comments.vue
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,35 @@
:background-color='$vuetify.theme.dark ? `grey darken-5` : `white`'
v-if='permissions.write'
:aria-label='$t(`common:comments.fieldContent`)'
ref='newCommentInput'
@keyup.native='onNewCommentKeyup'
@click.native='onNewCommentKeyup'
@keydown.native.capture='onNewCommentKeydown'
@input='onNewCommentInput'
)
//- Mention suggestions dropdown (new comment)
v-menu(
v-model='mentionMenuShown'
:position-x='mentionAnchor.left'
:position-y='mentionAnchor.top'
absolute
offset-y
max-width='320'
)
v-list(dense)
v-list-item(
v-for='(user,i) in mentionResults'
:key='user.id'
:input-value="i === mentionActiveIndex"
@mouseenter="mentionActiveIndex = i"
@mousedown.prevent
@click='insertMentionIntoNewComment(user)'
)
v-list-item-avatar
v-icon mdi-account-circle
v-list-item-content
v-list-item-title {{ user.name }}
//- v-list-item-subtitle {{ user.email }}
v-row.mt-2(dense, v-if='!isAuthenticated && permissions.write')
v-col(cols='12', lg='6')
v-text-field(
Expand Down Expand Up @@ -157,7 +185,13 @@ export default {
duration: 1500,
offset: 0,
easing: 'easeInOutCubic'
}
},
mentionMenuShown: false,
mentionAnchor: { left: 0, top: 0 },
mentionQuery: '',
mentionResults: [],
mentionDebounce: null,
mentionActiveIndex: 0
}
},
computed: {
Expand All @@ -166,6 +200,18 @@ export default {
isAuthenticated: get('user/authenticated'),
userDisplayName: get('user/name')
},
watch: {
mentionResults () {
this.mentionActiveIndex = 0
this.$nextTick(this.scrollActiveMentionIntoView)
},
mentionMenuShown (v) {
if (v) {
this.mentionActiveIndex = 0
this.$nextTick(this.scrollActiveMentionIntoView)
}
}
},
methods: {
onIntersect (entries, observer, isIntersecting) {
if (isIntersecting) {
Expand Down Expand Up @@ -221,6 +267,8 @@ export default {
* Post New Comment
*/
async postComment () {
this.closeMentionMenu()

let rules = {
comment: {
presence: {
Expand Down Expand Up @@ -493,6 +541,158 @@ export default {
}
this.isBusy = false
this.$store.commit(`loadingStop`, 'comments-delete')
},
/**
* User mentioning
*
* See `user mentioning logic` section in the editor-markdown for more info
*/
handleMentionKeydown (e, pickFn) {
if (!this.mentionMenuShown) return false

const n = (this.mentionResults || []).length

if (e.key === 'Escape') {
this.closeMentionMenu()
e.preventDefault()
e.stopPropagation()
return true
}

if (!n) return false

if (e.key === 'ArrowDown') {
this.mentionActiveIndex = (this.mentionActiveIndex + 1) % n
this.$nextTick(this.scrollActiveMentionIntoView)
e.preventDefault()
e.stopPropagation()
return true
}

if (e.key === 'ArrowUp') {
this.mentionActiveIndex = (this.mentionActiveIndex - 1 + n) % n
this.$nextTick(this.scrollActiveMentionIntoView)
e.preventDefault()
e.stopPropagation()
return true
}

if (e.key === 'Enter' || e.key === 'NumpadEnter' || e.key === 'Tab') {
// prevent newline / focus change BEFORE inserting
e.preventDefault()
e.stopPropagation()
e.stopImmediatePropagation()
const u = this.mentionResults[this.mentionActiveIndex]
if (u)
pickFn(u)
return true
}

return false
},
scrollActiveMentionIntoView () {
const items = this.$refs.mentionItem
const el = Array.isArray(items) ? items[this.mentionActiveIndex]?.$el : null
if (el?.scrollIntoView) el.scrollIntoView({ block: 'nearest' })
},
onNewCommentInput (val) {
const ta = this.getNewCommentTextarea()
if (!ta) return

const text = (typeof val === 'string') ? val : this.newcomment

const caret = ta.selectionStart
const beforeCaret = text.slice(0, caret)

const match = /@([^\s@]*)$/.exec(beforeCaret)
if (!match) {
this.closeMentionMenu()
return
}

this.mentionQuery = match[1] || ''
this.updateMentionMenuPosition()
this.fetchMentionResultsDebounced(this.mentionQuery)
},
getNewCommentTextarea () {
// Vuetify v-textarea exposes the real element as $refs.input
const cmp = this.$refs.newCommentInput
return (cmp && cmp.$refs && cmp.$refs.input) ? cmp.$refs.input : null
},
onNewCommentKeydown (e) {
// consume Up/Down/Enter/Tab/Esc when mention menu is open
this.handleMentionKeydown(e, this.insertMentionIntoNewComment)
},
updateMentionMenuPosition () {
const ta = this.getNewCommentTextarea()
if (!ta) return

const rect = ta.getBoundingClientRect()
this.mentionAnchor = {
left: rect.left + window.pageXOffset + 12,
top: rect.bottom + window.pageYOffset
}
this.mentionMenuShown = true
},
closeMentionMenu () {
this.mentionMenuShown = false
this.mentionQuery = ''
this.mentionResults = []
},
fetchMentionResultsDebounced (query) {
clearTimeout(this.mentionDebounce)
this.mentionDebounce = setTimeout(() => {
this.fetchMentionResults(query)
}, 150)
},
async fetchMentionResults (query) {
if (!query || query.length < 1) {
this.mentionResults = []
return
}

try {
const res = await this.$apollo.query({
query: gql`
query CommentUserSearch($query: String!) {
users {
searchMentions(query: $query) {
id
name
}
}
}
`,
variables: { query },
fetchPolicy: 'network-only'
})
this.mentionResults = _.get(res, 'data.users.searchMentions', [])
} catch (err) {
console.error('User search failed', err)
this.mentionResults = []
}
},
insertMentionIntoNewComment (user) {
const ta = this.getNewCommentTextarea()
if (!ta) return

const caret = ta.selectionStart
const before = this.newcomment.slice(0, caret)
const after = this.newcomment.slice(caret)

// Remove the "@partial" that triggered it
const cleanedBefore = before.replace(/@([^\s@]*)$/, '')

const inserted = `[@${user.name}](/user:${user.id})`

this.newcomment = cleanedBefore + inserted + after
this.closeMentionMenu()

this.$nextTick(() => {
ta.focus()
const pos = (cleanedBefore + inserted).length
ta.setSelectionRange(pos, pos)
})
}
}
}
Expand Down
Loading