Skip to content

Image

The Image extension provides a full-featured image node with resizable handles, float/text-wrapping controls, file upload via paste and drag-and-drop, an interactive URL popover, markdown input rule support, and defense-in-depth XSS protection. The extension ships as a separate package (@domternal/extension-image).

Click an image to select it and see the resize handles. Use the float buttons to wrap text around the image.

With the default theme enabled. Click the image toolbar button to open the URL popover, or select an image to see the bubble menu with float controls.

Click to try it out
Terminal window
pnpm add @domternal/extension-image
import { Editor, Document, Paragraph, Text } from '@domternal/core';
import { Image } from '@domternal/extension-image';
import '@domternal/theme';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [
Document, Paragraph, Text,
Image.configure({
// Optional: handle file uploads
uploadHandler: async (file) => {
const form = new FormData();
form.append('file', file);
const res = await fetch('/api/upload', { method: 'POST', body: form });
const { url } = await res.json();
return url;
},
}),
],
content: '<p>Check out this image:</p><img src="https://placehold.co/400x200" alt="Placeholder">',
});

With the theme, clicking the toolbar image button opens a URL/file popover. Paste or drag images to upload them via the uploadHandler. Select an image to see float controls in the bubble menu (requires BubbleMenu).

PropertyValue
ProseMirror nameimage
TypeNode
Groupblock (default) or inline (when inline: true)
ContentNone (atom node)
AtomYes
DraggableYes
HTML tag<img> (parses img[src] - requires src attribute)

Image is an atom node - it cannot contain text or other nodes. It is draggable by default. When selected, resize handles appear at the four corners.

OptionTypeDefaultDescription
inlinebooleanfalseWhen true, images appear inline within paragraphs instead of as block elements
allowBase64booleantrueAllow data:image/ base64 URLs. When false, data: URLs are blocked entirely
HTMLAttributesRecord<string, unknown>{}HTML attributes added to the <img> element
uploadHandler(file: File) => Promise<string> | nullnullAsync function that uploads a file and returns the URL. Enables paste/drop upload with loading placeholders
allowedMimeTypesstring[]See belowAllowed MIME types for file upload
maxFileSizenumber0Maximum file size in bytes. 0 means unlimited
onUploadStart(file: File) => void | nullnullCalled when upload starts for a file
onUploadError(error: Error, file: File) => void | nullnullCalled when upload fails

Default allowed MIME types:

['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif']
import { Image } from '@domternal/extension-image';
// Images appear within text flow instead of as separate blocks
const InlineImage = Image.configure({ inline: true });

When inline: true, the image node’s group changes from block to inline, allowing images inside paragraphs alongside text.

import { Image } from '@domternal/extension-image';
const UploadImage = Image.configure({
uploadHandler: async (file) => {
const formData = new FormData();
formData.append('image', file);
const res = await fetch('/api/upload', { method: 'POST', body: formData });
return (await res.json()).url;
},
maxFileSize: 5 * 1024 * 1024, // 5 MB
onUploadStart: (file) => {
console.log(`Uploading: ${file.name}`);
},
onUploadError: (error, file) => {
console.error(`Upload failed for ${file.name}:`, error.message);
},
});

When uploadHandler is provided:

  • Paste an image from clipboard to upload and insert it
  • Drag and drop an image file onto the editor to upload and insert it

For paste and drop, a decoration-based placeholder appears in the document during upload. On success, the placeholder is replaced with the real image node. On failure, the placeholder is removed and onUploadError is called. The onUploadStart callback fires for each file.

AttributeTypeDefaultDescription
srcstring | nullnullImage URL (validated for XSS)
altstring | nullnullAlternative text for accessibility
titlestring | nullnullTooltip text on hover
widthstring | nullnullImage width in pixels
heightstring | nullnullImage height in pixels
loading'lazy' | 'eager' | nullnullBrowser lazy-loading hint
crossorigin'anonymous' | 'use-credentials' | nullnullCORS policy for the image
float'none' | 'left' | 'right' | 'center''none'Text wrapping behavior

Default values (null for most, 'none' for float) are omitted from the rendered HTML.

The float attribute controls how text wraps around the image:

