<template> <div class="editor"> <div class="clear"></div> <vue-easymde :configs="config" @change="bubble" @update:modelValue="handleInput" class="content" v-if="isEditActive" v-model="text"/> <div class="preview content" v-html="preview" v-if="isPreviewActive && text !== ''"> </div> <p class="has-text-centered has-text-grey is-italic my-5" v-if="showPreviewText"> {{ emptyText }} <template v-if="isEditEnabled"> <a @click="toggleEdit">{{ $t('input.editor.edit') }}</a>. </template> </p> <ul class="actions" v-if="bottomActions.length > 0"> <li v-if="isEditEnabled && !showPreviewText && showSave"> <a v-if="showEditButton" @click="toggleEdit">{{ $t('input.editor.edit') }}</a> <a v-else-if="isEditActive" @click="toggleEdit" class="done-edit">{{ $t('misc.save') }}</a> </li> <li v-for="(action, k) in bottomActions" :key="k"> <a @click="action.action">{{ action.title }}</a> </li> </ul> <template v-else-if="isEditEnabled && showSave"> <ul v-if="showEditButton" class="actions"> <li> <a @click="toggleEdit">{{ $t('input.editor.edit') }}</a> </li> </ul> <x-button v-else-if="isEditActive" @click="toggleEdit" variant="secondary" :shadow="false" v-cy="'saveEditor'"> {{ $t('misc.save') }} </x-button> </template> </div> </template> <script> import VueEasymde from './vue-easymde/vue-easymde.vue' import {marked} from 'marked' import DOMPurify from 'dompurify' import hljs from 'highlight.js/lib/common' import {createEasyMDEConfig} from './editorConfig' import AttachmentModel from '../../models/attachment' import AttachmentService from '../../services/attachment' import {findCheckboxesInText} from '../../helpers/checklistFromText' import {createRandomID} from '@/helpers/randomId' export default { name: 'editor', components: { VueEasymde, }, props: { modelValue: { type: String, default: '', }, placeholder: { type: String, default: '', }, uploadEnabled: { type: Boolean, default: false, }, uploadCallback: { type: Function, }, hasPreview: { type: Boolean, default: true, }, previewIsDefault: { type: Boolean, default: true, }, isEditEnabled: { default: true, }, bottomActions: { default: () => [], }, emptyText: { type: String, default: '', }, showSave: { type: Boolean, default: false, }, }, emits: ['update:modelValue', 'change'], computed: { showPreviewText() { return this.isPreviewActive && this.text === '' && this.emptyText !== '' }, showEditButton() { return !this.isEditActive && this.text !== '' }, }, data() { return { text: '', changeTimeout: null, isEditActive: false, isPreviewActive: true, preview: '', attachmentService: null, loadedAttachments: {}, config: createEasyMDEConfig({ placeholder: this.placeholder, uploadImage: this.uploadEnabled, imageUploadFunction: this.uploadCallback, }), checkboxId: createRandomID(), } }, watch: { modelValue(modelValue) { this.text = modelValue this.$nextTick(this.renderPreview) }, text(newVal, oldVal) { // Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside. if (oldVal === '' && this.text === this.modelValue) { return } this.bubble() }, }, mounted() { if (this.modelValue !== '') { this.text = this.modelValue } if (this.previewIsDefault && this.hasPreview) { this.$nextTick(this.renderPreview) return } this.isPreviewActive = false this.isEditActive = true }, methods: { // This gets triggered when only pasting content into the editor. // A change event would not get generated by that, an input event does. // Therefore, we're using this handler to catch paste events. // But because this also gets triggered when typing into the editor, we give // it a higher timeout to make the timouts cancel each other in that case so // that in the end, only one change event is triggered to the outside per change. handleInput(val) { // Don't bubble if the text is up to date if (val === this.text) { return } this.text = val this.bubble(1000) }, bubble(timeout = 500) { if (this.changeTimeout !== null) { clearTimeout(this.changeTimeout) } this.changeTimeout = setTimeout(() => { this.$emit('update:modelValue',this.text) this.$emit('change', this.text) }, timeout) }, replaceAt(str, index, replacement) { return str.substr(0, index) + replacement + str.substr(index + replacement.length) }, findNthIndex(str, n) { const checkboxes = findCheckboxesInText(str) return checkboxes[n] }, renderPreview() { const renderer = new marked.Renderer() const linkRenderer = renderer.link let checkboxNum = -1 marked.use({ renderer: { image: (src, title, text) => { title = title ? ` title="${title}` : '' // If the url starts with the api url, the image is likely an attachment and // we'll need to download and parse it properly. if (src.substr(0, window.API_URL.length + 7) === `${window.API_URL}/tasks/`) { return `<img data-src="${src}" alt="${text}" ${title} class="attachment-image"/>` } return `<img src="${src}" alt="${text}" ${title}/>` }, checkbox: (checked) => { if (checked) { checked = ' checked="checked"' } checkboxNum++ return `<input type="checkbox" data-checkbox-num="${checkboxNum}" ${checked} class="text-checkbox-${this.checkboxId}"/>` }, link: (href, title, text) => { const isLocal = href.startsWith(`${location.protocol}//${location.hostname}`) const html = linkRenderer.call(renderer, href, title, text) return isLocal ? html : html.replace(/^<a /, '<a target="_blank" rel="noreferrer noopener nofollow" ') }, }, highlight: function (code, language) { const validLanguage = hljs.getLanguage(language) ? language : 'plaintext' return hljs.highlight(code, {language: validLanguage}).value }, }) this.preview = DOMPurify.sanitize(marked(this.text), {ADD_ATTR: ['target']}) // Since the render function is synchronous, we can't do async http requests in it. // Therefore, we can't resolve the blob url at (markdown) compile time. // To work around this, we modify the url after rendering it in the vue component. // We're doing the whole thing in the next tick to ensure the image elements are available in the // dom tree. If we're calling this right after setting this.preview it could be the images were // not already made available. // Some docs at https://stackoverflow.com/q/62865160/10924593 this.$nextTick(async () => { const attachmentImage = document.getElementsByClassName('attachment-image') if (attachmentImage) { for (const img of attachmentImage) { // The url is something like /tasks/<id>/attachments/<id> const parts = img.dataset.src.substr(window.API_URL.length + 1).split('/') const taskId = parseInt(parts[1]) const attachmentId = parseInt(parts[3]) const cacheKey = `${taskId}-${attachmentId}` if (typeof this.loadedAttachments[cacheKey] !== 'undefined') { img.src = this.loadedAttachments[cacheKey] continue } const attachment = new AttachmentModel({taskId: taskId, id: attachmentId}) if (this.attachmentService === null) { this.attachmentService = new AttachmentService() } const url = await this.attachmentService.getBlobUrl(attachment) img.src = url this.loadedAttachments[cacheKey] = url } } const textCheckbox = document.getElementsByClassName(`text-checkbox-${this.checkboxId}`) if (textCheckbox) { for (const check of textCheckbox) { check.removeEventListener('change', this.handleCheckboxClick) check.addEventListener('change', this.handleCheckboxClick) check.parentElement.classList.add('has-checkbox') } } }) }, handleCheckboxClick(e) { // Find the original markdown checkbox this is targeting const checked = e.target.checked const numMarkdownCheck = parseInt(e.target.dataset.checkboxNum) const index = this.findNthIndex(this.text, numMarkdownCheck) if (index < 0 || typeof index === 'undefined') { console.debug('no index found') return } console.debug(index, this.text.substr(index, 9)) const listPrefix = this.text.substr(index, 1) if (checked) { this.text = this.replaceAt(this.text, index, `${listPrefix} [x] `) } else { this.text = this.replaceAt(this.text, index, `${listPrefix} [ ] `) } this.bubble() this.renderPreview() }, toggleEdit() { if (this.isEditActive) { this.isPreviewActive = true this.isEditActive = false this.renderPreview() this.bubble(0) // save instantly } else { this.isPreviewActive = false this.isEditActive = true } }, }, } </script> <style lang="scss"> @import 'codemirror/lib/codemirror.css'; @import './vue-easymde/vue-easymde.css'; @import 'highlight.js/scss/base16/equilibrium-gray-light'; .editor { .clear { clear: both; } .preview.content { margin-bottom: .5rem; ul li { input[type="checkbox"] { margin-right: .5rem; } &.has-checkbox { margin-left: -2em; list-style: none; } } } } .CodeMirror { padding: .5rem; border: 1px solid var(--grey-200) !important; background: var(--white); &-lines pre { margin: 0 !important; } &-placeholder { color: var(--grey-400) !important; font-style: italic; } &-cursor { border-color: var(--grey-700); } } .editor-preview { padding: 0; &-side { padding: .5rem; } } .editor-toolbar { background: var(--grey-50); border: 1px solid var(--grey-200); border-bottom: none; button { color: var(--grey-700); svg { vertical-align: middle; &, rect { width: 20px; height: 20px; } } &::after { position: absolute; top: 24px; margin-left: -3px; } &:hover { background: var(--grey-200); border-color: var(--grey-300); } } i.separator { border-color: var(--grey-200) !important; } } pre.CodeMirror-line { margin-bottom: 0 !important; color: var(--grey-700) !important; } .cm-header { font-family: $vikunja-font; font-weight: 400; } ul.actions { font-size: .8rem; margin: 0; li { display: inline-block; &::after { content: 'ยท'; padding: 0 .25rem; } &:last-child:after { content: ''; } } &, a { color: var(--grey-500); &.done-edit { color: var(--primary); } } a:hover { text-decoration: underline; } } .vue-easymde.content { margin-bottom: 0 !important; } </style>