Skip to content

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.

API Reference