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).
Live Playground
Section titled “Live Playground”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.
Vanilla JS preview - Angular components produce the same output
Vanilla JS preview - React components produce the same output
Plain image without the theme. The buttons above the editor are custom HTML buttons wired to image commands like setImage(), setImageFloat(), deleteImage().
Installation
Section titled “Installation”pnpm add @domternal/extension-imageimport { 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).
import { Component, signal } from '@angular/core';import { DomternalEditorComponent, DomternalToolbarComponent } from '@domternal/angular';import { Editor, Document, Paragraph, Text } from '@domternal/core';import { Image } from '@domternal/extension-image';
@Component({ selector: 'app-editor', imports: [DomternalEditorComponent, DomternalToolbarComponent], templateUrl: './editor.html',})export class EditorComponent { editor = signal<Editor | null>(null); extensions = [Document, Paragraph, Text, Image]; content = '<p>Check out this image:</p><img src="https://placehold.co/400x200" alt="Placeholder">';}@if (editor(); as ed) { <domternal-toolbar [editor]="ed" />}<domternal-editor [extensions]="extensions" [content]="content" (editorCreated)="editor.set($event)"/>The toolbar auto-renders the image insert button. Add <domternal-bubble-menu> with [contexts]="{ image: true }" for float controls on selected images.
import { Domternal } from '@domternal/react';import { Document, Paragraph, Text } from '@domternal/core';import { Image } from '@domternal/extension-image';
export default function Editor() { return ( <Domternal extensions={[ Document, Paragraph, Text, Image.configure({ 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">' > <Domternal.Toolbar /> <Domternal.Content /> <Domternal.BubbleMenu contexts={{ image: true }} /> </Domternal> );}The <Domternal.Toolbar /> auto-renders the image insert button. Add <Domternal.BubbleMenu> with contexts={{ image: true }} for float controls on selected images.
import { Editor, Document, Paragraph, Text } from '@domternal/core';import { Image } from '@domternal/extension-image';
const editor = new Editor({ element: document.getElementById('editor')!, extensions: [Document, Paragraph, Text, Image],});
// Insert an image by URLeditor.commands.setImage({ src: 'https://placehold.co/400x200', alt: 'Placeholder' });
// Float an imageeditor.commands.setImageFloat('left');editor.commands.setImageFloat('none');
// Delete the selected imageeditor.commands.deleteImage();Schema
Section titled “Schema”| Property | Value |
|---|---|
| ProseMirror name | image |
| Type | Node |
| Group | block (default) or inline (when inline: true) |
| Content | None (atom node) |
| Atom | Yes |
| Draggable | Yes |
| 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.
Options
Section titled “Options”| Option | Type | Default | Description |
|---|---|---|---|
inline | boolean | false | When true, images appear inline within paragraphs instead of as block elements |
allowBase64 | boolean | true | Allow data:image/ base64 URLs. When false, data: URLs are blocked entirely |
HTMLAttributes | Record<string, unknown> | {} | HTML attributes added to the <img> element |
uploadHandler | (file: File) => Promise<string> | null | null | Async function that uploads a file and returns the URL. Enables paste/drop upload with loading placeholders |
allowedMimeTypes | string[] | See below | Allowed MIME types for file upload |
maxFileSize | number | 0 | Maximum file size in bytes. 0 means unlimited |
onUploadStart | (file: File) => void | null | null | Called when upload starts for a file |
onUploadError | (error: Error, file: File) => void | null | null | Called when upload fails |
Default allowed MIME types:
['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/avif']Inline images
Section titled “Inline images”import { Image } from '@domternal/extension-image';
// Images appear within text flow instead of as separate blocksconst InlineImage = Image.configure({ inline: true });When inline: true, the image node’s group changes from block to inline, allowing images inside paragraphs alongside text.
File upload
Section titled “File upload”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.
Attributes
Section titled “Attributes”| Attribute | Type | Default | Description |
|---|---|---|---|
src | string | null | null | Image URL (validated for XSS) |
alt | string | null | null | Alternative text for accessibility |
title | string | null | null | Tooltip text on hover |
width | string | null | null | Image width in pixels |
height | string | null | null | Image height in pixels |
loading | 'lazy' | 'eager' | null | null | Browser lazy-loading hint |
crossorigin | 'anonymous' | 'use-credentials' | null | null | CORS 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.
Float rendering
Section titled “Float rendering”The float attribute controls how text wraps around the image:
| Float | CSS output |
|---|---|
none | No inline style (default) |
left | float: left; margin: 0 1em 1em 0; |
right | float: right; margin: 0 0 1em 1em; |
center | display: block; margin-left: auto; margin-right: auto; |
Commands
Section titled “Commands”setImage
Section titled “setImage”Insert an image at the current selection.
editor.commands.setImage({ src: 'https://example.com/photo.jpg' });
// With all optionseditor.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.
setImageFloat
Section titled “setImageFloat”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.
deleteImage
Section titled “deleteImage”Delete the currently selected image.
editor.commands.deleteImage();
// With chainingeditor.chain().focus().deleteImage().run();Keyboard shortcuts
Section titled “Keyboard shortcuts”Image does not register any keyboard shortcuts. Use the toolbar button or the markdown input rule to insert images.
Input rules
Section titled “Input rules”| Input | Result |
|---|---|
 | Image with alt text |
 | Image with alt text and title |
Markdown-style image syntax. The title can be wrapped in single quotes, double quotes, or curly (smart) quotes.
The input rule validates the URL for XSS before inserting.
Toolbar items
Section titled “Toolbar items”Main toolbar
Section titled “Main toolbar”Image registers a button in the toolbar with the name image in group insert at priority 150.
| Item | Command | Icon | Event |
|---|---|---|---|
| Insert Image | setImage | image | insertImage |
Clicking the toolbar button emits an insertImage event which opens the image popover (URL input + file browser).
Bubble menu
Section titled “Bubble menu”Image registers five bubble menu items with bubbleMenu: 'image' for float controls and deletion:
| Item | Command | Icon | Label |
|---|---|---|---|
imageFloatNone | setImageFloat('none') | textIndent | Inline |
imageFloatLeft | setImageFloat('left') | textAlignLeft | Float left |
imageFloatCenter | setImageFloat('center') | textAlignCenter | Center |
imageFloatRight | setImageFloat('right') | textAlignRight | Float right |
deleteImage | deleteImage | trash | Delete |
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 elementconst 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);}Use <domternal-bubble-menu> with the contexts input. The component automatically discovers items with bubbleMenu: 'image':
<domternal-bubble-menu [editor]="editor()!" [contexts]="{ image: true }"/>You can also specify which items to show per context:
<domternal-bubble-menu [editor]="editor()!" [contexts]="{ image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'deleteImage'] }"/>Use <Domternal.BubbleMenu> with the contexts prop. The component automatically discovers items with bubbleMenu: 'image':
<Domternal.BubbleMenu contexts={{ image: true }} />You can also specify which items to show per context:
<Domternal.BubbleMenu contexts={{ image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'deleteImage'], }}/>Without a bubble menu UI, use the float and delete commands programmatically:
// Set image floateditor.commands.setImageFloat('left');editor.commands.setImageFloat('center');editor.commands.setImageFloat('none');
// Delete selected imageeditor.commands.deleteImage();Image popover
Section titled “Image popover”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
Enterto 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:
| Key | Action |
|---|---|
Enter (in input) | Insert URL and close |
Enter (on button) | Activate button |
Escape | Close popover |
Tab | Move 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.
Resizing
Section titled “Resizing”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
widthattribute on the node
Paste and drag-and-drop
Section titled “Paste and drag-and-drop”Without uploadHandler
Section titled “Without uploadHandler”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
With uploadHandler
Section titled “With uploadHandler”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.
XSS protection
Section titled “XSS protection”Image URLs are validated at four levels (defense in depth):
- parseHTML - validates
srcwhen parsing HTML content - renderHTML - re-validates on render (returns empty
srcif invalid) - setImage command - validates before insertion
- Input rule - validates in markdown syntax
Blocked protocols:
| Protocol | Reason |
|---|---|
javascript: | Script execution |
vbscript: | Script execution |
file: | Local file access |
data: | Blocked unless allowBase64 is true AND URL starts with data:image/ |
Allowed:
http://andhttps://URLs- Relative paths (
./images/photo.jpg) - Absolute paths (
/uploads/photo.jpg) - Protocol-relative URLs (
//cdn.example.com/img.png) data:image/URLs (whenallowBase64istrue)
NodeView
Section titled “NodeView”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’sposAtCoordsis unreliable) - Selection outline -
2px solidaccent color border when selected - Float styling -
data-floatattribute on the container controls CSS float behavior
leafText
Section titled “leafText”The alt attribute is returned as the image’s text representation. This affects editor.getText(), clipboard text, and accessibility.
Styling
Section titled “Styling”Image styles are provided by @domternal/theme in _image.scss:
| Class | Description |
|---|---|
.dm-image-resizable | Image wrapper with relative positioning |
.dm-image-handle | Resize handle (8x8px, accent-colored) |
.dm-image-handle-nw/ne/sw/se | Corner-specific handle positioning |
.dm-image-popover | Fixed-position URL input popover |
.dm-image-popover-input | URL text input (min-width: 14rem) |
.dm-image-popover-btn | Apply and browse buttons |
.dm-dragover | Editor overlay during image drag-and-drop |
Floated images are capped at max-width: 60%. The popover supports light and dark themes automatically.
Exports
Section titled “Exports”import { Image } from '@domternal/extension-image';import type { ImageOptions, SetImageOptions, ImageFloat } from '@domternal/extension-image';import { imageUploadPluginKey } from '@domternal/extension-image';| Export | Type | Description |
|---|---|---|
Image | Node extension | The image node extension |
ImageOptions | TypeScript type | Options for Image.configure() |
SetImageOptions | TypeScript type | Options for the setImage command |
ImageFloat | TypeScript type | 'none' | 'left' | 'right' | 'center' |
imageUploadPluginKey | PluginKey | ProseMirror plugin key for the upload plugin (useful for accessing upload state) |
JSON representation
Section titled “JSON representation”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?" }] } ]}Source
Section titled “Source”@domternal/extension-image - Image.ts