Skip to content

Image

The Image extension provides a full-featured image node with resizable handles, two placement models (float with text wrap, or Notion-style alignment), 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).

Choose Image when you need:

  • Drag-corner resize handles (most editors lock you to original dimensions or charge for resize)
  • Paste from clipboard or drag-and-drop upload via a custom uploadHandler
  • Float-with-text-wrap (left, center, right) for magazine-style layouts
  • Notion-style alignment (left, center, right, with the text staying below the picture) in the Notion preset, or wherever placement: 'align' is configured
  • Defense-in-depth XSS protection at four layers: parse, render, command, and input rule

Skip it if:

  • Your editor only renders static images from server-rendered HTML
  • You need cropping, filters, or AI editing (out of scope; pair with a separate library)
  • Your editor is inline-only and images break the inline flow (configure inline: true)

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 { Document, Paragraph, Text } from '@domternal/core';
import { Image } from '@domternal/extension-image';
import { DomternalEditor } from '@domternal/vanilla';
import '@domternal/theme';
const dm = new DomternalEditor(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
placement'float' | 'align' | nullnullWhich placement control the bubble menu offers. null follows the editor: the Notion preset offers align, everything else offers float (details)

Default allowed MIME types:

['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif']

The bubble menu offers exactly one placement control per editor. float wraps text around the picture; align only moves the picture within the measure and keeps the text below it, which is what Notion does. Offering both would ask the author to choose between two things that look the same until the text beside the picture is long enough to tell them apart.

By default (placement: null) the control follows the editor’s preset: an editor created with preset: 'notion' (or carrying the dm-notion-mode class) offers the three align buttons, everything else offers the four float buttons. Set the option explicitly to pin one regardless of preset:

// A classic editor that should place pictures the Notion way
Image.configure({ placement: 'align' })
// A Notion-styled editor that still wants text wrap
Image.configure({ placement: 'float' })

Whichever control is on offer, both attributes stay part of the schema, so a document written under one setting keeps its layout when opened under the other. The commands are also always available: setImageFloat works in an align editor and vice versa.

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. A bare number, a numeric string and a px-suffixed string (300, '300', '300px') all size identically, on screen and in every export.
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
align'none' | 'left' | 'center' | 'right''none'Position within the measure, text stays below (the Notion behavior)

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

float and align are one choice per image: setting either through its command clears the other, so a node never carries both. They are deliberately separate attributes rather than one attribute styled differently per preset, because the document has to record which of the two the author actually saw. An export reads attributes, not presets, and could not otherwise tell a wrapped picture from an aligned one.

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;

The align attribute only moves the picture inside the measure; nothing ever comes up beside it. It renders as a data-align attribute plus inline styles, so the same HTML lands in the right place in a plain browser with no theme loaded:

AlignHTML output
noneNothing (default)
leftdata-align="left" + display: block; width: fit-content; margin-right: auto;
centerdata-align="center" + display: block; width: fit-content; margin-left: auto; margin-right: auto;
rightdata-align="right" + display: block; width: fit-content; margin-left: auto;

Parsing reads align from the data-align attribute alone, never from the style: the centered form is written with the same auto margins float: 'center' uses, and a document that already carries a centered float must keep meaning that rather than acquire an alignment as well.

Unlike a floated image, an aligned image has no 60% width cap, since nothing has to fit beside it. In Notion mode the theme also neutralizes any left-over float from a classic document (the picture renders as its aligned equivalent), because the float controls are not on offer there and a layout the author cannot reach should not persist visually.

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. Also clears align, since the two placements are one choice.

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

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

Position a selected image within the measure without wrapping text around it. Also clears float.

editor.commands.setImageAlign('left');
editor.commands.setImageAlign('center');
editor.commands.setImageAlign('right');
editor.commands.setImageAlign('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 one item in the Media group of the slash command and floating menu at priority 200, matched by the keywords image, picture, photo, img:

ItemLabelDescription
imageImageUpload or embed with a link

The item inserts nothing by itself: it emits the same insertImage event as the toolbar button, so picking Image opens the image popover and the node is created when you apply a URL or pick a file. The popover is part of the extension’s own plugin, so it works with no extra setup. Because the menu passes no anchor element, the popover opens at the caret instead of under the toolbar button.

Image registers bubble menu items with bubbleMenu: 'image' for placement, alt-text editing, and deletion. The placement set depends on the resolved placement: a classic editor gets the four float controls, a Notion-preset editor gets the three align controls.

Float set (classic editors):

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

Align set (Notion preset, or placement: 'align'):

ItemCommandIconLabel
imageAlignLeftsetImageAlign('left')textAlignLeftAlign left
imageAlignCentersetImageAlign('center')textAlignCenterAlign center
imageAlignRightsetImageAlign('right')textAlignRightAlign right

Both sets are followed by the shared actions:

ItemCommandIconLabel
editImageemits editImagetextAaEdit alt text
deleteImagedeleteImagetrashDelete

These items have toolbar: false (hidden from the main toolbar) and bubbleMenu: 'image' (shown only when an image is selected). editImage opens the alt-text popover (see Editing alt text) and shows as active when the selected image already has non-empty alt text.

Add BubbleMenu and use DomternalBubbleMenu with an explicit context map so it only shows for image selections:

import { Document, Paragraph, Text, BubbleMenu } from '@domternal/core';
import { Image } from '@domternal/extension-image';
import { DomternalEditor, DomternalBubbleMenu } from '@domternal/vanilla';
import '@domternal/theme';
const bubbleEl = document.createElement('div');
bubbleEl.className = 'dm-bubble-menu';
const dm = new DomternalEditor(document.getElementById('editor')!, {
extensions: [
Document, Paragraph, Text,
Image,
BubbleMenu.configure({ element: bubbleEl }),
],
});
new DomternalBubbleMenu(bubbleEl, {
editor: dm.editor,
contexts: {
image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'editImage', 'deleteImage'],
},
});

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.

The insert popover is URL-only. Alt text is set afterward, on the image itself: see below.

Alt text can be set when you insert an image, but the moment that matters is afterward, when the image is on the page and you can see what to describe. Select an existing image and the bubble menu shows an Edit alt text action (it appears next to the float controls under the image context). It opens a compact popover with a single field, pre-filled with the image’s current alt, anchored to the image rather than to the toolbar:

// "Edit alt text" emits the `editImage` event. The extension opens an
// alt-only popover for the selected image and writes the new value back
// to the node's `alt` attribute, leaving `src` untouched.
editor.commands.setImage({ src: existingSrc, alt: 'Quarterly revenue up 12%' });

The menu is alt-only: there is no URL field or re-upload, so annotating an image can’t accidentally change it. The action highlights as active when the selected image already has non-empty alt text, so images still missing a description stand out at a glance. Applying writes the value to the node’s alt attribute and an empty value clears it; the alt you set travels with the image into the serialized HTML as a standard alt attribute, which is what a reader’s assistive technology relies on. The popover reuses the image popover container with its own field, inheriting the same keyboard handling (Enter to apply, Escape to cancel) and outside-click dismissal.

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.

Resizing is editing, so a read-only editor hides the handles and refuses the drag: a selected image keeps its stored width.

  • 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
  • Placement styling - data-float and data-align attributes on the container, written by one pass: whichever of the two is 'none' is removed rather than written, so the container never claims both placements at once

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-fieldsColumn wrapper for the popover’s input(s)
.dm-image-popover-inputURL text input (min-width: 14rem)
.dm-image-popover-alt-inputAlt-text input shown when editing an existing image
.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,
ImageAlign,
ImagePlacement,
} 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'
ImageAlignTypeScript type'none' | 'left' | 'center' | 'right'
ImagePlacementTypeScript type'float' | 'align', the value of the placement option
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",
"align": "none"
}
}

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",
"align": "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",
"align": "none"
}
},
{
"type": "paragraph",
"content": [{ "type": "text", "text": "Beautiful, right?" }]
}
]
}

@domternal/extension-image - Image.ts