Add mentions
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
07d4db5ce9
commit
d3176e2a8d
|
@ -27,6 +27,7 @@
|
||||||
"lodash": "^4.17.11",
|
"lodash": "^4.17.11",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
"register-service-worker": "^1.6.2",
|
"register-service-worker": "^1.6.2",
|
||||||
|
"tippy.js": "^4.3.1",
|
||||||
"tiptap": "^1.20.1",
|
"tiptap": "^1.20.1",
|
||||||
"tiptap-extensions": "^1.20.1",
|
"tiptap-extensions": "^1.20.1",
|
||||||
"typeface-signika": "0.0.72",
|
"typeface-signika": "0.0.72",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
|
<div>
|
||||||
<div class="editor">
|
<div class="editor">
|
||||||
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, focused }">
|
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, focused }">
|
||||||
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
|
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
|
||||||
|
@ -122,6 +123,23 @@
|
||||||
|
|
||||||
<editor-content class="editor__content" :editor="editor" />
|
<editor-content class="editor__content" :editor="editor" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
|
||||||
|
<template v-if="hasResults">
|
||||||
|
<div
|
||||||
|
v-for="(actor, index) in filteredActors"
|
||||||
|
:key="actor.id"
|
||||||
|
class="suggestion-list__item"
|
||||||
|
:class="{ 'is-selected': navigatedUserIndex === index }"
|
||||||
|
@click="selectActor(actor)"
|
||||||
|
>
|
||||||
|
{{ actor.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="suggestion-list__item is-empty">
|
||||||
|
No actors found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
@ -142,8 +160,14 @@ import {
|
||||||
Link,
|
Link,
|
||||||
Underline,
|
Underline,
|
||||||
History,
|
History,
|
||||||
Placeholder
|
Placeholder,
|
||||||
|
Mention,
|
||||||
|
Image,
|
||||||
} from 'tiptap-extensions';
|
} from 'tiptap-extensions';
|
||||||
|
import tippy, { Instance } from 'tippy.js';
|
||||||
|
import { SEARCH_PERSONS } from '@/graphql/search';
|
||||||
|
import { IActor } from '@/types/actor';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
|
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
|
||||||
|
@ -151,9 +175,33 @@ import {
|
||||||
export default class CreateEvent extends Vue {
|
export default class CreateEvent extends Vue {
|
||||||
@Prop({ required: true }) value!: String;
|
@Prop({ required: true }) value!: String;
|
||||||
editor: Editor = null;
|
editor: Editor = null;
|
||||||
linkUrl: string|null = null;
|
|
||||||
|
/**
|
||||||
|
* Editor Link
|
||||||
|
*/
|
||||||
|
linkUrl!: string|null;
|
||||||
linkMenuIsActive: boolean = false;
|
linkMenuIsActive: boolean = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Editor Suggestions
|
||||||
|
*/
|
||||||
|
query!: string|null;
|
||||||
|
filteredActors: IActor[] = [];
|
||||||
|
suggestionRange!: object|null;
|
||||||
|
navigatedUserIndex: number = 0;
|
||||||
|
popup!: Instance|null;
|
||||||
|
|
||||||
|
get hasResults() {
|
||||||
|
return this.filteredActors.length;
|
||||||
|
}
|
||||||
|
get showSuggestions() {
|
||||||
|
return this.query || this.hasResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertMention: Function = () => {};
|
||||||
|
observer!: MutationObserver|null;
|
||||||
|
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.editor = new Editor({
|
this.editor = new Editor({
|
||||||
extensions: [
|
extensions: [
|
||||||
|
@ -161,6 +209,77 @@ export default class CreateEvent extends Vue {
|
||||||
new BulletList(),
|
new BulletList(),
|
||||||
new HardBreak(),
|
new HardBreak(),
|
||||||
new Heading({ levels: [1, 2, 3] }),
|
new Heading({ levels: [1, 2, 3] }),
|
||||||
|
new Mention({
|
||||||
|
items: () => [],
|
||||||
|
onEnter: ({ items, query, range, command, virtualNode }) => {
|
||||||
|
this.query = query;
|
||||||
|
this.filteredActors = items;
|
||||||
|
this.suggestionRange = range;
|
||||||
|
this.renderPopup(virtualNode);
|
||||||
|
// we save the command for inserting a selected mention
|
||||||
|
// this allows us to call it inside of our custom popup
|
||||||
|
// via keyboard navigation and on click
|
||||||
|
this.insertMention = command;
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* is called when a suggestion has changed
|
||||||
|
*/
|
||||||
|
onChange: ({ items, query, range, virtualNode }) => {
|
||||||
|
this.query = query;
|
||||||
|
this.filteredActors = items;
|
||||||
|
this.suggestionRange = range;
|
||||||
|
this.navigatedUserIndex = 0;
|
||||||
|
this.renderPopup(virtualNode);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is called when a suggestion is cancelled
|
||||||
|
*/
|
||||||
|
onExit: () => {
|
||||||
|
// reset all saved values
|
||||||
|
this.query = null;
|
||||||
|
this.filteredActors = [];
|
||||||
|
this.suggestionRange = null;
|
||||||
|
this.navigatedUserIndex = 0;
|
||||||
|
this.destroyPopup();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* is called on every keyDown event while a suggestion is active
|
||||||
|
*/
|
||||||
|
onKeyDown: ({ event }) => {
|
||||||
|
// pressing up arrow
|
||||||
|
if (event.keyCode === 38) {
|
||||||
|
this.upHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// pressing down arrow
|
||||||
|
if (event.keyCode === 40) {
|
||||||
|
this.downHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// pressing enter
|
||||||
|
if (event.keyCode === 13) {
|
||||||
|
this.enterHandler();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onFilter: async (items, query) => {
|
||||||
|
if (!query) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
const result = await this.$apollo.query({
|
||||||
|
query: SEARCH_PERSONS,
|
||||||
|
variables: {
|
||||||
|
searchText: query,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// TODO: TipTap doesn't handle async for onFilter, hence the following line.
|
||||||
|
this.filteredActors = result.data.searchPersons.elements;
|
||||||
|
return this.filteredActors;
|
||||||
|
},
|
||||||
|
}),
|
||||||
new ListItem(),
|
new ListItem(),
|
||||||
new OrderedList(),
|
new OrderedList(),
|
||||||
new TodoItem(),
|
new TodoItem(),
|
||||||
|
@ -172,10 +291,11 @@ export default class CreateEvent extends Vue {
|
||||||
new Underline(),
|
new Underline(),
|
||||||
new History(),
|
new History(),
|
||||||
new Placeholder({
|
new Placeholder({
|
||||||
emptyClass: 'is-empty',
|
emptyClass: 'is-empty',
|
||||||
emptyNodeText: 'Write something …',
|
emptyNodeText: 'Write something …',
|
||||||
showOnlyWhenEditable: false,
|
showOnlyWhenEditable: false,
|
||||||
})
|
}),
|
||||||
|
new Image(),
|
||||||
],
|
],
|
||||||
onUpdate: ({ getHTML }) => {
|
onUpdate: ({ getHTML }) => {
|
||||||
this.$emit('input', getHTML());
|
this.$emit('input', getHTML());
|
||||||
|
@ -191,7 +311,7 @@ export default class CreateEvent extends Vue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
showLinkMenu(attrs) {
|
showLinkMenu(attrs: any) {
|
||||||
this.linkUrl = attrs.href;
|
this.linkUrl = attrs.href;
|
||||||
this.linkMenuIsActive = true;
|
this.linkMenuIsActive = true;
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
@ -209,6 +329,87 @@ export default class CreateEvent extends Vue {
|
||||||
this.editor.focus();
|
this.editor.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
upHandler() {
|
||||||
|
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* navigate to the next item
|
||||||
|
* if it's the last item, navigate to the first one
|
||||||
|
*/
|
||||||
|
downHandler() {
|
||||||
|
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredActors.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
enterHandler() {
|
||||||
|
const actor = this.filteredActors[this.navigatedUserIndex];
|
||||||
|
if (actor) {
|
||||||
|
this.selectActor(actor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* we have to replace our suggestion text with a mention
|
||||||
|
* so it's important to pass also the position of your suggestion text
|
||||||
|
* @param actor IActor
|
||||||
|
*/
|
||||||
|
selectActor(actor: IActor) {
|
||||||
|
this.insertMention({
|
||||||
|
range: this.suggestionRange,
|
||||||
|
attrs: {
|
||||||
|
id: actor.id,
|
||||||
|
label: actor.name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.editor.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* renders a popup with suggestions
|
||||||
|
* tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
|
||||||
|
* @param node
|
||||||
|
*/
|
||||||
|
renderPopup(node) {
|
||||||
|
if (this.popup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.popup = tippy(node, {
|
||||||
|
content: this.$refs.suggestions as HTMLElement,
|
||||||
|
trigger: 'mouseenter',
|
||||||
|
interactive: true,
|
||||||
|
theme: 'dark',
|
||||||
|
placement: 'top-start',
|
||||||
|
inertia: true,
|
||||||
|
duration: [400, 200],
|
||||||
|
showOnInit: true,
|
||||||
|
arrow: true,
|
||||||
|
arrowType: 'round',
|
||||||
|
}) as Instance;
|
||||||
|
// we have to update tippy whenever the DOM is updated
|
||||||
|
if (MutationObserver) {
|
||||||
|
this.observer = new MutationObserver(() => {
|
||||||
|
if (this.popup != null && this.popup.popperInstance) {
|
||||||
|
this.popup.popperInstance.scheduleUpdate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.observer.observe(this.$refs.suggestions as HTMLElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
characterData: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyPopup() {
|
||||||
|
if (this.popup) {
|
||||||
|
this.popup.destroy();
|
||||||
|
this.popup = null;
|
||||||
|
}
|
||||||
|
if (this.observer) {
|
||||||
|
this.observer.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
this.editor.destroy();
|
this.editor.destroy();
|
||||||
}
|
}
|
||||||
|
@ -272,11 +473,11 @@ export default class CreateEvent extends Vue {
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
div.ProseMirror {
|
div.ProseMirror {
|
||||||
background: #fff;
|
|
||||||
min-height: 10rem;
|
min-height: 10rem;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
border-color: #3273dc;
|
border-color: #3273dc;
|
||||||
|
background: #fff;
|
||||||
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25);
|
box-shadow: 0 0 0 0.125em rgba(50, 115, 220, 0.25);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -386,5 +587,68 @@ export default class CreateEvent extends Vue {
|
||||||
color: $color-white;
|
color: $color-white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.mention {
|
||||||
|
background: rgba($color-black, 0.1);
|
||||||
|
color: rgba($color-black, 0.6);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.mention-suggestion {
|
||||||
|
color: rgba($color-black, 0.6);
|
||||||
|
}
|
||||||
|
.suggestion-list {
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: 2px solid rgba($color-black, 0.1);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: bold;
|
||||||
|
&__no-results {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
}
|
||||||
|
&__item {
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
&.is-selected,
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba($color-white, 0.2);
|
||||||
|
}
|
||||||
|
&.is-empty {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.tippy-tooltip.dark-theme {
|
||||||
|
background-color: $color-black;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: inherit;
|
||||||
|
color: $color-white;
|
||||||
|
border-radius: 5px;
|
||||||
|
.tippy-backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.tippy-roundarrow {
|
||||||
|
fill: $color-black;
|
||||||
|
}
|
||||||
|
.tippy-popper[x-placement^=top] & .tippy-arrow {
|
||||||
|
border-top-color: $color-black;
|
||||||
|
}
|
||||||
|
.tippy-popper[x-placement^=bottom] & .tippy-arrow {
|
||||||
|
border-bottom-color: $color-black;
|
||||||
|
}
|
||||||
|
.tippy-popper[x-placement^=left] & .tippy-arrow {
|
||||||
|
border-left-color: $color-black;
|
||||||
|
}
|
||||||
|
.tippy-popper[x-placement^=right] & .tippy-arrow {
|
||||||
|
border-right-color: $color-black;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -34,3 +34,21 @@ query SearchGroups($searchText: String!) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const SEARCH_PERSONS = gql`
|
||||||
|
query SearchPersons($searchText: String!) {
|
||||||
|
searchPersons(search: $searchText) {
|
||||||
|
total,
|
||||||
|
elements {
|
||||||
|
id,
|
||||||
|
avatar {
|
||||||
|
url
|
||||||
|
},
|
||||||
|
domain,
|
||||||
|
preferredUsername,
|
||||||
|
name,
|
||||||
|
__typename
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { IGroup } from '@/types/actor';
|
import { IGroup, IPerson } from '@/types/actor';
|
||||||
import { IEvent } from '@/types/event.model';
|
import { IEvent } from '@/types/event.model';
|
||||||
|
|
||||||
export interface SearchEvent {
|
export interface SearchEvent {
|
||||||
|
@ -10,3 +10,8 @@ export interface SearchGroup {
|
||||||
total: number;
|
total: number;
|
||||||
elements: IGroup[];
|
elements: IGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SearchPerson {
|
||||||
|
total: number;
|
||||||
|
elements: IPerson[];
|
||||||
|
}
|
||||||
|
|
|
@ -142,9 +142,3 @@ export default class CreateEvent extends Vue {
|
||||||
// }
|
// }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
|
||||||
.markdown-render h1 {
|
|
||||||
font-size: 2em;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
12
js/yarn.lock
12
js/yarn.lock
|
@ -7738,6 +7738,11 @@ pofile@^1.0.10:
|
||||||
resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.0.11.tgz#35aff58c17491d127a07336d5522ebc9df57c954"
|
resolved "https://registry.yarnpkg.com/pofile/-/pofile-1.0.11.tgz#35aff58c17491d127a07336d5522ebc9df57c954"
|
||||||
integrity sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==
|
integrity sha512-Vy9eH1dRD9wHjYt/QqXcTz+RnX/zg53xK+KljFSX30PvdDMb2z+c6uDUeblUGqqJgz3QFsdlA0IJvHziPmWtQg==
|
||||||
|
|
||||||
|
popper.js@^1.14.7:
|
||||||
|
version "1.15.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2"
|
||||||
|
integrity sha512-w010cY1oCUmI+9KwwlWki+r5jxKfTFDVoadl7MSrIujHU5MJ5OR6HTDj6Xo8aoR/QsA56x8jKjA59qGH4ELtrA==
|
||||||
|
|
||||||
portfinder@^1.0.20:
|
portfinder@^1.0.20:
|
||||||
version "1.0.20"
|
version "1.0.20"
|
||||||
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.20.tgz#bea68632e54b2e13ab7b0c4775e9b41bf270e44a"
|
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.20.tgz#bea68632e54b2e13ab7b0c4775e9b41bf270e44a"
|
||||||
|
@ -9903,6 +9908,13 @@ timsort@^0.3.0:
|
||||||
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
|
resolved "https://registry.yarnpkg.com/timsort/-/timsort-0.3.0.tgz#405411a8e7e6339fe64db9a234de11dc31e02bd4"
|
||||||
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
|
integrity sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q=
|
||||||
|
|
||||||
|
tippy.js@^4.3.1:
|
||||||
|
version "4.3.1"
|
||||||
|
resolved "https://registry.yarnpkg.com/tippy.js/-/tippy.js-4.3.1.tgz#ea38fa7e1a2e3448ac35faa5115ccbb8414f50aa"
|
||||||
|
integrity sha512-H09joePakSu6eDSL1wj5LjEWwvpEELyJQlgsts4wVH7223t4DlyzGCaZNDO8/MQAnSuic4JhKpXtgzSYGlobvg==
|
||||||
|
dependencies:
|
||||||
|
popper.js "^1.14.7"
|
||||||
|
|
||||||
tiptap-commands@^1.10.5:
|
tiptap-commands@^1.10.5:
|
||||||
version "1.10.5"
|
version "1.10.5"
|
||||||
resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.10.5.tgz#e897b59debdddcbc20f8289c92f9e39c5d22e19a"
|
resolved "https://registry.yarnpkg.com/tiptap-commands/-/tiptap-commands-1.10.5.tgz#e897b59debdddcbc20f8289c92f9e39c5d22e19a"
|
||||||
|
|
Loading…
Reference in a new issue