From c16c7e49b4c8820a95f7393735a3fbc85d6a2f8b Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 1 Oct 2026 22:26:08 +0200 Subject: [PATCH 1/2] fix(web-pkg): embed pasted images in rich text editors --- .../src/editor/extensions/imageFileHandler.ts | 29 ++++-- .../extensions/imageFileHandler.spec.ts | 90 +++++++++++++++++++ .../extensions/markdownClipboard.spec.ts | 31 ++++++- 3 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 packages/web-pkg/tests/unit/editor/extensions/imageFileHandler.spec.ts diff --git a/packages/web-pkg/src/editor/extensions/imageFileHandler.ts b/packages/web-pkg/src/editor/extensions/imageFileHandler.ts index 6acf4e77054..c4aded93dc2 100644 --- a/packages/web-pkg/src/editor/extensions/imageFileHandler.ts +++ b/packages/web-pkg/src/editor/extensions/imageFileHandler.ts @@ -1,17 +1,29 @@ import FileHandler from '@tiptap/extension-file-handler' import type { Editor, Extension } from '@tiptap/core' -const isSupportedImageFile = (file: File) => file.type.startsWith('image/') +// Explicit list instead of `image/*`, because the file handler matches mime types exactly +const supportedImageMimeTypes = [ + 'image/apng', + 'image/avif', + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp' +] + +const isSupportedImageFile = (file: File) => supportedImageMimeTypes.includes(file.type) const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener('load', () => resolve(reader.result as string)) - reader.addEventListener('error', () => reject(new Error('Failed to read dropped image file'))) + reader.addEventListener('error', () => reject(new Error('Failed to read image file'))) reader.readAsDataURL(file) }) -const insertDroppedImages = async (editor: Editor, files: File[], pos: number) => { +const insertImages = async (editor: Editor, files: File[], pos: number) => { const imageFiles = files.filter(isSupportedImageFile) if (imageFiles.length === 0) { return @@ -32,12 +44,19 @@ const insertDroppedImages = async (editor: Editor, files: File[], pos: number) = } insertPos += 1 } catch { - // Ignore failed files and continue with remaining drops. + // Ignore failed files and continue with the remaining ones. } } } export const imageFileHandlerExtension = (): Extension => FileHandler.configure({ - onDrop: (editor, files, pos) => insertDroppedImages(editor, files, pos) + onDrop: (editor, files, pos) => insertImages(editor, files, pos), + // Copied images usually come with a text/html payload like `` + // (e.g. from the preview app). Its source only lives as long as the page that created + // it, so consume the event and embed the image file itself instead. + onPaste: (editor, files) => insertImages(editor, files, editor.state.selection.from), + consumePasteEvent: true, + // Lets clipboard payloads without images (e.g. other copied files) reach the default paste + allowedMimeTypes: supportedImageMimeTypes }) diff --git a/packages/web-pkg/tests/unit/editor/extensions/imageFileHandler.spec.ts b/packages/web-pkg/tests/unit/editor/extensions/imageFileHandler.spec.ts new file mode 100644 index 00000000000..e0881971a8b --- /dev/null +++ b/packages/web-pkg/tests/unit/editor/extensions/imageFileHandler.spec.ts @@ -0,0 +1,90 @@ +import { Editor } from '@tiptap/core' +import Image from '@tiptap/extension-image' +import StarterKit from '@tiptap/starter-kit' +import { describe, expect, it, vi } from 'vitest' +import { imageFileHandlerExtension } from '../../../../src/editor/extensions/imageFileHandler' + +function createEditor(): Editor { + return new Editor({ + extensions: [ + StarterKit, + Image.configure({ inline: false, allowBase64: true }), + imageFileHandlerExtension() + ] + }) +} + +function createClipboardEvent({ files = [], html = '' }: { files?: File[]; html?: string }) { + const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent + + Object.defineProperty(event, 'clipboardData', { + value: { + files, + getData: (type: string) => (type === 'text/html' ? html : '') + } + }) + + return event +} + +function getImageSources(editor: Editor) { + const sources: string[] = [] + editor.state.doc.descendants((node) => { + if (node.type.name === 'image') { + sources.push(node.attrs.src) + } + }) + return sources +} + +describe('image file handler extension', () => { + it('embeds a pasted image file as data url instead of the html image source', async () => { + const editor = createEditor() + const file = new File(['image'], 'image.png', { type: 'image/png' }) + const event = createClipboardEvent({ + files: [file], + html: '' + }) + + try { + editor.view.dom.dispatchEvent(event) + + expect(event.defaultPrevented).toBe(true) + await vi.waitFor(() => expect(getImageSources(editor)).toHaveLength(1)) + expect(getImageSources(editor)[0]).toMatch(/^data:image\/png;base64,/) + } finally { + editor.destroy() + } + }) + + it('leaves clipboard payloads without image files to the default paste handling', () => { + const editor = createEditor() + const file = new File(['pdf'], 'document.pdf', { type: 'application/pdf' }) + const event = createClipboardEvent({ files: [file], html: '

document.pdf