FloatCSS output
noneNo inline style (default)
leftfloat: left; margin: 0 1em 1em 0;
rightfloat: right; margin: 0 0 1em 1em;
centerdisplay: block; margin-left: auto; margin-right: auto;

Insert an image at the current selection.

editor.commands.setImage({ src: 'https://example.com/photo.jpg' });
// With all options
editor.commands.setImage({
src: 'https://example.com/photo.jpg',
alt: 'A scenic view',
title: 'Photo by Alice',
width: 400,
height: 300,
loading: 'lazy',
crossorigin: 'anonymous',
float: 'left',
});

If an image is already selected (NodeSelection), it is replaced with the new image. Returns false if the URL fails XSS validation or the cursor is inside a code block.

Change the float attribute of a selected image.

editor.commands.setImageFloat('left');
editor.commands.setImageFloat('center');
editor.commands.setImageFloat('none');

Returns false if no image is selected or the value is invalid.

Delete the currently selected image.

editor.commands.deleteImage();
// With chaining
editor.chain().focus().deleteImage().run();

Image does not register any keyboard shortcuts. Use the toolbar button or the markdown input rule to insert images.

InputResult
![alt](src)Image with alt text
![alt](src "title")Image with alt text and title

Markdown-style image syntax. The title can be wrapped in single quotes, double quotes, or curly (smart) quotes.

![A photo](https://example.com/photo.jpg)
![A photo](https://example.com/photo.jpg "Photo title")

The input rule validates the URL for XSS before inserting.

Image registers a button in the toolbar with the name image in group insert at priority 150.

ItemCommandIconEvent
Insert ImagesetImageimageinsertImage

Clicking the toolbar button emits an insertImage event which opens the image popover (URL input + file browser).

Image registers five bubble menu items with bubbleMenu: 'image' for float controls and deletion:

ItemCommandIconLabel
imageFloatNonesetImageFloat('none')textIndentInline
imageFloatLeftsetImageFloat('left')textAlignLeftFloat left
imageFloatCentersetImageFloat('center')textAlignCenterCenter
imageFloatRightsetImageFloat('right')textAlignRightFloat right
deleteImagedeleteImagetrashDelete

These items have toolbar: false (hidden from the main toolbar) and bubbleMenu: 'image' (shown only when an image is selected).

Add BubbleMenu with a custom shouldShow that detects image selection, then populate it with the registered items:

import { Editor, Document, Paragraph, Text, BubbleMenu, ToolbarController, defaultIcons } from '@domternal/core';
import { Image } from '@domternal/extension-image';
// Create the bubble menu element
const bubbleEl = document.createElement('div');
bubbleEl.className = 'dm-bubble-menu';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [
Document, Paragraph, Text,
Image,
BubbleMenu.configure({
element: bubbleEl,
shouldShow: ({ state }) => {
const sel = state.selection as any;
return sel.node?.type?.name === 'image';
},
}),
],
});
// Populate bubble menu with image items (float controls + delete)
const imageItems = editor.toolbarItems
.filter(item => item.type === 'button' && item.bubbleMenu === 'image')
.sort((a, b) => (b.priority ?? 100) - (a.priority ?? 100));
for (const item of imageItems) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'dm-toolbar-button';
btn.title = item.label;
btn.innerHTML = defaultIcons[item.icon] ?? '';
btn.addEventListener('mousedown', e => e.preventDefault());
btn.addEventListener('click', () => {
ToolbarController.executeItem(editor, item);
});
bubbleEl.appendChild(btn);
}

The Image extension includes a built-in popover for inserting images. It opens automatically when the toolbar button emits the insertImage event - no additional setup is required. The popover contains:

  • URL input - type or paste an image URL, press Enter to insert
  • Apply button (checkmark) - insert the URL from the input
  • Browse button (image icon) - open a file picker to select a local image

The browse button works regardless of uploadHandler. When an upload handler is set, the selected file is uploaded via the handler. When null, the file is converted to a base64 data URL and inserted directly.

Keyboard navigation within the popover:

KeyAction
Enter (in input)Insert URL and close
Enter (on button)Activate button
EscapeClose popover
TabMove focus forward: input → apply → browse → input
Shift+Tab (on buttons)Move focus backward: browse → apply → input

The popover is positioned below the toolbar button using Floating UI, appended to document.body to escape overflow: hidden containers, and closes when clicking outside.

