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"
2022-10-03 10:18:20 +00:00
@ 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-10-03 10:18:20 +00:00
< script setup lang = "ts" >
import { computed , nextTick , onMounted , ref , toRefs , watch } from 'vue'
2022-02-15 12:07:59 +00:00
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-10-02 18:46:26 +00:00
import { createEasyMDEConfig } from './editorConfig'
2022-10-03 10:18:20 +00:00
import AttachmentModel from '@/models/attachment'
import AttachmentService from '@/services/attachment'
import { setupMarkdownRenderer } from '@/helpers/markdownRenderer'
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-10-03 10:18:20 +00:00
import type { IAttachment } from '@/modelTypes/IAttachment'
import type { ITask } from '@/modelTypes/ITask'
2022-05-10 23:14:38 +00:00
2022-10-03 10:18:20 +00:00
const props = defineProps ( {
modelValue : {
type : String ,
default : '' ,
2020-09-05 20:35:52 +00:00
} ,
2022-10-03 10:18:20 +00:00
placeholder : {
type : String ,
default : '' ,
2020-09-05 20:35:52 +00:00
} ,
2022-10-03 10:18:20 +00:00
uploadEnabled : {
type : Boolean ,
default : false ,
2021-08-19 17:20:02 +00:00
} ,
2022-10-03 10:18:20 +00:00
uploadCallback : {
type : Function ,
2020-09-05 20:35:52 +00:00
} ,
2022-10-03 10:18:20 +00:00
hasPreview : {
type : Boolean ,
default : true ,
2020-09-05 20:35:52 +00:00
} ,
2022-10-03 10:18:20 +00:00
previewIsDefault : {
type : Boolean ,
default : true ,
} ,
isEditEnabled : {
default : true ,
} ,
bottomActions : {
2022-10-17 11:14:07 +00:00
type : Array ,
2022-10-03 10:18:20 +00:00
default : ( ) => [ ] ,
} ,
emptyText : {
type : String ,
default : '' ,
} ,
showSave : {
type : Boolean ,
default : false ,
} ,
// 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 : '' ,
} ,
} )
const emit = defineEmits ( [ 'update:modelValue' ] )
const text = ref ( '' )
const changeTimeout = ref < ReturnType < typeof setTimeout > | null > ( null )
const isEditActive = ref ( false )
const isPreviewActive = ref ( true )
const showPreviewText = computed ( ( ) => isPreviewActive . value && text . value === '' && props . emptyText !== '' )
const showEditButton = computed ( ( ) => ! isEditActive . value && text . value !== '' )
2020-07-14 19:26:05 +00:00
2022-10-03 10:18:20 +00:00
const preview = ref ( '' )
const attachmentService = new AttachmentService ( )
type CacheKey = ` ${ ITask [ 'id' ] } - ${ IAttachment [ 'id' ] } `
const loadedAttachments = ref < { [ key : CacheKey ] : string } > ( { } )
const config = ref ( createEasyMDEConfig ( {
placeholder : props . placeholder ,
uploadImage : props . uploadEnabled ,
imageUploadFunction : props . uploadCallback ,
} ) )
const checkboxId = ref ( createRandomID ( ) )
const { modelValue } = toRefs ( props )
watch (
modelValue ,
async ( value ) => {
text . value = value
await nextTick ( )
renderPreview ( )
} ,
)
watch (
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 === '' && text . value === modelValue . value ) {
2020-09-05 20:35:52 +00:00
return
}
2022-10-03 10:18:20 +00:00
bubble ( )
2020-09-05 20:35:52 +00:00
} ,
2022-10-03 10:18:20 +00:00
)
2021-01-30 20:16:15 +00:00
2020-07-14 19:26:05 +00:00
2022-10-03 10:18:20 +00:00
onMounted ( ( ) => {
if ( modelValue . value !== '' ) {
text . value = modelValue . value
}
if ( props . previewIsDefault && props . hasPreview ) {
nextTick ( ( ) => renderPreview ( ) )
return
}
isPreviewActive . value = false
isEditActive . value = true
} )
2020-09-05 20:35:52 +00:00
2022-10-03 10:18:20 +00:00
// 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.
function handleInput ( val : string ) {
// Don't bubble if the text is up to date
if ( val === text . value ) {
return
}
text . value = val
bubble ( 1000 )
}
function bubble ( timeout = 500 ) {
if ( changeTimeout . value !== null ) {
clearTimeout ( changeTimeout . value )
}
changeTimeout . value = setTimeout ( ( ) => {
emit ( 'update:modelValue' , text . value )
} , timeout )
}
function replaceAt ( str : string , index : number , replacement : string ) {
return str . slice ( 0 , index ) + replacement + str . slice ( index + replacement . length )
}
function findNthIndex ( str : string , n : number ) {
const checkboxes = findCheckboxesInText ( str )
return checkboxes [ n ]
}
function renderPreview ( ) {
setupMarkdownRenderer ( checkboxId . value )
preview . value = DOMPurify . sanitize ( marked ( text . value ) , { 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
nextTick ( ) . then ( async ( ) => {
const attachmentImage = document . querySelectorAll < HTMLImageElement > ( '.attachment-image' )
if ( attachmentImage ) {
Array . from ( attachmentImage ) . forEach ( async ( img ) => {
// The url is something like /tasks/<id>/attachments/<id>
const parts = img . dataset . src ? . slice ( window . API _URL . length + 1 ) . split ( '/' )
const taskId = Number ( parts [ 1 ] )
const attachmentId = Number ( parts [ 3 ] )
const cacheKey : CacheKey = ` ${ taskId } - ${ attachmentId } `
if ( typeof loadedAttachments . value [ cacheKey ] !== 'undefined' ) {
img . src = loadedAttachments . value [ cacheKey ]
return
2020-12-08 17:40:13 +00:00
}
2022-10-03 10:18:20 +00:00
const attachment = new AttachmentModel ( { taskId : taskId , id : attachmentId } )
const url = await attachmentService . getBlobUrl ( attachment )
img . src = url
loadedAttachments . value [ cacheKey ] = url
2020-09-05 20:35:52 +00:00
} )
2022-10-03 10:18:20 +00:00
}
2020-09-05 20:35:52 +00:00
2022-10-03 10:18:20 +00:00
const textCheckbox = document . querySelectorAll < HTMLInputElement > ( ` .text-checkbox- ${ checkboxId . value } ` )
if ( textCheckbox ) {
Array . from ( textCheckbox ) . forEach ( check => {
check . removeEventListener ( 'change' , handleCheckboxClick )
check . addEventListener ( 'change' , handleCheckboxClick )
check . parentElement ? . classList . add ( 'has-checkbox' )
} )
}
} )
}
2021-09-29 18:31:14 +00:00
2022-10-03 10:18:20 +00:00
function handleCheckboxClick ( e : Event ) {
// Find the original markdown checkbox this is targeting
const checked = ( e . target as HTMLInputElement ) . checked
const numMarkdownCheck = Number ( ( e . target as HTMLInputElement ) . dataset . checkboxNum )
const index = findNthIndex ( text . value , numMarkdownCheck )
if ( index < 0 || typeof index === 'undefined' ) {
console . debug ( 'no index found' )
return
}
2022-11-13 21:04:57 +00:00
const projectPrefix = text . value . substring ( index , index + 1 )
2022-10-23 12:39:28 +00:00
2022-11-13 21:04:57 +00:00
console . debug ( { index , projectPrefix , checked , text : text . value } )
2022-10-03 10:18:20 +00:00
2022-11-13 21:04:57 +00:00
text . value = replaceAt ( text . value , index , ` ${ projectPrefix } ${ checked ? '[x]' : '[ ]' } ` )
2022-10-03 10:18:20 +00:00
bubble ( )
renderPreview ( )
}
function toggleEdit ( ) {
if ( isEditActive . value ) {
isPreviewActive . value = true
isEditActive . value = false
renderPreview ( )
bubble ( 0 ) // save instantly
} else {
isPreviewActive . value = false
isEditActive . value = true
}
}
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 >