Merge pull request #1406 from Human-Connection/fix-editor-bugs

Fix Editor Bugs
This commit is contained in:
Robert Schäfer 2019-08-28 23:55:31 +02:00 committed by GitHub
commit 2070a49b64
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 510 additions and 584 deletions

View File

@ -23,7 +23,7 @@
/>
<small class="smallTag">{{ form.contentLength }}/{{ contentMax }}</small>
</client-only>
<ds-space margin-bottom="xxx-large" />
<ds-space margin-bottom="small" />
<hc-categories-select
model="categoryIds"
@updateCategories="updateCategories"

View File

@ -0,0 +1,95 @@
<script>
import tippy from 'tippy.js'
export default {
props: {
content: Object,
node: Object,
},
methods: {
displayContextMenu(target, content, type) {
const placement = type === 'link' ? 'right' : 'top-start'
const trigger = type === 'link' ? 'click' : 'mouseenter'
const showOnInit = type !== 'link'
if (this.menu) {
return
}
this.menu = tippy(target, {
arrow: true,
arrowType: 'round',
content: content,
duration: [400, 200],
inertia: true,
interactive: true,
placement,
showOnInit,
theme: 'human-connection',
trigger,
onMount(instance) {
const input = instance.popper.querySelector('input')
if (input) {
input.focus({ preventScroll: true })
}
},
})
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
this.observer = new MutationObserver(() => {
this.menu.popperInstance.scheduleUpdate()
})
this.observer.observe(content, {
childList: true,
subtree: true,
characterData: true,
})
}
},
hideContextMenu() {
if (this.menu) {
this.menu.destroy()
this.menu = null
}
if (this.observer) {
this.observer.disconnect()
}
},
},
render() {
return null
},
}
</script>
<style lang="scss">
.tippy-tooltip.human-connection-theme {
background-color: $color-primary;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-neutral-100;
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-primary;
}
.tippy-popper[x-placement^='top'] & .tippy-arrow {
border-top-color: $color-primary;
}
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
border-bottom-color: $color-primary;
}
.tippy-popper[x-placement^='left'] & .tippy-arrow {
border-left-color: $color-primary;
}
.tippy-popper[x-placement^='right'] & .tippy-arrow {
border-right-color: $color-primary;
}
}
</style>

View File

