Fix editor buttons reloading page 😰

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-10-10 10:25:33 +02:00
parent 84f8e16cd0
commit 651d7e1e80
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
4 changed files with 209 additions and 172 deletions

View File

@ -1,135 +1,128 @@
<template> <template>
<div> <div v-if="editor">
<div class="editor" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id"> <div class="editor" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
<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 }">
<button
class="menubar__button"
:class="{ 'is-active': isActive.bold() }"
@click="commands.bold"
>
<b-icon icon="format-bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.italic() }"
@click="commands.italic"
>
<b-icon icon="format-italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.underline() }"
@click="commands.underline"
>
<b-icon icon="format-underline" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 1 }) }"
@click="commands.heading({ level: 1 })"
>
<b-icon icon="format-header-1" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 2 }) }"
@click="commands.heading({ level: 2 })"
>
<b-icon icon="format-header-2" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 3 }) }"
@click="commands.heading({ level: 3 })"
>
<b-icon icon="format-header-3" />
</button>
<button
class="menubar__button"
@click="showImagePrompt(commands.image)"
>
<b-icon icon="image" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.bullet_list() }"
@click="commands.bullet_list"
>
<b-icon icon="format-list-bulleted" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.ordered_list() }"
@click="commands.ordered_list"
>
<b-icon icon="format-list-numbered" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.blockquote() }"
@click="commands.blockquote"
>
<b-icon icon="format-quote-close" />
</button>
<button
class="menubar__button"
@click="commands.undo"
>
<b-icon icon="undo" />
</button>
<button
class="menubar__button"
@click="commands.redo"
>
<b-icon icon="redo" />
</button>
</div>
</editor-menu-bar>
<editor-menu-bubble class="menububble" :editor="editor" @hide="hideLinkMenu" v-slot="{ commands, isActive, getMarkAttrs, menu }">
<div
class="menububble"
:class="{ 'is-active': menu.isActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<form class="menububble__form" v-if="linkMenuIsActive" @submit.prevent="setLinkUrl(commands.link, linkUrl)">
<input class="menububble__input" type="text" v-model="linkUrl" placeholder="https://" ref="linkInput" @keydown.esc="hideLinkMenu"/>
<button class="menububble__button" @click="setLinkUrl(commands.link, null)" type="button">
<b-icon icon="delete" />
</button>
</form>
<template v-else>
<button <button
class="menububble__button" class="menubar__button"
@click="showLinkMenu(getMarkAttrs('link'))" :class="{ 'is-active': isActive.bold() }"
:class="{ 'is-active': isActive.link() }" @click="commands.bold"
type="button"
>
<b-icon icon="format-bold" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.italic() }"
@click="commands.italic"
type="button"
>
<b-icon icon="format-italic" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.underline() }"
@click="commands.underline"
type="button"
>
<b-icon icon="format-underline" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 1 }) }"
@click="commands.heading({ level: 1 })"
type="button"
>
<b-icon icon="format-header-1" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 2 }) }"
@click="commands.heading({ level: 2 })"
type="button"
>
<b-icon icon="format-header-2" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 3 }) }"
@click="commands.heading({ level: 3 })"
type="button"
>
<b-icon icon="format-header-3" />
</button>
<button
class="menubar__button"
@click="showLinkMenu(commands.link, isActive.link())"
:class="{ 'is-active': isActive.link() }"
type="button"
> >
<span>{{ isActive.link() ? 'Update Link' : 'Add Link'}}</span>
<b-icon icon="link" /> <b-icon icon="link" />
</button> </button>
</template>
</div> <button
</editor-menu-bubble> class="menubar__button"
@click="showImagePrompt(commands.image)"
type="button"
>
<b-icon icon="image" />
</button>
<editor-content class="editor__content" :editor="editor" /> <button
</div> class="menubar__button"
:class="{ 'is-active': isActive.bullet_list() }"
@click="commands.bullet_list"
type="button"
>
<b-icon icon="format-list-bulleted" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.ordered_list() }"
@click="commands.ordered_list"
type="button"
>
<b-icon icon="format-list-numbered" />
</button>
<button
class="menubar__button"
:class="{ 'is-active': isActive.blockquote() }"
@click="commands.blockquote"
type="button"
>
<b-icon icon="format-quote-close" />
</button>
<button
class="menubar__button"
@click="commands.undo"
type="button"
>
<b-icon icon="undo" />
</button>
<button
class="menubar__button"
@click="commands.redo"
type="button"
>
<b-icon icon="redo" />
</button>
</div>
</editor-menu-bar>
<editor-content class="editor__content" :editor="editor" />
</div>
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions"> <div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
<template v-if="hasResults"> <template v-if="hasResults">
<div <div
@ -186,18 +179,12 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
}, },
}, },
}) })
export default class CreateEvent extends Vue { export default class EditorComponent extends Vue {
@Prop({ required: true }) value!: String; @Prop({ required: true }) value!: string;
currentActor!: IPerson; currentActor!: IPerson;
editor: Editor = null; editor: Editor|null = null;
/**
* Editor Link
*/
linkUrl!: string|null;
linkMenuIsActive: boolean = false;
/** /**
* Editor Suggestions * Editor Suggestions
@ -233,14 +220,14 @@ export default class CreateEvent extends Vue {
this.filteredActors = items; this.filteredActors = items;
this.suggestionRange = range; this.suggestionRange = range;
this.renderPopup(virtualNode); this.renderPopup(virtualNode);
// we save the command for inserting a selected mention // we save the command for inserting a selected mention
// this allows us to call it inside of our custom popup // this allows us to call it inside of our custom popup
// via keyboard navigation and on click // via keyboard navigation and on click
this.insertMention = command; this.insertMention = command;
}, },
/** /**
* is called when a suggestion has changed * is called when a suggestion has changed
*/ */
onChange: ({ items, query, range, virtualNode }) => { onChange: ({ items, query, range, virtualNode }) => {
this.query = query; this.query = query;
this.filteredActors = items; this.filteredActors = items;
@ -249,11 +236,11 @@ export default class CreateEvent extends Vue {
this.renderPopup(virtualNode); this.renderPopup(virtualNode);
}, },
/** /**
* is called when a suggestion is cancelled * is called when a suggestion is cancelled
*/ */
onExit: () => { onExit: () => {
// reset all saved values // reset all saved values
this.query = null; this.query = null;
this.filteredActors = []; this.filteredActors = [];
this.suggestionRange = null; this.suggestionRange = null;
@ -261,21 +248,21 @@ export default class CreateEvent extends Vue {
this.destroyPopup(); this.destroyPopup();
}, },
/** /**
* is called on every keyDown event while a suggestion is active * is called on every keyDown event while a suggestion is active
*/ */
onKeyDown: ({ event }) => { onKeyDown: ({ event }) => {
// pressing up arrow // pressing up arrow
if (event.keyCode === 38) { if (event.keyCode === 38) {
this.upHandler(); this.upHandler();
return true; return true;
} }
// pressing down arrow // pressing down arrow
if (event.keyCode === 40) { if (event.keyCode === 40) {
this.downHandler(); this.downHandler();
return true; return true;
} }
// pressing enter // pressing enter
if (event.keyCode === 13) { if (event.keyCode === 13) {
this.enterHandler(); this.enterHandler();
return true; return true;
@ -292,7 +279,7 @@ export default class CreateEvent extends Vue {
searchText: query, searchText: query,
}, },
}); });
// TODO: TipTap doesn't handle async for onFilter, hence the following line. // TODO: TipTap doesn't handle async for onFilter, hence the following line.
this.filteredActors = result.data.searchPersons.elements; this.filteredActors = result.data.searchPersons.elements;
return this.filteredActors; return this.filteredActors;
}, },
@ -323,28 +310,29 @@ export default class CreateEvent extends Vue {
@Watch('value') @Watch('value')
onValueChanged(val: string) { onValueChanged(val: string) {
if (!this.editor) return;
if (val !== this.editor.getHTML()) { if (val !== this.editor.getHTML()) {
this.editor.setContent(val); this.editor.setContent(val);
} }
} }
showLinkMenu(attrs: any) { showLinkMenu(command, active: boolean) {
this.linkUrl = attrs.href; if (!this.editor) return;
this.linkMenuIsActive = true; if (active) return command({ href: null });
this.$nextTick(() => { this.$buefy.dialog.prompt({
const linkInput = this.$refs.linkInput as HTMLElement; message: this.$t('Enter the link URL') as string,
linkInput.focus(); inputAttrs: {
type: 'url',
},
// @ts-ignore https://github.com/buefy/buefy/commit/62539ac4026c8610509850a3a973fc283bac50ef#diff-02b38ee0a78d8316f075e520b3a442ae
trapFocus: true,
onConfirm: (value) => {
command({ href: value });
if (!this.editor) return;
this.editor.focus();
},
}); });
} }
hideLinkMenu() {
this.linkUrl = '';
this.linkMenuIsActive = false;
}
setLinkUrl(command, url: string) {
command({ href: url });
this.hideLinkMenu();
this.editor.focus();
}
upHandler() { upHandler() {
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredActors.length) - 1) % this.filteredActors.length; this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
@ -378,6 +366,7 @@ export default class CreateEvent extends Vue {
label: actor.name, label: actor.name,
}, },
}); });
if (!this.editor) return;
this.editor.focus(); this.editor.focus();
} }
@ -447,6 +436,7 @@ export default class CreateEvent extends Vue {
} }
beforeDestroy() { beforeDestroy() {
if (!this.editor) return;
this.editor.destroy(); this.editor.destroy();
} }
} }
@ -539,10 +529,6 @@ export default class CreateEvent extends Vue {
margin: 0; margin: 0;
} }
a {
color: inherit;
}
blockquote { blockquote {
border-left: 3px solid rgba($color-black, 0.1); border-left: 3px solid rgba($color-black, 0.1);
color: rgba($color-black, 0.8); color: rgba($color-black, 0.8);

28
js/src/typings/tiptap-extensions.d.ts vendored Normal file
View File

@ -0,0 +1,28 @@
declare module 'tiptap-extensions' {
import Vue from 'vue';
export class Blockquote {}
export class CodeBlock {}
export class HardBreak {}
export class Heading {
constructor(object: object)
}
export class OrderedList {}
export class BulletList {}
export class ListItem {}
export class TodoItem {}
export class TodoList {}
export class Bold {}
export class Code {}
export class Italic {}
export class Link {}
export class Strike {}
export class Underline {}
export class History {}
export class Placeholder {
constructor(object: object)
}
export class Mention {
constructor(object: object)
}
}

24
js/src/typings/tiptap.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
declare module 'tiptap' {
import Vue from 'vue';
export class Editor {
public constructor({});
public setOptions({}): void;
public setContent(content: string): void;
public focus(): void;
public getHTML(): string;
public destroy(): void;
}
export class Node {}
export class Plugin {
public constructor({});
}
export class EditorMenuBar extends Vue {}
export class EditorContent extends Vue {}
export class EditorMenuBubble extends Vue {}
}

View File

@ -1,4 +1,3 @@
import {ParticipantRole} from "@/types/event.model";
<template> <template>
<section> <section>
<div class="container"> <div class="container">
@ -246,7 +245,7 @@ import {
import { CURRENT_ACTOR_CLIENT, IDENTITIES, LOGGED_USER_DRAFTS, LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor'; import { CURRENT_ACTOR_CLIENT, IDENTITIES, LOGGED_USER_DRAFTS, LOGGED_USER_PARTICIPATIONS } from '@/graphql/actor';
import { IPerson, Person } from '@/types/actor'; import { IPerson, Person } from '@/types/actor';
import PictureUpload from '@/components/PictureUpload.vue'; import PictureUpload from '@/components/PictureUpload.vue';
import Editor from '@/components/Editor.vue'; import EditorComponent from '@/components/Editor.vue';
import DateTimePicker from '@/components/Event/DateTimePicker.vue'; import DateTimePicker from '@/components/Event/DateTimePicker.vue';
import TagInput from '@/components/Event/TagInput.vue'; import TagInput from '@/components/Event/TagInput.vue';
import { TAGS } from '@/graphql/tags'; import { TAGS } from '@/graphql/tags';
@ -257,7 +256,7 @@ import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
import { RouteName } from '@/router'; import { RouteName } from '@/router';
@Component({ @Component({
components: { IdentityPickerWrapper, AddressAutoComplete, TagInput, DateTimePicker, PictureUpload, Editor }, components: { IdentityPickerWrapper, AddressAutoComplete, TagInput, DateTimePicker, PictureUpload, Editor: EditorComponent },
apollo: { apollo: {
currentActor: { currentActor: {
query: CURRENT_ACTOR_CLIENT, query: CURRENT_ACTOR_CLIENT,