From 38e0095e6989b582177664c7302a679afb7c07fd Mon Sep 17 00:00:00 2001 From: fedos Date: Tue, 2 Dec 2025 04:08:32 +0300 Subject: [PATCH 1/5] feat: user mentioning basic functionality --- client/components/editor/editor-markdown.vue | 108 ++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index 4ca6e192c5..fd23906885 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -110,6 +110,26 @@ 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 in mentionResults' + :key='user.id' + @click='insertMention(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 }} .editor-markdown-main .editor-markdown-sidebar v-tooltip(right, color='teal') @@ -387,7 +407,12 @@ 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: [] } }, computed: { @@ -721,6 +746,86 @@ export default { markerElm.className = 'CodeMirror-buttonmarker' markerElm.addEventListener('click', action) this.cm.markText(from, to, { replacedWith: markerElm, __kind: kind }) + }, + /** + * User mentioning logic + */ + 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 EditorUserSearch($query: String!) { + users { + search(query: $query) { + id + name + email + } + } + } + `, + variables: { query }, + fetchPolicy: 'network-only' + }) + this.mentionResults = (res.data.users && res.data.users.search) || [] + } 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 +866,7 @@ export default { this.$store.set('editor/content', c.getValue()) this.onCmInput(this.$store.get('editor/content')) }) + this.cm.on('inputRead', this.onEditorInputRead) if (this.$vuetify.breakpoint.mdAndUp) { this.cm.setSize(null, 'calc(100vh - 112px - 24px)') } else { From c13d7f9677325727ba058f2bb7cb4829b71f4c15 Mon Sep 17 00:00:00 2001 From: fedos Date: Tue, 23 Dec 2025 21:39:41 +0300 Subject: [PATCH 2/5] mentioning users in comment section --- client/components/comments.vue | 146 ++++++++++++++++++++++++++++++++- 1 file changed, 145 insertions(+), 1 deletion(-) diff --git a/client/components/comments.vue b/client/components/comments.vue index 4091c90256..ee4085a8d6 100644 --- a/client/components/comments.vue +++ b/client/components/comments.vue @@ -13,7 +13,33 @@ :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='onNewCommentKeydown' ) + //- 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 in mentionResults' + :key='user.id' + @mousedown.prevent + @click='insertMentionIntoNewComment(user)' + ) + v-list-item-avatar + v-icon mdi-account-circle + v-list-item-content + v-list-item-title {{ user.name }} + //- Caution: email is often sensitive + 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 +183,12 @@ export default { duration: 1500, offset: 0, easing: 'easeInOutCubic' - } + }, + mentionMenuShown: false, + mentionAnchor: { left: 0, top: 0 }, + mentionQuery: '', + mentionResults: [], + mentionDebounce: null } }, computed: { @@ -221,6 +252,8 @@ export default { * Post New Comment */ async postComment () { + this.closeMentionMenu() + let rules = { comment: { presence: { @@ -493,7 +526,118 @@ export default { } this.isBusy = false this.$store.commit(`loadingStop`, 'comments-delete') + }, + + 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) { + if (e.key === 'Escape' && this.mentionMenuShown) { + this.closeMentionMenu() + e.stopPropagation() + } + }, + + onNewCommentKeyup () { + const ta = this.getNewCommentTextarea() + if (!ta) return + + const caret = ta.selectionStart + const beforeCaret = this.newcomment.slice(0, caret) + + // last "@something" right before caret + const match = /@([^\s@]*)$/.exec(beforeCaret) + if (!match) { + this.closeMentionMenu() + return + } + + this.mentionQuery = match[1] || '' + this.updateMentionMenuPosition() + this.fetchMentionResultsDebounced(this.mentionQuery) + }, + + 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 { + search(query: $query) { + id + name + email + } + } + } + `, + variables: { query }, + fetchPolicy: 'network-only' + }) + + this.mentionResults = (res.data.users && res.data.users.search) || [] + } 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) + }) } + } } From 5c6b1ee121e373f23f115e806700d5d28d0a24ac Mon Sep 17 00:00:00 2001 From: fedos Date: Tue, 23 Dec 2025 23:07:31 +0300 Subject: [PATCH 3/5] implement a public resolver for user mentioning queries --- client/components/comments.vue | 12 ++++++------ client/components/editor/editor-markdown.vue | 7 +++---- server/graph/resolvers/user.js | 18 ++++++++++++++++++ server/graph/schemas/user.graphql | 10 ++++++++++ 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/client/components/comments.vue b/client/components/comments.vue index ee4085a8d6..3cc110730f 100644 --- a/client/components/comments.vue +++ b/client/components/comments.vue @@ -39,7 +39,7 @@ v-list-item-content v-list-item-title {{ user.name }} //- Caution: email is often sensitive - v-list-item-subtitle {{ user.email }} + //- 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( @@ -527,7 +527,9 @@ export default { this.isBusy = false this.$store.commit(`loadingStop`, 'comments-delete') }, - + /** + * User mentioning + */ getNewCommentTextarea () { // Vuetify v-textarea exposes the real element as $refs.input const cmp = this.$refs.newCommentInput @@ -596,10 +598,9 @@ export default { query: gql` query CommentUserSearch($query: String!) { users { - search(query: $query) { + searchMentions(query: $query) { id name - email } } } @@ -607,8 +608,7 @@ export default { variables: { query }, fetchPolicy: 'network-only' }) - - this.mentionResults = (res.data.users && res.data.users.search) || [] + this.mentionResults = _.get(res, 'data.users.searchMentions', []) } catch (err) { console.error('User search failed', err) this.mentionResults = [] diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index fd23906885..27babe6018 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -783,12 +783,11 @@ export default { try { const res = await this.$apollo.query({ query: gql` - query EditorUserSearch($query: String!) { + query MentionLookup($query: String!) { users { - search(query: $query) { + searchMentions(query: $query) { id name - email } } } @@ -796,7 +795,7 @@ export default { variables: { query }, fetchPolicy: 'network-only' }) - this.mentionResults = (res.data.users && res.data.users.search) || [] + this.mentionResults = _.get(res, 'data.users.searchMentions', []) } catch (err) { console.error('User search failed', err) this.mentionResults = [] 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! From 2b163a0fd61ceff06e6c37d59ff83c5ec9a97bf1 Mon Sep 17 00:00:00 2001 From: fedos Date: Sat, 27 Dec 2025 16:16:34 +0300 Subject: [PATCH 4/5] feat: add confluence-like css to mentions. Remove links. See comments in .vue files for explanations. --- client/components/comments.vue | 4 +++- client/components/editor/editor-markdown.vue | 10 +++++--- client/scss/app.scss | 1 + client/scss/components/mentions.scss | 24 ++++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 client/scss/components/mentions.scss diff --git a/client/components/comments.vue b/client/components/comments.vue index 3cc110730f..8e330a0299 100644 --- a/client/components/comments.vue +++ b/client/components/comments.vue @@ -529,6 +529,8 @@ export default { }, /** * User mentioning + * + * See `user mentioning logic` section in the editor-markdown for more info */ getNewCommentTextarea () { // Vuetify v-textarea exposes the real element as $refs.input @@ -626,7 +628,7 @@ export default { // Remove the "@partial" that triggered it const cleanedBefore = before.replace(/@([^\s@]*)$/, '') - const inserted = `@[${user.name}](/user:${user.id})` + const inserted = `[@${user.name}](/user:${user.id})` this.newcomment = cleanedBefore + inserted + after this.closeMentionMenu() diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index 27babe6018..13db0f5b2a 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -129,7 +129,7 @@ v-icon mdi-account-circle v-list-item-content v-list-item-title {{ user.name }} - v-list-item-subtitle {{ user.email }} + //- v-list-item-subtitle {{ user.email }} .editor-markdown-main .editor-markdown-sidebar v-tooltip(right, color='teal') @@ -747,8 +747,13 @@ export default { 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. */ onEditorInputRead (cm, change) { const cursor = cm.getCursor() @@ -801,7 +806,6 @@ export default { this.mentionResults = [] } }, - insertMention (user) { const cm = this.cm const cursor = cm.getCursor() @@ -812,7 +816,7 @@ export default { const after = line.slice(cursor.ch) const cleanedBefore = before.replace(/@([^\s@]*)$/, '') - const mentionMarkdown = `@[${user.name}](user:${user.id})` + const mentionMarkdown = `[@${user.name}](/user:${user.id})` cm.replaceRange( cleanedBefore + mentionMarkdown + ' ' + after, 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..3f4d64bfac --- /dev/null +++ b/client/scss/components/mentions.scss @@ -0,0 +1,24 @@ +#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; + } +} \ No newline at end of file From 523340c9c676c99d6afeca0343133402ff5f3b3d Mon Sep 17 00:00:00 2001 From: fedos Date: Sat, 27 Dec 2025 23:38:51 +0300 Subject: [PATCH 5/5] feat: support hotkeys in mention dropbox --- client/components/comments.vue | 96 +++++++++++++++----- client/components/editor/editor-markdown.vue | 64 ++++++++++++- client/scss/components/mentions.scss | 5 +- 3 files changed, 141 insertions(+), 24 deletions(-) diff --git a/client/components/comments.vue b/client/components/comments.vue index 8e330a0299..d8fe961357 100644 --- a/client/components/comments.vue +++ b/client/components/comments.vue @@ -16,7 +16,8 @@ ref='newCommentInput' @keyup.native='onNewCommentKeyup' @click.native='onNewCommentKeyup' - @keydown.native='onNewCommentKeydown' + @keydown.native.capture='onNewCommentKeydown' + @input='onNewCommentInput' ) //- Mention suggestions dropdown (new comment) v-menu( @@ -29,8 +30,10 @@ ) v-list(dense) v-list-item( - v-for='user in mentionResults' + v-for='(user,i) in mentionResults' :key='user.id' + :input-value="i === mentionActiveIndex" + @mouseenter="mentionActiveIndex = i" @mousedown.prevent @click='insertMentionIntoNewComment(user)' ) @@ -38,7 +41,6 @@ v-icon mdi-account-circle v-list-item-content v-list-item-title {{ user.name }} - //- Caution: email is often sensitive //- v-list-item-subtitle {{ user.email }} v-row.mt-2(dense, v-if='!isAuthenticated && permissions.write') v-col(cols='12', lg='6') @@ -188,7 +190,8 @@ export default { mentionAnchor: { left: 0, top: 0 }, mentionQuery: '', mentionResults: [], - mentionDebounce: null + mentionDebounce: null, + mentionActiveIndex: 0 } }, computed: { @@ -197,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) { @@ -532,27 +547,63 @@ export default { * * See `user mentioning logic` section in the editor-markdown for more info */ - 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 - }, + handleMentionKeydown (e, pickFn) { + if (!this.mentionMenuShown) return false - onNewCommentKeydown (e) { - if (e.key === 'Escape' && this.mentionMenuShown) { + 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 } - }, - onNewCommentKeyup () { + 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 = this.newcomment.slice(0, caret) + const beforeCaret = text.slice(0, caret) - // last "@something" right before caret const match = /@([^\s@]*)$/.exec(beforeCaret) if (!match) { this.closeMentionMenu() @@ -563,7 +614,15 @@ export default { 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 @@ -575,20 +634,17 @@ export default { } 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 = [] @@ -616,7 +672,6 @@ export default { this.mentionResults = [] } }, - insertMentionIntoNewComment (user) { const ta = this.getNewCommentTextarea() if (!ta) return @@ -639,7 +694,6 @@ export default { ta.setSelectionRange(pos, pos) }) } - } } diff --git a/client/components/editor/editor-markdown.vue b/client/components/editor/editor-markdown.vue index 13db0f5b2a..883437813b 100644 --- a/client/components/editor/editor-markdown.vue +++ b/client/components/editor/editor-markdown.vue @@ -121,9 +121,12 @@ ) v-list v-list-item( - v-for='user in mentionResults' + 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 @@ -412,7 +415,8 @@ export default { mentionMenuShown: false, mentionAnchor: { left: 0, top: 0 }, // where to show the menu mentionQuery: '', - mentionResults: [] + mentionResults: [], + mentionActiveIndex: 0 } }, computed: { @@ -428,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(() => { @@ -755,6 +770,49 @@ export default { * 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) @@ -870,6 +928,7 @@ export default { 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 { @@ -965,6 +1024,7 @@ export default { }, beforeDestroy() { this.$root.$off('editorInsert') + this.cm.off('keydown', this.onEditorKeydown) } } diff --git a/client/scss/components/mentions.scss b/client/scss/components/mentions.scss index 3f4d64bfac..09fe7186e7 100644 --- a/client/scss/components/mentions.scss +++ b/client/scss/components/mentions.scss @@ -16,9 +16,12 @@ 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