fix: Rich editor pasting images within text (#19674)

This commit is contained in:
Dan Harrin
2026-04-11 13:09:49 +01:00
committed by GitHub
parent a0ebc37890
commit bd1badf886
2 changed files with 278 additions and 137 deletions
File diff suppressed because one or more lines are too long
@@ -1,4 +1,5 @@
import { Extension } from '@tiptap/core'
import { DOMParser as ProseMirrorDOMParser } from '@tiptap/pm/model'
import { Plugin, PluginKey } from '@tiptap/pm/state'
const dispatchFormEvent = (editorView, name, detail = {}) => {
@@ -31,6 +32,14 @@ const validateFiles = ({
return null
}
const generateFileKey = () =>
([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
).toString(16),
)
const LocalFilesPlugin = ({
editor,
acceptedTypes,
@@ -112,15 +121,7 @@ const LocalFilesPlugin = ({
}),
)
let fileKey = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] &
(15 >> (c / 4)))
).toString(16),
)
const fileKey = generateFileKey()
get$WireUsing().upload(
`componentFileAttachments.${statePath}.${fileKey}`,
@@ -170,121 +171,261 @@ const LocalFilesPlugin = ({
return true
},
handlePaste(editorView, event) {
if (!event.clipboardData?.files.length) {
return false
}
const files = event.clipboardData?.files?.length
? Array.from(event.clipboardData.files)
: []
const hasText = event.clipboardData?.getData('text')?.length > 0
const html = event.clipboardData?.getData('text/html') || ''
if (event.clipboardData?.getData('text').length) {
return false
}
if (files.length && !hasText) {
const validationMessage = validateFiles({
files,
acceptedTypes,
acceptedTypesValidationMessage,
maxSize,
maxSizeValidationMessage,
})
const files = Array.from(event.clipboardData.files)
if (validationMessage) {
editorView.dom.dispatchEvent(
new CustomEvent(
'rich-editor-file-validation-message',
{
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
validationMessage,
},
},
),
)
const validationMessage = validateFiles({
files,
acceptedTypes,
acceptedTypesValidationMessage,
maxSize,
maxSizeValidationMessage,
})
return false
}
if (validationMessage) {
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-file-validation-message', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
validationMessage,
event.preventDefault()
event.stopPropagation()
dispatchFormEvent(editorView, 'form-processing-started', {
message: uploadingMessage,
})
files.forEach((file, fileIndex) => {
editor.setEditable(false)
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-uploading-file', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
},
}),
)
const fileKey = generateFileKey()
get$WireUsing().upload(
`componentFileAttachments.${statePath}.${fileKey}`,
file,
() => {
getFileAttachmentUrl(fileKey).then((url) => {
if (!url) {
return
}
editor
.chain()
.insertContentAt(
editor.state.selection.anchor,
{
type: 'image',
attrs: {
id: fileKey,
src: url,
},
},
)
.run()
editor.setEditable(true)
editorView.dom.dispatchEvent(
new CustomEvent(
'rich-editor-uploaded-file',
{
bubbles: true,
detail: {
key,
livewireId:
get$WireUsing().id,
},
},
),
)
if (fileIndex === files.length - 1) {
dispatchFormEvent(
editorView,
'form-processing-finished',
)
}
})
},
}),
)
)
})
return false
return true
}
if (!files.length) {
return false
}
if (html) {
const domParser = new DOMParser()
const parsed = domParser.parseFromString(html, 'text/html')
const images = parsed.querySelectorAll('img[src]')
event.preventDefault()
event.stopPropagation()
if (images.length) {
event.stopPropagation()
dispatchFormEvent(editorView, 'form-processing-started', {
message: uploadingMessage,
})
const { from, to } = editor.state.selection
files.forEach((file, fileIndex) => {
editor.setEditable(false)
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-uploading-file', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
},
}),
)
dispatchFormEvent(
editorView,
'form-processing-started',
{ message: uploadingMessage },
)
let fileKey = ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(
/[018]/g,
(c) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] &
(15 >> (c / 4)))
).toString(16),
)
editor.setEditable(false)
get$WireUsing().upload(
`componentFileAttachments.${statePath}.${fileKey}`,
file,
() => {
getFileAttachmentUrl(fileKey).then((url) => {
if (!url) {
return
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-uploading-file', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
},
}),
)
;(async () => {
for (const image of images) {
const src = image.getAttribute('src')
if (!src.startsWith('data:image/')) {
continue
}
editor
.chain()
.insertContentAt(
editor.state.selection.anchor,
{
type: 'image',
attrs: {
id: fileKey,
src: url,
},
},
)
.run()
let blob
editor.setEditable(true)
editorView.dom.dispatchEvent(
new CustomEvent(
'rich-editor-uploaded-file',
{
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
},
},
),
try {
const [header, base64] = src.split(',')
const mimeType = header.match(/:(.*?);/)[1]
const bytes = atob(base64)
const array = new Uint8Array(bytes.length)
for (let i = 0; i < bytes.length; i++) {
array[i] = bytes.charCodeAt(i)
}
blob = new Blob([array], {
type: mimeType,
})
} catch {
continue
}
const extension =
blob.type.split('/')[1]?.split('+')[0] ||
'png'
const file = new File(
[blob],
`image.${extension}`,
{ type: blob.type },
)
if (fileIndex === files.length - 1) {
dispatchFormEvent(
editorView,
'form-processing-finished',
)
}
})
},
)
})
const validationMessage = validateFiles({
files: [file],
acceptedTypes,
acceptedTypesValidationMessage,
maxSize,
maxSizeValidationMessage,
})
return true
if (validationMessage) {
continue
}
const fileKey = generateFileKey()
const url = await new Promise((resolve) => {
get$WireUsing().upload(
`componentFileAttachments.${statePath}.${fileKey}`,
file,
() => {
getFileAttachmentUrl(fileKey).then(
(uploadedUrl) =>
resolve(
uploadedUrl ?? null,
),
)
},
() => resolve(null),
)
})
if (url) {
image.setAttribute('src', url)
image.setAttribute('data-id', fileKey)
}
}
for (const image of Array.from(
parsed.querySelectorAll('img[src]'),
)) {
const p = parsed.createElement('p')
image.replaceWith(p)
p.appendChild(image)
}
let cleanedHtml = parsed.body.innerHTML
editor.view.someProp('transformPastedHTML', (f) => {
cleanedHtml = f(cleanedHtml)
})
const wrapper = document.createElement('div')
wrapper.innerHTML = cleanedHtml
const slice = ProseMirrorDOMParser.fromSchema(
editor.state.schema,
).parseSlice(wrapper, {
preserveWhitespace: false,
})
editor.view.dispatch(
editor.state.tr.replaceRange(from, to, slice),
)
editor.setEditable(true)
editorView.dom.dispatchEvent(
new CustomEvent('rich-editor-uploaded-file', {
bubbles: true,
detail: {
key,
livewireId: get$WireUsing().id,
},
}),
)
dispatchFormEvent(
editorView,
'form-processing-finished',
)
})()
return true
}
}
return false
},
},
})