Uploads
Implement one adapter; the editor drives drops, pastes, progress, cancel and retry.
@blockleaf/editor never talks to a storage provider. You implement one
interface; the editor drives it.
<BlockleafEditor upload={adapter} />Give it an adapter and dropping, pasting or picking a file just works. Without one the upload extension is not added at all, so files fall through to the browser rather than being swallowed by an editor with nowhere to put them.
Why you own storage
Baking in S3, or Uploadthing, or anything else, makes the package useless to everyone storing files somewhere else โ and every host already has an upload endpoint. Shipping a provider also means shipping its SDK, which every consumer pays for whether they use it or not.
So there is one interface, two reference implementations in this document, and no upload dependency in the package.
The interface
import type { UploadAdapter } from '@blockleaf/editor';
interface UploadResult {
id: string; // stable, passed back to remove()
src: string; // the URL stored in the document
width?: number; // intrinsic pixels, if you know them
height?: number;
}
interface UploadContext {
signal: AbortSignal;
onProgress?: (fraction: number) => void;
}
interface UploadAdapter {
upload(file: File, context: UploadContext): Promise<UploadResult>;
remove?(id: string): Promise<void>;
}PROJECT_PLAN.md ยง4 sketches upload(file, signal). The second argument is an
object instead, because progress needs somewhere to live and adding a third
positional argument later would be a breaking change.
width and height are worth returning
Without them the editor cannot reserve space before the image loads, and the document reflows as each one arrives. If your endpoint knows the dimensions, return them.
remove is optional
Plenty of hosts would rather sweep orphaned files in a cron job than trust an editor to tell them what to delete. Omit it and nothing calls it.
Progress
fetch cannot report upload progress โ there is no event for it. If you want a
real progress bar rather than a spinner, use XMLHttpRequest:
const adapter: UploadAdapter = {
upload(file, { signal, onProgress }) {
return new Promise((resolve, reject) => {
const body = new FormData();
body.append('file', file);
const request = new XMLHttpRequest();
request.open('POST', '/api/upload');
request.responseType = 'json';
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress?.(event.loaded / event.total);
}
});
request.addEventListener('load', () => {
if (request.status >= 200 && request.status < 300) {
resolve(request.response as UploadResult);
} else {
reject(new Error(`Upload failed: ${request.status}`));
}
});
request.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
signal.addEventListener('abort', () => {
request.abort();
reject(new DOMException('Aborted', 'AbortError'));
});
request.send(body);
});
}
};Calling onProgress is optional in both directions. An adapter that never
calls it gets an indeterminate state, which is the right answer for an upload
whose size you cannot measure.
Cancelling versus failing
These are different outcomes and the editor treats them differently.
- Reject with an
AbortErrorโ the upload was cancelled. The placeholder disappears, silently.fetchthrows this for you when the signal fires. - Reject with anything else โ the upload failed. The placeholder stays and offers a retry.
The check is on error.name === 'AbortError', so you can throw your own error
with that name if you are not using fetch.
The signal fires when the user cancels, and when the editor unmounts with an upload still in flight. Pass it through; an adapter that ignores it leaks a request per abandoned upload.
Validation
What your adapter resolves with is checked before any of it reaches the
document, by isUploadResult. It is exported, so you can use it in your own
tests:
import { isUploadResult } from '@blockleaf/editor';
expect(isUploadResult(await adapter.upload(file, context))).toBe(true);The mistake it exists to catch is an endpoint answering with { url } instead
of { src } โ without the guard that stores an image node with no source, in
your database, and you find out later.
The check is structural: extra properties pass, so returning your own richer object is fine.
src itself is sanitised by the image node rather than here. An <img> that
arrives through a paste never passes through an adapter, so the gate that
catches every path has to live on the node.
Reference: presigned upload
Two round trips, no file bytes through your server.
const adapter: UploadAdapter = {
async upload(file, { signal }) {
const presign = await fetch('/api/uploads/presign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: file.name, type: file.type }),
signal
});
if (!presign.ok) throw new Error('Could not presign upload');
const { uploadUrl, id, src } = (await presign.json()) as {
uploadUrl: string;
id: string;
src: string;
};
const put = await fetch(uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type },
signal
});
if (!put.ok) throw new Error('Upload failed');
return { id, src };
},
async remove(id) {
await fetch(`/api/uploads/${id}`, { method: 'DELETE' });
}
};Both fetch calls take the signal, so cancelling during either one aborts
cleanly. Neither reports progress โ see above.
Validate on the server too
An adapter runs in the browser, which means it runs on the user's machine and
nothing it returns can be trusted. Check the file type and size in your
endpoint, not only in the adapter, and validate the stored document at the
mutation boundary with @blockleaf/editor/validate.
Entry points
Three ways in, all of them the same pipeline:
| How | What triggers it |
|---|---|
| Drop | Files dropped anywhere on the editor. |
| Paste | Files on the clipboard โ a screenshot, a copied file. |
| Picker | editor.commands.openUploadPicker(). |
A drop lands the image where the pointer was, not where the caret was. A paste lands it at the caret.
Options
| Option | Default | What it does |
|---|---|---|
upload | โ | The adapter. Without it, nothing runs. |
uploadAccept | ['image/*'] | MIME patterns accepted. |
uploadMaxBytes | 10485760 (10 MB) | Largest file accepted. |
onUploadError | โ | Called per rejected or failed file. |
uploadAccept takes the two forms an <input accept> takes: an exact type
(image/png) and a wildcard subtype (image/*). Extension entries (.png)
are not supported, deliberately โ the browser-reported MIME type is what a
server should be checking, and a file named photo.png is not necessarily a
PNG.
Screening happens before the upload
Type and size are checked in the browser, before the adapter is called, so
a 40 MB video dropped by accident never leaves the machine. Each rejection
calls onUploadError with a reason:
reason | Means |
|---|---|
no-adapter | No adapter configured; the file was discarded. |
type | Not in uploadAccept. |
size | Larger than uploadMaxBytes. |
adapter | The adapter rejected. |
result | The adapter resolved with something unusable. |
None of this replaces checking on the server. See the last section.
The placeholder is not a node
While an upload runs, the spot it will fill is held by a widget decoration, not by a node in the document.
That matters for two reasons. ProseMirror maps a decoration's position through every edit made while the upload is in flight, so an image dropped at the end of a paragraph still lands there after you type three lines above it. And because it is not a node, there is nothing to strip out of the document before saving โ a document serialised mid-upload contains no trace of the pending one.
Progress, cancel and retry
The placeholder shows a progress bar driven by whatever your adapter reports
through onProgress, with a Cancel button that fires the AbortSignal.
Until an adapter reports a fraction, the bar is indeterminate โ a sweeping
animation rather than a percentage. That is not a fallback, it is the correct
state: fetch cannot measure upload progress, so a bar claiming 40% would be
inventing it. An indeterminate progressbar omits aria-valuenow rather than
reporting zero, because saying nothing is honest and saying zero is a claim.
Progress updates are throttled to 5% steps. Each one is a ProseMirror
transaction, and an adapter reporting every chunk of a 5 MB file would produce
hundreds. They change no document, so they never reach onChange or the undo
stack, but they are not free either.
A failed upload does not disappear
When an adapter rejects, the placeholder stays, switches to an error state, and offers Retry and Dismiss.
It stays for a specific reason: it is still mapping its position. Retry puts the image where the file was originally dropped, even if you have been editing above it for the last thirty seconds. A placeholder that vanished on failure would have nowhere to retry into.
Cancelling is not failing
| Outcome | Placeholder | onUploadError |
|---|---|---|
| Resolved | Replaced by the image | โ |
Rejected with an AbortError | Removed | Not called |
| Rejected with anything else | Kept, in error state | Called |
The editor unmounting mid-upload aborts every in-flight request through the same path, so an abandoned upload does not keep a request alive against an editor that no longer exists โ and it is not announced as a failure, because nothing failed.
Driving it yourself
editor.commands.cancelUpload(id);
editor.commands.retryUpload(id);
editor.commands.dismissUpload(id);id comes from the plugin state, if you are building your own surface:
import { UPLOAD_PLUGIN_KEY } from '@blockleaf/editor';
const { pending } = UPLOAD_PLUGIN_KEY.getState(editor.state) ?? { pending: [] };
// [{ id, name, pos, progress, error, file }]The image block
An uploaded image becomes an image node:
{
"type": "image",
"attrs": {
"id": "img-8f3a",
"src": "https://cdn.test/photo.png",
"alt": "A cat on a wall",
"width": 1200,
"align": "center"
}
}id is assigned by the editor, not by your adapter โ it is the block id every
other node carries. The id your adapter returns is the storage identifier,
and it is what remove receives.
src is sanitised by the schema
Allowed:
https:- site-relative and relative paths (
/uploads/a.png,img/a.png) - base64 data URLs for raster formats โ
png,jpeg,gif,webp,avif
Refused:
javascript:, and anything else carrying an unexpected schemehttp:, matching the link rule, and blocked as mixed content anyway- protocol-relative URLs (
//cdn.test/a.png), which inherit the page scheme blob:, which points into one browser session and is dead on reloaddata:image/svg+xmlโ an SVG is a document, not a bitmap, and this one would be attacker-authored markup stored in your database
An <img> that arrives through a paste with an unsafe src is dropped,
not stored with a null source. setImage and updateImage refuse the same
sources, so host code calling the commands directly is held to the rule too.
Alt text
alt has three states and they are not the same thing:
| Value | Means |
|---|---|
null | Nobody has said. A screen reader reads the file name. |
"" | Decorative. A screen reader skips it. |
"A cat on..." | Described. |
The editor's alt field writes the second when submitted empty, so marking an image decorative is something a user can actually do.