From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Discussion
    • Comments
    • Suggestion
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • List Classic
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Toggle
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Cursor Overlay
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

Media

PreviousNext

Embed medias like videos or tweets into your document.

PlusImage ElementVideo ElementAudio ElementFile ElementMedia Embed ElementMedia PopoverMedia Placeholder ElementMedia Upload ToastMedia Toolbar Button
Loading…
List ClassicMention

On This Page

FeaturesMedia SupportMedia FeaturesUploadKit UsageInstallationAdd KitAdd API RoutesEnvironment SetupManual UsageInstallationAdd PluginsConfigure PluginsCaptionsCustom Upload ImplementationAdd Toolbar ButtonInsert Toolbar ButtonPlate PlusPluginsImagePluginVideoPluginAudioPluginFilePluginMediaEmbedPluginPlaceholderPluginAPIeditor.api.placeholder.addUploadingFileeditor.plugin(PlaceholderPlugin).store.get('uploadingFile', id)editor.api.placeholder.removeUploadingFileTransformseditor.plugin(PlaceholderPlugin).update.insertMediaeditor.plugin(BasePlaceholderPlugin).update.inserteditor.plugin(BaseImagePlugin).update.inserteditor.plugin(BaseMediaEmbedPlugin).update.insertinsertMediaUrlRegistry Media UIUtilitiesparseMediaUrlparseVideoUrlparseTwitterUrlparseIframeUrlTypesMedia element typesPlaceholderElementEmbedUrlData
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

Media Support

  • File types:
    • Image
    • Video
    • Audio
    • Others (PDF, Word, etc.)
  • Video providers:
    • Local video files
    • YouTube, Vimeo, Dailymotion, Youku, Coub
  • Embed providers:
    • Tweets

Media Features

  • Editable captions
  • Resizable elements
  • Embed URLs are normalized into url and provider, with an optional sourceUrl preserved for reversible editing.

Upload

  • Multiple upload methods:
    • Toolbar button with file picker
    • Drag and drop from file system
    • Paste from clipboard (images)
    • URL embedding for external media
  • Upload experience:
    • Real-time progress tracking
    • Preview during upload
    • Automatically converts the placeholder to the appropriate media element (image, video, audio, file) once the upload or embed is submitted
    • Error handling
    • File size validation
    • Type validation
Report an issue

Kit Usage

Installation

The fastest way to add comprehensive media support is with the MediaKit, which includes pre-configured ImagePlugin, VideoPlugin, AudioPlugin, FilePlugin, MediaEmbedPlugin, and PlaceholderPlugin with their Plate UI components.

'use client';
 
import {
  PlaceholderPlugin,
  UploadErrorCode,
  AudioPlugin,
  FilePlugin,
  MediaEmbedPlugin,
  VideoPlugin,
} from '@platejs/media/react';
import { usePluginStore } from 'platejs/react';
import * as React from 'react';
import { toast } from 'sonner';
 
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 { PlaceholderElement } from '@/components/editor/media-placeholder';
import {
  imagePlugin,
  MediaPreviewDialog,
} from '@/components/editor/media-preview-dialog';
import { VideoElement } from '@/components/editor/media-video';
 
export function MediaUploadToast() {
  const uploadError = usePluginStore(PlaceholderPlugin, 'error');
 
  React.useEffect(() => {
    if (!uploadError) return;
 
    const { code, data } = uploadError;
 
    switch (code) {
      case UploadErrorCode.INVALID_FILE_SIZE: {
        toast.error(
          `The size of files ${data.files
            .map((f) => f.name)
            .join(', ')} is invalid`
        );
 
        break;
      }
      case UploadErrorCode.INVALID_FILE_TYPE: {
        toast.error(
          `The type of files ${data.files
            .map((f) => f.name)
            .join(', ')} is invalid`
        );
 
        break;
      }
      case UploadErrorCode.TOO_LARGE: {
        toast.error(
          `The size of files ${data.files
            .map((f) => f.name)
            .join(', ')} is too large than ${data.maxFileSize}`
        );
 
        break;
      }
      case UploadErrorCode.TOO_LESS_FILES: {
        toast.error(
          `The mini um number of files is ${data.minFileCount} for ${data.fileType}`
        );
 
        break;
      }
      case UploadErrorCode.TOO_MANY_FILES: {
        toast.error(
          `The maximum number of files is ${data.maxFileCount} ${
            data.fileType ? `for ${data.fileType}` : ''
          }`
        );
 
        break;
      }
    }
  }, [uploadError]);
 
  return null;
}
 
