diff options
author | Luka Trovic <luka@nextcloud.com> | 2022-07-23 21:35:32 +0300 |
---|---|---|
committer | Julius Härtl <jus@bitgrid.net> | 2022-08-25 12:04:53 +0300 |
commit | 5e0d9f49ee6bd3c2d61d64b9cc1ff73e904319df (patch) | |
tree | 55b4fcd9cde7e2d83324ec642bc0870e610a4d22 /src | |
parent | 63a36201ccc91ef10f4390efda6566464fb23ab7 (diff) |
feat: add user mentions feature
Signed-off-by: Luka Trovic <luka@nextcloud.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/EditorFactory.js | 9 | ||||
-rw-r--r-- | src/components/Mention/List.vue | 118 | ||||
-rw-r--r-- | src/components/Mention/suggestion.js | 77 | ||||
-rw-r--r-- | src/extensions/Mention.js | 30 | ||||
-rw-r--r-- | src/extensions/index.js | 2 | ||||
-rw-r--r-- | src/markdownit/index.js | 2 |
6 files changed, 237 insertions, 1 deletions
diff --git a/src/EditorFactory.js b/src/EditorFactory.js index ba86a2954..f20db99a9 100644 --- a/src/EditorFactory.js +++ b/src/EditorFactory.js @@ -28,12 +28,13 @@ import Placeholder from '@tiptap/extension-placeholder' import TrailingNode from './nodes/TrailingNode.js' import EditableTable from './nodes/EditableTable.js' import { Editor } from '@tiptap/core' -import { Emoji, Markdown, PlainText, RichText } from './extensions/index.js' +import { Emoji, Markdown, Mention, PlainText, RichText } from './extensions/index.js' import { translate as t } from '@nextcloud/l10n' import { listLanguages, registerLanguage } from 'lowlight/lib/core.js' import { emojiSearch } from '@nextcloud/vue/dist/Functions/emoji.js' import { VueRenderer } from '@tiptap/vue-2' import EmojiList from './components/EmojiList.vue' +import MentionSuggestion from './components/Mention/suggestion' import tippy from 'tippy.js' import 'proxy-polyfill' @@ -111,6 +112,12 @@ const createEditor = ({ content, onCreate, onUpdate, extensions, enableRichEditi }, }, }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: MentionSuggestion, + }), Placeholder.configure({ emptyNodeClass: 'is-empty', placeholder: t('text', 'Add notes, lists or links …'), diff --git a/src/components/Mention/List.vue b/src/components/Mention/List.vue new file mode 100644 index 000000000..5a270606e --- /dev/null +++ b/src/components/Mention/List.vue @@ -0,0 +1,118 @@ +<template> + <div class="items"> + <template v-if="items.length"> + <button + class="item" + :class="{ 'is-selected': index === selectedIndex }" + v-for="(item, index) in items" + :key="index" + @click="selectItem(index)" + > + {{ item }} + </button> + </template> + <div class="item" v-else> + No result + </div> + </div> +</template> + +<script> +export default { + props: { + items: { + type: Array, + required: true, + }, + + command: { + type: Function, + required: true, + }, + }, + + data() { + return { + selectedIndex: 0, + } + }, + + watch: { + items() { + this.selectedIndex = 0 + }, + }, + + methods: { + onKeyDown({ event }) { + if (event.key === 'ArrowUp') { + this.upHandler() + return true + } + + if (event.key === 'ArrowDown') { + this.downHandler() + return true + } + + if (event.key === 'Enter') { + this.enterHandler() + return true + } + + return false + }, + + upHandler() { + this.selectedIndex = ((this.selectedIndex + this.items.length) - 1) % this.items.length + }, + + downHandler() { + this.selectedIndex = (this.selectedIndex + 1) % this.items.length + }, + + enterHandler() { + this.selectItem(this.selectedIndex) + }, + + selectItem(index) { + const item = this.items[index] + + if (item) { + this.command({ id: item }) + } + }, + }, +} +</script> + +<style lang="scss"> +.items { + padding: 0.2rem; + position: relative; + border-radius: 0.5rem; + background: #FFF; + color: rgba(0, 0, 0, 0.8); + overflow: hidden; + font-size: 0.9rem; + box-shadow: + 0 0 0 1px rgba(0, 0, 0, 0.05), + 0px 10px 20px rgba(0, 0, 0, 0.1), + ; +} + +.item { + display: block; + margin: 0; + width: 100%; + text-align: left; + background: transparent; + border-radius: 0.4rem; + border: 1px solid transparent; + padding: 0.2rem 0.4rem; + + &.is-selected { + border-color: #000; + } +} +</style>
\ No newline at end of file diff --git a/src/components/Mention/suggestion.js b/src/components/Mention/suggestion.js new file mode 100644 index 000000000..369109759 --- /dev/null +++ b/src/components/Mention/suggestion.js @@ -0,0 +1,77 @@ +import axios from '@nextcloud/axios' +import { VueRenderer } from '@tiptap/vue-2' +import { generateUrl } from '@nextcloud/router' +import tippy from 'tippy.js' +import List from './List.vue' + +const USERS_LIST_ENDPOINT_URL = generateUrl('apps/text/api/v1/users'); + +export default { + items: async ({ query }) => { + + const params = { filter: query }; + let response = await axios.post(USERS_LIST_ENDPOINT_URL, params); + let users = JSON.parse(JSON.stringify(response.data)); + + return Object.keys(users).map(key => users[key]); + + return [ + 'Lea Thompson', 'Cyndi Lauper', 'Tom Cruise', 'Madonna', 'Jerry Hall', 'Joan Collins', 'Winona Ryder', 'Christina Applegate', 'Alyssa Milano', 'Molly Ringwald', 'Ally Sheedy', 'Debbie Harry', 'Olivia Newton-John', 'Elton John', 'Michael J. Fox', 'Axl Rose', 'Emilio Estevez', 'Ralph Macchio', 'Rob Lowe', 'Jennifer Grey', 'Mickey Rourke', 'John Cusack', 'Matthew Broderick', 'Justine Bateman', 'Lisa Bonet', + ].filter(item => item.toLowerCase().startsWith(query.toLowerCase())).slice(0, 5) + }, + + render: () => { + let component + let popup + + return { + onStart: props => { + component = new VueRenderer(List, { + parent: this, + propsData: props + }) + + if (!props.clientRect) { + return + } + + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component.updateProps(props) + + if (!props.clientRect) { + return + } + + popup[0].setProps({ + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup[0].hide() + + return true + } + + return component.ref?.onKeyDown(props) + }, + + onExit() { + popup[0].destroy() + component.destroy() + }, + } + }, +}
\ No newline at end of file diff --git a/src/extensions/Mention.js b/src/extensions/Mention.js new file mode 100644 index 000000000..9add5deec --- /dev/null +++ b/src/extensions/Mention.js @@ -0,0 +1,30 @@ +import { mergeAttributes, Node } from '@tiptap/core' +import TipTapMention from '@tiptap/extension-mention' + +export default TipTapMention.extend({ + parseHTML() { + return [ + { + tag: `span[data-type="${this.name}"]`, + getAttrs: element => (element.getAttribute('data-type') === this.name) && (element.hasAttribute('data-id')) && null, + }, + ] + }, + + renderHTML({ node, HTMLAttributes }) { + return [ + 'span', + mergeAttributes({ 'data-type': this.name }, this.options.HTMLAttributes, HTMLAttributes), + this.options.renderLabel({ + options: this.options, + node, + }), + ] + }, + + toMarkdown(state, node, parent, index) { + state.write(' ') + state.write(`@[${node.attrs.id}](mention://user/${node.attrs.id})`) + state.write(' ') + }, +});
\ No newline at end of file diff --git a/src/extensions/index.js b/src/extensions/index.js index a4707939f..02c16b3ff 100644 --- a/src/extensions/index.js +++ b/src/extensions/index.js @@ -29,6 +29,7 @@ import Markdown from './Markdown.js' import PlainText from './PlainText.js' import RichText from './RichText.js' import KeepSyntax from './KeepSyntax.js' +import Mention from './Mention' export { Emoji, @@ -40,4 +41,5 @@ export { PlainText, RichText, KeepSyntax, + Mention, } diff --git a/src/markdownit/index.js b/src/markdownit/index.js index 81fe0fb11..71c36cc92 100644 --- a/src/markdownit/index.js +++ b/src/markdownit/index.js @@ -1,5 +1,6 @@ import MarkdownIt from 'markdown-it' import taskLists from '@hedgedoc/markdown-it-task-lists' +import markdownitMentions from '@quartzy/markdown-it-mentions' import underline from './underline.js' import splitMixedLists from './splitMixedLists.js' import callouts from './callouts.js' @@ -13,5 +14,6 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false }) .use(underline) .use(callouts) .use(keepSyntax) + .use(markdownitMentions) export default markdownit |