feat: implement clipboard image paste upload in markdown editor

Added support for pasting images directly into the markdown editor. Images are automatically uploaded to a dedicated clipboard_pictures folder and inserted as markdown image syntax. Includes fallback to current folder if clipboard folder is unavailable, handles multiple clipboard API methods for browser compatibility, and sanitizes folder slugs. Server-side upload controller now accepts folderId as direct parameter with fallback to legacy
pull/7941/head
MikePl 2 months ago
parent d14b0a5509
commit 09f77be908

File diff suppressed because one or more lines are too long

@ -0,0 +1,148 @@
.contents details[open] {
background-color: #fff!important
}
.contents details {
border: 0!important
}
.contents table {
border-collapse: collapse!important
}
.mad-cm-lnk-active {
background: rgba(255,255,255,.16);
box-shadow: inset 4px 0 0 rgba(255,255,255,.4)
}
.mad-cm-lnk-active2 {
background: rgba(255,255,255,.24);
box-shadow: inset 4px 0 0 rgba(255,255,255,.64)!important
}
.mad-cm-collapsible {
cursor: default;
font-size: 13px!important;
font-weight: 500!important;
color: #fff!important
}
.mad-cm-collapsible:hover {
background: rgba(255,255,255,.08);
cursor: pointer
}
.mad-cm-collapsible-active {
box-shadow: inset 4px 0 0 rgba(255,255,255,.4);
transition-delay: 0s
}
.mad-cm-collapsible-inactive {
box-shadow: inset 4px 0 0 rgba(255,255,255,0);
transition-delay: 0.25s
}
.mad-cm-section {
background: rgba(255,255,255,.08);
box-shadow: inset 4px 0 0 rgba(255,255,255,.4);
overflow: hidden;
max-height: 0;
transition: max-height .5s cubic-bezier(0, 1, 0, 1)
}
.expanded {
box-shadow: inset 4px 0 0 rgba(255,255,255,.4);
max-height: 99em;
transition: max-height 1.5s ease-in-out
}
.mb {
opacity: 1;
left: 4px!important;
top: 67px!important;
transform-origin: center center
}
.mad-pmb {
opacity: 1;
left: 234px!important;
top: 67px!important;
transform-origin: center center
}
.mad-nav-hidden {
visibility: hidden!important;
transform: translateX(-100%)!important;
transition: transform .5s ease-in .2s,visibility 0s .75s!important
}
.mad-nav-visible {
visibility: visible;
transform: translateX(0);
transition: transform .75s ease-out!important
}
.flyout {
position: relative;
display: inline-block
}
.flyout-content {
display: none;
position: absolute;
background-color: #f1f1f1;
min-width: 160px;
box-shadow: 0 8px 16px 0 rgba(0,0,0,.2);
z-index: 1
}
.flyout-content a {
color: #000;
padding: 12px 16px;
text-decoration: none;
display: block
}
.flyout-content a:hover {
background-color: #ddd
}
.flyout:hover .flyout-content {
display: block
}
.flyout:hover .flybtn {
background-color: #3e8e41
}
.faccordion p {
font-size: 1.1em;
margin: 1em 0
}
.faccordion ul {
padding: 0
}
.faccordion li {
margin: .5em 0;
padding-right: 10em
}
.faccordion li:before {
content: none!important
}
.faccordion li > h1.collapsed:before,
.faccordion li > h2.collapsed:before,
.faccordion li > h3.collapsed:before,
.faccordion li > h4.collapsed:before,
.faccordion li > h5.collapsed:before,
.faccordion li > h6.collapsed:before {
content: "\2B9E ";
border-right: .1em solid rgba(255,0,255,0)
}
.faccordion li > h1.expanded:before,
.faccordion li > h2.expanded:before,
.faccordion li > h3.expanded:before,
.faccordion li > h4.expanded:before,
.faccordion li > h5.expanded:before,
.faccordion li > h6.expanded:before {
content: "\2B9F "
}
.faccordion li > h1.single:before,
.faccordion li > h2.single:before,
.faccordion li > h3.single:before,
.faccordion li > h4.single:before,
.faccordion li > h5.single:before,
.faccordion li > h6.single:before {
content: ""
}
.faccordion li > h1:hover,
.faccordion li > h2:hover,
.faccordion li > h3:hover,
.faccordion li > h4:hover,
.faccordion li > h5:hover,
.faccordion li > h6:hover {
cursor: pointer
}
.faccordion .inner {
padding-left: 1em;
overflow: hidden;
display: none
}

