Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • 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
    • Drag & Drop
    • Navigation Feedback
    • Linting
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • DOCX
    • HTML
    • Markdown

Media

PreviousNext

Embed medias like videos or tweets into your document.

PlusImage ElementVideo ElementAudio ElementFile ElementMedia Embed ElementMedia PopoverUpload FilesMedia KitMedia Toolbar Button
Loading…
LinkMention

On This Page

FeaturesMedia supportMedia featuresUploadKit usageInstallationAdd kitConfigure uploadsManual usageInstallationAdd pluginsConfigure pluginsCaptionsUpload local filesAdd toolbar buttonInsert toolbar buttonPlate PlusPluginsImagePluginVideoPluginAudioPluginFilePluginMediaEmbedPluginUploadPluginAPIUpload tasksTransformseditor.plugin(UploadPlugin).update.submiteditor.plugin(BaseImagePlugin).update.inserteditor.plugin(BaseMediaEmbedPlugin).update.insertapi.insertUrlRegistry media UIUtilitiesparseMediaUrlparseVideoUrlparseTwitterUrlparseIframeUrlTypesMedia element typesUploadElementEmbedUrlData
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
    • Replaces each keyed draft slot with the completed image, video, audio, or file
    • 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, 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 kit

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';









Configure uploads

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.

Manual usage

Installation

pnpm add platejs @tanstack/react-virtual
pnpm add platejs @tanstack/react-virtual

Add plugins

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 plugins

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: In Markdown, an image with only url, alt, and title is written as ![alt](url "title"). An image with other properties, such as width, uses <img src="…" />, and a captioned image uses <figure> with <img /> and <figcaption>. Video, audio, file, and embed use their own tags, such as <video src="…">Caption</video>; embeds keep the canonical src and provider, plus an optional sourceUrl so edits remain reversible. A caption must be one Markdown paragraph. 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 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 keeps that empty caption hidden until the caret enters it, so selecting the asset does not shift the page. 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
Plate NodeSelection at the media pathFocuses the asset and shows its selection ring while an empty caption stays hidden, lets the forward arrow or ArrowDown enter a caption the page shows, and lets Delete remove the media node.
Plate TextSelection inside the media childrenEdits 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. The stock caption hides an empty caption, so arrow keys skip it, and the media toolbar's Caption button puts the caret in it. Left and right follow the direction of the block that holds the caret, or of the selected media, so they reverse in a right-to-left paragraph even inside a left-to-right editor.

Arrow keys stop only on caption text the page draws. When your UI hides a caption, ArrowUp from the block below or from a selected media below selects the media. The forward arrow or ArrowDown from the selected asset moves past the hidden caption to the next block. When your UI shows an empty caption, ArrowUp from below enters it.

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 block after the media element: the block the media's container declares there, a paragraph in most editors. This also applies when the selection extends into the next paragraph; the media asset keeps its identity.

Upload local files

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.

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

  • Files SDK upload integration with application-owned storage
  • 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 object image elements whose direct inline children store captions.

OptionsImagePluginState

    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.

UploadPlugin handles pasted image files. ImagePlugin handles image URLs.

VideoPlugin

Plugin for non-void object video elements whose direct inline children store captions. Extends MediaPluginState.

AudioPlugin

Plugin for non-void object audio elements whose direct inline children store captions. Extends MediaPluginState.

Video and audio serialize to HTML as <figure class="editor-video"> or <figure class="editor-audio">, holding a native <video controls> or <audio controls> and a <figcaption> for the caption. Pasted HTML also accepts a bare <video> or <audio>, reading src or its first <source>; browser fallback text is not kept as a caption.

FilePlugin

Plugin for non-void object file elements whose direct inline children store captions. Extends MediaPluginState.

MediaEmbedPlugin

Plugin for non-void object media embed elements whose direct inline children store captions. Extends MediaPluginState.

UploadPlugin

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 also lands dropped files through the block-drag landing rules.

OptionsUploadPluginState

    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 lands dropped files at the block edge under the pointer, through the same landing rules as a block drag. A refused drop inserts nothing. An editor with only BaseUploadPlugin treats a dropped file like a pasted one, without landing rules or vetoes.

API

Upload tasks

OperationContract
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.

Transforms

editor.plugin(UploadPlugin).update.submit

Parameters

    The complete batch to validate and upload.

    Pass { slot: NodeKey } to fill one existing draft. Otherwise pass BlockInsertOptions, including at, before, after, replaceEmpty, or select. Slot and insertion options are mutually exclusive.

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.

editor.plugin(BaseImagePlugin).update.insert

Inserts an image element into the editor.

Parameters

    The image URL.

    Exact-path and selection options.

OptionsNodeInsertOptions

    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.

OptionsNodeInsertOptions

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

    Select the inserted embed.

api.insertUrl

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.

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.