export const MediaKit = [
  imagePlugin.configure({
    component: ImageElement,
    initialState: { disableUploadInsert: true },
    render: { afterEditable: MediaPreviewDialog },
  }),
  MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
  VideoPlugin.configure({ component: VideoElement }),
  AudioPlugin.configure({ component: AudioElement }),
  FilePlugin.configure({ component: FileElement }),
  PlaceholderPlugin.configure({
    component: PlaceholderElement,
    initialState: {
      disableEmptyPlaceholder: true,
      maxFileCount: 5,
      uploadConfig: {
        audio: {
          maxFileCount: 1,
          maxFileSize: '8MB',
          mediaType: 'audio',
          minFileCount: 1,
        },
        blob: {
          maxFileCount: 1,
          maxFileSize: '8MB',
          mediaType: 'file',
          minFileCount: 1,
        },
        image: {
          maxFileCount: 3,
          maxFileSize: '4MB',
          mediaType: 'image',
          minFileCount: 1,
        },
        pdf: {
          maxFileCount: 1,
          maxFileSize: '4MB',
          mediaType: 'file',
          minFileCount: 1,
        },
        text: {
          maxFileCount: 1,
          maxFileSize: '64KB',
          mediaType: 'file',
          minFileCount: 1,
        },
        video: {
          maxFileCount: 1,
          maxFileSize: '16MB',
          mediaType: 'video',
          minFileCount: 1,
        },
      },
    },
    render: { afterEditable: MediaUploadToast },
  }),
];
'use client';
 
import {
  PlaceholderPlugin,
  UploadErrorCode,
  AudioPlugin,
  FilePlugin,
  MediaEmbedPlugin,
  VideoPlugin,
} from '@platejs/media/react';
import { usePluginStore } from 'platejs/react';
import * as React from 'react';
import { toast } from 'sonner';
 
import { AudioElement } from '@/components/editor/media-audio';
import { MediaEmbedElement } from '@/components/editor/media-embed';
import { FileElement } from '@/components/editor/media-file';
import { ImageElement } from

























































































































  • ImageElement: Renders image elements.
  • VideoElement: Renders video elements.
  • AudioElement: Renders audio elements.
  • FileElement: Renders file elements.
  • MediaEmbedElement: Renders embedded media.
  • PlaceholderElement: Renders upload placeholders.
  • MediaUploadToast: Shows upload progress notifications.
  • MediaPreviewDialog: Provides media preview functionality.

Add Kit

Add the kit to your plugins:

import { createPlateEditor } from 'platejs/react';
import { MediaKit } from '@/components/editor/media';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    ...MediaKit,
  ],
});
import { createPlateEditor } from 'platejs/react';
import { MediaKit } from '@/components/editor/media';
 
const editor = createPlateEditor({




Add API Routes

npx shadcn@latest add @plate/media-uploadthing-api

Environment Setup

Get your secret key from UploadThing and add it to .env:

.env
UPLOADTHING_TOKEN=xxx
.env
UPLOADTHING_TOKEN=xxx

Manual Usage

Installation

pnpm add @platejs/media
pnpm add @platejs/media

Add Plugins

Include the media plugins in your Plate plugins array when creating the editor.

import {
  AudioPlugin,
  FilePlugin,
  ImagePlugin,
  MediaEmbedPlugin,
  PlaceholderPlugin,
  VideoPlugin,
} from '@platejs/media/react';
import { createPlateEditor } from 'platejs/react';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    ImagePlugin,
    VideoPlugin,
    AudioPlugin,
    FilePlugin,
    MediaEmbedPlugin,
    PlaceholderPlugin,
  ],
});
import {
  AudioPlugin,
  FilePlugin,
  ImagePlugin,
  MediaEmbedPlugin,
  PlaceholderPlugin,
  VideoPlugin,
} from '@platejs/media/react';
import { createPlateEditor } from 'platejs/react';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    ImagePlugin,
    VideoPlugin,
    AudioPlugin,
    FilePlugin,
    MediaEmbedPlugin,
    PlaceholderPlugin,
  ],
});

