Skip to content

Commit e9e7c7a

Browse files
authored
fix(web-pkg): embed pasted images in rich text editors (#3527)
* fix(web-pkg): embed pasted images in rich text editors * test(web-pkg): drop markdown clipboard image paste test
1 parent 418e179 commit e9e7c7a

2 files changed

Lines changed: 114 additions & 5 deletions

File tree

‎packages/web-pkg/src/editor/extensions/imageFileHandler.ts‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,29 @@
11
import FileHandler from '@tiptap/extension-file-handler'
22
import type { Editor, Extension } from '@tiptap/core'
33

4-
const isSupportedImageFile = (file: File) => file.type.startsWith('image/')
4+
// Explicit list instead of `image/*`, because the file handler matches mime types exactly
5+
const supportedImageMimeTypes = [
6+
'image/apng',
7+
'image/avif',
8+
'image/bmp',
9+
'image/gif',
10+
'image/jpeg',
11+
'image/png',
12+
'image/svg+xml',
13+
'image/webp'
14+
]
15+
16+
const isSupportedImageFile = (file: File) => supportedImageMimeTypes.includes(file.type)
517

618
const readFileAsDataUrl = (file: File): Promise<string> =>
719
new Promise((resolve, reject) => {
820
const reader = new FileReader()
921
reader.addEventListener('load', () => resolve(reader.result as string))
10-
reader.addEventListener('error', () => reject(new Error('Failed to read dropped image file')))
22+
reader.addEventListener('error', () => reject(new Error('Failed to read image file')))
1123
reader.readAsDataURL(file)
1224
})
1325

14-
const insertDroppedImages = async (editor: Editor, files: File[], pos: number) => {
26+
const insertImages = async (editor: Editor, files: File[], pos: number) => {
1527
const imageFiles = files.filter(isSupportedImageFile)
1628
if (imageFiles.length === 0) {
1729
return
@@ -32,12 +44,19 @@ const insertDroppedImages = async (editor: Editor, files: File[], pos: number) =
3244
}
3345
insertPos += 1
3446
} catch {
35-
// Ignore failed files and continue with remaining drops.
47+
// Ignore failed files and continue with the remaining ones.
3648
}
3749
}
3850
}
3951

4052
export const imageFileHandlerExtension = (): Extension =>
4153
FileHandler.configure({
42-
onDrop: (editor, files, pos) => insertDroppedImages(editor, files, pos)
54+
onDrop: (editor, files, pos) => insertImages(editor, files, pos),
55+
// Copied images usually come with a text/html payload like `<img src="blob:...">`
56+
// (e.g. from the preview app). Its source only lives as long as the page that created
57+
// it, so consume the event and embed the image file itself instead.
58+
onPaste: (editor, files) => insertImages(editor, files, editor.state.selection.from),
59+
consumePasteEvent: true,
60+
// Lets clipboard payloads without images (e.g. other copied files) reach the default paste
61+
allowedMimeTypes: supportedImageMimeTypes
4362
})
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Editor } from '@tiptap/core'
2+
import Image from '@tiptap/extension-image'
3+
import StarterKit from '@tiptap/starter-kit'
4+
import { describe, expect, it, vi } from 'vitest'
5+
import { imageFileHandlerExtension } from '../../../../src/editor/extensions/imageFileHandler'
6+
7+
function createEditor(): Editor {
8+
return new Editor({
9+
extensions: [
10+
StarterKit,
11+
Image.configure({ inline: false, allowBase64: true }),
12+
imageFileHandlerExtension()
13+
]
14+
})
15+
}
16+
17+
function createClipboardEvent({ files = [], html = '' }: { files?: File[]; html?: string }) {
18+
const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent
19+
20+
Object.defineProperty(event, 'clipboardData', {
21+
value: {
22+
files,
23+
getData: (type: string) => (type === 'text/html' ? html : '')
24+
}
25+
})
26+
27+
return event
28+
}
29+
30+
function getImageSources(editor: Editor) {
31+
const sources: string[] = []
32+
editor.state.doc.descendants((node) => {
33+
if (node.type.name === 'image') {
34+
sources.push(node.attrs.src)
35+
}
36+
})
37+
return sources
38+
}
39+
40+
describe('image file handler extension', () => {
41+
it('embeds a pasted image file as data url instead of the html image source', async () => {
42+
const editor = createEditor()
43+
const file = new File(['image'], 'image.png', { type: 'image/png' })
44+
const event = createClipboardEvent({
45+
files: [file],
46+
html: '<img src="blob:https://localhost/2c3c4f5e">'
47+
})
48+
49+
try {
50+
editor.view.dom.dispatchEvent(event)
51+
52+
expect(event.defaultPrevented).toBe(true)
53+
await vi.waitFor(() => expect(getImageSources(editor)).toHaveLength(1))
54+
expect(getImageSources(editor)[0]).toMatch(/^data:image\/png;base64,/)
55+
} finally {
56+
editor.destroy()
57+
}
58+
})
59+
60+
it('leaves clipboard payloads without image files to the default paste handling', () => {
61+
const editor = createEditor()
62+
const file = new File(['pdf'], 'document.pdf', { type: 'application/pdf' })
63+
const event = createClipboardEvent({ files: [file], html: '<p>document.pdf</p>' })
64+
65+
try {
66+
editor.view.dom.dispatchEvent(event)
67+
68+
expect(getImageSources(editor)).toHaveLength(0)
69+
expect(editor.state.doc.textContent).toBe('document.pdf')
70+
} finally {
71+
editor.destroy()
72+
}
73+
})
74+
75+
it('does not paste images into read-only editors', async () => {
76+
const editor = createEditor()
77+
editor.setEditable(false)
78+
const file = new File(['image'], 'image.png', { type: 'image/png' })
79+
const event = createClipboardEvent({ files: [file] })
80+
81+
try {
82+
editor.view.dom.dispatchEvent(event)
83+
84+
await new Promise((resolve) => setTimeout(resolve))
85+
expect(getImageSources(editor)).toHaveLength(0)
86+
} finally {
87+
editor.destroy()
88+
}
89+
})
90+
})

0 commit comments

Comments
 (0)