2020-07-14 19:26:05 +00:00
< template >
2021-08-17 19:10:32 +00:00
< div class = "editor" >
2021-01-17 12:04:49 +00:00
< div class = "clear" > < / div >
2022-05-08 21:31:38 +00:00
2020-09-05 20:35:52 +00:00
< vue -easymde
: configs = "config"
@ change = "bubble"
2021-08-23 19:18:12 +00:00
@ update : modelValue = "handleInput"
2020-09-05 20:35:52 +00:00
class = "content"
v - if = "isEditActive"
v - model = "text" / >
2020-07-14 19:26:05 +00:00
2021-01-20 21:33:11 +00:00
< div class = "preview content" v-html ="preview" v-if="isPreviewActive && text !== ''" >
2020-07-14 19:26:05 +00:00
< / div >
2020-11-15 16:17:08 +00:00
2021-10-04 19:20:40 +00:00
< p class = "has-text-centered has-text-grey is-italic my-5" v-if ="showPreviewText" >
2021-01-20 21:33:11 +00:00
{ { emptyText } }
2021-06-03 14:27:41 +00:00
< template v-if ="isEditEnabled" >
2022-09-07 17:55:59 +00:00
< ButtonLink
@ click = "toggleEdit"
v - shortcut = "editShortcut"
class = "d-print-none" >
{ { $t ( 'input.editor.edit' ) } }
< / ButtonLink > .
2021-06-03 14:27:41 +00:00
< / template >
2021-01-20 21:33:11 +00:00
< / p >
2022-06-02 21:00:21 +00:00
< ul class = "actions d-print-none" v-if ="bottomActions.length > 0" >
2021-10-02 18:46:26 +00:00
< li v-if ="isEditEnabled && !showPreviewText && showSave" >
2022-09-07 17:55:59 +00:00
< BaseButton
v - if = "showEditButton"
@ click = "toggleEdit"
v - shortcut = "editShortcut" >
{ { $t ( 'input.editor.edit' ) } }
< / BaseButton >
< BaseButton
v - else - if = "isEditActive"
@ click = "toggleEdit"
class = "done-edit" >
{ { $t ( 'misc.save' ) } }
< / BaseButton >
2021-10-02 18:46:26 +00:00
< / li >
2020-12-30 21:52:43 +00:00
< li v-for ="(action, k) in bottomActions" :key ="k" >
2022-05-10 23:14:38 +00:00
< BaseButton @click ="action.action" > {{ action.title }} < / BaseButton >
2020-12-30 21:52:43 +00:00
< / li >
2020-11-15 16:17:08 +00:00
< / ul >
2021-09-29 19:22:44 +00:00
< template v -else -if = " isEditEnabled & & showSave " >
2022-06-02 21:00:21 +00:00
< ul v-if ="showEditButton" class="actions d-print-none" >
2021-08-18 20:57:11 +00:00
< li >
2022-09-07 17:55:59 +00:00
< BaseButton
@ click = "toggleEdit"
v - shortcut = "editShortcut" >
{ { $t ( 'input.editor.edit' ) } }
< / BaseButton >
2021-08-18 20:57:11 +00:00
< / li >
< / ul >
2022-05-09 06:15:12 +00:00
< x -button
v - else - if = "isEditActive"
@ click = "toggleEdit"
variant = "secondary"
: shadow = "false"
v - cy = "'saveEditor'" >
2021-08-18 20:57:11 +00:00
{ { $t ( 'misc.save' ) } }
< / x - b u t t o n >
< / template >
2020-07-14 19:26:05 +00:00
< / div >
< / template >
2022-02-15 12:07:34 +00:00
< script lang = "ts" >
2022-02-15 12:07:59 +00:00
import { defineComponent } from 'vue'
2022-05-22 20:44:22 +00:00
import VueEasymde from './vue-easymde.vue'
2021-11-02 17:39:56 +00:00
import { marked } from 'marked'
2020-09-05 20:35:52 +00:00
import DOMPurify from 'dompurify'
2021-08-23 16:38:29 +00:00
import hljs from 'highlight.js/lib/common'
2020-09-05 20:35:52 +00:00
2021-10-02 18:46:26 +00:00
import { createEasyMDEConfig } from './editorConfig'
2020-09-05 20:35:52 +00:00
import AttachmentModel from '../../models/attachment'
import AttachmentService from '../../services/attachment'
2021-09-29 18:31:14 +00:00
import { findCheckboxesInText } from '../../helpers/checklistFromText'
2021-10-11 16:28:41 +00:00
import { createRandomID } from '@/helpers/randomId'
2020-09-05 20:35:52 +00:00
2022-05-10 23:14:38 +00:00
import BaseButton from '@/components/base/BaseButton.vue'
2022-06-22 19:37:23 +00:00
import ButtonLink from '@/components/misc/ButtonLink.vue'
2022-05-10 23:14:38 +00:00
2022-02-15 12:07:59 +00:00
export default defineComponent ( {
2020-09-05 20:35:52 +00:00
name : 'editor' ,
components : {
VueEasymde ,
2022-05-10 23:14:38 +00:00
BaseButton ,
2022-06-22 19:37:23 +00:00
ButtonLink ,
2020-09-05 20:35:52 +00:00
} ,
props : {
2021-08-23 19:18:12 +00:00
modelValue : {
2020-09-05 20:35:52 +00:00
type : String ,
default : '' ,
2020-07-14 19:26:05 +00:00
} ,
2020-09-05 20:35:52 +00:00
placeholder : {
type : String ,
default : '' ,
} ,
uploadEnabled : {
type : Boolean ,
2021-10-02 18:46:26 +00:00
default : false ,
2020-07-14 19:26:05 +00:00
} ,
2020-09-05 20:35:52 +00:00
uploadCallback : {
type : Function ,
2020-07-14 19:26:05 +00:00
} ,
2020-09-05 20:35:52 +00:00
hasPreview : {
type : Boolean ,
default : true ,
} ,
previewIsDefault : {
type : Boolean ,
default : true ,
} ,
isEditEnabled : {
default : true ,
} ,
2020-11-15 16:17:08 +00:00
bottomActions : {
default : ( ) => [ ] ,
} ,
2021-01-20 21:33:11 +00:00
emptyText : {
type : String ,
2021-10-02 18:46:26 +00:00
default : '' ,
2021-01-20 21:33:11 +00:00
} ,
2021-08-18 20:57:11 +00:00
showSave : {
2021-08-20 16:56:50 +00:00
type : Boolean ,
default : false ,
2021-08-18 20:57:11 +00:00
} ,
2022-09-07 17:55:59 +00:00
// If a key is passed the editor will go in "edit" mode when the key is pressed.
// Disabled if an empty string is passed.
editShortcut : {
type : String ,
default : '' ,
} ,
2020-09-05 20:35:52 +00:00
} ,
2022-09-13 15:30:33 +00:00
emits : [ 'update:modelValue' ] ,
2021-08-17 19:10:32 +00:00
computed : {
showPreviewText ( ) {
return this . isPreviewActive && this . text === '' && this . emptyText !== ''
} ,
2021-10-04 19:20:40 +00:00
showEditButton ( ) {
return ! this . isEditActive && this . text !== ''
} ,
2021-08-19 17:20:02 +00:00
} ,
data ( ) {
return {
text : '' ,
changeTimeout : null ,
isEditActive : false ,
isPreviewActive : true ,
preview : '' ,
attachmentService : null ,
loadedAttachments : { } ,
2021-10-02 18:46:26 +00:00
config : createEasyMDEConfig ( {
placeholder : this . placeholder ,
uploadImage : this . uploadEnabled ,
imageUploadFunction : this . uploadCallback ,
} ) ,
2021-10-11 16:28:41 +00:00
checkboxId : createRandomID ( ) ,
2020-09-05 20:35:52 +00:00
}
} ,
watch : {
2021-08-23 19:18:12 +00:00
modelValue ( modelValue ) {
this . text = modelValue
2020-09-05 20:35:52 +00:00
this . $nextTick ( this . renderPreview )
2020-07-14 19:26:05 +00:00
} ,
2020-09-05 20:35:52 +00:00
text ( newVal , oldVal ) {
2021-04-25 15:34:49 +00:00
// Only bubble the new value if it actually changed, but not if the component just got mounted and the text changed from the outside.
2021-08-23 19:18:12 +00:00
if ( oldVal === '' && this . text === this . modelValue ) {
2020-07-14 19:26:05 +00:00
return
}
2020-09-05 20:35:52 +00:00
this . bubble ( )
} ,
} ,
2021-04-25 15:34:49 +00:00
mounted ( ) {
2021-08-23 19:18:12 +00:00
if ( this . modelValue !== '' ) {
this . text = this . modelValue
2021-04-25 15:34:49 +00:00
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
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 ) {
2021-01-30 20:16:15 +00:00
// Don't bubble if the text is up to date
2021-07-09 16:04:49 +00:00
if ( val === this . text ) {
2021-01-30 20:16:15 +00:00
return
}
2020-09-05 20:35:52 +00:00
this . text = val
this . bubble ( 1000 )
2020-07-14 19:26:05 +00:00
} ,
2020-09-05 20:35:52 +00:00
bubble ( timeout = 500 ) {
if ( this . changeTimeout !== null ) {
clearTimeout ( this . changeTimeout )
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
this . changeTimeout = setTimeout ( ( ) => {
2022-05-08 21:31:38 +00:00
this . $emit ( 'update:modelValue' , this . text )
2020-09-05 20:35:52 +00:00
} , timeout )
} ,
replaceAt ( str , index , replacement ) {
return str . substr ( 0 , index ) + replacement + str . substr ( index + replacement . length )
} ,
findNthIndex ( str , n ) {
2021-09-29 18:31:14 +00:00
const checkboxes = findCheckboxesInText ( str )
2020-09-05 20:35:52 +00:00
return checkboxes [ n ]
} ,
renderPreview ( ) {
2021-01-20 21:20:35 +00:00
const renderer = new marked . Renderer ( )
const linkRenderer = renderer . link
2020-09-05 20:35:52 +00:00
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"/> `
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
return ` <img src=" ${ src } " alt=" ${ text } " ${ title } /> `
} ,
checkbox : ( checked ) => {
if ( checked ) {
checked = ' checked="checked"'
2020-07-14 19:26:05 +00:00
}
2020-09-05 20:35:52 +00:00
checkboxNum ++
2021-10-11 16:28:41 +00:00
return ` <input type="checkbox" data-checkbox-num=" ${ checkboxNum } " ${ checked } class="text-checkbox- ${ this . checkboxId } "/> `
2020-09-05 20:35:52 +00:00
} ,
2021-01-20 21:20:35 +00:00
link : ( href , title , text ) => {
const isLocal = href . startsWith ( ` ${ location . protocol } // ${ location . hostname } ` )
const html = linkRenderer . call ( renderer , href , title , text )
2021-07-17 21:21:46 +00:00
return isLocal ? html : html . replace ( /^<a / , '<a target="_blank" rel="noreferrer noopener nofollow" ' )
2021-01-20 21:20:35 +00:00
} ,
2020-09-05 20:35:52 +00:00
} ,
2021-01-14 21:35:08 +00:00
highlight : function ( code , language ) {
const validLanguage = hljs . getLanguage ( language ) ? language : 'plaintext'
2021-05-30 10:12:59 +00:00
return hljs . highlight ( code , { language : validLanguage } ) . value
2021-01-10 18:03:47 +00:00
} ,
2020-09-05 20:35:52 +00:00
} )
2021-07-09 16:04:49 +00:00
this . preview = DOMPurify . sanitize ( marked ( this . text ) , { ADD _ATTR : [ 'target' ] } )
2020-09-05 20:35:52 +00:00
// 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
2021-10-11 17:37:20 +00:00
this . $nextTick ( async ( ) => {
2020-12-08 17:40:13 +00:00
const attachmentImage = document . getElementsByClassName ( 'attachment-image' )
2021-01-14 21:35:08 +00:00
if ( attachmentImage ) {
for ( const img of attachmentImage ) {
2020-12-08 17:40:13 +00:00
// 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 ] )
2021-07-09 16:04:49 +00:00
const cacheKey = ` ${ taskId } - ${ attachmentId } `
if ( typeof this . loadedAttachments [ cacheKey ] !== 'undefined' ) {
img . src = this . loadedAttachments [ cacheKey ]
continue
}
2020-12-08 17:40:13 +00:00
const attachment = new AttachmentModel ( { taskId : taskId , id : attachmentId } )
2020-12-08 17:51:46 +00:00
2020-12-08 17:40:13 +00:00
if ( this . attachmentService === null ) {
this . attachmentService = new AttachmentService ( )
}
2020-12-08 17:51:46 +00:00
2021-10-11 17:37:20 +00:00
const url = await this . attachmentService . getBlobUrl ( attachment )
img . src = url
this . loadedAttachments [ cacheKey ] = url
2021-01-14 21:35:08 +00:00
}
2020-12-08 17:40:13 +00:00
}
2020-09-05 20:35:52 +00:00
2021-10-11 16:28:41 +00:00
const textCheckbox = document . getElementsByClassName ( ` text-checkbox- ${ this . checkboxId } ` )
2021-01-14 21:35:08 +00:00
if ( textCheckbox ) {
for ( const check of textCheckbox ) {
2020-12-08 17:40:13 +00:00
check . removeEventListener ( 'change' , this . handleCheckboxClick )
check . addEventListener ( 'change' , this . handleCheckboxClick )
2021-09-26 18:53:27 +00:00
check . parentElement . classList . add ( 'has-checkbox' )
2021-01-14 21:35:08 +00:00
}
2020-12-08 17:40:13 +00:00
}
2020-09-05 20:35:52 +00:00
} )
2020-07-14 19:26:05 +00:00
} ,
2020-09-05 20:35:52 +00:00
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' ) {
2021-01-14 21:35:08 +00:00
console . debug ( 'no index found' )
2020-09-05 20:35:52 +00:00
return
}
2021-01-14 21:35:08 +00:00
console . debug ( index , this . text . substr ( index , 9 ) )
2020-09-05 20:35:52 +00:00
2021-09-26 18:50:57 +00:00
const listPrefix = this . text . substr ( index , 1 )
2021-09-29 18:31:14 +00:00
2020-09-05 20:35:52 +00:00
if ( checked ) {
2021-09-26 18:50:57 +00:00
this . text = this . replaceAt ( this . text , index , ` ${ listPrefix } [x] ` )
2020-09-05 20:35:52 +00:00
} else {
2021-09-26 18:50:57 +00:00
this . text = this . replaceAt ( this . text , index , ` ${ listPrefix } [ ] ` )
2020-09-05 20:35:52 +00:00
}
this . bubble ( )
this . renderPreview ( )
} ,
2020-12-30 21:52:43 +00:00
toggleEdit ( ) {
if ( this . isEditActive ) {
2021-01-14 21:35:08 +00:00
this . isPreviewActive = true
this . isEditActive = false
this . renderPreview ( )
this . bubble ( 0 ) // save instantly
2020-12-30 21:52:43 +00:00
} else {
2021-01-14 21:35:08 +00:00
this . isPreviewActive = false
this . isEditActive = true
2020-12-30 21:52:43 +00:00
}
2020-09-05 20:35:52 +00:00
} ,
} ,
2022-02-15 12:07:59 +00:00
} )
2020-07-14 19:26:05 +00:00
< / script >
< style lang = "scss" >
2021-10-02 18:46:26 +00:00
@ import 'codemirror/lib/codemirror.css' ;
@ import 'highlight.js/scss/base16/equilibrium-gray-light' ;
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
. editor {
2021-01-17 12:04:49 +00:00
. clear {
clear : both ;
2020-07-14 19:26:05 +00:00
}
2020-12-08 17:51:46 +00:00
2021-08-17 19:10:32 +00:00
. preview . content {
margin - bottom : .5 rem ;
2022-05-15 20:51:43 +00:00
overflow - wrap : anywhere ; // Safari does not understand "break-word" so we put that first to make sure it at least is able to show it somewhat properly there.
overflow - wrap : break - word ;
2021-08-17 19:10:32 +00:00
2021-09-26 18:53:27 +00:00
ul li {
input [ type = "checkbox" ] {
margin - right : .5 rem ;
}
2021-09-29 18:31:14 +00:00
2021-09-26 18:53:27 +00:00
& . has - checkbox {
2022-04-02 10:55:08 +00:00
margin - left : - 1.25 rem ;
2021-09-26 18:53:27 +00:00
list - style : none ;
}
2021-08-17 19:10:32 +00:00
}
2020-12-08 17:51:46 +00:00
}
2020-09-05 20:35:52 +00:00
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
. CodeMirror {
2020-10-18 19:11:54 +00:00
padding : .5 rem ;
2022-01-09 10:16:13 +00:00
border : 1 px solid var ( -- grey - 200 ) ! important ;
2021-11-22 21:12:54 +00:00
background : var ( -- white ) ;
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
& - lines pre {
margin : 0 ! important ;
2020-07-14 19:26:05 +00:00
}
2021-01-26 19:46:17 +00:00
& - placeholder {
2021-11-22 21:12:54 +00:00
color : var ( -- grey - 400 ) ! important ;
2021-01-26 19:46:17 +00:00
font - style : italic ;
}
2022-05-08 21:31:38 +00:00
2022-01-08 16:34:13 +00:00
& - cursor {
border - color : var ( -- grey - 700 ) ;
}
2020-09-05 20:35:52 +00:00
}
2020-07-14 19:26:05 +00:00
2020-10-18 19:11:54 +00:00
. editor - preview {
padding : 0 ;
& - side {
padding : .5 rem ;
}
2020-09-05 20:35:52 +00:00
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
. editor - toolbar {
2022-01-09 10:16:13 +00:00
background : var ( -- grey - 50 ) ;
border : 1 px solid var ( -- grey - 200 ) ;
border - bottom : none ;
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
button {
2022-01-09 10:16:13 +00:00
color : var ( -- grey - 700 ) ;
2022-05-08 21:31:38 +00:00
& . active {
background : var ( -- grey - 200 ) ;
}
2020-09-05 20:35:52 +00:00
svg {
vertical - align : middle ;
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
& , rect {
width : 20 px ;
height : 20 px ;
2020-07-14 19:26:05 +00:00
}
2020-09-05 20:35:52 +00:00
}
2020-07-14 19:26:05 +00:00
2021-10-18 12:33:52 +00:00
& : : after {
2020-09-05 20:35:52 +00:00
position : absolute ;
top : 24 px ;
margin - left : - 3 px ;
2020-07-14 19:26:05 +00:00
}
2022-05-08 21:31:38 +00:00
2022-01-09 10:16:13 +00:00
& : hover {
background : var ( -- grey - 200 ) ;
border - color : var ( -- grey - 300 ) ;
}
}
i . separator {
border - color : var ( -- grey - 200 ) ! important ;
2020-07-14 19:26:05 +00:00
}
2020-09-05 20:35:52 +00:00
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
pre . CodeMirror - line {
margin - bottom : 0 ! important ;
2021-11-22 21:12:54 +00:00
color : var ( -- grey - 700 ) ! important ;
2020-09-05 20:35:52 +00:00
}
2020-07-14 19:26:05 +00:00
2020-09-05 20:35:52 +00:00
. cm - header {
font - family : $vikunja - font ;
font - weight : 400 ;
}
2020-11-15 16:17:08 +00:00
ul . actions {
2021-01-23 17:18:09 +00:00
font - size : .8 rem ;
2020-11-15 16:17:08 +00:00
margin : 0 ;
li {
display : inline - block ;
2021-10-18 12:33:52 +00:00
& : : after {
2020-11-15 16:17:08 +00:00
content : '·' ;
padding : 0 .25 rem ;
}
& : last - child : after {
content : '' ;
}
}
& , a {
2021-11-22 21:12:54 +00:00
color : var ( -- grey - 500 ) ;
2021-08-17 19:10:32 +00:00
& . done - edit {
2021-11-22 21:12:54 +00:00
color : var ( -- primary ) ;
2021-08-17 19:10:32 +00:00
}
2020-11-15 16:17:08 +00:00
}
a : hover {
text - decoration : underline ;
}
}
. vue - easymde . content {
margin - bottom : 0 ! important ;
}
2020-07-14 19:26:05 +00:00
< / style >