In an editable editor, a click on media-file selects the file and a double-click opens it in a new tab. A read-only editor opens the file on a click. While an image, file, audio or video node is selected, media-toolbar shows a Preview button beside its Edit link, Caption and delete buttons. Preview opens media-preview-dialog for an image and opens the file in a new tab for the other media.

'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({
  onPreview,
  plugin,
}: {
  onPreview?: () => void;
  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>
 
      {onPreview && (
        <Button
          size="sm"
          variant="ghost"
          onClick={onPreview}
          onMouseDown={(event) => {
            event.preventDefault();
          }}
        >
          Preview
        </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,
  onPreview,
  placement = 'bottom',
  plugin,
  selected,
}: {
  children: React.ReactElement;
  disabled?: boolean;
  onPreview?: () => void;
  plugin: MediaPlugin;
  /** `top` keeps the toolbar off the block below a short media node. */
  placement?: 'bottom' | 'top';
  selected: boolean;
}) {
  const isFocusedLast = useFocusedLast();
  const readOnly = useEditorReadOnly();
  const open = isFocusedLast && !readOnly && selected && !disabled;
 
  return (
    <FloatingPopover open={open} modal={false}>
      <FloatingPopoverAnchor element={children} />
 
      <FloatingPopoverContent
        align={placement === 'bottom' ? 'center' : 'end'}
        className="w-auto p-1"
        side={placement}
        onInitialFocus={(e) => {
          e.preventDefault();
        }}
      >
        {open ? (
          <MediaToolbarContent onPreview={onPreview} 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 { 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 } 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 previewOpen = usePluginStore(imagePlugin, 'previewOpen');
  const openPreview = () => {
    editor.plugin(imagePlugin).api.preview.open(path, props.element.url);
  };
 
  return (
    <MediaToolbar
      disabled={previewOpen}
      onPreview={openPreview}
      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, jsx-a11y/no-noninteractive-element-interactions -- [P1 local-invariant] The editor node owns a user URL, native draggable image that starts its block drag, composed ref, and resizable width. */}
                <img
                  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',
                    '[[data-editor-dragging]_&]:opacity-50'
                  )}
                  alt={props.element.alt}
                  draggable
                  src={props.element.url}
                  onDragStart={(event) => {
                    editor.api.dom.drag.start(event.nativeEvent, {
                      node: props.element,
                    });
                  }}
                  onDoubleClickCapture={openPreview}
                />
              </div>
              <ResizeHandle
                className={mediaResizeHandleVariants({
                  direction: 'right',
                })}
                direction="right"
              />
            </Resizable>
          </div>
          <Caption
            align={textAlign}
            element={props.element}
            slots={props.slots}
          >
            {props.children}
          </Caption>
        </figure>
      </EditorElement>
    </MediaToolbar>
  );
}
'use client';
 
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 } from './caption';
import { imagePlugin } from './media-preview-dialog';
import { MediaToolbar } from './media-toolbar';



























































































'use client';
 
