Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 24 additions & 5 deletions packages/web-pkg/src/editor/extensions/imageFileHandler.ts
Original file line number Diff line number Diff line change
@@ -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<string> =>
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
Expand All @@ -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 `<img src="blob:...">`
// (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
})
Original file line number Diff line number Diff line change
@@ -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: '<img src="blob:https://localhost/2c3c4f5e">'
})

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: '<p>document.pdf</p>' })

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()
}
})
})
Loading