url and provider, with an optional sourceUrl preserved for reversible editing.The fastest way to add comprehensive media support is with the MediaKit,
which includes pre-configured ImagePlugin, VideoPlugin, AudioPlugin,
FilePlugin, and MediaEmbedPlugin with their
Plate UI components.
Add UploadKit when the editor accepts local files. It owns the draft renderer
and upload controls; MediaKit owns completed media.
import {
AudioPlugin,
FilePlugin,
MediaEmbedPlugin,
VideoPlugin,
} from 'platejs/media/react';
import { AudioElement } from '@/components/editor/media-audio';
import { MediaEmbedElement } from '@/components/editor/media-embed';
import { FileElement } from '@/components/editor/media-file';
import { ImageElement } from '@/components/editor/media-image';
import {
imagePlugin,
MediaPreviewDialog,
} from '@/components/editor/media-preview-dialog';
import { VideoElement } from '@/components/editor/media-video';
export const
'use client';
import { createFilesClient } from 'files-sdk/client';
import { AudioLines, FileUp, Film, ImageIcon, Loader2Icon } from 'lucide-react';
import {
EditorElement,
useEditor,
useEditorReadOnly,
usePath,
usePluginStore,
type EditorElementProps,
} from 'platejs/react';
import type { UploadFailure, UploadKind } from 'platejs/upload';
import { UploadPlugin } from 'platejs/upload/react';
import * as React from 'react';
import { toast } from 'sonner';
ImageElement: Renders image elements.VideoElement: Renders video elements.AudioElement: Renders audio elements.FileElement: Renders file elements.MediaEmbedElement: Renders embedded media.UploadElement: Renders UploadKit's unbound, uploading, and failed draft slots.MediaPreviewDialog: Provides media preview functionality.Add the kit to your plugins:
import { createEditor } from 'platejs/react';
import { MediaKit } from '@/components/editor/media';
import { UploadKit } from '@/components/editor/upload';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...MediaKit,
...UploadKit,
],
});import { createEditor } from 'platejs/react';
Install the Files gateway and configure UploadKit with a FilesClient and a
durable URL resolver. See Upload files for the client, route,
access policy, and environment setup.
Include the media plugins in your Plate plugins array when creating the editor.
import { AudioPlugin, FilePlugin, ImagePlugin, MediaEmbedPlugin, VideoPlugin } from 'platejs/media/react';
import { createEditor } from 'platejs/react';
const editor = createEditor({
plugins: [
// ...otherPlugins,
ImagePlugin,
VideoPlugin,
AudioPlugin,
FilePlugin,
MediaEmbedPlugin,
],
});import { AudioPlugin, FilePlugin, ImagePlugin, MediaEmbedPlugin, VideoPlugin } from 'platejs/media/react'
Configure the media plugins with custom renderers.
import { AudioPlugin, FilePlugin, ImagePlugin, MediaEmbedPlugin, VideoPlugin } from 'platejs/media/react';
import { createEditor } from 'platejs/react';
import { AudioElement } from '@/components/editor/media-audio';
import { FileElement } from '@/components/editor/media-file';
import { ImageElement } from '@/components/editor/media-image';
import { MediaEmbedElement } from '@/components/editor/media-embed';
import { VideoElement } from '@/components/editor/media-video';
const editor = createEditor({
plugins: [
// ...otherPlugins,
ImagePlugin.configure({ component: ImageElement }),
VideoPlugin.configure({ component: VideoElement }),
component: Assigns custom components to render each media type.For local file uploads, add UploadPlugin separately as shown in
Upload files.
Note: When serialized to Markdown or MDX, embeds persist the canonical url and provider, plus an optional sourceUrl so edits remain reversible. Allowlisted provider snippets (e.g. YouTube, Tweet) are reduced to canonical URLs on paste. Raw <script> or custom embed chrome is out of scope — add your own rules if you need to preserve it.
Every image, audio, video, file, and embed is a non-void object element. The object role makes the asset selectable, meaningful even with an empty caption, and structurally isolating. Its direct inline children are the caption; the media plugin owns the schema and behavior, with no separate caption plugin, node type, or content root.
Configure only the media plugin and its renderer:
import { ImagePlugin } from 'platejs/media/react';
import { ImageElement } from '@/components/editor/media-image';
export const MediaKit = [
ImagePlugin.configure({ component: ImageElement }),
];import { ImagePlugin } from 'platejs/media/react';
import { ImageElement } from '@/components/editor/media-image';
export const MediaKit = [
ImagePlugin.configure
Keep the asset DOM non-editable and render the media element's ordinary child slot as the caption:
<figure>
<div contentEditable={false}>{/* media chrome */}</div>
<figcaption>{props.children}</figcaption>
</figure><figure>
<div contentEditable={false}>{/* media chrome */}</div>
<figcaption>{props.children}</figcaption>
</figure>Pass a string or inline children through the construction-only caption field:
editor.plugin(ImagePlugin).update.insert({
url: 'https://example.com/image.png',
caption: 'Plain caption',
});
editor.plugin(ImagePlugin).update.insert({
url: 'https://example.com/diagram.png',
caption: [
{ text: 'Rich ' },
{ text: 'caption', bold: true },
],
});editor.plugin
The insert command compiles caption into direct children. Persist only the
resulting media element:
{
type: 'image',
url: 'https://example.com/image.png',
children: [{ text: 'Plain caption' }],
}{
type: 'image',
url: 'https://example.com/image.png',
children: [{ text: 'Plain caption' }],
}An empty text child is the canonical absent-caption state:
{
type: 'image',
url: 'https://example.com/image.png',
children: [{ text: '' }],
}{
type: 'image',
url: 'https://example.com/image.png',
children: [{ text: '' }],
}The renderer can hide that empty caption until the media asset is focused.
Placeholder visibility is UI state and does not change the persisted element.
Shared media nodes persist url, optional rendered width, and direct caption children. Only FileElement adds optional name; Image, Audio, Video, and Media Embed do not inherit filename metadata.
Asset focus and caption editing are separate selection states:
| Selection | Behavior |
|---|---|
Plate NodeSelection at the media path | Focuses the asset, shows its selection ring and empty-caption placeholder, lets the forward arrow or ArrowDown enter the caption, and lets Delete remove the media node. |
Plate TextSelection inside the media children | Edits the caption; the backward arrow or ArrowUp at its start returns focus to the asset. |
In left-to-right text, ArrowRight from the text before a media block selects
the asset before the next press enters its caption. Moving back visits the
caption, then the selected asset, then the preceding text. An empty caption
remains one editable caret stop. Left and right keys reverse in a right-to-left
editable.
Copying or cutting the media NodeSelection transfers the asset and caption
together. Selecting only caption text transfers that text without the asset.
Enter in a caption moves its unselected suffix into a fresh paragraph after
the media element. This also applies when the selection extends into the next
paragraph; the media asset keeps its identity.
UploadPlugin uses a FilesClient from files-sdk/client to
upload local files. It validates a batch, authors keyed draft slots, and starts
requests after the document transaction commits. The application supplies a
synchronous getUrl that turns each SDK upload outcome into a durable absolute
URL. A completed slot becomes an image, video, audio, or file node through the
installed media plugin.
Use editor.plugin(UploadPlugin).update.submit(files) for a new batch,
{ slot: key } for an existing draft, or block placement options such as
{ after: key }. Cancel one task with editor.plugin(UploadPlugin).api.cancel(key).
Failed live tasks retain their local File for an explicit retry. Reloaded
drafts have no local file and need a new selection. See Upload files
for setup, task progress, and the gateway access policy.
You can add MediaToolbarButton to your Toolbar to upload and insert media.
You can add these items to the Insert Toolbar Button to insert media elements:
{
icon: <ImageIcon />,
label: 'Image',
value: PLUGINS.image,
}{
icon: <ImageIcon />,
label: 'Image',
value: PLUGINS.image,
}Plugin for non-void object image elements whose direct inline children store captions.
UploadPlugin handles pasted image files. ImagePlugin handles image URLs.
Plugin for non-void object video elements whose direct
inline children store captions. Extends MediaPluginState.
Plugin for non-void object audio elements whose direct
inline children store captions. Extends MediaPluginState.
Plugin for non-void object file elements whose direct
inline children store captions. Extends MediaPluginState.
Plugin for non-void object media embed elements whose
direct inline children store captions. Extends MediaPluginState.
Plugin for persisted media draft slots and editor-owned upload tasks. The
headless BaseUploadPlugin from platejs/upload owns the same state machine;
the React plugin adds the optional native DOM drop adapter.
The client created by createFilesClient from files-sdk/client.
Default: null.
Synchronously maps an SDK outcome to a durable absolute URL.
Default: null.
Maps audio | blob | image | pdf | text | video to a required draft
kind and optional minFiles, maxFiles, and maxBytes limits.
Maximum files in one submitted batch. Default:
Number.POSITIVE_INFINITY.
Receives each rejected batch or failed task once. Default: null.
The React UploadPlugin also accepts nativeDrop: boolean (default false).
Leave it disabled when DndPlugin owns file placement.
| Operation | Contract |
|---|---|
editor.plugin(UploadPlugin).update.submit(files, options) | Validates and inserts one complete batch, then starts its SDK requests after commit. |
editor.plugin(UploadPlugin).api.cancel(key) | Aborts the task owned by that live draft key. |
editor.plugin(UploadPlugin).store.get('task', key) | Returns UploadTask or undefined; its file, getSnapshot, and subscribe support progress and retry UI. |
A task remains authoritative while its NodeKey, draft type, and kind
remain live. Moving the draft preserves authority. Removal, retagging,
whole-document replacement, retry, or plugin cleanup aborts it. Read-only mode
blocks new submissions but does not cancel a request that already committed.
UploadFailure distinguishes admission, configuration, upload, and URL-result
failures. Upload failures retain the SDK error as unknown; an invalid or
throwing getUrl is a result failure. The task snapshot uses the SDK's
AggregateProgress (loaded, total, fraction) rather than a percent value.
Slot mode reuses the first draft and inserts any additional files next to it.
The first file must resolve to the slot's kind. Every mode validates the
whole batch before changing the document.
Inserts an image element into the editor.
Transforms and normalizes a URL, then inserts a media embed.
The selected media plugin captures the insertion target while the application
resolves URL input. Image, embed, audio, video and file plugins expose this API.
The operation calls the installed update.insert, including its URL validation
and application overrides. It returns false for cancelled or invalid input or
a removed target. A rejected resolver promise propagates to the caller.
const inserted = await editor.plugin(BaseImagePlugin).api.insertUrl(
getUrlFromDialog,
{ caption: 'An optional caption', select: true }
);const inserted = await editor.plugin(BaseImagePlugin).api.insertUrl(
getUrlFromDialog,
{ caption: 'An optional caption', select: true }
);getUrlFromDialog is an application function returning a URL, null, or a promise
of either. Options accept the insertion at location, after source block,
replaceEmpty, caption and select. With replaceEmpty: true, the captured
source is replaced only if it is still empty when the URL resolves. Moves and
intervening text edits preserve the source identity and content.
The application owns the URL dialog and selects the media plugin.
media-toolbar owns URL editing, submission, cancellation, and focus restore.
Each copied media node reads its typed element and primitive editor state
directly. media-image renders the image and opens
media-preview-dialog, which owns preview navigation, scale, translation, and
download behavior.
'use client';
import { Link, Trash2Icon } from 'lucide-react';
import type { MediaPlugin } from 'platejs/media/react';
import {
useEditor,
useElement,
useEditorReadOnly,
useFocusedLast,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { CaptionButton } from './caption';
function MediaToolbarContent({ plugin }: { plugin: MediaPlugin }) {
const editor = useEditor();
const element = useElement(plugin);
const [isEditing, setIsEditing] = React.useState(false);
const [url, setUrl] = React.useState('');
const reset = () => {
setUrl('');
setIsEditing(false);
};
if (isEditing) {
return (
<div className="flex w-[330px] flex-col">
<div className="flex items-center">
<div className="flex items-center pr-1 pl-2 text-muted-foreground">
<Link className="size-4" />
</div>
<Input
className="h-7 border-none bg-transparent px-1.5 py-1 focus-visible:ring-transparent"
value={url}
placeholder="Paste the embed link..."
onChange={(event) => {
setUrl(event.target.value);
}}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
if (
url !== element.url &&
!editor.plugin(plugin).update.setUrl({ element, url })
) {
return;
}
reset();
editor.api.dom.focus();
}
if (event.key === 'Escape') {
reset();
editor.api.dom.focus();
}
}}
autoFocus
/>
</div>
</div>
);
}
return (
<div className="box-content flex items-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
const sourceUrl =
'sourceUrl' in element && typeof element.sourceUrl === 'string'
? element.sourceUrl
: undefined;
setUrl(sourceUrl ?? element.url);
setIsEditing(true);
}}
>
Edit link
</Button>
<CaptionButton size="sm" variant="ghost">
Caption
</CaptionButton>
<Separator orientation="vertical" className="mx-1 h-6" />
<Button
size="sm"
variant="ghost"
onClick={() => {
editor.update.nodes.remove({ at: element });
editor.api.dom.focus();
}}
onMouseDown={(event) => {
event.preventDefault();
}}
>
<Trash2Icon />
</Button>
</div>
);
}
export function MediaToolbar({
children,
disabled = false,
plugin,
selected,
}: {
children: React.ReactElement;
disabled?: boolean;
plugin: MediaPlugin;
selected: boolean;
}) {
const isFocusedLast = useFocusedLast();
const readOnly = useEditorReadOnly();
const open = isFocusedLast && !readOnly && selected && !disabled;
return (
<FloatingPopover open={open} modal={false}>
<FloatingPopoverAnchor element={children} />
<FloatingPopoverContent
className="w-auto p-1"
onInitialFocus={(e) => {
e.preventDefault();
}}
>
{open ? <MediaToolbarContent plugin={plugin} /> : null}
</FloatingPopoverContent>
</FloatingPopover>
);
}'use client';
import { Link, Trash2Icon } from 'lucide-react';
import type { MediaPlugin } from 'platejs/media/react';
import {
useEditor,
useElement,
useEditorReadOnly,
useFocusedLast,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import {
FloatingPopover,
FloatingPopoverAnchor,
'use client';
import { useDraggable } from 'platejs/dnd/react';
import { ImagePlugin } from 'platejs/media/react';
import {
EditorElement,
useEditor,
useEditorFocused,
useElementSelected,
usePath,
usePluginStore,
type EditorElementProps,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Caption, useCaptionFocused } from './caption';
import { imagePlugin } from './media-preview-dialog';
import { MediaToolbar } from './media-toolbar';
import {
mediaResizeHandleVariants,
Resizable,
ResizeHandle,
} from './resize-handle';
export function ImageElement(props: EditorElementProps<typeof imagePlugin>) {
const path = usePath();
const focused = useEditorFocused();
const selected = useElementSelected({ mode: 'node' });
const textAlign =
'textAlign' in props.element &&
(props.element.textAlign === 'left' ||
props.element.textAlign === 'right' ||
props.element.textAlign === 'center')
? props.element.textAlign
: 'center';
const editor = useEditor();
const captionFocused = useCaptionFocused(path);
const previewOpen = usePluginStore(imagePlugin, 'previewOpen');
const { isDragging, handleRef } = useDraggable({
element: props.element,
});
return (
<MediaToolbar
disabled={previewOpen}
plugin={ImagePlugin}
selected={selected}
>
<EditorElement
{...props}
attributes={{
...props.attributes,
'data-node-selection-highlight': 'self',
}}
className="py-2.5"
>
<figure className="relative m-0 hover:[&_.editor-media-resize-handle]:after:opacity-100">
<div contentEditable={false}>
<Resizable
align={textAlign}
minWidth={92}
onResizeEnd={(width) => {
editor.plugin(imagePlugin).update.set({ width }, { at: path });
}}
width={props.element.width}
>
<ResizeHandle
className={mediaResizeHandleVariants({ direction: 'left' })}
direction="left"
/>
<div>
{/* oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] The editor node owns a user URL, native draggable image, composed ref, and resizable width. */}
<img
ref={handleRef}
className={cn(
'block w-full max-w-full cursor-pointer object-cover px-0',
'rounded-sm',
focused && selected && 'ring-2 ring-ring ring-offset-2',
isDragging && 'opacity-50'
)}
alt={props.element.alt}
draggable
src={props.element.url}
onDoubleClickCapture={() => {
editor
.plugin(imagePlugin)
.api.preview.open(props.element, props.element.url);
}}
/>
</div>
<ResizeHandle
className={mediaResizeHandleVariants({
direction: 'right',
})}
direction="right"
/>
</Resizable>
</div>
<Caption
active={selected || captionFocused}
align={textAlign}
element={props.element}
slots={props.slots}
>
{props.children}
</Caption>
</figure>
</EditorElement>
</MediaToolbar>
);
}'use client';
import { useDraggable } from 'platejs/dnd/react';
import { ImagePlugin } from 'platejs/media/react';
import {
EditorElement,
useEditor,
useEditorFocused,
useElementSelected,
usePath,
usePluginStore,
type EditorElementProps,
} from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Caption, useCaptionFocused } from './caption';
import { imagePlugin } from './media-preview-dialog';
'use client';
import { cva } from 'class-variance-authority';
import { ArrowLeft, ArrowRight, Download, Minus, Plus, X } from 'lucide-react';
import { type NodeKey, isHotkey } from 'platejs';
import { BaseImagePlugin, type ImageElement } from 'platejs/media';
import { ImagePlugin } from 'platejs/media/react';
import { useComposedRef, useEditor, usePluginStore } from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva('rounded bg-[rgba(0,0,0,0.5)] px-1', {
defaultVariants: {
variant: 'default',
},
variants: {
variant: {
default: 'text-white',
disabled: 'cursor-not-allowed text-gray-400',
},
},
});
const SCROLL_SPEED = 4;
const DEFAULT_DOWNLOAD_FILENAME = 'image';
const ZOOM_LEVELS = [0, 0.5, 1, 1.5, 2];
type PreviewItem = {
key: NodeKey;
url: string;
};
type ImagePreviewState = {
boundingClientRect: DOMRect | null;
currentPreview: PreviewItem | null;
isEditingScale: boolean;
openEditorId: string | null;
previewList: PreviewItem[];
scale: number;
translate: { x: number; y: number };
};
const createInitialPreviewState = (): ImagePreviewState => ({
boundingClientRect: null,
currentPreview: null,
isEditingScale: false,
openEditorId: null,
previewList: [],
scale: 1,
translate: { x: 0, y: 0 },
});
export const imagePlugin = ImagePlugin.extend({
initialState: { preview: createInitialPreviewState() },
}).extend(({ editor, store }) => ({
api: () => ({
preview: {
close: () => {
store.set({ preview: createInitialPreviewState() });
editor.api.dom.focus();
},
next: () => {
const preview = store.get('preview');
const currentIndex = preview.currentPreview
? preview.previewList.findIndex(
(item) =>
item.url === preview.currentPreview?.url &&
item.key === preview.currentPreview.key
)
: -1;
if (
currentIndex >= 0 &&
currentIndex < preview.previewList.length - 1
) {
store.set({
preview: {
...preview,
boundingClientRect: null,
currentPreview: preview.previewList[currentIndex + 1],
isEditingScale: false,
scale: 1,
translate: { x: 0, y: 0 },
},
});
}
},
open: (element: ImageElement, resolvedUrl = element.url) => {
const currentKey = editor.key(element);
if (currentKey == null) return;
store.set({
preview: {
...createInitialPreviewState(),
currentPreview: {
key: currentKey,
url: resolvedUrl,
},
openEditorId: editor.id,
previewList: Array.from(
editor.read.nodes.entries({ at: [], type: BaseImagePlugin })
).flatMap(([node, path]) => {
const key = editor.key(path);
return key == null
? []
: [
{
key,
url: key === currentKey ? resolvedUrl : node.url,
},
];
}),
},
});
},
previous: () => {
const preview = store.get('preview');
const currentIndex = preview.currentPreview
? preview.previewList.findIndex(
(item) =>
item.url === preview.currentPreview?.url &&
item.key === preview.currentPreview.key
)
: -1;
if (currentIndex > 0) {
store.set({
preview: {
...preview,
boundingClientRect: null,
currentPreview: preview.previewList[currentIndex - 1],
isEditingScale: false,
scale: 1,
translate: { x: 0, y: 0 },
},
});
}
},
setEditingScale: (isEditingScale: boolean) => {
const preview = store.get('preview');
store.set({ preview: { ...preview, isEditingScale } });
},
setScale: (scale: number) => {
const preview = store.get('preview');
store.set({
preview: {
...preview,
boundingClientRect: scale <= 1 ? null : preview.boundingClientRect,
scale,
translate: scale <= 1 ? { x: 0, y: 0 } : preview.translate,
},
});
},
setTranslate: (translate: { x: number; y: number }) => {
const preview = store.get('preview');
store.set({ preview: { ...preview, translate } });
},
zoomIn: () => {
const preview = store.get('preview');
const scale = ZOOM_LEVELS.find((target) => preview.scale < target);
if (scale !== undefined) {
store.set({ preview: { ...preview, scale } });
}
},
zoomOut: () => {
const preview = store.get('preview');
const scale = ZOOM_LEVELS.findLast((target) => preview.scale > target);
if (scale !== undefined) {
store.set({
preview: {
...preview,
boundingClientRect:
scale <= 1 ? null : preview.boundingClientRect,
scale,
translate: scale <= 1 ? { x: 0, y: 0 } : preview.translate,
},
});
}
},
},
}),
selectors: {
previewOpen: (state) => state.preview.openEditorId === editor.id,
},
}));
export function MediaPreviewDialog() {
const { api } = useEditor().plugin(imagePlugin);
const preview = usePluginStore(imagePlugin, 'preview');
const isOpen = usePluginStore(imagePlugin, 'previewOpen');
const {
boundingClientRect,
currentPreview,
isEditingScale,
previewList,
scale,
translate,
} = preview;
const currentPreviewIndex = currentPreview
? previewList.findIndex(
(item) =>
item.url === currentPreview.url && item.key === currentPreview.key
)
: null;
const prevDisabled = currentPreviewIndex === 0;
const nextDisabled = currentPreviewIndex === previewList.length - 1;
const zoomOutDisabled = scale <= 0.5;
const zoomInDisabled = scale >= 2;
const downloadDisabled = !currentPreview?.url;
React.useEffect(() => {
if (!isOpen) return undefined;
const onWheel = (event: WheelEvent) => {
if (scale <= 1 || !boundingClientRect) return;
event.preventDefault();
const { deltaX, deltaY } = event;
const { x, y } = translate;
const { bottom, left, right, top } = boundingClientRect;
let nextX = x - deltaX / SCROLL_SPEED;
let nextY = y - deltaY / SCROLL_SPEED;
if (left - deltaX / SCROLL_SPEED > window.innerWidth / 2 && deltaX < 0) {
nextX = x;
}
if (right - deltaX / SCROLL_SPEED < window.innerWidth / 2 && deltaX > 0) {
nextX = x;
}
if (top - deltaY / SCROLL_SPEED > window.innerHeight / 2 && deltaY < 0) {
nextY = y;
}
if (
bottom - deltaY / SCROLL_SPEED < window.innerHeight / 2 &&
deltaY > 0
) {
nextY = y;
}
api.preview.setTranslate({ x: nextX, y: nextY });
};
document.addEventListener('wheel', onWheel, { passive: false });
return () => {
document.removeEventListener('wheel', onWheel);
};
}, [api.preview, boundingClientRect, isOpen, scale, translate]);
React.useEffect(() => {
if (!isOpen) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (!isHotkey('escape')(event)) return;
event.stopPropagation();
api.preview.close();
};
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
}, [api.preview, isOpen]);
const handleDownload = () => {
if (!currentPreview?.url) return;
const link = document.createElement('a');
link.download = getImageDownloadFilename(currentPreview.url);
link.href = currentPreview.url;
link.rel = 'noopener noreferrer';
document.body.append(link);
link.click();
link.remove();
};
return (
<div
className={cn(
'fixed top-0 left-0 z-50 h-screen w-screen select-none',
!isOpen && 'hidden'
)}
onContextMenu={(e) => {
e.stopPropagation();
}}
>
<button
aria-label="Close preview"
className="absolute inset-0 size-full border-0 bg-black p-0 opacity-60"
onClick={api.preview.close}
type="button"
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative flex max-h-screen w-full items-center">
<PreviewImage
className={cn(
'mx-auto block max-h-[calc(100vh-4rem)] w-auto object-contain transition-transform'
)}
/>
<div className="absolute bottom-0 left-1/2 z-40 flex w-fit -translate-x-1/2 justify-center gap-4 p-2 text-center text-white">
<div className="flex gap-1">
<button
aria-label="Previous image"
className={cn(
buttonVariants({
variant: prevDisabled ? 'disabled' : 'default',
})
)}
disabled={prevDisabled}
onClick={api.preview.previous}
type="button"
>
<ArrowLeft />
</button>
{(currentPreviewIndex ?? 0) + 1}
<button
aria-label="Next image"
className={cn(
buttonVariants({
variant: nextDisabled ? 'disabled' : 'default',
})
)}
disabled={nextDisabled}
onClick={api.preview.next}
type="button"
>
<ArrowRight />
</button>
</div>
<div className="flex">
<button
aria-label="Zoom out"
className={cn(
buttonVariants({
variant: zoomOutDisabled ? 'disabled' : 'default',
})
)}
disabled={zoomOutDisabled}
onClick={api.preview.zoomOut}
type="button"
>
<Minus className="size-4" />
</button>
<div className="mx-px">
{isEditingScale ? (
<>
<ScaleInput
key={scale}
className="w-10 rounded px-1 text-slate-500 outline"
scale={scale}
onCommit={(nextScale) => {
api.preview.setScale(nextScale);
api.preview.setEditingScale(false);
}}
/>{' '}
<span>%</span>
</>
) : (
<button
aria-label="Set zoom level"
className="border-0 bg-transparent p-0 text-inherit"
onClick={() => {
api.preview.setEditingScale(true);
}}
type="button"
>
{`${scale * 100}%`}
</button>
)}
</div>
<button
aria-label="Zoom in"
className={cn(
buttonVariants({
variant: zoomInDisabled ? 'disabled' : 'default',
})
)}
disabled={zoomInDisabled}
onClick={api.preview.zoomIn}
type="button"
>
<Plus className="size-4" />
</button>
</div>
<button
aria-label="Download image"
className={cn(
buttonVariants({
variant: downloadDisabled ? 'disabled' : 'default',
})
)}
disabled={downloadDisabled}
onClick={handleDownload}
type="button"
>
<Download className="size-4" />
</button>
<button
aria-label="Close preview"
className={cn(buttonVariants())}
onClick={api.preview.close}
type="button"
>
<X className="size-4" />
</button>
</div>
</div>
</div>
</div>
);
}
function PreviewImage({
alt = '',
ref,
...props
}: React.ComponentPropsWithRef<'img'>) {
const { api, store } = useEditor().plugin(imagePlugin);
const preview = usePluginStore(imagePlugin, 'preview');
const imageRef = React.useRef<HTMLImageElement>(null);
const isZoomIn = preview.scale <= 1;
React.useEffect(() => {
if (preview.scale <= 1) return;
const boundingClientRect = imageRef.current?.getBoundingClientRect();
if (!boundingClientRect) return;
store.set({ preview: { ...store.get('preview'), boundingClientRect } });
}, [preview.scale, preview.translate.x, preview.translate.y, store]);
return (
<button
aria-label={isZoomIn ? 'Zoom in preview image' : 'Zoom out preview image'}
className="block border-0 bg-transparent p-0"
onClick={(event) => {
event.stopPropagation();
api.preview[isZoomIn ? 'zoomIn' : 'zoomOut']();
}}
type="button"
>
{/* oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] The preview owns a runtime URL, imperative ref, and live CSS transform that Next Image cannot preserve. */}
<img
alt={alt}
ref={useComposedRef(imageRef, ref)}
draggable={false}
src={preview.currentPreview?.url}
style={{
cursor: isZoomIn ? 'zoom-in' : 'zoom-out',
transform: `translate(${preview.translate.x}px, ${preview.translate.y}px) scale(${preview.scale})`,
}}
{...props}
/>
</button>
);
}
function ScaleInput({
onCommit,
scale,
...props
}: React.ComponentProps<'input'> & {
scale: number;
onCommit: (scale: number) => void;
}) {
const [value, setValue] = React.useState(`${scale * 100}`);
return (
<input
autoFocus
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
onFocus={(event) => {
event.currentTarget.select();
}}
onKeyDown={(event) => {
if (!isHotkey('enter')(event)) return;
event.preventDefault();
const percentage = Number(value);
if (!Number.isFinite(percentage)) return;
const nextScale = Math.min(200, Math.max(50, percentage)) / 100;
onCommit(Number(nextScale.toFixed(2)));
}}
{...props}
/>
);
}
function getImageDownloadFilename(url: string) {
try {
const { pathname } = new URL(url, window.location.href);
const filename = pathname.split('/').findLast(Boolean);
return filename || DEFAULT_DOWNLOAD_FILENAME;
} catch {
return DEFAULT_DOWNLOAD_FILENAME;
}
}'use client';
import { cva } from 'class-variance-authority';
import { ArrowLeft, ArrowRight, Download, Minus, Plus, X } from 'lucide-react';
import { type NodeKey, isHotkey } from 'platejs';
import { BaseImagePlugin, type ImageElement } from 'platejs/media';
import { ImagePlugin } from 'platejs/media/react';
import { useComposedRef, useEditor, usePluginStore } from 'platejs/react';
import * as React from 'react';
import { cn } from '@/lib/utils';
const buttonVariants = cva('rounded bg-[rgba(0,0,0,0.5)] px-1'
For resizable media, compose the direct Resizable and ResizeHandle
components from platejs/react. The media renderer
commits the final width through its scoped plugin update.
Parses a media URL for plugin-specific handling.
Parses a video URL and extracts the video ID and provider-specific embed URL.
Parses a Twitter URL and extracts the tweet ID.
Parses the URL of an iframe embed.
import type { AudioElement, FileElement, ImageElement, MediaEmbedElement, VideoElement } from 'platejs/media';import type { AudioElement, FileElement, ImageElement, MediaEmbedElement, VideoElement } from 'platejs/media';Each alias is derived from its owning plugin schema. Element.children stores
the caption's direct inline content. Use
[{ text: '' }] when the caption is absent.
import type { UploadElement } from 'platejs/upload';import type { UploadElement } from 'platejs/upload';UploadElement is derived from BaseUploadPlugin and requires a
strict kind: 'audio' | 'file' | 'image' | 'video'.
export interface EmbedUrlData {
id?: string;
provider?: string;
sourceKind?: 'allowlisted_snippet' | 'iframe' | 'url';
sourceUrl?: string;
url?: string;
}export interface EmbedUrlData {
id?: string;
provider?: string;
sourceKind?: 'allowlisted_snippet' | 'iframe' | 'url';
sourceUrl?: string;
url?: string;
}import {
AudioPlugin,
FilePlugin,
MediaEmbedPlugin,
VideoPlugin,
} from 'platejs/media/react';
import { AudioElement } from '@/components/editor/media-audio';
import { MediaEmbedElement } from '@/components/editor/media-embed';
import { FileElement } from '@/components/editor/media-file';
import { ImageElement } from '@/components/editor/media-image';
import {
imagePlugin,
MediaPreviewDialog,
} from '@/components/editor/media-preview-dialog';
import { VideoElement } from '@/components/editor/media-video';
export const MediaKit = [
imagePlugin.configure({
component: ImageElement,
slots: { afterEditable: MediaPreviewDialog },
}),
MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
VideoPlugin.configure({ component: VideoElement }),
AudioPlugin.configure({ component: AudioElement }),
FilePlugin.configure({ component: FileElement }),
];'use client';
import { createFilesClient } from 'files-sdk/client';
import { AudioLines, FileUp, Film, ImageIcon, Loader2Icon } from 'lucide-react';
import {
EditorElement,
useEditor,
useEditorReadOnly,
usePath,
usePluginStore,
type EditorElementProps,
} from 'platejs/react';
import type { UploadFailure, UploadKind } from 'platejs/upload';
import { UploadPlugin } from 'platejs/upload/react';
import * as React from 'react';
import { toast } from 'sonner';
import { useFilePicker } from 'use-file-picker';
import { cn } from '@/lib/utils';
import { useObjectUrl } from '@/hooks/use-object-url';
// The copied playground uses one document; applications supply their active document ID.
const endpoint = '/api/files?documentId=playground';
const client = createFilesClient({ endpoint });
const CONTENT: Record<
UploadKind,
{
accept: string[];
content: React.ReactNode;
icon: React.ReactNode;
}
> = {
audio: {
accept: ['audio/*'],
content: 'Add an audio file',
icon: <AudioLines />,
},
file: {
accept: ['*'],
content: 'Add a file',
icon: <FileUp />,
},
image: {
accept: ['image/*'],
content: 'Add an image',
icon: <ImageIcon />,
},
video: {
accept: ['video/*'],
content: 'Add a video',
icon: <Film />,
},
};
const fileNames = (files: readonly File[]) =>
files.map((file) => file.name).join(', ');
const showUploadFailure = (failure: UploadFailure) => {
if (failure.phase === 'admission') {
switch (failure.code) {
case 'unsupported-file-type': {
toast.error(
`The type of ${fileNames(failure.files)} is not supported.`
);
return;
}
case 'file-too-large': {
toast.error(
`${fileNames(failure.files)} exceeds the ${formatBytes(failure.maxBytes)} limit.`
);
return;
}
case 'too-few-files': {
toast.error(
`Select at least ${failure.minFiles} ${failure.fileType} file${failure.minFiles === 1 ? '' : 's'}.`
);
return;
}
case 'too-many-files': {
toast.error(
`Select at most ${failure.maxFiles}${failure.fileType ? ` ${failure.fileType}` : ''} file${failure.maxFiles === 1 ? '' : 's'}.`
);
return;
}
}
}
switch (failure.code) {
case 'missing-client':
case 'missing-url-resolver':
case 'missing-destination': {
toast.error('File uploads are not configured.');
break;
}
case 'upload-error':
case 'resolver-error': {
toast.error(
failure.error instanceof Error
? failure.error.message
: `Could not upload ${failure.file.name}.`
);
break;
}
case 'invalid-url': {
toast.error(
`The upload for ${failure.file.name} returned an invalid URL.`
);
break;
}
}
};
export function UploadElement(props: EditorElementProps<typeof UploadPlugin>) {
const { element } = props;
const editor = useEditor();
const readOnly = useEditorReadOnly();
const nodeKey = usePath((path) => editor.key(path));
if (!nodeKey) {
throw new Error('File upload element requires a live node key.');
}
const { api, update } = editor.plugin(UploadPlugin);
const task = usePluginStore(UploadPlugin, 'task', nodeKey);
const state = React.useSyncExternalStore(
React.useCallback(
(listener) => task?.subscribe(listener) ?? (() => {}),
[task]
),
React.useCallback(() => task?.getSnapshot() ?? null, [task]),
() => null
);
const currentContent = CONTENT[element.kind];
const currentFile = task?.file;
const progress = Math.round((state?.progress.fraction ?? 0) * 100);
const loading = state?.status === 'uploading';
const failed = state?.status === 'failed';
const isImage = element.kind === 'image';
const { openFilePicker } = useFilePicker({
accept: currentContent.accept,
multiple: true,
readFilesContent: false,
onFilesSelected: ({ plainFiles }) => {
if (readOnly) return;
update.submit(plainFiles, { slot: nodeKey });
},
});
return (
<EditorElement className="relative my-1" {...props}>
<div contentEditable={false}>
{(!loading || !isImage) && (
<button
className={cn(
'flex w-full cursor-pointer select-none items-center rounded-sm bg-muted p-3 pr-9 text-left hover:bg-primary/10'
)}
disabled={loading || readOnly}
onClick={() => openFilePicker()}
type="button"
>
<div className="relative mr-3 flex text-muted-foreground/80 [&_svg]:size-6">
{currentContent.icon}
</div>
<div className="text-sm whitespace-nowrap text-muted-foreground">
<div>{currentFile?.name ?? currentContent.content}</div>
{loading && !isImage && currentFile && (
<div className="mt-1 flex items-center gap-1.5">
<div>{formatBytes(currentFile.size)}</div>
<div>–</div>
<div className="flex items-center">
<Loader2Icon className="mr-1 size-3.5 animate-spin text-muted-foreground" />
{progress}%
</div>
</div>
)}
{failed && <div className="mt-1">Upload failed. Try again.</div>}
</div>
</button>
)}
{isImage && loading && currentFile && (
<ImageProgress file={currentFile} progress={progress} />
)}
{loading && !readOnly && (
<button
aria-label="Cancel upload"
className="absolute top-2 right-2 rounded-sm bg-background/80 p-1 text-xs text-foreground"
onClick={() => api.cancel(nodeKey)}
type="button"
>
Cancel
</button>
)}
</div>
{props.children}
</EditorElement>
);
}
export function ImageProgress({
className,
file,
progress = 0,
}: {
file: File;
className?: string;
progress?: number;
}) {
const previewUrl = useObjectUrl(file);
if (!previewUrl) return null;
return (
<div className={cn('relative', className)} contentEditable={false}>
{/* oxlint-disable-next-line nextjs/no-img-element -- [P1 local-invariant] This local blob preview owns and revokes its object URL. */}
<img
className="h-auto w-full rounded-sm object-cover"
alt={file.name}
src={previewUrl}
/>
<div className="absolute right-1 bottom-1 flex items-center gap-2 rounded-full bg-black/50 px-1 py-0.5">
<Loader2Icon className="size-3.5 animate-spin text-muted-foreground" />
<span className="text-xs font-medium text-white">
{Math.round(progress)}%
</span>
</div>
</div>
);
}
export const UploadKit = [
UploadPlugin.configure({
component: UploadElement,
initialState: {
client,
getUrl: ({ key }) => {
const url = new URL(endpoint, window.location.origin);
url.searchParams.set('op', 'download');
url.searchParams.set('key', key);
return url.href;
},
maxFiles: 5,
onError: showUploadFailure,
rules: {
audio: {
kind: 'audio',
maxBytes: 8 * 1024 * 1024,
maxFiles: 1,
minFiles: 1,
},
blob: {
kind: 'file',
maxBytes: 8 * 1024 * 1024,
maxFiles: 1,
minFiles: 1,
},
image: {
kind: 'image',
maxBytes: 4 * 1024 * 1024,
maxFiles: 3,
minFiles: 1,
},
pdf: {
kind: 'file',
maxBytes: 4 * 1024 * 1024,
maxFiles: 1,
minFiles: 1,
},
text: {
kind: 'file',
maxBytes: 64 * 1024,
maxFiles: 1,
minFiles: 1,
},
video: {
kind: 'video',
maxBytes: 16 * 1024 * 1024,
maxFiles: 1,
minFiles: 1,
},
},
},
}),
];
function formatBytes(
bytes: number,
opts: {
decimals?: number;
sizeType?: 'accurate' | 'normal';
} = {}
) {
const { decimals = 0, sizeType = 'normal' } = opts;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const accurateSizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
if (bytes === 0) return '0 Byte';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / 1024 ** i).toFixed(decimals)} ${
sizeType === 'accurate'
? (accurateSizes[i] ?? 'Bytes')
: (sizes[i] ?? 'Bytes')
}`;
}import { AudioPlugin, FilePlugin, ImagePlugin, MediaEmbedPlugin, VideoPlugin } from 'platejs/media/react';
import { createEditor } from 'platejs/react';
import { AudioElement } from '@/components/editor/media-audio';
import { FileElement } from '@/components/editor/media-file';
import { ImageElement } from '@/components/editor/media-image';
import { MediaEmbedElement } from '@/components/editor/media-embed';
import { VideoElement } from '@/components/editor/media-video';
const editor = createEditor({
plugins: [
// ...otherPlugins,
ImagePlugin.configure({ component: ImageElement }),
VideoPlugin.configure({ component: VideoElement }),
AudioPlugin.configure({ component: AudioElement }),
FilePlugin.configure({ component: FileElement }),
MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
],
});