mirror of
https://github.com/Ocelot-Social-Community/Ocelot-Social.git
synced 2025-12-13 07:46:06 +00:00
Merge pull request #1406 from Human-Connection/fix-editor-bugs
Fix Editor Bugs
This commit is contained in:
commit
2070a49b64
@ -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"
|
||||
|
||||
95
webapp/components/Editor/ContextMenu.vue
Normal file
95
webapp/components/Editor/ContextMenu.vue
Normal 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>
|
||||
@ -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>
|
||||
|
||||
33
webapp/components/Editor/LinkInput.vue
Normal file
33
webapp/components/Editor/LinkInput.vue
Normal 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>
|
||||
74
webapp/components/Editor/MenuBar.vue
Normal file
74
webapp/components/Editor/MenuBar.vue
Normal 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>
|
||||
16
webapp/components/Editor/MenuBarButton.vue
Normal file
16
webapp/components/Editor/MenuBarButton.vue
Normal 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>
|
||||
96
webapp/components/Editor/SuggestionList.vue
Normal file
96
webapp/components/Editor/SuggestionList.vue
Normal 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>
|
||||
2
webapp/constants/editor.js
Normal file
2
webapp/constants/editor.js
Normal file
@ -0,0 +1,2 @@
|
||||
export const HASHTAG = 'hashtag'
|
||||
export const MENTION = 'mention'
|
||||
4
webapp/constants/keycodes.js
Normal file
4
webapp/constants/keycodes.js
Normal file
@ -0,0 +1,4 @@
|
||||
export const ARROW_UP = 38
|
||||
export const ARROW_DOWN = 40
|
||||
export const RETURN = 13
|
||||
export const SPACE = 32
|
||||
Loading…
x
Reference in New Issue
Block a user