import { ArrowLeft, ArrowRight, Download, Minus, Plus, X } from 'lucide-react';
import { type NodeKey, type Path, isHotkey } from 'platejs';
import { BaseImagePlugin } from 'platejs/media';
import { ImagePlugin } from 'platejs/media/react';
import { useComposedRef, useEditor, usePluginStore } from 'platejs/react';
import * as React from 'react';
import { createPortal } from 'react-dom';
 
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
 
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 },
            },
          });
        }
      },
      // Keys differ between the owner and view editors, so the image passes
      // its path and this editor resolves every key.
      open: (at: Path, resolvedUrl: string) => {
        const currentKey = editor.key(at);
 
        if (!currentKey) 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 editor = useEditor();
  const { api } = editor.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();
    };
 
    // Capture so an editor or popover handler cannot swallow Escape first.
    document.addEventListener('keydown', onKeyDown, true);
 
    return () => {
      document.removeEventListener('keydown', onKeyDown, true);
    };
  }, [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();
  };
 
  if (!isOpen) return null;
 
  // Portal to the body so no ancestor stacking context or overlay covers it.
  return createPortal(
    <div
      className="fixed top-0 left-0 z-50 h-screen w-screen select-none"
      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="pointer-events-none absolute inset-0 flex items-center justify-center">
        <div className="relative flex max-h-screen w-full items-center justify-center">
          <PreviewImage
            className={cn(
              'mx-auto block max-h-[calc(100vh-10rem)] w-auto object-contain transition-transform'
            )}
          />
          <div className="pointer-events-auto fixed bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-1 rounded-lg border bg-popover p-1 text-popover-foreground shadow-md">
            <Button
              aria-label="Previous image"
              disabled={prevDisabled}
              onClick={api.preview.previous}
              size="icon-sm"
              variant="ghost"
            >
              <ArrowLeft />
            </Button>
            <span className="min-w-10 text-center text-sm text-muted-foreground tabular-nums">
              {(currentPreviewIndex ?? 0) + 1} / {previewList.length}
            </span>
            <Button
              aria-label="Next image"
              disabled={nextDisabled}
              onClick={api.preview.next}
              size="icon-sm"
              variant="ghost"
            >
              <ArrowRight />
            </Button>
            <Separator
              className="mx-1 data-[orientation=vertical]:h-5"
              orientation="vertical"
            />
            <Button
              aria-label="Zoom out"
              disabled={zoomOutDisabled}
              onClick={api.preview.zoomOut}
              size="icon-sm"
              variant="ghost"
            >
              <Minus />
            </Button>
            {isEditingScale ? (
              <div className="flex items-center gap-1 px-1 text-sm">
                <ScaleInput
                  key={scale}
                  className="h-7 w-12 rounded-md border bg-transparent px-1.5 text-center tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  scale={scale}
                  onCommit={(nextScale) => {
                    api.preview.setScale(nextScale);
                    api.preview.setEditingScale(false);
                  }}
                />
                %
              </div>
            ) : (
              <Button
                aria-label="Set zoom level"
                className="min-w-14 tabular-nums"
                onClick={() => {
                  api.preview.setEditingScale(true);
                }}
                size="sm"
                variant="ghost"
              >
                {`${scale * 100}%`}
              </Button>
            )}
            <Button
              aria-label="Zoom in"
              disabled={zoomInDisabled}
              onClick={api.preview.zoomIn}
              size="icon-sm"
              variant="ghost"
            >
              <Plus />
            </Button>
            <Separator
              className="mx-1 data-[orientation=vertical]:h-5"
              orientation="vertical"
            />
            <Button
              aria-label="Download image"
              disabled={downloadDisabled}
              onClick={handleDownload}
              size="icon-sm"
              variant="ghost"
            >
              <Download />
            </Button>
            <Button
              aria-label="Close preview"
              onClick={api.preview.close}
              size="icon-sm"
              variant="ghost"
            >
              <X />
            </Button>
          </div>
        </div>
      </div>
    </div>,
    editor.api.dom.getWindow().document.body
  );
}
 
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="pointer-events-auto 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 { ArrowLeft, ArrowRight, Download, Minus, Plus, X } from 'lucide-react';
import { type NodeKey, type Path, isHotkey } from 'platejs';
import { BaseImagePlugin } from 'platejs/media';
import { ImagePlugin } from 'platejs/media/react';
import { useComposedRef, useEditor, usePluginStore } from 'platejs/react';
import * as React from 'react';
import { createPortal } from 'react-dom';
 
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';

































































































































































































































































































































































































































































































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.

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.

