Skip to content
GitHub

File

Handle file pasting, dropping and uploading in the editor.

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:

The simplest method is to update the document after uploading the file and receiving the file URL:

import { insertNodefunction insertNode(options: InsertNodeOptions): Command
Returns a command that inserts the given node at the current selection or at the given position.
@public
} from 'prosekit/core'
import { defineFileDropHandlerfunction defineFileDropHandler(handler: FileDropHandler): PlainExtension, defineFilePasteHandlerfunction defineFilePasteHandler(handler: FilePasteHandler): PlainExtension, } from 'prosekit/extensions/file' import type { ImageAttrs } from 'prosekit/extensions/image' import type { Commandtype Command = (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean
Commands are functions that take a state and a an optional transaction dispatch function and... - determine whether they apply to this state - if not, return false - if `dispatch` was passed, perform their effect, possibly by passing a transaction to `dispatch` - return true In some cases, the editor view is passed as a third argument.
} from 'prosekit/pm/state'
import type { EditorViewclass EditorView
An editor view manages the DOM structure that represents an editable document. Its state and behavior are determined by its [props](https://prosemirror.net/docs/ref/#view.DirectEditorProps).
} from 'prosekit/pm/view'
async function handleFilefunction handleFile(view: EditorView, file: File, pos?: number): Promise<boolean>(viewview: EditorView: EditorViewclass EditorView
An editor view manages the DOM structure that represents an editable document. Its state and behavior are determined by its [props](https://prosemirror.net/docs/ref/#view.DirectEditorProps).
, filefile: File: File, pospos: number | undefined?: number) {
// Upload the file to a remote server and get the URL const urlconst url: string: string = await myUploaderfunction myUploader(file: File): Promise<string>(filefile: File) // Attributes for the image node const attrsconst attrs: ImageAttrs: ImageAttrs = { srcImageAttrs.src?: string | null | undefined: urlconst url: string } // For paste, insert the image node at current text cursor position. // For drop, insert the image node at the drop position. const commandconst command: Command: Commandtype Command = (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean
Commands are functions that take a state and a an optional transaction dispatch function and... - determine whether they apply to this state - if not, return false - if `dispatch` was passed, perform their effect, possibly by passing a transaction to `dispatch` - return true In some cases, the editor view is passed as a third argument.
= pospos: number | undefined
? insertNodefunction insertNode(options: InsertNodeOptions): Command
Returns a command that inserts the given node at the current selection or at the given position.
@public
({ typeInsertNodeOptions.type?: string | NodeType | undefined
The type of the node to insert. Either this or `node` must be provided.
: 'image', attrsInsertNodeOptions.attrs?: Attrs | undefined
When `type` is provided, the attributes of the node to insert.
, posInsertNodeOptions.pos?: number | undefined
The position to insert the node at. By default it will be the anchor position of current selection.
})
: insertNodefunction insertNode(options: InsertNodeOptions): Command
Returns a command that inserts the given node at the current selection or at the given position.
@public
({ typeInsertNodeOptions.type?: string | NodeType | undefined
The type of the node to insert. Either this or `node` must be provided.
: 'image', attrsInsertNodeOptions.attrs?: Attrs | undefined
When `type` is provided, the attributes of the node to insert.
})
return commandconst command: (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean(viewview: EditorView.stateEditorView.state: EditorState
The view's current [state](https://prosemirror.net/docs/ref/#state.EditorState).
, viewview: EditorView.dispatchEditorView.dispatch(tr: Transaction): void
Dispatch a transaction. Will call [`dispatchTransaction`](https://prosemirror.net/docs/ref/#view.DirectEditorProps.dispatchTransaction) when given, and otherwise defaults to applying the transaction to the current state and calling [`updateState`](https://prosemirror.net/docs/ref/#view.EditorView.updateState) with the result. This method is bound to the view instance, so that it can be easily passed around.
, viewview: EditorView)
} const imagePasteExtensionconst imagePasteExtension: PlainExtension = defineFilePasteHandlerfunction defineFilePasteHandler(handler: FilePasteHandler): PlainExtension(({ viewview: EditorView
The editor view.
, filefile: File
The file that was pasted.
}) => {
if (!filefile: File
The file that was pasted.
.typeBlob.type: string
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type)
.startsWithString.startsWith(searchString: string, position?: number): boolean
Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at position. Otherwise returns false.
('image/')) return false
void handleFilefunction handleFile(view: EditorView, file: File, pos?: number): Promise<boolean>(viewview: EditorView
The editor view.
, filefile: File
The file that was pasted.
)
return true }) const imageDropExtensionconst imageDropExtension: PlainExtension = defineFileDropHandlerfunction defineFileDropHandler(handler: FileDropHandler): PlainExtension(({ viewview: EditorView
The editor view.
, filefile: File
The file that was dropped.
, pospos: number
The position of the document where the file was dropped.
}) => {
if (!filefile: File
The file that was dropped.
.typeBlob.type: string
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type)
.startsWithString.startsWith(searchString: string, position?: number): boolean
Returns true if the sequence of elements of searchString converted to a String is the same as the corresponding elements of this object (converted to a String) starting at position. Otherwise returns false.
('image/')) return false
void handleFilefunction handleFile(view: EditorView, file: File, pos?: number): Promise<boolean>(viewview: EditorView
The editor view.
, filefile: File
The file that was dropped.
, pospos: number
The position of the document where the file was dropped.
)
return true })

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:

upload-file.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:

You can check the full example here.