Configure Plugins

Configure the plugins with custom components and upload settings.

import {
  AudioPlugin,
  FilePlugin,
  ImagePlugin,
  MediaEmbedPlugin,
  PlaceholderPlugin,
  VideoPlugin,
} from '@platejs/media/react';
import { createPlateEditor } from 'platejs/react';
import { 
  AudioElement, 
  FileElement, 
  ImageElement, 
  MediaEmbedElement, 
  PlaceholderElement, 
  VideoElement 
} from '@/components/editor/media-nodes';
import { MediaUploadToast } from '@/components/editor/media';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,











  • component: Assigns custom components to render each media type.
  • initialState.disableEmptyPlaceholder: Prevents showing placeholder when no file is uploading.
  • render.afterEditable: Renders upload progress toast outside the editor.

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.

Captions

Every image, audio, video, file, and embed is a non-void, isolating, keyboard-selectable element. 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({ component: ImageElement }),

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(ImagePlugin).update.










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:

SelectionBehavior
Plite NodeSelection at the media pathFocuses the asset, shows its selection ring and empty-caption placeholder, lets ArrowDown enter the caption, and lets Delete remove the media node.
Plite TextSelection inside the media childrenEdits the caption and lets ArrowUp at the caption start return focus to the asset.

Custom Upload Implementation

For custom upload implementations, create an upload hook that matches this interface:

interface UseUploadFileProps {
  onUploadComplete?: (file: UploadedFile) => void;
  onUploadError?: (error: unknown) => void;
  headers?: Record<string, string>;
  onUploadBegin?: (fileName: string) => void;
  onUploadProgress?: (progress: { progress: number }) => void;
  skipPolling?: boolean;
}
 