@ -171,6 +171,7 @@ import { get, sync } from 'vuex-pathify'
import markdownHelp from './markdown/help.vue'
import gql from 'graphql-tag'
import DOMPurify from 'dompurify'
import Cookies from 'js-cookie'
/* global siteConfig, siteLangs */
@ -367,6 +368,29 @@ md.renderer.rules.emoji = (token, idx) => {
// ========================================
let mermaidId = 0
const CLIPBOARD_ROOT_FOLDER = 'clipboard_pictures'
const listAssetFoldersByParentQuery = gql`
query ($parentFolderId: Int!) {
assets {
folders(parentFolderId: $parentFolderId) {
id
slug
}
}
}
`
const createAssetFolderMutation = gql`
mutation ($parentFolderId: Int!, $slug: String!) {
assets {
createFolder(parentFolderId: $parentFolderId, slug: $slug) {
responseResult {
succeeded
message
}
}
}
}
`
export default {
components: {
@ -432,22 +456,257 @@ export default {
onCmInput: _.debounce(function (newContent) {
this.processContent(newContent)
}, 600),
onCmPaste (cm, ev) {
// const clipItems = (ev.clipboardData || ev.originalEvent.clipboardData).items
// for (let clipItem of clipItems) {
// if (_.startsWith(clipItem.type, 'image/')) {
// const file = clipItem.getAsFile()
// const reader = new FileReader()
// reader.onload = evt => {
// this.$store.commit(`loadingStart`, 'editor-paste-image')
// this.insertAfter({
// content: `![${file.name}](${evt.target.result})`,
// newLine: true
// })
// }
// reader.readAsDataURL(file)
// }
// }
async onCmPaste (cm, ev) {
const clipboardData = ev.clipboardData || _.get(ev, 'originalEvent.clipboardData', null)
let file = null
if (clipboardData) {
const imageItem = _.find(Array.from(clipboardData.items || []), item => {
return item.kind === 'file' && _.startsWith(item.type, 'image/')
})
if (imageItem && imageItem.getAsFile) {
file = imageItem.getAsFile()
}
if (!file) {
const imageFile = _.find(Array.from(clipboardData.files || []), f => _.startsWith(f.type, 'image/'))
if (imageFile) {
file = imageFile
}
}
}
if (!file && navigator.clipboard && navigator.clipboard.read) {
try {
const clipItems = await navigator.clipboard.read()
for (const clipItem of clipItems) {
const imgType = _.find(clipItem.types || [], t => _.startsWith(t, 'image/'))
if (imgType) {
file = await clipItem.getType(imgType)
break
}
}
} catch (err) {}
}
if (!file) {
return
}
ev.preventDefault()
ev.stopPropagation()
if (!file) {
return this.$store.commit('showNotification', {
message: 'Clipboard image could not be read.',
style: 'warning',
icon: 'warning'
})
}
let uploadTarget
try {
uploadTarget = await this.resolveClipboardUploadTarget()
} catch (err) {
uploadTarget = this.getCurrentEditorUploadTarget()
this.$store.commit('showNotification', {
message: `Clipboard target folder not available, using current folder. ${err.message}`,
style: 'warning',
icon: 'warning'
})
}
const jwtToken = Cookies.get('jwt')
if (!jwtToken) {
return this.$store.commit('showNotification', {
message: 'Clipboard image upload failed: missing auth token.',
style: 'error',
icon: 'warning'
})
}
const ext = this.getClipboardImageExt(file)
const filename = `clipboard-${Date.now()}.${ext}`
const fallbackTarget = this.getCurrentEditorUploadTarget()
const canFallbackUpload = uploadTarget.folderId !== fallbackTarget.folderId || uploadTarget.folderPath !== fallbackTarget.folderPath
this.$store.commit('loadingStart', 'editor-paste-image')
try {
let finalTarget = uploadTarget
try {
await this.uploadClipboardFile({
file,
filename,
folderId: uploadTarget.folderId,
jwtToken
})
} catch (primaryErr) {
if (!canFallbackUpload) {
throw primaryErr
}
try {
await this.uploadClipboardFile({
file,
filename,
folderId: fallbackTarget.folderId,
jwtToken
})
finalTarget = fallbackTarget
this.$store.commit('showNotification', {
message: 'Upload target not allowed, fallback to current folder.',
style: 'warning',
icon: 'warning'
})
} catch (fallbackErr) {
throw fallbackErr
}
}
const assetPath = finalTarget.folderId > 0 && finalTarget.folderPath ? `/${finalTarget.folderPath}/${filename}` : `/${filename}`
this.insertAtCursor({
content: `![${filename}](${assetPath})`
})
this.$store.commit('showNotification', {
message: 'Image pasted and uploaded successfully.',
style: 'success',
icon: 'check'
})
} catch (err) {
this.$store.commit('showNotification', {
message: err.message,
style: 'error',
icon: 'warning'
})
}
this.$store.commit('loadingStop', 'editor-paste-image')
},
getCurrentEditorUploadTarget () {
const folderId = _.toInteger(_.get(this.$store.state, 'editor.media.currentFolderId', 0))
const folderTree = _.get(this.$store.state, 'editor.media.folderTree', [])
const folderPath = folderId > 0 ? _.map(folderTree, 'slug').join('/') : ''
return {
folderId,
folderPath
}
},
async uploadClipboardFile ({ file, filename, folderId, jwtToken }) {
const formData = new FormData()
formData.append('folderId', `${_.toInteger(folderId)}`)
formData.append('mediaUpload', file, filename)
const resp = await fetch('/u', {
method: 'POST',
headers: {
Authorization: `Bearer ${jwtToken}`
},
credentials: 'same-origin',
body: formData
})
if (resp.ok) {
return
}
let errMsg = `Image upload failed (HTTP ${resp.status}).`
try {
const contentType = _.toString(resp.headers.get('content-type'))
if (_.includes(contentType, 'application/json')) {
const errBody = await resp.json()
errMsg = _.get(errBody, 'message', errMsg)
} else {
const errTxt = _.trim(await resp.text())
if (errTxt) {
errMsg = errTxt
}
}
} catch (err) {}
throw new Error(errMsg)
},
getClipboardImageExt (file) {
const mimeExtMap = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
'image/bmp': 'bmp',
'image/svg+xml': 'svg'
}
return _.get(mimeExtMap, file.type, 'png')
},
sanitizeAssetFolderSlug (value) {
return _.toString(value)
.trim()
.toLowerCase()
.replace(/[\s,;#]+/g, '_')
.replace(/[^a-z0-9_-]/g, '')
},
getClipboardFolderSegments () {
return [CLIPBOARD_ROOT_FOLDER]
},
async ensureAssetFolder ({ parentFolderId, slug }) {
const parentId = _.toInteger(parentFolderId)
const safeSlug = this.sanitizeAssetFolderSlug(slug)
if (!safeSlug) {
throw new Error('Invalid asset folder slug.')
}
const findFolder = async () => {
const folderResp = await this.$apollo.query({
query: listAssetFoldersByParentQuery,
variables: {
parentFolderId: parentId
},
fetchPolicy: 'network-only'
})
const folders = _.get(folderResp, 'data.assets.folders', [])
return _.find(folders, ['slug', safeSlug])
}
const existingFolder = await findFolder()
if (existingFolder) {
return existingFolder
}
const createResp = await this.$apollo.mutate({
mutation: createAssetFolderMutation,
variables: {
parentFolderId: parentId,
slug: safeSlug
}
})
const succeeded = _.get(createResp, 'data.assets.createFolder.responseResult.succeeded', false)
if (!succeeded) {
const folderAfterFailedCreate = await findFolder()
if (folderAfterFailedCreate) {
return folderAfterFailedCreate
}
const message = _.get(createResp, 'data.assets.createFolder.responseResult.message', 'Failed to create asset folder.')
throw new Error(message)
}
const createdFolder = await findFolder()
if (!createdFolder) {
throw new Error(`Failed to resolve created folder "${safeSlug}".`)
}
return createdFolder
},
async resolveClipboardUploadTarget () {
const segments = this.getClipboardFolderSegments()
let parentFolderId = 0
const resolvedSlugs = []
for (const segment of segments) {
const folder = await this.ensureAssetFolder({
parentFolderId,
slug: segment
})
parentFolderId = folder.id
resolvedSlugs.push(folder.slug)
}
return {
folderId: parentFolderId,
folderPath: resolvedSlugs.join('/')
}
},
processContent (newContent) {
linesMap = []

@ -46,14 +46,23 @@ router.post('/u', (req, res, next) => {
// Get folder Id
let folderId = null
try {
const folderRaw = _.get(req, 'body.mediaUpload', false)
if (folderRaw) {
folderId = _.get(JSON.parse(folderRaw), 'folderId', null)
if (folderId === 0) {
const rawFolderId = _.get(req, 'body.folderId', null)
if (!_.isNil(rawFolderId) && _.toString(rawFolderId) !== '') {
folderId = _.toInteger(rawFolderId)
if (!_.isFinite(folderId) || folderId <= 0) {
folderId = null
}
} else {
throw new Error('Missing File Metadata')
const folderRawInput = _.get(req, 'body.mediaUpload', false)
const folderRaw = _.isArray(folderRawInput) ? _.find(folderRawInput, _.isString) : folderRawInput
if (folderRaw) {
folderId = _.get(JSON.parse(folderRaw), 'folderId', null)
if (folderId === 0) {
folderId = null
}
} else {
throw new Error('Missing File Metadata')
}
}
} catch (err) {
return res.status(400).json({

Loading…
Cancel
Save