File
Handle file pasting, dropping and uploading in the editor.
Usage
It's common to upload files to a remote server when pasting or dropping files into the editor. Here are two approaches to implement this functionality:
Basic Approach: Update After Upload
The simplest method is to update the document after uploading the file and receiving the file URL:
ts
import { insertNode } from 'prosekit/core'
import {
defineFileDropHandler,
defineFilePasteHandler,
} from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { Command } from 'prosekit/pm/state'
import type { EditorView } from 'prosekit/pm/view'
async function handleFile(view: EditorView, file: File, pos?: number) {
// Upload the file to a remote server and get the URL
const url: string = await myUploader(file)
// Attributes for the image node
const attrs: ImageAttrs = { src: url }
// For paste, insert the image node at current text cursor position.
// For drop, insert the image node at the drop position.
const command: Command = pos
? insertNode({ type: 'image', attrs, pos })
: insertNode({ type: 'image', attrs })
return command(view.state, view.dispatch, view)
}
const imagePasteExtension = defineFilePasteHandler(({ view, file }) => {
if (!file.type.startsWith('image/')) return false
void handleFile(view, file)
return true
})
const imageDropExtension = defineFileDropHandler(({ view, file, pos }) => {
if (!file.type.startsWith('image/')) return false
void handleFile(view, file, pos)
return true
})
Advanced Approach: Immediate Display with UploadTask
To improve user experience, especially with slow uploads, you can insert the node immediately and update it once the upload is complete:
First, create an UploadTask
instance on paste or drop:
ts
import {
insertNode,
union,
} from 'prosekit/core'
import {
defineFileDropHandler,
defineFilePasteHandler,
UploadTask,
type Uploader,
} from 'prosekit/extensions/file'
/**
* Returns an extension that handles image file uploads when pasting or dropping
* images into the editor.
*/
export function defineImageFileHandlers() {
return union(
defineFilePasteHandler(({ view, file }) => {
// Only handle image files
if (!file.type.startsWith('image/')) {
return false
}
// Upload the image to https://tmpfiles.org/
const uploadTask = new UploadTask({
file,
uploader: tmpfilesUploader,
})
// Insert the image node at the current text selection position
const command = insertNode({
type: 'image',
attrs: { src: uploadTask.objectURL },
})
return command(view.state, view.dispatch, view)
}),
defineFileDropHandler(({ view, file, pos }) => {
// Only handle image files
if (!file.type.startsWith('image/')) {
return false
}
// Upload the image to https://tmpfiles.org/
const uploadTask = new UploadTask({
file,
uploader: tmpfilesUploader,
})
// Insert the image node at the drop position
const command = insertNode({
type: 'image',
attrs: { src: uploadTask.objectURL },
pos,
})
return command(view.state, view.dispatch, view)
}),
)
}
/**
* Uploads the given file to https://tmpfiles.org/ and returns the URL of the
* uploaded file.
*
* This function is only for demonstration purposes. All uploaded files will be
* deleted after 1 hour.
*/
const tmpfilesUploader: Uploader<string> = ({
file,
onProgress,
}): Promise<string> => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
const formData = new FormData()
formData.append('file', file)
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable && onProgress) {
onProgress({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText)
const url: string = (json.data.url as string).replace(
'tmpfiles.org/',
'tmpfiles.org/dl/',
)
// Simulate a larger delay
setTimeout(() => resolve(url), 1000)
} catch (error) {
reject(new Error('Failed to parse response', { cause: error }))
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'))
})
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload', true)
xhr.send(formData)
})
}
Then, use the UploadTask
in your node view. When the upload is complete, update the node's attrs
with the new file URL:
tsx
import { UploadTask } from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { ReactNodeViewProps } from 'prosekit/react'
import {
ResizableHandle,
ResizableRoot,
} from 'prosekit/react/resizable'
import {
useEffect,
useState,
type SyntheticEvent,
} from 'react'
export default function ImageView(props: ReactNodeViewProps) {
const { setAttrs, node } = props
const attrs = node.attrs as ImageAttrs
const url = attrs.src || ''
const uploading = url.startsWith('blob:')
const [aspectRatio, setAspectRatio] = useState<number | undefined>()
const [error, setError] = useState<string | undefined>()
const [progress, setProgress] = useState(0)
useEffect(() => {
if (!url.startsWith('blob:')) {
return
}
const uploadTask = UploadTask.get<string>(url)
if (!uploadTask) {
return
}
const abortController = new AbortController()
void uploadTask.finished
.then((resultUrl) => {
if (resultUrl && typeof resultUrl === 'string') {
if (abortController.signal.aborted) {
return
}
setAttrs({ src: resultUrl })
} else {
if (abortController.signal.aborted) {
return
}
setError('Unexpected upload result')
}
UploadTask.delete(uploadTask.objectURL)
})
.catch((error) => {
if (abortController.signal.aborted) {
return
}
setError(String(error))
UploadTask.delete(uploadTask.objectURL)
})
const unsubscribe = uploadTask.subscribeProgress(({ loaded, total }) => {
if (abortController.signal.aborted) {
return
}
if (total > 0) {
setProgress(loaded / total)
}
})
return () => {
unsubscribe()
abortController.abort()
}
}, [url, setAttrs])
const handleImageLoad = (event: SyntheticEvent) => {
const img = event.target as HTMLImageElement
const { naturalWidth, naturalHeight } = img
const ratio = naturalWidth / naturalHeight
if (ratio && Number.isFinite(ratio)) {
setAspectRatio(ratio)
}
if (naturalWidth && naturalHeight && (!attrs.width || !attrs.height)) {
setAttrs({ width: naturalWidth, height: naturalHeight })
}
}
return (
<ResizableRoot
width={attrs.width ?? undefined}
height={attrs.height ?? undefined}
aspectRatio={aspectRatio}
onResizeEnd={(event) => setAttrs(event.detail)}
data-selected={props.selected ? '' : undefined}
className='relative flex items-center justify-center box-border overflow-hidden my-2 group max-h-[600px] max-w-full min-h-[64px] min-w-[64px] outline-2 outline-transparent data-[selected]:outline-blue-500 outline-solid'
>
{url && !error && (
<img
src={url}
onLoad={handleImageLoad}
className='h-full w-full max-w-full max-h-full object-contain'
/>
)}
{uploading && !error && (
<div className='absolute bottom-0 left-0 m-1 flex content-center items-center gap-2 rounded bg-gray-800/60 p-1.5 text-xs text-white/80 transition'>
<div className='i-lucide-loader-circle h-4 w-4 animate-spin'></div>
<div>{Math.round(progress * 100)}%</div>
</div>
)}
{error && (
<div className='absolute bottom-0 left-0 right-0 top-0 flex flex-col items-center justify-center gap-4 bg-gray-200 p-2 text-sm dark:bg-gray-800 @container'>
<div className='i-lucide-image-off h-8 w-8'></div>
<div className='hidden opacity-80 @xs:block'>
Failed to upload image
</div>
</div>
)}
<ResizableHandle
className='absolute bottom-0 right-0 rounded m-1.5 p-1 transition bg-gray-900/30 active:bg-gray-800/60 hover:bg-gray-800/60 text-white/50 active:text-white/80 active:translate-x-0.5 active:translate-y-0.5 opacity-0 hover:opacity-100 group-hover:opacity-100 group-[[data-resizing]]:opacity-100'
position="bottom-right"
>
<div className='i-lucide-arrow-down-right h-4 w-4'></div>
</ResizableHandle>
</ResizableRoot>
)
}
You can check the full example here.