interface UploadedFile {





Example implementation with S3 presigned URLs:

export function useUploadFile({ 
  onUploadComplete, 
  onUploadError, 
  onUploadProgress 
}: UseUploadFileProps = {}) {
  const [uploadedFile, setUploadedFile] = useState<UploadedFile>();
  const [uploadingFile, setUploadingFile] = useState<File>();
  const [progress, setProgress] = useState(0);
  const [isUploading, setIsUploading] = useState(false);
 
  async



















































Then integrate your custom upload hook with the media components:

import { type PlateElementProps, useEditor } from 'platejs/react';
import { PlaceholderPlugin } from '@platejs/media/react';
import { PLUGINS } from 'platejs';
 
import { useUploadFile } from '@/hooks/use-upload-file'; // Your custom hook
 
export function PlaceholderElement({
  element,
}: PlateElementProps<typeof PlaceholderPlugin>) {
  const editor = useEditor();
  const { uploadFile, isUploading, progress } = useUploadFile({
    onUploadComplete: (uploadedFile) 































Add Toolbar Button

You can add MediaToolbarButton to your Toolbar to upload and insert media.

Insert Toolbar Button

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,
}

Plate Plus

  • Integration with UploadThing
  • Use slash commands for quick insertion
  • Displays clickable placeholders for various media types (image, video, audio, file)
  • Opens a popover with two tabs when the placeholder is clicked:
    • Upload tab: Allows uploading local files directly
    • Embed tab: Enables pasting embed links for media content
  • Image-specific features:
    • Better loading rendering and image replacement
    • Alignment options
    • Expand/collapse view
    • Download button
  • Video-specific features:
    • Lazy load
    • Alignment options
    • Caption support
    • View original source
  • Floating toolbar appears at the top right of media elements:
    • Alignment dropdown menu
    • Caption button
    • Expand button
    • Download button
  • Beautifully crafted UI
Get the code

Plugins

ImagePlugin

Plugin for non-void, isolating, keyboard-selectable image elements whose direct inline children store captions.

OptionsImagePluginState

    Function to upload image to a server. Receives:

    • Data URL (string) from FileReader.readAsDataURL
    • ArrayBuffer from clipboard data Returns:
    • URL string to uploaded image
    • Original data URL/ArrayBuffer if no upload needed
    • Default: Returns original input

    Disables file upload on data insertion.

    • Default: false

    Disables URL embed on data insertion.

    • Default: false

    A function to check whether a text string is a URL.

    A function to transform the URL.

VideoPlugin

Plugin for non-void, isolating, keyboard-selectable video elements whose direct inline children store captions. Extends MediaPluginState.

AudioPlugin

Plugin for non-void, isolating, keyboard-selectable audio elements whose direct inline children store captions. Extends MediaPluginState.

FilePlugin

Plugin for non-void, isolating, keyboard-selectable file elements whose direct inline children store captions. Extends MediaPluginState.

MediaEmbedPlugin

Plugin for non-void, isolating, keyboard-selectable media embed elements whose direct inline children store captions. Extends MediaPluginState.

PlaceholderPlugin

Plugin for managing media placeholders during upload. Handles file uploads, drag & drop, and clipboard paste events.

Optionsobject

    Configuration for different file types. The package maps every supported file family to its media plugin without imposing size or per-type count limits. The copied MediaKit applies this product policy:

    {
      audio: {
        maxFileCount: 1,
        maxFileSize: '8MB',
        mediaType: 'audio',
        minFileCount: 1,
      },
      blob: {
        maxFileCount: 1,
        maxFileSize: '8MB',
        mediaType: 'file',
        minFileCount: 1,
      },
      image: {
        maxFileCount: 3,
        maxFileSize: '4MB',
        mediaType: 'image',
        minFileCount: 1,
      },
      pdf: {
        maxFileCount: 1,
        maxFileSize: '4MB',
        mediaType: 'file',
        minFileCount: 1,
      },
      text: {
        maxFileCount: 1,
        maxFileSize: '64KB',
        mediaType: 'file',
        minFileCount: 1,
      },
      video: {
        maxFileCount: 1,
        maxFileSize: '16MB',
        mediaType: 'video',
        minFileCount: 1,
      },
    }
    {
      audio: {
        maxFileCount: 1,
        maxFileSize: '8MB',
        mediaType: 'audio',
        minFileCount: 1,
      },
      blob: {
        maxFileCount: 1,
        maxFileSize: '8MB',
        mediaType: 'file',
        minFileCount: 1,
      },
      image: {
        maxFileCount: 3,
        maxFileSize: '4MB',
        mediaType: 'image',
        minFileCount
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    

    Supported file types: 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'blob'

    Disable empty placeholder when no file is uploading.

    • Default: false

    Disable drag and drop file upload functionality.

    • Default: false

    Maximum number of files that can be uploaded at once, if not specified by uploadConfig.

    • Default: Number.POSITIVE_INFINITY

    Allow multiple files of the same type to be uploaded.

    • Default: true

API

editor.api.placeholder.addUploadingFile

Tracks a file that is currently being uploaded.

Parameters

    Unique identifier for the placeholder element.

    The file being uploaded.

editor.plugin(PlaceholderPlugin).store.get('uploadingFile', id)

Gets a file that is currently being uploaded.

Parameters

    Unique identifier for the placeholder element.

Returns

    The uploading file if found, undefined otherwise.

editor.api.placeholder.removeUploadingFile

Removes a file from the uploading tracking state after upload completes or fails.

Parameters

    Unique identifier for the placeholder element to remove.

Transforms

editor.plugin(PlaceholderPlugin).update.insertMedia

Inserts media files into the editor with upload placeholders.

Parameters

    Files to upload. Validates against configured file types and limits.

    Exact-path and selection options.

Optionsobject

    Exact insertion path. When omitted, placeholders are inserted after the selected block, or appended when there is no selection.

    Select the inserted placeholders.

Validates files against configured limits (size, count, type), creates placeholder elements for each file, handles multiple file uploads sequentially, maintains upload history for undo/redo operations, and triggers error handling if validation fails.

Error codes:

enum UploadErrorCode {
  INVALID_FILE_TYPE = 400,
  TOO_MANY_FILES = 402,
  INVALID_FILE_SIZE = 403,
  TOO_LESS_FILES = 405,
  TOO_LARGE = 413,
}
enum UploadErrorCode {
  INVALID_FILE_TYPE = 400,
  TOO_MANY_FILES = 402,
  INVALID_FILE_SIZE = 403,
  TOO_LESS_FILES = 405,
  TOO_LARGE = 413,
}

editor.plugin(BasePlaceholderPlugin).update.insert

Inserts one headless placeholder for an image, video, audio, or file.

Parameters

    The resolved persisted media type created after upload, such as editor.plugin(ImagePlugin).schema.type.

    Exact-path and selection options.

editor.plugin(BaseImagePlugin).update.insert

Inserts an image element into the editor.

Parameters

    The image URL.

    Exact-path and selection options.

OptionsPlateNodeInsertOptions

    Exact insertion target. When omitted, the image is inserted after the selected block.

    Select the inserted image.

editor.plugin(BaseMediaEmbedPlugin).update.insert

Transforms and normalizes a URL, then inserts a media embed.

Parameters

    A media URL or embed snippet.

    Exact-path and selection options.

OptionsPlateNodeInsertOptions

    Exact insertion target. When omitted, the current selection is required.

    Select the inserted embed.

insertMediaUrl

Prompts for an image or embed URL and dispatches to its scoped plugin update. Import it from @platejs/media/react.

Parameters

    The editor receiving the media element.

    URL resolution, media type, exact-path, and selection options.

OptionsInsertMediaUrlOptions

    Resolves a URL without showing the browser prompt.

    The resolved image or media-embed schema type. When omitted, the helper prefers the installed image type, then the installed media-embed type.

Registry Media UI

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 type { MediaPlugin } from '@platejs/media/react';
import { Link, Trash2Icon } from 'lucide-react';
import {
  useEditor,
  useElement,
  useEditorReadOnly,
  useFocusedLast,
} from 'platejs/react';
import * as React from 'react';
 
import { Button, buttonVariants } 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
        className={buttonVariants({ 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 type { MediaPlugin } from '@platejs/media/react';
import { Link, Trash2Icon } from 'lucide-react';
import {
  useEditor,
  useElement,
  useEditorReadOnly,
  useFocusedLast,
} from 'platejs/react';
import * as React from 'react';
 
import { Button, buttonVariants } 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';
import { ImagePlugin } from '@platejs/media/react';
import type { PlateElementProps } from 'platejs/react';
import {
  PlateElement,
  useEditor,
  useEditorFocused,
  useElementSelected,
  usePath,
  usePluginStore,
} 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: PlateElementProps<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}
    >
      <PlateElement {...props} className="py-2.5">
        <figure className="group relative m-0">
          <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>
      </PlateElement>
    </MediaToolbar>
  );
}
'use client';
 
import { useDraggable } from '@platejs/dnd';
import { ImagePlugin } from '@platejs/media/react';
import type { PlateElementProps } from 'platejs/react';
import {
  PlateElement,
  useEditor,
  useEditorFocused,
  useElementSelected,
  usePath,
  usePluginStore,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
 
import { Caption, useCaptionFocused } from './caption';
import { imagePlugin } 






















































































'use client';
 
import {
  BaseImagePlugin,
  type ImageElement as ImageNode,
} from '@platejs/media';
import { ImagePlugin } from '@platejs/media/react';
import { useComposedRef } from '@udecode/react-utils';
import { cva } from 'class-variance-authority';
import { ArrowLeft, ArrowRight, Download, Minus, Plus, X } from 'lucide-react';
import type { NodeKey } from 'platejs';
import { isHotkey } from 'platejs';
import { useEditorPlugin, 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: ImageNode, 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 } = useEditorPlugin(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 } = useEditorPlugin(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 {
  BaseImagePlugin,
  type ImageElement as ImageNode,
} from '@platejs/media';
import { ImagePlugin } from '@platejs/media/react';
import { useComposedRef } from '@udecode/react-utils';
import { cva } from 'class-variance-authority';
import { ArrowLeft, ArrowRight, Download, Minus, Plus, X } from 'lucide-react';
import type { NodeKey } from 'platejs';
import { isHotkey } from 'platejs';
import { useEditorPlugin, usePluginStore } from 'platejs/react';
import * as React 
























































































































































































































































































































































































































































































































For resizable media, compose the direct Resizable and ResizeHandle components from @platejs/resizable. The media renderer commits the final width through its scoped plugin update.

Utilities

parseMediaUrl

Parses a media URL for plugin-specific handling.

Parameters

    The media URL to parse.

    Ordered parsers used to recognize the URL.

ReturnsEmbedUrlData | undefined

    The first safe parser result, or undefined when no parser matches.

parseVideoUrl

Parses a video URL and extracts the video ID and provider-specific embed URL.

Parameters

    The video URL to parse.

ReturnsEmbedUrlData | undefined

    An object containing the video ID and provider if parsing is successful, undefined if URL is invalid or unsupported.

parseTwitterUrl

Parses a Twitter URL and extracts the tweet ID.

Parameters

    The Twitter URL.

Returns

    An object containing the tweet ID and provider if the parsing is successful. Returns undefined if the URL is not valid or does not match any supported video providers.

parseIframeUrl

Parses the URL of an iframe embed.

Parameters

    The URL or embed code of the iframe.

Types

Media element types

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.

PlaceholderElement

import type { PlaceholderElement } from '@platejs/media';
import type { PlaceholderElement } from '@platejs/media';

PlaceholderElement is derived from BasePlaceholderPlugin and requires a string mediaType.

EmbedUrlData

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;
}
'@/components/editor/media-image'
;
import { PlaceholderElement } from '@/components/editor/media-placeholder';
import {
imagePlugin,
MediaPreviewDialog,
} from '@/components/editor/media-preview-dialog';
import { VideoElement } from '@/components/editor/media-video';
export function MediaUploadToast() {
const uploadError = usePluginStore(PlaceholderPlugin, 'error');
React.useEffect(() => {
if (!uploadError) return;
const { code, data } = uploadError;
switch (code) {
case UploadErrorCode.INVALID_FILE_SIZE: {
toast.error(
`The size of files ${data.files
.map((f) => f.name)
.join(', ')} is invalid`
);
break;
}
case UploadErrorCode.INVALID_FILE_TYPE: {
toast.error(
`The type of files ${data.files
.map((f) => f.name)
.join(', ')} is invalid`
);
break;
}
case UploadErrorCode.TOO_LARGE: {
toast.error(
`The size of files ${data.files
.map((f) => f.name)
.join(', ')} is too large than ${data.maxFileSize}`
);
break;
}
case UploadErrorCode.TOO_LESS_FILES: {
toast.error(
`The mini um number of files is ${data.minFileCount} for ${data.fileType}`
);
break;
}
case UploadErrorCode.TOO_MANY_FILES: {
toast.error(
`The maximum number of files is ${data.maxFileCount} ${
data.fileType ? `for ${data.fileType}` : ''
}`
);
break;
}
}
}, [uploadError]);
return null;
}
export const MediaKit = [
imagePlugin.configure({
component: ImageElement,
initialState: { disableUploadInsert: true },
render: { afterEditable: MediaPreviewDialog },
}),
MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
VideoPlugin.configure({ component: VideoElement }),
AudioPlugin.configure({ component: AudioElement }),
FilePlugin.configure({ component: FileElement }),
PlaceholderPlugin.configure({
component: PlaceholderElement,
initialState: {
disableEmptyPlaceholder: true,
maxFileCount: 5,
uploadConfig: {
audio: {
maxFileCount: 1,
maxFileSize: '8MB',
mediaType: 'audio',
minFileCount: 1,
},
blob: {
maxFileCount: 1,
maxFileSize: '8MB',
mediaType: 'file',
minFileCount: 1,
},
image: {
maxFileCount: 3,
maxFileSize: '4MB',
mediaType: 'image',
minFileCount: 1,
},
pdf: {
maxFileCount: 1,
maxFileSize: '4MB',
mediaType: 'file',
minFileCount: 1,
},
text: {
maxFileCount: 1,
maxFileSize: '64KB',
mediaType: 'file',
minFileCount: 1,
},
video: {
maxFileCount: 1,
maxFileSize: '16MB',
mediaType: 'video',
minFileCount: 1,
},
},
},
render: { afterEditable: MediaUploadToast },
}),
];
plugins: [
// ...otherPlugins,
...MediaKit,
],
});
ImagePlugin.
configure
({ component: ImageElement }),
VideoPlugin.configure({ component: VideoElement }),
AudioPlugin.configure({ component: AudioElement }),
FilePlugin.configure({ component: FileElement }),
MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
PlaceholderPlugin.configure({
component: PlaceholderElement,
initialState: { disableEmptyPlaceholder: true },
render: { afterEditable: MediaUploadToast },
}),
],
});
import {
  AudioPlugin,
  FilePlugin,
  ImagePlugin,
  MediaEmbedPlugin,
  PlaceholderPlugin,
  VideoPlugin,
} from '@platejs/media/react';
import { createPlateEditor } from 'platejs/react';
import { 
  AudioElement, 
  FileElement, 
  ImageElement, 
  MediaEmbedElement, 
  PlaceholderElement, 
  VideoElement 
} from '@/components/editor/media-nodes';
import { MediaUploadToast } from '@/components/editor/media';
 
const editor = createPlateEditor({
  plugins: [
    // ...otherPlugins,
    ImagePlugin.configure({ component: ImageElement }),
    VideoPlugin.configure({ component: VideoElement }),
    AudioPlugin.configure({ component: AudioElement }),
    FilePlugin.configure({ component: FileElement }),
    MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
    PlaceholderPlugin.configure({
      component: PlaceholderElement,
      initialState: { disableEmptyPlaceholder: true },
      render: { afterEditable: MediaUploadToast },
    }),
  ],
});
];
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 },
],
});
key: string; // Unique identifier
url: string; // Public URL of the uploaded file
name: string; // Original filename
size: number; // File size in bytes
type: string; // MIME type
}
interface UseUploadFileProps {
  onUploadComplete?: (file: UploadedFile) => void;
  onUploadError?: (error: unknown) => void;
  headers?: Record<string, string>;
  onUploadBegin?: (fileName: string) => void;
  onUploadProgress?: (progress: { progress: number }) => void;
  skipPolling?: boolean;
}
 