' }) + + try { + editor.view.dom.dispatchEvent(event) + + expect(getImageSources(editor)).toHaveLength(0) + expect(editor.state.doc.textContent).toBe('document.pdf') + } finally { + editor.destroy() + } + }) + + it('does not paste images into read-only editors', async () => { + const editor = createEditor() + editor.setEditable(false) + const file = new File(['image'], 'image.png', { type: 'image/png' }) + const event = createClipboardEvent({ files: [file] }) + + try { + editor.view.dom.dispatchEvent(event) + + await new Promise((resolve) => setTimeout(resolve)) + expect(getImageSources(editor)).toHaveLength(0) + } finally { + editor.destroy() + } + }) +}) diff --git a/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts b/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts index b4dfc3df8de..c3274e0afda 100644 --- a/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts +++ b/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts @@ -4,11 +4,13 @@ import { AllSelection } from '@tiptap/pm/state' import StarterKit from '@tiptap/starter-kit' import { Marked } from 'marked' import type { marked as markedDefault } from 'marked' -import { describe, expect, it } from 'vitest' +import Image from '@tiptap/extension-image' +import { describe, expect, it, vi } from 'vitest' +import { imageFileHandlerExtension } from '../../../../src/editor/extensions/imageFileHandler' import { createLinkExtension } from '../../../../src/editor/extensions/link' import { createMarkdownClipboardExtension } from '../../../../src/editor/extensions/markdownClipboard' -function createEditor(): Editor { +function createEditor({ withImages = false } = {}): Editor { const marked = new Marked() as unknown as typeof markedDefault return new Editor({ @@ -16,7 +18,10 @@ function createEditor(): Editor { StarterKit.configure({ link: false }), Markdown.configure({ marked }), createMarkdownClipboardExtension(), - createLinkExtension() + createLinkExtension(), + ...(withImages + ? [Image.configure({ inline: false, allowBase64: true }), imageFileHandlerExtension()] + : []) ] }) } @@ -25,11 +30,12 @@ function copySelectionAsText(editor: Editor) { return editor.view.serializeForClipboard(editor.state.selection.content()).text } -function createClipboardEvent(text: string, html: string): ClipboardEvent { +function createClipboardEvent(text: string, html: string, files: File[] = []): ClipboardEvent { const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent Object.defineProperty(event, 'clipboardData', { value: { + files, getData: (type: string) => { if (type === 'text/plain') { return text @@ -226,4 +232,21 @@ describe('markdown clipboard extension', () => { editor.destroy() } }) + + it('pastes image files as embedded images instead of their text payload', async () => { + const editor = createEditor({ withImages: true }) + const file = new File(['image'], 'image.png', { type: 'image/png' }) + const event = createClipboardEvent('image.png', '', [file]) + + try { + pasteClipboardEvent(editor, event) + + await vi.waitFor(() => + expect(editor.markdown?.serialize(editor.getJSON())).toContain('![](data:image/png;base64,') + ) + expect(editor.state.doc.textContent).not.toContain('image.png') + } finally { + editor.destroy() + } + }) }) From d051f7bb77c315bf38857c93a0c1d3c529ea5495 Mon Sep 17 00:00:00 2001 From: Alexander Ackermann Date: Thu, 1 Oct 2026 22:29:40 +0200 Subject: [PATCH 2/2] test(web-pkg): drop markdown clipboard image paste test --- .../extensions/markdownClipboard.spec.ts | 31 +++---------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts b/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts index c3274e0afda..b4dfc3df8de 100644 --- a/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts +++ b/packages/web-pkg/tests/unit/editor/extensions/markdownClipboard.spec.ts @@ -4,13 +4,11 @@ import { AllSelection } from '@tiptap/pm/state' import StarterKit from '@tiptap/starter-kit' import { Marked } from 'marked' import type { marked as markedDefault } from 'marked' -import Image from '@tiptap/extension-image' -import { describe, expect, it, vi } from 'vitest' -import { imageFileHandlerExtension } from '../../../../src/editor/extensions/imageFileHandler' +import { describe, expect, it } from 'vitest' import { createLinkExtension } from '../../../../src/editor/extensions/link' import { createMarkdownClipboardExtension } from '../../../../src/editor/extensions/markdownClipboard' -function createEditor({ withImages = false } = {}): Editor { +function createEditor(): Editor { const marked = new Marked() as unknown as typeof markedDefault return new Editor({ @@ -18,10 +16,7 @@ function createEditor({ withImages = false } = {}): Editor { StarterKit.configure({ link: false }), Markdown.configure({ marked }), createMarkdownClipboardExtension(), - createLinkExtension(), - ...(withImages - ? [Image.configure({ inline: false, allowBase64: true }), imageFileHandlerExtension()] - : []) + createLinkExtension() ] }) } @@ -30,12 +25,11 @@ function copySelectionAsText(editor: Editor) { return editor.view.serializeForClipboard(editor.state.selection.content()).text } -function createClipboardEvent(text: string, html: string, files: File[] = []): ClipboardEvent { +function createClipboardEvent(text: string, html: string): ClipboardEvent { const event = new Event('paste', { bubbles: true, cancelable: true }) as ClipboardEvent Object.defineProperty(event, 'clipboardData', { value: { - files, getData: (type: string) => { if (type === 'text/plain') { return text @@ -232,21 +226,4 @@ describe('markdown clipboard extension', () => { editor.destroy() } }) - - it('pastes image files as embedded images instead of their text payload', async () => { - const editor = createEditor({ withImages: true }) - const file = new File(['image'], 'image.png', { type: 'image/png' }) - const event = createClipboardEvent('image.png', '', [file]) - - try { - pasteClipboardEvent(editor, event) - - await vi.waitFor(() => - expect(editor.markdown?.serialize(editor.getJSON())).toContain('![](data:image/png;base64,') - ) - expect(editor.state.doc.textContent).not.toContain('image.png') - } finally { - editor.destroy() - } - }) })