diff --git a/client/components/comments.vue b/client/components/comments.vue index 4091c90256..d8fe961357 100644 --- a/client/components/comments.vue +++ b/client/components/comments.vue @@ -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( @@ -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: { @@ -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) { @@ -221,6 +267,8 @@ export default { * Post New Comment */ async postComment () { + this.closeMentionMenu() + let rules = { comment: { presence: { @@ -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) + }) } } } diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index 4ca6e192c5..883437813b 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -110,6 +110,29 @@ v-btn.animated.fadeIn.wait-p2s(icon, tile, v-on='on', @click='previewShown = !previewShown').mx-0 v-icon mdi-book-open-outline span {{$t('editor:markup.togglePreviewPane')}} + // Mention suggestions dropdown + v-menu( + v-model='mentionMenuShown' + :position-x='mentionAnchor.left' + :position-y='mentionAnchor.top' + absolute + offset-y + max-width='320' + ) + v-list + v-list-item( + v-for='(user,i) in mentionResults' + :key='user.id' + :input-value="i === mentionActiveIndex" + @click='insertMention(user)' + @mouseenter="mentionActiveIndex = i" + @mousedown.prevent + ) + 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 }} .editor-markdown-main .editor-markdown-sidebar v-tooltip(right, color='teal') @@ -387,7 +410,13 @@ export default { previewHTML: '', helpShown: false, spellModeActive: false, - insertLinkDialog: false + insertLinkDialog: false, + + mentionMenuShown: false, + mentionAnchor: { left: 0, top: 0 }, // where to show the menu + mentionQuery: '', + mentionResults: [], + mentionActiveIndex: 0 } }, computed: { @@ -403,6 +432,17 @@ export default { activeModal: sync('editor/activeModal') }, watch: { + mentionResults () { + // whenever results refresh, highlight first item + this.mentionActiveIndex = 0 + this.$nextTick(this.scrollActiveMentionIntoView) + }, + mentionMenuShown (v) { + if (v) { + this.mentionActiveIndex = 0 + this.$nextTick(this.scrollActiveMentionIntoView) + } + }, previewShown (newValue, oldValue) { if (newValue && !oldValue) { this.$nextTick(() => { @@ -721,6 +761,132 @@ export default { markerElm.className = 'CodeMirror-buttonmarker' markerElm.addEventListener('click', action) this.cm.markText(from, to, { replacedWith: markerElm, __kind: kind }) + }, + + /** + * User mentioning logic + * + * IMPORTANT: as of right now, wikijs does not provide profile view, so there is no link + * included in the mentionMarkdown. Whenever the profiles and links to them are implemented, + * just add a proper link to the mentionMarkdown const (see insertMention), and change mentions.scss. + */ + onEditorKeydown (cm, e) { + // consume Up/Down/Enter/Tab/Esc when mention menu is open + this.handleMentionKeydown(e, this.insertMention) + }, + handleMentionKeydown (e, pickFn) { + if (!this.mentionMenuShown) return false + const n = (this.mentionResults || []).length + // allow Esc even if list empty + 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 === 'Tab') { + const u = this.mentionResults[this.mentionActiveIndex] + if (u) pickFn(u) + e.preventDefault() + e.stopPropagation() + 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' }) + }, + onEditorInputRead (cm, change) { + const cursor = cm.getCursor() + const lineText = cm.getLine(cursor.line).slice(0, cursor.ch) + + // Find last "@something" before cursor + const match = /@([^\s@]*)$/.exec(lineText) + if (!match) { + this.closeMentionMenu() + return + } + + this.mentionQuery = match[1] // text after @ + this.updateMentionMenuPosition() + this.fetchMentionResults(this.mentionQuery) + }, + updateMentionMenuPosition () { + const coords = this.cm.cursorCoords() + this.mentionAnchor = { left: coords.left, top: coords.bottom } + this.mentionMenuShown = true + }, + closeMentionMenu () { + this.mentionMenuShown = false + this.mentionQuery = '' + this.mentionResults = [] + }, + async fetchMentionResults (query) { + if (!query || query.length < 1) { + this.mentionResults = [] + return + } + try { + const res = await this.$apollo.query({ + query: gql` + query MentionLookup($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 = [] + } + }, + insertMention (user) { + const cm = this.cm + const cursor = cm.getCursor() + const line = cm.getLine(cursor.line) + + // Remove the "@partial" that triggered the menu + const before = line.slice(0, cursor.ch) + const after = line.slice(cursor.ch) + const cleanedBefore = before.replace(/@([^\s@]*)$/, '') + + const mentionMarkdown = `[@${user.name}](/user:${user.id})` + + cm.replaceRange( + cleanedBefore + mentionMarkdown + ' ' + after, + { line: cursor.line, ch: 0 }, + { line: cursor.line, ch: line.length } + ) + + // Move cursor after the mention + const newCh = (cleanedBefore + mentionMarkdown + ' ').length + cm.setCursor({ line: cursor.line, ch: newCh }) + + this.closeMentionMenu() } }, mounted() { @@ -761,6 +927,8 @@ export default { this.$store.set('editor/content', c.getValue()) this.onCmInput(this.$store.get('editor/content')) }) + this.cm.on('inputRead', this.onEditorInputRead) + this.cm.on('keydown', this.onEditorKeydown) if (this.$vuetify.breakpoint.mdAndUp) { this.cm.setSize(null, 'calc(100vh - 112px - 24px)') } else { @@ -856,6 +1024,7 @@ export default { }, beforeDestroy() { this.$root.$off('editorInsert') + this.cm.off('keydown', this.onEditorKeydown) } } diff --git a/client/scss/app.scss b/client/scss/app.scss index 566934ec1a..d6f0d2c6de 100644 --- a/client/scss/app.scss +++ b/client/scss/app.scss @@ -15,6 +15,7 @@ @import 'components/v-dialog'; @import 'components/v-form'; @import 'components/v-tabs'; +@import 'components/mentions.scss'; // @import '../libs/twemoji/twemoji-awesome'; // @import '../libs/prism/prism.css'; diff --git a/client/scss/components/mentions.scss b/client/scss/components/mentions.scss new file mode 100644 index 0000000000..09fe7186e7 --- /dev/null +++ b/client/scss/components/mentions.scss @@ -0,0 +1,27 @@ +#root .v-application { + a[href^="/user:"] { + display: inline-flex; + align-items: center; + padding: 0 6px; + border-radius: 4px; + font-weight: 500; + line-height: 1.6; + + background: rgba(0, 82, 204, 0.12); + color: rgb(0, 82, 204); + + text-decoration: none !important; + + // TODO: remove this when the link to profiles are implemented + pointer-events: none; + cursor: default; + } + + a.is-invalid-page[href^="/user:"]{ + opacity: 1; + text-decoration: none !important; + } + .mention-active { + background: rgba(255,255,255,.08); + } +} \ No newline at end of file diff --git a/server/graph/resolvers/user.js b/server/graph/resolvers/user.js index a94aef8a5a..4c544916c1 100644 --- a/server/graph/resolvers/user.js +++ b/server/graph/resolvers/user.js @@ -59,6 +59,24 @@ module.exports = { .whereNotNull('lastLoginAt') .orderBy('lastLoginAt', 'desc') .limit(10) + }, + async searchMentions (obj, args, context) { + const search = _.trim(_.get(args, 'query', '')) + + const limit = 5 + const qLower = search.toLowerCase() + + const results = await WIKI.models.users.query() + .select('id', 'name') + .where('isActive', true) + .whereNotIn('id', [2]) // guest + .andWhere(builder => { + builder.whereRaw('LOWER(name) LIKE ?', [`%${qLower}%`]) + }) + .orderBy('name', 'asc') + .limit(limit) + + return results } }, UserMutation: { diff --git a/server/graph/schemas/user.graphql b/server/graph/schemas/user.graphql index 0beef80f9f..106e013c08 100644 --- a/server/graph/schemas/user.graphql +++ b/server/graph/schemas/user.graphql @@ -24,6 +24,11 @@ type UserQuery { query: String! ): [UserMinimal] @auth(requires: ["write:groups", "manage:groups", "write:users", "manage:users", "manage:system"]) + searchMentions( + query: String!, + limit: Int = 10 + ): [UserMentionResult]! + single( id: Int! ): User @auth(requires: ["manage:users", "manage:system"]) @@ -131,6 +136,11 @@ type UserMinimal { lastLoginAt: Date } +type UserMentionResult { + id: Int! + name: String! +} + type User { id: Int! name: String!