interface UploadedFile {
  key: string;    // Unique identifier
  url: string;    // Public URL of the uploaded file
  name: string;   // Original filename
  size: number;   // File size in bytes
  type: string;   // MIME type
}
function
uploadFile
(
file
:
File
) {
setIsUploading(true);
setUploadingFile(file);
try {
// Get presigned URL and final URL from your backend
const { presignedUrl, fileUrl, fileKey } = await fetch('/api/upload', {
method: 'POST',
body: JSON.stringify({
filename: file.name,
contentType: file.type,
}),
}).then(r => r.json());
// Upload to S3 using presigned URL
await axios.put(presignedUrl, file, {
headers: { 'Content-Type': file.type },
onUploadProgress: (progressEvent) => {
const progress = (progressEvent.loaded / progressEvent.total) * 100;
setProgress(progress);
onUploadProgress?.({ progress });
},
});
const uploadedFile = {
key: fileKey,
url: fileUrl,
name: file.name,
size: file.size,
type: file.type,
};
setUploadedFile(uploadedFile);
onUploadComplete?.(uploadedFile);
return uploadedFile;
} catch (error) {
onUploadError?.(error);
throw error;
} finally {
setProgress(0);
setIsUploading(false);
setUploadingFile(undefined);
}
}
return {
isUploading,
progress,
uploadFile,
uploadedFile,
uploadingFile,
};
}
export function useUploadFile({ 
  onUploadComplete, 
  onUploadError, 
  onUploadProgress 
}: UseUploadFileProps = {}) {
  const [uploadedFile, setUploadedFile] = useState<UploadedFile>();
  const [uploadingFile, setUploadingFile] = useState<File>();
  const [progress, setProgress] = useState(0);
  const [isUploading, setIsUploading] = useState(false);
 
  async function uploadFile(file: File) {
    setIsUploading(true);
    setUploadingFile(file);
 
    try {
      // Get presigned URL and final URL from your backend
      const { presignedUrl, fileUrl, fileKey } = await fetch('/api/upload', {
        method: 'POST',
        body: JSON.stringify({
          filename: file.name,
          contentType: file.type,
        }),
      }).then(r => r.json());
 
      // Upload to S3 using presigned URL
      await axios.put(presignedUrl, file, {
        headers: { 'Content-Type': file.type },
        onUploadProgress: (progressEvent) => {
          const progress = (progressEvent.loaded / progressEvent.total) * 100;
          setProgress(progress);
          onUploadProgress?.({ progress });
        },
      });
 
      const uploadedFile = {
        key: fileKey,
        url: fileUrl,
        name: file.name,
        size: file.size,
        type: file.type,
      };
 
      setUploadedFile(uploadedFile);
      onUploadComplete?.(uploadedFile);
      return uploadedFile;
    } catch (error) {
      onUploadError?.(error);
      throw error;
    } finally {
      setProgress(0);
      setIsUploading(false);
      setUploadingFile(undefined);
    }
  }
 
  return {
    isUploading,
    progress,
    uploadFile,
    uploadedFile,
    uploadingFile,
  };
}
=>
{
const path = editor.read.nodes.path(element);
if (!path) return;
const file = editor.plugin(PLUGINS.file);
editor.update({ history: 'skip' }, (tx) => {
tx.nodes.remove({ at: path });
tx.nodes.insert(
{
children: [{ text: '' }],
provider:
element.mediaType === editor.plugin(PLUGINS.video).schema.type
? 'file'
: undefined,
...(file.installed && element.mediaType === file.schema.type
? { name: uploadedFile.name }
: {}),
type: element.mediaType,
url: uploadedFile.url,
},
{ at: path }
);
});
},
onUploadError: (error) => {
console.error('Upload failed:', error);
},
});
// Call uploadFile when the user drops or selects a file.
}
import { type PlateElementProps, useEditor } from 'platejs/react';
import { PlaceholderPlugin } from '@platejs/media/react';
import { PLUGINS } from 'platejs';
 
