Some thoughts on implementing image drag-and-drop and paste with vue-simplemde

Intro

The project uses Vue and needed a markdown editor. I looked on npm, found simplemde (opens in a new tab) was decent. I’m pretty lazy, so I searched npm again, found the vue-simplemde (opens in a new tab) package, and started using it.

But vue-simplemde does not support drag-and-drop image upload or paste upload. You can’t really blame vue-simplemde; it is only a Vue wrapper around simplemde. So it comes down to simplemde not shipping this. For UX this feature is necessary, unless you drop the markdown editor and switch to a rich-text one, in which case a lot of the project would have to change. I looked up articles and some GitHub code. Analysis below.

Drag and drop

The core of the drag-and-drop API is the drop event: it fires when you drag a file from the desktop into the browser and release.

We all know that if you drag an image into the browser, it just opens the image. That is the default: dragging a file into the browser opens the file. We need to block that native behavior.

First, a snippet that suppresses the default:

window.addEventListener("drop", e => {
e = e || event
if (e.target.className === 'CodeMirror-scroll') { // if it enters the editor, prevent the default event
e.preventDefault()
}
}, false)

CodeMirror-scroll is the class name of the simplemde editor.

Now if we drag a file onto the editor and release, nothing happens. Outside the editor, the default still fires.

Next, get the simplemde instance and attach a drop handler.

// assume the page has three editor windows, so we loop to bind listeners
[ this.$refs.simplemde1,
this.$refs.simplemde2,
this.$refs.simplemde3
].map(({simplemde}) => {
simplemde.codemirror.on('drop', (editor, e) => {
if (!(e.dataTransfer && e.dataTransfer.files)) {
// alert that this browser does not support this operation
return
}
let dataList = e.dataTransfer.files
let imageFiles = [] // array of file instances to upload
// loop because several image files may be dragged at once
for (let i = 0; i < dataList.length; i++) {
// if not an image, warn that only dragging image files is supported
if (dataList[i].type.indexOf('image') === -1) {
// the continue below means: if the user drags 2 images and one document at once, the document is not uploaded while the images are uploaded as usual.
continue
}
imageFiles.push(dataList[i]) // push the current file into the array first, then upload them all together after the for loop ends.
}
// uploadImagesFile is the method that uploads images
// simplemde.codemirror is used to tell which editor the current image upload belongs to
this.uploadImagesFile(simplemde.codemirror, imageFiles)
// because the code below already exists, the default-event blocking code above is unnecessary
e.preventDefault()
})
})

At first glance it looks like a lot of code; that’s the comments. Here it is without comments. Read it and form your own take:

[ this.$refs.simplemde1,
this.$refs.simplemde2,
this.$refs.simplemde3
].map(({simplemde}) => {
simplemde.codemirror.on('drop', (editor, e) => {
if (!(e.dataTransfer && e.dataTransfer.files)) {
return
}
let dataList = e.dataTransfer.files
let imageFiles = []
for (let i = 0; i < dataList.length; i++) {
if (dataList[i].type.indexOf('image') === -1) {
continue
}
imageFiles.push(dataList[i])
}
this.uploadImagesFile(simplemde.codemirror, imageFiles)
e.preventDefault()
})
})

Paste

The paste API is the paste event. Unlike drop, paste does not need preventDefault. If you copy an image and press ctrl+v in the browser, nothing happens, so there is no need to block the default.

Code:

simplemde.codemirror.on('paste', (editor, e) => { // handler triggered when pasting an image
if (!(e.clipboardData && e.clipboardData.items)) {
// alert that this browser does not support this operation
return
}
try {
let dataList = e.clipboardData.items
if (dataList[0].kind === 'file' && dataList[0].getAsFile().type.indexOf('image') !== -1) {
this.uploadImagesFile(simplemde.codemirror, [dataList[0].getAsFile()])
}
} catch (e) {
// alert that only images can be pasted
}
})

The try...catch is there because if you paste a file, items is empty, and the if below reads dataList[0].kind, i.e. e.clipboardData.items[0].kind. Accessing kind on a missing item throws. So we need try...catch.

dataList[0].getAsFile().type.indexOf('image') !== -1 checks that what you pasted is actually an image, not something else.

The upload call inside if differs in [dataList[0].getAsFile()]. I wrap it in [] so the shape matches what uploadImagesFile expects. dataList[0].getAsFile() is the file instance.

Upload

Upload is a bit more of a hassle:

uploadImagesFile (simplemde, files) {
// wrap each file instance with FormData and return an array
let params = files.map(file => {
let param = new FormData()
param.append('file', file, file.name)
return param
})
let makeRequest = params => {
return this.$http.post('/Api/upload', params)
}
let requests = params.map(makeRequest)
this.$http.spread = callback => {
return arr => {
return callback.apply(null, arr)
}
}
// the server returns the format {state: Boolean, data: String}
// when state is false, data is the returned error message
// when state is true, data is the uploaded image's url, an absolute path relative to the site, like below:
// /static/upload/2cfd6a50-3d30-11e8-b351-0d25ce9162a3.png
Promise.all(requests)
.then(this.$http.spread((...resps) => {
for (let i = 0; i < resps.length; i++) {
let {state, data} = resps[i].data
if (!state) {
// alert showing the error message in data
continue
}
let url = `![](${location.origin + data})` // assemble into markdown syntax
let content = simplemde.getValue()
simplemde.setValue(content + url + '\n') // concatenate with the editor's existing content
}
}))
}

I wrapped axios as a Vue plugin, so this.$http is the instance, not axios itself. The axios maintainers’ fix is to import the axios package again. I don’t think that is needed. Internally axios.all is Promise.all. axios.spread is small, so I copied it and assigned it back onto axios.

So the snippet above is

Promise.all(requests)
.then(this.$http.spread((...resps) => {
// code
})

Which is equivalent to

axios.all(requests)
.then(axios.spread((...resps) => {
// code
})

For this, see the official thread: axios-all-is-not-a-function-inside-vue-component (opens in a new tab). You can also look at the axios source: axios.js#L45-L48 (opens in a new tab)

I won’t go further on this. Back to the topic.

When state is true, data is an absolute path on the site, e.g. /static/upload/2cfd6a50-3d30-11e8-b351-0d25ce9162a3.png

We need to concatenate, hence ![](${location.origin + data}). The last two lines get the previous content and append the url.

Wrap-up

Here is the final result:

Full code: Subject.vue#L378-L465 (opens in a new tab)

References && thanks

code from skecozo’s laravel-demo (opens in a new tab)

Lemon’s article “Implementing drag-and-drop and paste image upload in simplemde” (opens in a new tab)

f-loat’s vue-simplemde package (opens in a new tab)

wescossick’s simplemde package (opens in a new tab)

Reply to this post on X (opens in a new tab) | View as Markdown