UploadElement

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'.

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;
}
MediaKit
=
[
imagePlugin.configure({
component: ImageElement,
slots: { afterEditable: MediaPreviewDialog },
}),
MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
VideoPlugin.configure({ component: VideoElement }),
AudioPlugin.configure({ component: AudioElement }),
FilePlugin.configure({ component: FileElement }),
];
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 }),
];
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')
}`;
}
'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
{ MediaKit }
from
'@/components/editor/media'
;
import { UploadKit } from '@/components/editor/upload';
const editor = createEditor({
plugins: [
// ...otherPlugins,
...MediaKit,
...UploadKit,
],
});
;
import { createEditor } from 'platejs/react';
const editor = createEditor({
plugins: [
// ...otherPlugins,
ImagePlugin,
VideoPlugin,
AudioPlugin,
FilePlugin,
MediaEmbedPlugin,
],
});
AudioPlugin.configure({ component: AudioElement }),
FilePlugin.configure({ component: FileElement }),
MediaEmbedPlugin.configure({ component: MediaEmbedElement }),
],
});
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 }),
  ],
});
({ component: ImageElement }),
];
(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 },
],
});
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { CaptionButton } from './caption';
function MediaToolbarContent({
onPreview,
plugin,
}: {
onPreview?: () => void;
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>
{onPreview && (
<Button
size="sm"
variant="ghost"
onClick={onPreview}
onMouseDown={(event) => {
event.preventDefault();
}}
>
Preview
</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,
onPreview,
placement = 'bottom',
plugin,
selected,
}: {
children: React.ReactElement;
disabled?: boolean;
onPreview?: () => void;
plugin: MediaPlugin;
/** `top` keeps the toolbar off the block below a short media node. */
placement?: 'bottom' | 'top';
selected: boolean;
}) {
const isFocusedLast = useFocusedLast();
const readOnly = useEditorReadOnly();
const open = isFocusedLast && !readOnly && selected && !disabled;
return (
<FloatingPopover open={open} modal={false}>
<FloatingPopoverAnchor element={children} />
<FloatingPopoverContent
align={placement === 'bottom' ? 'center' : 'end'}
className="w-auto p-1"
side={placement}
onInitialFocus={(e) => {
e.preventDefault();
}}
>
{open ? (
<MediaToolbarContent onPreview={onPreview} plugin={plugin} />
) : null}
</FloatingPopoverContent>
</FloatingPopover>
);
}
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 previewOpen = usePluginStore(imagePlugin, 'previewOpen');
const openPreview = () => {
editor.plugin(imagePlugin).api.preview.open(path, props.element.url);
};
return (
<MediaToolbar
disabled={previewOpen}
onPreview={openPreview}
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, jsx-a11y/no-noninteractive-element-interactions -- [P1 local-invariant] The editor node owns a user URL, native draggable image that starts its block drag, composed ref, and resizable width. */}
<img
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',
'[[data-editor-dragging]_&]:opacity-50'
)}
alt={props.element.alt}
draggable
src={props.element.url}
onDragStart={(event) => {
editor.api.dom.drag.start(event.nativeEvent, {
node: props.element,
});
}}
onDoubleClickCapture={openPreview}
/>
</div>
<ResizeHandle
className={mediaResizeHandleVariants({
direction: 'right',
})}
direction="right"
/>
</Resizable>
</div>
<Caption
align={textAlign}
element={props.element}
slots={props.slots}
>
{props.children}
</Caption>
</figure>
</EditorElement>
</MediaToolbar>
);
}
import
{ cn }
from
'@/lib/utils'
;
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 },
},
});
}
},
// Keys differ between the owner and view editors, so the image passes
// its path and this editor resolves every key.
open: (at: Path, resolvedUrl: string) => {
const currentKey = editor.key(at);
if (!currentKey) 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 editor = useEditor();
const { api } = editor.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();
};
// Capture so an editor or popover handler cannot swallow Escape first.
document.addEventListener('keydown', onKeyDown, true);
return () => {
document.removeEventListener('keydown', onKeyDown, true);
};
}, [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();
};
if (!isOpen) return null;
// Portal to the body so no ancestor stacking context or overlay covers it.
return createPortal(
<div
className="fixed top-0 left-0 z-50 h-screen w-screen select-none"
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="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="relative flex max-h-screen w-full items-center justify-center">
<PreviewImage
className={cn(
'mx-auto block max-h-[calc(100vh-10rem)] w-auto object-contain transition-transform'
)}
/>
<div className="pointer-events-auto fixed bottom-6 left-1/2 z-40 flex -translate-x-1/2 items-center gap-1 rounded-lg border bg-popover p-1 text-popover-foreground shadow-md">
<Button
aria-label="Previous image"
disabled={prevDisabled}
onClick={api.preview.previous}
size="icon-sm"
variant="ghost"
>
<ArrowLeft />
</Button>
<span className="min-w-10 text-center text-sm text-muted-foreground tabular-nums">
{(currentPreviewIndex ?? 0) + 1} / {previewList.length}
</span>
<Button
aria-label="Next image"
disabled={nextDisabled}
onClick={api.preview.next}
size="icon-sm"
variant="ghost"
>
<ArrowRight />
</Button>
<Separator
className="mx-1 data-[orientation=vertical]:h-5"
orientation="vertical"
/>
<Button
aria-label="Zoom out"
disabled={zoomOutDisabled}
onClick={api.preview.zoomOut}
size="icon-sm"
variant="ghost"
>
<Minus />
</Button>
{isEditingScale ? (
<div className="flex items-center gap-1 px-1 text-sm">
<ScaleInput
key={scale}
className="h-7 w-12 rounded-md border bg-transparent px-1.5 text-center tabular-nums outline-none focus-visible:ring-2 focus-visible:ring-ring"
scale={scale}
onCommit={(nextScale) => {
api.preview.setScale(nextScale);
api.preview.setEditingScale(false);
}}
/>
%
</div>
) : (
<Button
aria-label="Set zoom level"
className="min-w-14 tabular-nums"
onClick={() => {
api.preview.setEditingScale(true);
}}
size="sm"
variant="ghost"
>
{`${scale * 100}%`}
</Button>
)}
<Button
aria-label="Zoom in"
disabled={zoomInDisabled}
onClick={api.preview.zoomIn}
size="icon-sm"
variant="ghost"
>
<Plus />
</Button>
<Separator
className="mx-1 data-[orientation=vertical]:h-5"
orientation="vertical"
/>
<Button
aria-label="Download image"
disabled={downloadDisabled}
onClick={handleDownload}
size="icon-sm"
variant="ghost"
>
<Download />
</Button>
<Button
aria-label="Close preview"
onClick={api.preview.close}
size="icon-sm"
variant="ghost"
>
<X />
</Button>
</div>
</div>
</div>
</div>,
editor.api.dom.getWindow().document.body
);
}
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="pointer-events-auto 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;
}
}