@ -1,203 +1,52 @@
<template>
<div class="editor">
<!-- Mention and Hashtag Suggestions Menu -->
<div v-show="showSuggestions" ref="suggestions" class="suggestion-list">
<!-- "filteredItems" array is not empty -->
<template v-if="hasResults">
<div
v-for="(item, index) in filteredItems"
:key="item.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedItemIndex === index }"
@click="selectItem(item)"
>
<div v-if="isMention">@{{ item.slug }}</div>
<div v-if="isHashtag">#{{ item.id }}</div>
</div>
<div v-if="isHashtag">
<!-- if query is not empty and is find fully in the suggestions array ... -->
<div v-if="query && !filteredItems.find(el => el.id === query)">
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
<div class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</div>
</div>
<!-- otherwise if sanitized query is empty advice the user to add a char -->
<div v-else-if="!query">
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addLetter') }}</div>
</div>
</div>
</template>
<!-- if "!hasResults" -->
<div v-else>
<div v-if="isMention" class="suggestion-list__item is-empty">
{{ $t('editor.mention.noUsersFound') }}
</div>
<div v-if="isHashtag">
<div v-if="query === ''" class="suggestion-list__item is-empty">
{{ $t('editor.hashtag.noHashtagsFound') }}
</div>
<!-- if "query" is not empty -->
<div v-else>
<div class="suggestion-list__item is-empty">{{ $t('editor.hashtag.addHashtag') }}</div>
<div class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</div>
</div>
</div>
</div>
</div>
<editor-menu-bubble :editor="editor">
<div
ref="menu"
slot-scope="{ commands, getMarkAttrs, isActive, menu }"
class="menububble tooltip"
x-placement="top"
:class="{ 'is-active': menu.isActive || linkMenuIsActive }"
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
>
<div class="tooltip-wrapper">
<template v-if="linkMenuIsActive">
<ds-input
ref="linkInput"
v-model="linkUrl"
class="editor-menu-link-input"
placeholder="http://"
@blur.native.capture="hideMenu(menu.isActive)"
@keydown.native.esc.prevent="hideMenu(menu.isActive)"
@keydown.native.enter.prevent="setLinkUrl(commands.link, linkUrl)"
/>
</template>
<template v-else>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.bold()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="commands.bold"
>
<ds-icon name="bold" />
</ds-button>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.italic()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="commands.italic"
>
<ds-icon name="italic" />
</ds-button>
<ds-button
class="menububble__button"
size="small"
:hover="isActive.link()"
ghost
@click.prevent="() => {}"
@mousedown.native.prevent="showLinkMenu(getMarkAttrs('link'))"
>
<ds-icon name="link" />
</ds-button>
</template>
</div>
<div class="tooltip-arrow" />
</div>
</editor-menu-bubble>
<editor-floating-menu :editor="editor">
<div
slot-scope="{ commands, isActive, menu }"
class="editor__floating-menu"
:class="{ 'is-active': menu.isActive }"
:style="`top: ${menu.top}px`"
>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.paragraph()"
@click.prevent="commands.paragraph()"
>
<ds-icon name="paragraph" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.heading({ level: 3 })"
@click.prevent="commands.heading({ level: 3 })"
>
H3
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.heading({ level: 4 })"
@click.prevent="commands.heading({ level: 4 })"
>
H4
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.bullet_list()"
@click.prevent="commands.bullet_list()"
>
<ds-icon name="list-ul" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.ordered_list()"
@click.prevent="commands.ordered_list()"
>
<ds-icon name="list-ol" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.blockquote()"
@click.prevent="commands.blockquote"
>
<ds-icon name="quote-right" />
</ds-button>
<ds-button
class="menubar__button"
size="small"
:ghost="!isActive.horizontal_rule()"
@click.prevent="commands.horizontal_rule"
>
<ds-icon name="minus" />
</ds-button>
</div>
</editor-floating-menu>
<editor-content ref="editor" :editor="editor" />
<menu-bar :editor="editor" :toggleLinkInput="toggleLinkInput" />
<editor-content ref="editor" :editor="editor" class="ds-input editor-content" />
<context-menu ref="contextMenu" />
<suggestion-list
ref="suggestions"
:suggestion-type="suggestionType"
:filtered-items="filteredItems"
:navigated-item-index="navigatedItemIndex"
:query="query"
:select-item="selectItem"
/>
<link-input
v-show="isLinkInputActive"
ref="linkInput"
:toggle-link-input="toggleLinkInput"
:set-link-url="setLinkUrl"
/>
</div>
</template>
<script>
import defaultExtensions from './defaultExtensions.js'
import { mapGetters } from 'vuex'
import { Editor, EditorContent } from 'tiptap'
import { History } from 'tiptap-extensions'
import linkify from 'linkify-it'
import stringHash from 'string-hash'
import Fuse from 'fuse.js'
import tippy from 'tippy.js'
import { Editor, EditorContent, EditorFloatingMenu, EditorMenuBubble } from 'tiptap'
import * as key from '../../constants/keycodes'
import { HASHTAG, MENTION } from '../../constants/editor'
import defaultExtensions from './defaultExtensions.js'
import EventHandler from './plugins/eventHandler.js'
import { History } from 'tiptap-extensions'
import Hashtag from './nodes/Hashtag.js'
import Mention from './nodes/Mention.js'
import { mapGetters } from 'vuex'
import MenuBar from './MenuBar'
import ContextMenu from './ContextMenu'
import SuggestionList from './SuggestionList'
import LinkInput from './LinkInput'
let throttleInputEvent
export default {
components: {
ContextMenu,
EditorContent,
EditorFloatingMenu,
EditorMenuBubble,
LinkInput,
MenuBar,
SuggestionList,
},
props: {
users: { type: Array, default: () => null }, // If 'null', than the Mention extention is not assigned.
@ -206,189 +55,11 @@ export default {
doc: { type: Object, default: () => {} },
},
data() {
// Set array of optional extensions by analysing the props.
let optionalExtensions = []
// Don't change the following line. The functionallity is in danger!
if (this.users) {
optionalExtensions.push(
new Mention({
// a list of all suggested items
items: () => {
return this.users
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.mentionSuggestionType
this.query = query
this.filteredItems = 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.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = query
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 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
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
if (!query) {
return items
}
const fuse = new Fuse(items, {
threshold: 0.2,
keys: ['slug'],
})
return fuse.search(query)
},
}),
)
}
// Don't change the following line. The functionallity is in danger!
if (this.hashtags) {
optionalExtensions.push(
new Hashtag({
// a list of all suggested items
items: () => {
return this.hashtags
},
// is called when a suggestion starts
onEnter: ({ items, query, range, command, virtualNode }) => {
this.suggestionType = this.hashtagSuggestionType
this.query = this.sanitizedQuery(query)
this.filteredItems = 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.insertMentionOrHashtag = command
},
// is called when a suggestion has changed
onChange: ({ items, query, range, virtualNode }) => {
this.query = this.sanitizedQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.renderPopup(virtualNode)
},
// is called when a suggestion is cancelled
onExit: () => {
this.suggestionType = this.nullSuggestionType
// reset all saved values
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 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
}
// pressing space
if (event.keyCode === 32) {
this.spaceHandler()
return true
}
return false
},
// is called when a suggestion has changed
// this function is optional because there is basic filtering built-in
// you can overwrite it if you prefer your own filtering
// in this example we use fuse.js with support for fuzzy search
onFilter: (items, query) => {
query = this.sanitizedQuery(query)
if (!query) {
return items
}
return items.filter(item =>
JSON.stringify(item)
.toLowerCase()
.includes(query.toLowerCase()),
)
},
}),
)
}
return {
lastValueHash: null,
editor: new Editor({
content: this.value || '',
doc: this.doc,
extensions: [
...defaultExtensions(this),
new EventHandler(),
new History(),
...optionalExtensions,
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
},
}),
linkUrl: null,
linkMenuIsActive: false,
nullSuggestionType: '',
mentionSuggestionType: 'mention',
hashtagSuggestionType: 'hashtag',
suggestionType: this.nullSuggestionType,
editor: null,
isLinkInputActive: false,
suggestionType: '',
query: null,
suggestionRange: null,
filteredItems: [],
@ -399,17 +70,39 @@ export default {
},
computed: {
...mapGetters({ placeholder: 'editor/placeholder' }),
hasResults() {
return this.filteredItems.length
},
showSuggestions() {
return this.query || this.hasResults
},
isMention() {
return this.suggestionType === this.mentionSuggestionType
},
isHashtag() {
return this.suggestionType === this.hashtagSuggestionType
optionalExtensions() {
const extensions = []
// Don't change the following line. The functionallity is in danger!
if (this.users) {
extensions.push(
new Mention({
items: () => {
return this.users
},
onEnter: props => this.openSuggestionList(props, MENTION),
onChange: this.updateSuggestionList,
onExit: this.closeSuggestionList,
onKeyDown: this.navigateSuggestionList,
onFilter: this.filterSuggestionList,
}),
)
}
// Don't change the following line. The functionallity is in danger!
if (this.hashtags) {
extensions.push(
new Hashtag({
items: () => {
return this.hashtags
},
onEnter: props => this.openSuggestionList(props, HASHTAG),
onChange: this.updateSuggestionList,
onExit: this.closeSuggestionList,
onKeyDown: this.navigateSuggestionList,
onFilter: this.filterSuggestionList,
}),
)
}
return extensions
},
},
watch: {
@ -421,52 +114,110 @@ export default {
return
}
this.lastValueHash = contentHash
this.editor.setContent(content)
this.$nextTick(() => this.editor.setContent(content))
},
},
placeholder: {
immediate: true,
handler: function(val) {
if (!val) {
if (!val || !this.editor) {
return
}
this.editor.extensions.options.placeholder.emptyNodeText = val
},
},
},
created() {
this.editor = new Editor({
content: this.value || '',
doc: this.doc,
extensions: [
...defaultExtensions(this),
new EventHandler(),
new History(),
...this.optionalExtensions,
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
},
})
},
beforeDestroy() {
this.editor.destroy()
},
methods: {
sanitizedQuery(query) {
// remove all not allowed chars
query = query.replace(/[^a-zA-Z0-9]/gm, '')
// if the query is only made of digits, make it empty
return query.replace(/[0-9]/gm, '') === '' ? '' : query
openSuggestionList({ items, query, range, command, virtualNode }, suggestionType) {
this.suggestionType = suggestionType
this.query = this.sanitizeQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.$refs.contextMenu.displayContextMenu(virtualNode, this.$refs.suggestions.$el)
this.insertMentionOrHashtag = command
},
// navigate to the previous item
// if it's the first item, navigate to the last one
upHandler() {
this.navigatedItemIndex =
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
updateSuggestionList({ items, query, range, virtualNode }) {
this.query = this.sanitizeQuery(query)
this.filteredItems = items
this.suggestionRange = range
this.navigatedItemIndex = 0
this.$refs.contextMenu.displayContextMenu(virtualNode, this.$refs.suggestions.$el)
},
// navigate to the next item
// if it's the last item, navigate to the first one
downHandler() {
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
closeSuggestionList() {
this.suggestionType = ''
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.$refs.contextMenu.hideContextMenu()
},
// Handles pressing of enter.
enterHandler() {
navigateSuggestionList({ event }) {
const item = this.filteredItems[this.navigatedItemIndex]
if (item) {
this.selectItem(item)
switch (event.keyCode) {
case key.ARROW_UP:
this.navigatedItemIndex =
(this.navigatedItemIndex + this.filteredItems.length - 1) % this.filteredItems.length
return true
case key.ARROW_DOWN:
this.navigatedItemIndex = (this.navigatedItemIndex + 1) % this.filteredItems.length
return true
case key.RETURN:
if (item) {
this.selectItem(item)
}
return true
case key.SPACE:
if (this.suggestionType === HASHTAG && this.query !== '') {
this.selectItem({ id: this.query })
}
return true
default:
return false
}
},
// For hashtags handles pressing of space.
spaceHandler() {
if (this.suggestionType === this.hashtagSuggestionType && this.query !== '') {
this.selectItem({ id: this.query })
filterSuggestionList(items, query) {
query = this.sanitizeQuery(query)
if (!query) {
return items
}
return items.filter(item => {
const itemString = item.slug || item.id
return itemString.toLowerCase().includes(query.toLowerCase())
})
},
sanitizeQuery(query) {
if (this.suggestionType === HASHTAG) {
// remove all not allowed chars
query = query.replace(/[^a-zA-Z0-9]/gm, '')
// if the query is only made of digits, make it empty
return query.replace(/[0-9]/gm, '') === '' ? '' : query
}
return query
},
// we have to replace our suggestion text with a mention
// so it's important to pass also the position of your suggestion text
@ -487,45 +238,6 @@ export default {
})
this.editor.focus()
},
// renders a popup with suggestions
// tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
renderPopup(node) {
if (this.popup) {
return
}
this.popup = tippy(node, {
content: this.$refs.suggestions,
trigger: 'mouseenter',
interactive: true,
theme: 'dark',
placement: 'top-start',
inertia: true,
duration: [400, 200],
showOnInit: true,
arrow: true,
arrowType: 'round',
})
// we have to update tippy whenever the DOM is updated
if (MutationObserver) {
this.observer = new MutationObserver(() => {
this.popup.popperInstance.scheduleUpdate()
})
this.observer.observe(this.$refs.suggestions, {
childList: true,
subtree: true,
characterData: true,
})
}
},
destroyPopup() {
if (this.popup) {
this.popup.destroy()
this.popup = null
}
if (this.observer) {
this.observer.disconnect()
}
},
onUpdate(e) {
const content = e.getHTML()
const contentHash = stringHash(content)
@ -534,36 +246,28 @@ export default {
this.$emit('input', content)
}
},
showLinkMenu(attrs) {
this.linkUrl = attrs.href
this.linkMenuIsActive = true
this.$nextTick(() => {
try {
const $el = this.$refs.linkInput.$el.getElementsByTagName('input')[0]
$el.focus()
$el.select()
} catch (err) {}
})
toggleLinkInput(attrs, element) {
if (!this.isLinkInputActive && attrs && element) {
this.$refs.linkInput.linkUrl = attrs.href
this.isLinkInputActive = true
this.$refs.contextMenu.displayContextMenu(element, this.$refs.linkInput.$el, 'link')
} else {
this.$refs.contextMenu.hideContextMenu()
this.isLinkInputActive = false
this.editor.focus()
}
},
hideLinkMenu() {
this.linkUrl = null
this.linkMenuIsActive = false
this.editor.focus()
},
hideMenu(isActive) {
isActive = false
this.hideLinkMenu()
},
setLinkUrl(command, url) {
const links = linkify().match(url)
if (links) {
setLinkUrl(url) {
const normalizedLinks = url ? linkify().match(url) : null
const command = this.editor.commands.link
if (normalizedLinks) {
// add valid link
command({
href: links.pop().url,
href: normalizedLinks.pop().url,
})
this.hideLinkMenu()
this.toggleLinkInput()
this.editor.focus()
} else if (!url) {
} else {
// remove link
command({ href: null })
}
@ -576,70 +280,6 @@ export default {
</script>
<style lang="scss">
.suggestion-list {
padding: 0.2rem;
border: 2px solid rgba($color-neutral-0, 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-neutral-100, 0.2);
}
&.is-empty {
opacity: 0.5;
}
}
}
.tippy-tooltip.dark-theme {
background-color: $color-neutral-0;
padding: 0;
font-size: 1rem;
text-align: inherit;
color: $color-neutral-100;
border-radius: 5px;
.tippy-backdrop {
display: none;
}
.tippy-roundarrow {
fill: $color-neutral-0;
}
.tippy-popper[x-placement^='top'] & .tippy-arrow {
border-top-color: $color-neutral-0;
}
.tippy-popper[x-placement^='bottom'] & .tippy-arrow {
border-bottom-color: $color-neutral-0;
}
.tippy-popper[x-placement^='left'] & .tippy-arrow {
border-left-color: $color-neutral-0;
}
.tippy-popper[x-placement^='right'] & .tippy-arrow {
border-right-color: $color-neutral-0;
}
}
.ProseMirror {
padding: $space-base;
margin: -$space-base;
min-height: $space-large;
}
.ProseMirror:focus {
outline: none;
}
.editor p.is-empty:first-child::before {
content: attr(data-empty-text);
float: left;
@ -659,74 +299,40 @@ li > p {
}
.editor {
.mention-suggestion {
color: $color-primary;
}
display: flex;
flex-direction: column;
.hashtag {
color: $color-primary;
}
.hashtag-suggestion {
color: $color-primary;
}
&__floating-menu {
position: absolute;
margin-top: -0.25rem;
margin-left: $space-xx-small;
visibility: hidden;
opacity: 0;
transition: opacity 0.2s, visibility 0.2s;
background-color: #fff;
&.is-active {
opacity: 1;
visibility: visible;
}
.mention-suggestion {
color: $color-primary;
}
.menububble {
position: absolute;
// margin-top: -0.5rem;
visibility: hidden;
opacity: 0;
transition: opacity 200ms, visibility 200ms;
// transition-delay: 50ms;
transform: translate(-50%, -10%);
}
background-color: $background-color-inverse-soft;
// color: $text-color-inverse;
border-radius: $border-radius-base;
padding: $space-xx-small;
box-shadow: $box-shadow-large;
.editor-content {
flex-grow: 1;
margin-top: $space-small;
height: auto;
.ds-button {
color: $text-color-inverse;
&:focus-within {
border-color: $color-primary;
background-color: $color-neutral-100;
}
}
&.ds-button-hover,
&:hover {
color: $text-color-base;
}
}
.ProseMirror {
min-height: 100px;
&.is-active {
opacity: 1;
visibility: visible;
}
&:focus {
outline: none;
}
.tooltip-arrow {
left: calc(50% - 10px);
}
input,
button {
border: none;
border-radius: 2px;
}
.ds-input {
height: auto;
}
input {
padding: $space-xx-small $space-x-small;
}
p {
margin: 0 0 $space-x-small;
}
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div>
<ds-input
id="linkInputId"
v-model="linkUrl"
class="editor-menu-link-input"
placeholder="https://"
@blur.native.capture="toggleLinkInput()"
@keydown.native.esc.prevent="toggleLinkInput()"
@keydown.native.enter.prevent="enterLink()"
/>
</div>
</template>
<script>
export default {
props: {
toggleLinkInput: Function,
setLinkUrl: Function,
},
data() {
return {
linkUrl: null,
}
},
methods: {
enterLink() {
this.setLinkUrl(this.linkUrl)
this.linkUrl = null
},
},
}
</script>

View File

@ -0,0 +1,74 @@
<template>
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, getMarkAttrs }">
<div>
<menu-bar-button :isActive="isActive.bold()" :onClick="commands.bold" icon="bold" />
<menu-bar-button :isActive="isActive.italic()" :onClick="commands.italic" icon="italic" />
<menu-bar-button
ref="linkButton"
:isActive="isActive.link()"
:onClick="event => toggleLinkInput(getMarkAttrs('link'), event.currentTarget)"
icon="link"
/>
<menu-bar-button
:isActive="isActive.paragraph()"
:onClick="commands.paragraph"
icon="paragraph"
/>
<menu-bar-button
:isActive="isActive.heading({ level: 3 })"
:onClick="() => commands.heading({ level: 3 })"
label="H3"
/>
<menu-bar-button
:isActive="isActive.heading({ level: 4 })"
:onClick="() => commands.heading({ level: 4 })"
label="H4"
/>
<menu-bar-button
:isActive="isActive.bullet_list()"
:onClick="commands.bullet_list"
icon="list-ul"
/>
<menu-bar-button
:isActive="isActive.ordered_list()"
:onClick="commands.ordered_list"
icon="list-ol"
/>
<menu-bar-button
:isActive="isActive.blockquote()"
:onClick="commands.blockquote"
icon="quote-right"
/>
<menu-bar-button
:isActive="isActive.horizontal_rule()"
:onClick="commands.horizontal_rule"
icon="minus"
/>
</div>
</editor-menu-bar>
</template>
<script>
import { EditorMenuBar } from 'tiptap'
import MenuBarButton from './MenuBarButton'
export default {
components: {
EditorMenuBar,
MenuBarButton,
},
props: {
editor: Object,
toggleLinkInput: Function,
},
}
</script>

View File

@ -0,0 +1,16 @@
<template>
<ds-button size="small" :ghost="!isActive" @click.prevent="onClick" :icon="icon">
<span v-if="label">{{ label }}</span>
</ds-button>
</template>
<script>
export default {
props: {
isActive: Boolean,
icon: String,
label: String,
onClick: Function,
},
}
</script>

View File

@ -0,0 +1,96 @@
<template>
<ul v-show="showSuggestions" class="suggestion-list">
<li
v-for="(item, index) in filteredItems"
:key="item.id"
class="suggestion-list__item"
:class="{ 'is-selected': navigatedItemIndex === index }"
@click="selectItem(item)"
>
{{ createItemLabel(item) }}
</li>
<template v-if="isHashtag">
<li v-if="!query" class="suggestion-list__item hint">
{{ $t('editor.hashtag.addLetter') }}
</li>
<template v-else-if="!filteredItems.find(el => el.id === query)">
<li class="suggestion-list__item hint">{{ $t('editor.hashtag.addHashtag') }}</li>
<li class="suggestion-list__item" @click="selectItem({ id: query })">#{{ query }}</li>
</template>
</template>
<template v-else-if="isMention">
<li v-if="!hasResults" class="suggestion-list__item hint">
{{ $t('editor.mention.noUsersFound') }}
</li>
</template>
</ul>
</template>
<script>
import { HASHTAG, MENTION } from '../../constants/editor'
export default {
props: {
suggestionType: String,
filteredItems: Array,
query: String,
navigatedItemIndex: Number,
selectItem: Function,
},
computed: {
hasResults() {
return this.filteredItems.length > 0
},
isMention() {
return this.suggestionType === MENTION
},
isHashtag() {
return this.suggestionType === HASHTAG
},
showSuggestions() {
return this.query || this.hasResults
},
},
methods: {
createItemLabel(item) {
if (this.isMention) {
return `@${item.slug}`
} else {
return `#${item.id}`
}
},
},
}
</script>
<style lang="scss">
.suggestion-list {
list-style-type: none;
padding: 0.2rem;
border-radius: 5px;
border: 2px solid $color-primary;
font-size: 0.8rem;
font-weight: bold;
}
.suggestion-list__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-neutral-100, 0.3);
}
&.hint {
opacity: 0.7;
pointer-events: none;
}
}
</style>

View File

@ -0,0 +1,2 @@
export const HASHTAG = 'hashtag'
export const MENTION = 'mention'

View File

@ -0,0 +1,4 @@
export const ARROW_UP = 38
export const ARROW_DOWN = 40
export const RETURN = 13
export const SPACE = 32