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:
Basic Approach: Update After Upload
Section titled “Basic Approach: Update After Upload”The simplest method is to update the document after uploading the file and receiving the file URL:
import { insertNode function insertNode(options: InsertNodeOptions): Command
Returns a command that inserts the given node at the current selection or at
the given position. } from 'prosekit/core'
import {
defineFileDropHandler function defineFileDropHandler(handler: FileDropHandler): PlainExtension
,
defineFilePasteHandler function defineFilePasteHandler(handler: FilePasteHandler): PlainExtension
,
} from 'prosekit/extensions/file'
import type { ImageAttrs } from 'prosekit/extensions/image'
import type { Command type 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 { EditorView class 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 handleFile function handleFile(view: EditorView, file: File, pos?: number): Promise<boolean>
(view view: EditorView
: EditorView class 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). , file file: File
: File, pos pos: number | undefined
?: number) {
// Upload the file to a remote server and get the URL
const url const url: string
: string = await myUploader function myUploader(file: File): Promise<string>
(file file: File
)
// Attributes for the image node
const attrs const attrs: ImageAttrs
: ImageAttrs = { src ImageAttrs.src?: string | null | undefined
: url const url: string
}
// For paste, insert the image node at current text cursor position.
// For drop, insert the image node at the drop position.
const command const command: Command
: Command type 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. = pos pos: number | undefined
? insertNode function insertNode(options: InsertNodeOptions): Command
Returns a command that inserts the given node at the current selection or at
the given position. ({ type InsertNodeOptions.type?: string | NodeType | undefined
The type of the node to insert. Either this or `node` must be provided. : 'image', attrs InsertNodeOptions.attrs?: Attrs | undefined
When `type` is provided, the attributes of the node to insert. , pos InsertNodeOptions.pos?: number | undefined
The position to insert the node at. By default it will be the anchor
position of current selection. })
: insertNode function insertNode(options: InsertNodeOptions): Command
Returns a command that inserts the given node at the current selection or at
the given position. ({ type InsertNodeOptions.type?: string | NodeType | undefined
The type of the node to insert. Either this or `node` must be provided. : 'image', attrs InsertNodeOptions.attrs?: Attrs | undefined
When `type` is provided, the attributes of the node to insert. })
return command const command: (state: EditorState, dispatch?: (tr: Transaction) => void, view?: EditorView) => boolean
(view view: EditorView
.state EditorView.state: EditorState
The view's current [state](https://prosemirror.net/docs/ref/#state.EditorState). , view view: EditorView
.dispatch EditorView.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. , view view: EditorView
)
}
const imagePasteExtension const imagePasteExtension: PlainExtension
= defineFilePasteHandler function defineFilePasteHandler(handler: FilePasteHandler): PlainExtension
(({ view view: EditorView
The editor view. , file file: File
The file that was pasted. }) => {
if (!file file: File
The file that was pasted. .type Blob.type: string
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) .startsWith String.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 handleFile function handleFile(view: EditorView, file: File, pos?: number): Promise<boolean>
(view view: EditorView
The editor view. , file file: File
The file that was pasted. )
return true
})
const imageDropExtension const imageDropExtension: PlainExtension
= defineFileDropHandler function defineFileDropHandler(handler: FileDropHandler): PlainExtension
(({ view view: EditorView
The editor view. , file file: File
The file that was dropped. , pos pos: number
The position of the document where the file was dropped. }) => {
if (!file file: File
The file that was dropped. .type Blob.type: string
[MDN Reference](https://developer.mozilla.org/docs/Web/API/Blob/type) .startsWith String.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 handleFile function handleFile(view: EditorView, file: File, pos?: number): Promise<boolean>
(view view: EditorView
The editor view. , file file: File
The file that was dropped. , pos pos: number
The position of the document where the file was dropped. )
return true
})
Advanced Approach: Immediate Display with UploadTask
Section titled “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:
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({
loaded: event.loaded,
total: event.total,
})
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const json = JSON.parse(xhr.responseText) as { data: { url: string } }
const url: string = json.data.url.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.