roschaefer a7a1e4affc fix: editor not visible in server-side-rendering
This is fixing a bug where sometimes the editor would not get displayed
until you click into the title text fields. This commit also removes
some obscure optimizations.

I'm really annoyed by the cruft that we still carry around until this
very day.

Every single line of untested code, which you left because you thought:
"Well sure this is going to improve performance!" is going to bite you.
2019-12-14 01:36:59 +01:00

400 lines
9.7 KiB
Vue

<template>
<div class="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 { Editor, EditorContent } from 'tiptap'
import { History } from 'tiptap-extensions'
import linkify from 'linkify-it'
import { replace, build } from 'xregexp/xregexp-all.js'
import * as key from '../../constants/keycodes'
import { HASHTAG, MENTION } from '../../constants/editor'
import defaultExtensions from './defaultExtensions.js'
import EventHandler from './plugins/eventHandler.js'
import Hashtag from './nodes/Hashtag.js'
import Mention from './nodes/Mention.js'
import MenuBar from './MenuBar'
import ContextMenu from './ContextMenu'
import SuggestionList from './SuggestionList'
import LinkInput from './LinkInput'
let throttleInputEvent
export default {
components: {
ContextMenu,
EditorContent,
LinkInput,
MenuBar,
SuggestionList,
},
props: {
users: { type: Array, default: () => null }, // If 'null', than the Mention extention is not assigned.
hashtags: { type: Array, default: () => null }, // If 'null', than the Hashtag extention is not assigned.
value: { type: String, default: '' },
doc: { type: Object, default: () => {} },
},
data() {
return {
lastValueHash: null,
editor: null,
isLinkInputActive: false,
suggestionType: '',
query: null,
suggestionRange: null,
filteredItems: [],
navigatedItemIndex: 0,
insertMentionOrHashtag: () => {},
observer: null,
}
},
computed: {
placeholder() {
return this.$t('editor.placeholder')
},
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
},
},
mounted() {
this.editor = new Editor({
content: this.value || '',
doc: this.doc,
extensions: [
// Hashtags must come first, see
// https://github.com/scrumpy/tiptap/issues/421#issuecomment-523037460
...this.optionalExtensions,
...defaultExtensions(this),
new EventHandler(),
new History(),
],
onUpdate: e => {
clearTimeout(throttleInputEvent)
throttleInputEvent = setTimeout(() => this.onUpdate(e), 300)
},
})
},
beforeDestroy() {
this.editor.destroy()
},
methods: {
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
},
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)
},
closeSuggestionList() {
this.suggestionType = ''
this.query = null
this.filteredItems = []
this.suggestionRange = null
this.navigatedItemIndex = 0
this.$refs.contextMenu.hideContextMenu()
},
navigateSuggestionList({ event }) {
const item = this.filteredItems[this.navigatedItemIndex]
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
}
},
filterSuggestionList(items, query) {
query = this.sanitizeQuery(query)
if (!query) {
return items.slice(0, 15)
}
const filteredList = items.filter(item => {
const itemString = item.slug || item.id
return itemString.toLowerCase().includes(query.toLowerCase())
})
return filteredList.slice(0, 15)
},
sanitizeQuery(query) {
if (this.suggestionType === HASHTAG) {
// remove all non unicode letters and non digits
const regexMatchAllNonUnicodeLettersOrDigits = build('[^\\pL0-9]')
query = replace(query, regexMatchAllNonUnicodeLettersOrDigits, '', 'all')
// 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
selectItem(item) {
const typeAttrs = {
mention: {
id: item.id,
label: item.slug,
},
hashtag: {
id: item.id,
label: item.id,
},
}
this.insertMentionOrHashtag({
range: this.suggestionRange,
attrs: typeAttrs[this.suggestionType],
})
this.editor.focus()
},
onUpdate(e) {
const content = e.getHTML()
this.$emit('input', content)
},
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()
}
},
setLinkUrl(url) {
const normalizedLinks = url ? linkify().match(url) : null
const command = this.editor.commands.link
if (normalizedLinks) {
// add valid link
command({
href: normalizedLinks.pop().url,
})
this.toggleLinkInput()
this.editor.focus()
} else {
// remove link
command({ href: null })
}
},
clear() {
this.editor.clearContent(true)
},
},
}
</script>
<style lang="scss">
.editor p.is-empty:first-child::before {
content: attr(data-empty-text);
float: left;
color: $text-color-disabled;
padding-left: $space-xx-small;
pointer-events: none;
height: 0;
}
.menubar__button {
font-weight: normal;
}
li > p {
margin-top: $space-xx-small;
margin-bottom: $space-xx-small;
}
.editor {
display: flex;
flex-direction: column;
.hashtag {
color: $color-primary;
}
.hashtag-suggestion {
color: $color-primary;
}
.mention-suggestion {
color: $color-primary;
}
}
.editor-content {
flex-grow: 1;
margin-top: $space-small;
height: auto;
&:focus-within {
border-color: $color-primary;
background-color: $color-neutral-100;
}
}
.ProseMirror {
min-height: 100px;
&:focus {
outline: none;
}
p {
margin: 0 0 $space-x-small;
}
}
.ProseMirror[contenteditable='false'] {
.embed-close-button {
display: none;
}
}
.embed-container {
position: relative;
padding: 0;
margin: $space-small auto;
overflow: hidden;
border-radius: $border-radius-base;
border: 1px solid $color-neutral-70;
background-color: $color-neutral-90;
}
.embed-content {
width: 100%;
height: 100%;
h4 {
margin: $space-small 0 0 $space-small;
}
p,
a {
display: block;
margin: 0 0 0 $space-small;
}
}
.embed-preview-image {
width: 100%;
height: auto;
max-height: 450px;
}
.embed-preview-image--clickable {
cursor: pointer;
}
.embed-html {
width: 100%;
iframe {
width: 100%;
}
}
.embed-overlay {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
padding: $space-large;
background-color: $color-neutral-100;
}
.embed-buttons {
button {
margin-right: $space-small;
}
}
.embed-checkbox {
display: flex;
input {
margin-right: $space-small;
}
}
.embed-close-button {
position: absolute;
top: $space-x-small;
right: $space-x-small;
background-color: rgba(250, 249, 250, 0.6);
}
</style>