User Mentions in editor and tasks #2641

Open
ElectricCookie wants to merge 2 commits from ElectricCookie/vikunja:main into main
5 changed files with 261 additions and 0 deletions

View File

@ -78,6 +78,7 @@
"@tiptap/extension-italic": "2.5.4",
"@tiptap/extension-link": "2.5.4",
"@tiptap/extension-list-item": "2.5.4",
"@tiptap/extension-mention": "2.5.4",
"@tiptap/extension-ordered-list": "2.5.4",
"@tiptap/extension-paragraph": "2.5.4",
"@tiptap/extension-placeholder": "2.5.4",

View File

@ -100,6 +100,9 @@ importers:
'@tiptap/extension-list-item':
specifier: 2.5.4
version: 2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))
'@tiptap/extension-mention':
specifier: 2.5.4
version: 2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))(@tiptap/pm@2.5.4)(@tiptap/suggestion@2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))(@tiptap/pm@2.5.4))
'@tiptap/extension-ordered-list':
specifier: 2.5.4
version: 2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))
@ -2179,6 +2182,13 @@ packages:
peerDependencies:
'@tiptap/core': ^2.5.4
'@tiptap/extension-mention@2.5.4':
resolution: {integrity: sha512-U5Kqjhs7FraJzopZydy14/v0+X6unmfYYt42QHhVeSEdZ8y7QtyFigJktJUBzE12CpwGkyh8e3xI9Ozi7lFb0w==}
peerDependencies:
'@tiptap/core': ^2.5.4
'@tiptap/pm': ^2.5.4
'@tiptap/suggestion': ^2.5.4
'@tiptap/extension-ordered-list@2.5.4':
resolution: {integrity: sha512-cl3cTJitY6yDUmxqgjDUtDWCyX1VVsZNJ6i9yiPeARcxvzFc81KmUJxTGl8WPT5TjqmM+TleRkZjsxgvXX57+Q==}
peerDependencies:
@ -8161,6 +8171,12 @@ snapshots:
dependencies:
'@tiptap/core': 2.5.4(@tiptap/pm@2.5.4)
'@tiptap/extension-mention@2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))(@tiptap/pm@2.5.4)(@tiptap/suggestion@2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))(@tiptap/pm@2.5.4))':
dependencies:
'@tiptap/core': 2.5.4(@tiptap/pm@2.5.4)
'@tiptap/pm': 2.5.4
'@tiptap/suggestion': 2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))(@tiptap/pm@2.5.4)
'@tiptap/extension-ordered-list@2.5.4(@tiptap/core@2.5.4(@tiptap/pm@2.5.4))':
dependencies:
'@tiptap/core': 2.5.4(@tiptap/pm@2.5.4)

View File

@ -0,0 +1,144 @@
<template>
<div class="items">
<template v-if="items.length">
<button
v-for="(item, index) in items"
:key="index"
class="item"
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
>
<p class="username">
@{{ item.username }}
</p>
<p class="name">
{{ item.name }}
</p>
</button>
</template>
<div
v-else
class="item"
>
No result
Review

This should be translated.

This should be translated.
</div>
</div>
</template>
<script lang="ts">
/* eslint-disable vue/component-api-style */
export default {
Review

Please use the vue composition API so that it is consistent with the rest of the codebase.

Please use the vue composition API so that it is consistent with the rest of the codebase.
props: {
items: {
type: Array,
required: true,
},
editor: {
type: Object,
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]
this.command({id: item.username})
},
},
}
</script>
<style lang="scss" scoped>
.items {
padding: 0.2rem;
position: relative;
border-radius: 0.5rem;
background: var(--white);
color: var(--grey-900);
overflow: hidden;
font-size: 0.9rem;
box-shadow: var(--shadow-md);
}
.item {
align-items: center;
margin: 0;
width: 100%;
text-align: left;
background: transparent;
border-radius: $radius;
border: 0;
padding: 0.2rem 0.4rem;
transition: background-color $transition;
&.is-selected, &:hover {
background: var(--grey-100);
cursor: pointer;
}
}
.item .name {
font-size: .8rem;
color: var(--grey-600);
}
.item p {
font-size: .9rem;
color: var(--grey-800);
}
</style>

View File

@ -168,6 +168,7 @@ import {History} from '@tiptap/extension-history'
import {HorizontalRule} from '@tiptap/extension-horizontal-rule'
import {Italic} from '@tiptap/extension-italic'
import {ListItem} from '@tiptap/extension-list-item'
import Mention from '@tiptap/extension-mention'
import {OrderedList} from '@tiptap/extension-ordered-list'
import {Paragraph} from '@tiptap/extension-paragraph'
import {Strike} from '@tiptap/extension-strike'
@ -177,6 +178,8 @@ import {Node} from '@tiptap/pm/model'
import Commands from './commands'
import suggestionSetup from './suggestion'
import mentionSuggestionSetup from './mention'
import {lowlight} from 'lowlight'
@ -392,6 +395,12 @@ const extensions : Extensions = [
)).test(href),
protocols: additionalLinkProtocols,
}),
Mention.configure({
suggestion: mentionSuggestionSetup(t),
HTMLAttributes: {
class: 'mention',
},
}),
Table.configure({
resizable: true,
}),
@ -1025,4 +1034,10 @@ ul.tiptap__editor-actions {
text-decoration: underline;
}
}
.mention {
background: var(--grey-200);
border-radius: $radius;
padding: .15rem .25rem;
}
</style>

View File

@ -0,0 +1,85 @@
import {VueRenderer} from '@tiptap/vue-3'
import tippy from 'tippy.js'
import UserService from '@/services/user'
import type {IUser} from '@/modelTypes/IUser'
import MentionList from './MentionList.vue'
export default function mentionSuggestionSetup() {
const userService = new UserService()
return {
items: async ({query}: { query: string }) => {
if (query === '') {
return []
}
const response = await userService.getAll({}, {s: query}) as IUser[]
return response.filter(item => item.username.toLowerCase().startsWith(query.toLowerCase()))
},
render: () => {
let component: VueRenderer
let popup
return {
onStart: props => {
component = new VueRenderer(MentionList, {
// using vue 2:
// parent: this,
// propsData: props,
Review

Please remove this commented code

Please remove this commented code
props,
editor: props.editor,
})
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()
},
}
},
}
}