Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/text.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorLuka Trovic <luka@nextcloud.com>2022-07-23 21:35:32 +0300
committerJulius Härtl <jus@bitgrid.net>2022-08-25 12:04:53 +0300
commit5e0d9f49ee6bd3c2d61d64b9cc1ff73e904319df (patch)
tree55b4fcd9cde7e2d83324ec642bc0870e610a4d22 /src
parent63a36201ccc91ef10f4390efda6566464fb23ab7 (diff)
feat: add user mentions feature
Signed-off-by: Luka Trovic <luka@nextcloud.com>
Diffstat (limited to 'src')
-rw-r--r--src/EditorFactory.js9
-rw-r--r--src/components/Mention/List.vue118
-rw-r--r--src/components/Mention/suggestion.js77
-rw-r--r--src/extensions/Mention.js30
-rw-r--r--src/extensions/index.js2
-rw-r--r--src/markdownit/index.js2
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