Click an image to select it. When selected, four corner resize handles appear (NW, NE, SW, SE). Drag any handle to resize the image width - height scales automatically to preserve aspect ratio.

  • Minimum width: 50px
  • Maximum width: 100% of the container
  • Floated images are capped at 60% width
  • Width is stored as the width attribute on the node

When uploadHandler is null (default):

  • Paste: Image files from clipboard are converted to base64 data URLs and inserted at the cursor position
  • Drop: Image files dropped onto the editor are converted to base64 data URLs and inserted at the drop position

When uploadHandler is provided:

  • Paste: Image files are uploaded via the handler. A placeholder decoration appears during upload.
  • Drop: Image files are uploaded via the handler. A placeholder decoration appears at the drop position.

In both cases, files are validated against allowedMimeTypes and maxFileSize before processing.

A visual drag overlay (dm-dragover class) appears on the editor when dragging image files over it, providing visual feedback.

Image URLs are validated at four levels (defense in depth):

  1. parseHTML - validates src when parsing HTML content
  2. renderHTML - re-validates on render (returns empty src if invalid)
  3. setImage command - validates before insertion
  4. Input rule - validates in markdown syntax

Blocked protocols:

ProtocolReason
javascript:Script execution
vbscript:Script execution
file:Local file access
data:Blocked unless allowBase64 is true AND URL starts with data:image/

Allowed:

  • http:// and https:// URLs
  • Relative paths (./images/photo.jpg)
  • Absolute paths (/uploads/photo.jpg)
  • Protocol-relative URLs (//cdn.example.com/img.png)
  • data:image/ URLs (when allowBase64 is true)

Image uses a custom NodeView that provides:

  • Resizable container (div.dm-image-resizable) with four corner handles
  • Click-to-select - clicking the image creates a NodeSelection (necessary for floated images where ProseMirror’s posAtCoords is unreliable)
  • Selection outline - 2px solid accent color border when selected
  • Float styling - data-float attribute on the container controls CSS float behavior

The alt attribute is returned as the image’s text representation. This affects editor.getText(), clipboard text, and accessibility.

Image styles are provided by @domternal/theme in _image.scss:

ClassDescription
.dm-image-resizableImage wrapper with relative positioning
.dm-image-handleResize handle (8x8px, accent-colored)
.dm-image-handle-nw/ne/sw/seCorner-specific handle positioning
.dm-image-popoverFixed-position URL input popover
.dm-image-popover-inputURL text input (min-width: 14rem)
.dm-image-popover-btnApply and browse buttons
.dm-dragoverEditor overlay during image drag-and-drop

Floated images are capped at max-width: 60%. The popover supports light and dark themes automatically.

import { Image } from '@domternal/extension-image';
import type { ImageOptions, SetImageOptions, ImageFloat } from '@domternal/extension-image';
import { imageUploadPluginKey } from '@domternal/extension-image';
ExportTypeDescription
ImageNode extensionThe image node extension
ImageOptionsTypeScript typeOptions for Image.configure()
SetImageOptionsTypeScript typeOptions for the setImage command
ImageFloatTypeScript type'none' | 'left' | 'right' | 'center'
imageUploadPluginKeyPluginKeyProseMirror plugin key for the upload plugin (useful for accessing upload state)

An image with all attributes:

{
"type": "image",
"attrs": {
"src": "https://example.com/photo.jpg",
"alt": "A scenic view",
"title": "Photo by Alice",
"width": "400",
"height": "300",
"loading": "lazy",
"crossorigin": null,
"float": "left"
}
}

A minimal image (all attributes shown with their defaults):

{
"type": "image",
"attrs": {
"src": "https://example.com/photo.jpg",
"alt": null,
"title": null,
"width": null,
"height": null,
"loading": null,
"crossorigin": null,
"float": "none"
}
}

A document with a centered image between paragraphs:

{
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Check out this photo:" }]
},
{
"type": "image",
"attrs": {
"src": "https://example.com/photo.jpg",
"alt": "A scenic view",
"title": null,
"width": "600",
"height": null,
"loading": null,
"crossorigin": null,
"float": "center"
}
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Beautiful, right?" }]
}
]
}

@domternal/extension-image - Image.ts