Media plugin
@nextgensoftwares/folio-plugin-media adds images, video, audio and file attachments: uploads with placeholders and progress, validation, layout for file cards, painters, commands and UI items. working
import { composePlugins } from '@nextgensoftwares/folio-editor';
import { indexedDbUploader, mediaPlugin, mediaSizer } from '@nextgensoftwares/folio-plugin-media';
const media = mediaPlugin({
uploader: indexedDbUploader({ dbName: 'my-app-media' }),
onError: (message) => toast(message),
});
const composed = composePlugins(standardSchema, [media]);
const layout = (d) => layoutDocument(d, { measurer, renderers: composed.renderers, mediaSize: mediaSizer, cache });
// <FolioView painters={composed.painters} ui={composed.ui} input={composed.input} … />Hosts must also pass mediaSizer (or chain their own, e.g. (a) => hostSizer(a) ?? mediaSizer(a)) as the layout's mediaSize, and forward paste/drop: <FolioView input={composed.input}> does it, or call media.input.install(editor, element, locate) on your own page container.
What it adds to the document
resizableMedia(the standard image/video/audio node) gets extra declared attributes:naturalWidth/naturalHeight(intrinsic px size, for the aspect ratio),duration(s) andposter(URL) for video/audio,nameandmime(original file), anduploadId(set while uploading; the node is a placeholder until it's cleared).fileAttachment: a new atom block for documents (PDF, Word, Excel, PowerPoint, OpenDocument, CSV, RTF, text) shown as a compact card: type badge, name, and "PDF · 1.2 MB · 12 pages". The file is linked, never inlined. Its attributes:src,mediaId,name,size,mime,pages,uploadId.
File cards lay out as one custom fragment of fixed height (56 px; 400 px wide unless their width says otherwise, at least 120 px), hugging the start edge (right in RTL) unless aligned, and never splitting, so pagination stays exact and cheap. cardFlow(attrs, ctx, geometry) is that layout; plugins that draw bigger cards (file previews) call it with their own height.
Size and placement (layout options)
Images, video, audio and file cards all take Word's layout options, stored as attributes and validated as untrusted input (bad values are dropped by the schema and clamped by the commands):
| Attribute | Values | Meaning |
|---|---|---|
wrap | inline (default), square, topBottom, behind, front | how text flows around the object |
float | left / right | the side of a square object (older documents: float alone means square) |
alignment | left, center, right | inline/top-and-bottom alignment; the anchor of behind/front |
width, height | '320px', '50%', 'auto' | size (files and audio: width only; height comes from their layout) |
distT, distB, distL, distR | px, 0–480 | distance from text (Word's defaults: 12 px left/right for square) |
offsetX, offsetY | px, ±4000 | behind/front: offset from the aligned position |
anchor | paragraph (default), margin | behind/front: measured from the anchor paragraph or the page's content top |
borderRadius, objectFit | rounded corners and fit (images, video) |
- Inline and top and bottom are blocks on their own line (spaced by
distT/distB). - Square floats the object to its side; the lines of the following paragraphs (lists and quotes included) get narrower beside it, and RTL text starts at the right edge next to it. Tables, code and other non-text blocks start below it (v1). A float that doesn't fit on the page moves to the next page with the text that follows it, as in Word. Floats inside list items, quotes or table cells are laid out as aligned blocks (v1).
- Behind / in front leave the text alone; the object is drawn under / over it (and text over a behind-text object stays clickable).
See Layout pipeline ▸ Floats for how wrapping works and what it costs.
Uploaders
An uploader is a plain async function (file, { onProgress, signal }) => Promise<UploadResult> with an optional resolve(src) that turns stored src values into loadable URLs.
| Uploader | Stores | Use for |
|---|---|---|
objectUrlUploader({ delayMs }) | a blob: URL (dies on reload) | tests, sandboxes; progress is simulated |
indexedDbUploader({ dbName, storeName }) | the blob in IndexedDB, src = "idb:<key>" | local-first documents that survive reloads; resolve, remove(src), blob(src) for exporters |
serverUploader({ url, field, headers, fields, withCredentials, parse }) | whatever URL your server returns | production: multipart/form-data POST via XHR (real upload progress) |
The default serverUploader response parser accepts { src | url, mediaId | id, width, height, duration, poster }.
Validate on the server
Client-side checks are UX only. The server must re-validate type and size, store files outside the web root, and serve documents with Content-Disposition: attachment and a strict Content-Type.
Validation
Files are checked by MIME type, then by extension when the browser reports a generic type, against per-kind rules:
| Kind | Default types | Max size |
|---|---|---|
| image | png, jpeg, gif, webp, avif, bmp (no SVG: it can carry script) | 20 MB |
| video | mp4, m4v, webm, ogv, mov | 500 MB |
| audio | mp3, m4a, ogg, oga, wav, weba, aac, flac | 100 MB |
| file | pdf, doc, docx, odt, rtf, xls, xlsx, ods, csv, ppt, pptx, odp, txt | 50 MB |
Override per kind with rules: { image: { maxBytes: 5 * 1024 * 1024 } }; at most 50 files are accepted per insert. Only safe src schemes are ever inserted (isSafeSrc).
The upload lifecycle
UploadController is a small state machine that never blocks the editor:
- validate the files;
- insert a placeholder node at the drop point or selection (an undoable edit), with a local preview for images and video;
- upload (at most 3 at a time; the rest queue), with progress kept in an
UploadStoreoutside the document: progress ticks would otherwise become transactions, undo noise, collab traffic and relayouts. Painters subscribe per upload id, so a tick repaints one box; - probe the file in the browser (intrinsic size, duration, a JPEG poster frame for video, PDF page count) unless
probe: false; - write the final attributes as attribute steps, out of undo history, at the placeholder's mapped position; or mark it error (retry or remove), or remove it if aborted.
If a placeholder disappears (cut/paste, undo) for longer than lostGraceMs (default 4000 ms), its upload is aborted.
Commands
| Command | Does |
|---|---|
media.insertFiles(files, opts?) | upload files at opts.pos or the selection (expected kind, replace an existing node) |
media.insertUrl(src, kind?, { alt, title }) | insert media by URL (no upload) |
media.setAlt(alt), media.setTitle(title) | accessibility text of the selected media |
media.setAlignment('left' | 'center' | 'right') | alignment |
media.setWidth(width) | a preset or CSS length ('50%', '320px', 320, 'natural'); height back to auto |
media.resize(width, height?) | exact box in px; omit height to keep the aspect ratio |
media.setWrap(wrap, side?) | layout option: 'inline' | 'square' | 'topBottom' | 'behind' | 'front', square side 'left' | 'right' |
media.setPlacement(patch) | any of wrap, side, alignment, distT/B/L/R, offsetX/Y, anchor, borderRadius, objectFit at once (validated, one undo step) |
media.setSize({ width, height? }) | px or % width; height in px, or omitted/null to keep the aspect ratio (files and audio: width only) |
media.delete() | delete the selected media/file (cancelling its upload) |
media.cancelUpload(id), media.retryUpload(id) | upload control |
UI
Toolbar: Insert ▸ Image / Video / Audio / File (opens the file picker filtered by kind).
Placement controls (surface-neutral
ui.items, groupmedia.placement):media.size(width/height in px or %, aspect lock, presets: yoursizePresetsfor images, Compact/Standard/Wide for file cards),media.layout(Word's layout-options picker with icons, alignment or side, behind/front offsets),media.distance(distance from text) andmedia.style(corners, fit). They show for a selected image, video, audio bar or file card, in the bubble bar above it and the right-click menu by default. Each item has a Reactrendercontrol (a trigger + popover on bars, the full panel in menus and side panels) and plainchildrenfor menus that can't render components.Routing them elsewhere is one line in your app plugin, e.g. the toolbar, the context bar or your own navbar:
tsconst app: FolioPlugin = { name: 'app', ui: { route: { 'media.placement': ['toolbar', 'contextMenu'] } } }; // or a custom component: ui: { route: { 'media.placement': ['my-navbar'] } } → composed.ui.surface('my-navbar')See Customizing the UI ▸ Routing. The playground's Customize interface ▸ Controls placement switches it live.
Resize handles on the selected object: corners keep the aspect ratio (Shift frees it), side handles change the width; file cards and audio get side handles only. The drag is a local preview, committed as one undoable transaction; a
%width stays in%.Context menu on a selected media or file node: Replace…, Alt text…, Size ▸, Layout options ▸, Distance from text…, Corners and fit…, Download, Cancel/Retry upload, Delete.
Painters:
media:image,media:video,media:audio(with resize handles and an upload overlay) andfileAttachment(the card; the name links to the file).Text prompts (alt text) use
prompt(defaultwindow.prompt).
Paste is treated as a file paste only when there's no real text, so copying from Word or Docs (which put a picture of the selection next to the HTML) still pastes text.
Print and export
A static page can't play a video. mediaPrintFallback(attrs) and filePrintFallback(attrs) describe what to draw instead: an optional poster image, a title, a detail line ("Video · 3:25", "PDF · 1.2 MB · 12 pages") and the link. The plugin exposes them under exporters.pdf and exporters.docx, keyed media:video, media:audio, media:iframe and fileAttachment, plus a resolveSrc entry that turns stored srcs (idb:…) into fetchable URLs. Both exporters accept this map directly (their painters and node exporters take (attrs, ctx) and may return a PrintFallback); see Exporters.
Placement exports too: the PDF draws the laid-out pages, so floats and wrapped text come out as on screen. DOCX writes floating pictures as Word anchors (wp:anchor with wrapSquare / wrapTopAndBottom / wrapNone, positions and distances), and file/video cards as card paragraphs narrowed to their width and aligned, or as Word frames (w:framePr, text wrapping around) when they float.
API
See @nextgensoftwares/folio-plugin-media for every export.