import { useUploadFile } from '@/hooks/use-upload-file'; // Your custom hook
 
export function PlaceholderElement({
  element,
}: PlateElementProps<typeof PlaceholderPlugin>) {
  const editor = useEditor();
  const { uploadFile, isUploading, progress } = useUploadFile({
    onUploadComplete: (uploadedFile) => {
      const path = editor.read.nodes.path(element);
 
      if (!path) return;
 
      const file = editor.plugin(PLUGINS.file);
 
      editor.update({ history: 'skip' }, (tx) => {
        tx.nodes.remove({ at: path });
        tx.nodes.insert(
          {
            children: [{ text: '' }],
            provider:
              element.mediaType === editor.plugin(PLUGINS.video).schema.type
                ? 'file'
                : undefined,
            ...(file.installed && element.mediaType === file.schema.type
              ? { name: uploadedFile.name }
              : {}),
            type: element.mediaType,
            url: uploadedFile.url,
          },
          { at: path }
        );
      });
    },
    onUploadError: (error) => {
      console.error('Upload failed:', error);
    },
  });
 
  // Call uploadFile when the user drops or selects a file.
}
:
1
,
},
pdf: {
maxFileCount: 1,
maxFileSize: '4MB',
mediaType: 'file',
minFileCount: 1,
},
text: {
maxFileCount: 1,
maxFileSize: '64KB',
mediaType: 'file',
minFileCount: 1,
},
video: {
maxFileCount: 1,
maxFileSize: '16MB',
mediaType: 'video',
minFileCount: 1,
},
}
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
className={buttonVariants({ 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>
);
}
from
'./media-preview-dialog'
;
import { MediaToolbar } from './media-toolbar';
import {
mediaResizeHandleVariants,
Resizable,
ResizeHandle,
} from './resize-handle';
export function ImageElement(props: PlateElementProps<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}
>
<PlateElement {...props} className="py-2.5">
<figure className="group relative m-0">
<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>
</PlateElement>
</MediaToolbar>
);
}
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: ImageNode, 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 } = useEditorPlugin(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 } = useEditorPlugin(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;
}
}