diff --git a/packages/web-pkg/src/editor/extensions/imageFileHandler.ts b/packages/web-pkg/src/editor/extensions/imageFileHandler.ts index 6acf4e7705..c4aded93dc 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 0000000000..e0881971a8 --- /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() + } + }) +})