Skip to content

Angular

The @domternal/angular package provides six standalone components that wrap the headless editor with Angular-native APIs. All components use signals, OnPush change detection, and modern Angular 17.1+ features.

Click to try it out

Use the Angular wrapper when you need:

  • Six standalone components that integrate with Angular signals and OnPush change detection
  • Reactive Forms support via ControlValueAccessor on the editor component
  • Native Angular APIs (inputs, outputs, signals) instead of a thin React-binding wrapper
  • TypeScript-first APIs that match modern Angular patterns

Skip it if:

  • You target older Angular versions (< 17.1) without standalone components or signals
  • You use Material’s wysiwyg primitives or NgZorro’s rich-text editor and want to stay inside one ecosystem
  • You only need a headless API and prefer to wire the toolbar yourself (use the vanilla package instead)
Terminal window
pnpm add @domternal/core @domternal/theme @domternal/angular

Add the theme to your global stylesheet:

styles.scss
@use '@domternal/theme';
import { Component, signal } from '@angular/core';
import {
DomternalEditorComponent,
DomternalToolbarComponent,
DomternalBubbleMenuComponent,
} from '@domternal/angular';
import { Editor, StarterKit, BubbleMenu } from '@domternal/core';
@Component({
selector: 'app-editor',
imports: [DomternalEditorComponent, DomternalToolbarComponent, DomternalBubbleMenuComponent],
templateUrl: './editor.html',
})
export class EditorComponent {
editor = signal<Editor | null>(null);
extensions = [StarterKit, BubbleMenu];
content = '<p>Hello from Angular!</p>';
}
editor.html
@if (editor(); as ed) {
<domternal-toolbar [editor]="ed" />
}
<domternal-editor
[extensions]="extensions"
[content]="content"
(editorCreated)="editor.set($event)"
/>
@if (editor(); as ed) {
<domternal-bubble-menu [editor]="ed" />
}

The toolbar and bubble menu auto-render buttons based on extensions. No manual button wiring needed.

The core editor component. Wraps ProseMirror with Angular inputs/outputs and implements ControlValueAccessor for form integration.

Selector: domternal-editor

Host class: dm-editor

InputTypeDefaultDescription
extensionsAnyExtension[][]Extensions to load. Default extensions (Document, Paragraph, Text, BaseKeymap, History) are always included.
historybooleantrueWhether the built-in History extension is included. Set to false when an extension brings its own undo/redo, such as collaborative editing. NEW in v0.12.0.
contentContent''Initial content (HTML string or JSON). Reactive: changing this input updates the editor.
editablebooleantrueWhether the editor is editable. Reactive.
preset'classic' | 'notion'resolved'notion' paints dm-notion-mode on the host and switches preset-aware extensions to their Notion behavior. Create-time only. See the preset option.
autofocusFocusPositionfalseFocus on mount: true, 'start', 'end', 'all', or a position number.
outputFormat'html' | 'json''html'Format for value changes (affects ControlValueAccessor and contentUpdated).
OutputTypeDescription
editorCreatedEditorEmitted when the editor instance is created. Use this to pass the editor to toolbar and bubble menu.
contentUpdated{ editor: Editor }Emitted when the document content changes.
selectionChanged{ editor: Editor }Emitted when the selection changes (without content change).
focusChanged{ editor: Editor; event: FocusEvent }Emitted when the editor receives focus.
blurChanged{ editor: Editor; event: FocusEvent }Emitted when the editor loses focus.
editorDestroyedvoidEmitted when the editor is destroyed.
SignalTypeDescription
htmlContentstringCurrent document as HTML.
jsonContentJSONContent | nullCurrent document as JSON.
isEmptybooleanWhether the document is empty.
isFocusedbooleanWhether the editor has focus.
isEditablebooleanWhether the editor is editable.
// Via output event
editor = signal<Editor | null>(null);
// In template
(editorCreated)="editor.set($event)"
// Or via ViewChild
@ViewChild(DomternalEditorComponent) editorComponent!: DomternalEditorComponent;
// Then: this.editorComponent.editor

Auto-renders toolbar buttons, dropdowns, separators, and keyboard navigation based on the editor’s extensions.

Selector: domternal-toolbar

Host class: dm-toolbar

Host attributes: role="toolbar", aria-label="Editor formatting"

InputTypeDefaultDescription
editorEditorrequiredThe editor instance.
iconsIconSet | nullnullCustom icon set (map of icon name to SVG string). When omitted or null, the built-in Phosphor icons are used; when provided, it replaces the whole set (no per-icon fallback).
layoutToolbarLayoutEntry[]-Custom layout to reorder, group, or filter toolbar items.

By default, the toolbar renders all items from extensions grouped by their group property. Use layout to customize:

// Show only specific items in order
layout = ['bold', 'italic', 'underline', '|', 'heading', 'bulletList', 'orderedList'];

The '|' string inserts a visual separator.

The toolbar supports full keyboard navigation:

KeyAction
ArrowRightFocus next button
ArrowLeftFocus previous button
ArrowDownOpen dropdown / focus next dropdown item
ArrowUpFocus previous dropdown item
HomeFocus first button
EndFocus last button
EscapeClose open dropdown

An inline formatting toolbar that appears when the user selects text. Renders buttons based on the editor’s extensions.

Selector: domternal-bubble-menu

InputTypeDefaultDescription
editorEditorrequiredThe editor instance.
itemsstring[]-Fixed list of item names to show (e.g., ['bold', 'italic', 'code']).
contextsRecord<string, string[] | true | null>-Context-aware items. Key is a node type name or 'text'/'table'. Value is item names, true for all valid items, or null to hide the menu.
shouldShowfunction-Custom predicate (props) => boolean to control visibility.
placement'top' | 'bottom''top'Menu placement relative to the selection.
offsetnumber8Pixel offset from the selection.
updateDelaynumber0Delay in milliseconds before updating position.
iconsIconSet-Icon overrides, merged over the built-in Phosphor icons per icon (unlike domternal-toolbar, which replaces the whole set). NEW in v0.7.0.

In Notion mode, the bubble menu also renders trailing buttons automatically when the matching extensions are loaded: an “A” trigger (when NotionColorPicker is loaded and something is listening for notionColorOpen, which <domternal-notion-color-picker> does while it is in the template) opens the color picker; a ”…” trigger (when BlockContextMenu is loaded) opens the block context menu. Both are hidden on node selection (image, HR); ”…” is also disabled on multi-block selection.

Pressing the “A” does nothing but emit notionColorOpen, so the listener check is what keeps it from rendering dead in an app that registers the extension without ever mounting a picker panel. The count is re-read on every state sync rather than once at construction: a picker declared behind an @if that flips true after the bubble menu exists brings the trigger back on the next transaction, and destroying the last listener hides it again.

Add custom buttons via <ng-content>:

<domternal-bubble-menu [editor]="ed">
<button (click)="customAction()">Custom</button>
</domternal-bubble-menu>

Show different buttons depending on what the user selected:

<domternal-bubble-menu
[editor]="ed"
[contexts]="{
text: ['bold', 'italic', 'underline', '|', 'link'],
heading: ['bold', 'italic', '|', 'link'],
codeBlock: null,
image: ['imageFloatLeft', 'imageFloatCenter', 'imageFloatRight', '|', 'deleteImage']
}"
/>
  • text - Default for text selections
  • null - Hide the menu in that context
  • true - Show all valid items for that context

A menu that appears on empty lines, allowing users to insert block-level content.

Selector: domternal-floating-menu

InputTypeDefaultDescription
editorEditorrequiredThe editor instance.
shouldShowfunction-Custom predicate to control visibility. Default: shows on empty paragraphs.
offsetnumber0Pixel offset from the cursor position.
itemsFloatingMenuItemsOverride-Override the item list collected from extensions via addFloatingMenuItems(). An array replaces the defaults; a function receives them and returns a new list.
keymapFloatingMenuKeymap{ enterMenu: ['Alt-F10', 'Mod-/'] }Shortcuts that move focus from the editor into the menu. Pass { enterMenu: [] } to disable keyboard entry.
iconsIconSet-Icon overrides. Names missing from the set fall back to the built-in Phosphor icons.
requireExplicitTriggerbooleanfalseWhen true the menu no longer auto-shows on every empty paragraph; it opens only when the BlockHandle + button (or any caller of showFloatingMenu) triggers it. This is what Notion Mode sets.

See Floating Menu for the full items API.

The component renders the items it collects from the editor’s extensions. It has no <ng-content>, so markup nested inside the element is dropped. Change the list with items and swap individual icons with icons:

import type { FloatingMenuItemsOverride } from '@domternal/core';
menuItems: FloatingMenuItemsOverride = (defaults) =>
defaults.filter((item) => item.name !== 'horizontal-rule');
<domternal-floating-menu [editor]="ed" [items]="menuItems" />

A searchable emoji picker panel that opens from the toolbar emoji button.

Selector: domternal-emoji-picker

InputTypeDefaultDescription
editorEditorrequiredThe editor instance.
emojisEmojiPickerItem[]requiredArray of emoji definitions with emoji, name, and group properties.
import { DomternalEmojiPickerComponent } from '@domternal/angular';
import { Emoji, emojis } from '@domternal/extension-emoji';
extensions = [StarterKit, Emoji];
emojiData = emojis;
<domternal-emoji-picker [editor]="ed" [emojis]="emojiData" />

The picker opens when the user clicks the emoji toolbar button. It includes search, category tabs, frequently used section, and smooth scrolling.


A Notion-style named-token color picker that opens when the bubble menu’s “A” trigger is clicked. Renders a 9-color text + 9-color background palette. Available in v0.7.0 across all framework wrappers.

Selector: domternal-notion-color-picker

InputTypeDefaultDescription
editorEditorrequiredThe editor instance.
import { DomternalNotionColorPickerComponent } from '@domternal/angular';
import { NotionColorPicker, TextStyle, TextColor, Highlight } from '@domternal/core';
extensions = [
StarterKit,
TextStyle, TextColor, Highlight,
NotionColorPicker,
BubbleMenu,
];
@if (editor(); as ed) {
<domternal-bubble-menu [editor]="ed" />
<domternal-notion-color-picker [editor]="ed" />
}

The picker listens for the notionColorOpen event emitted by the bubble menu’s “A” trigger and renders the panel anchored to the trigger. Supports keyboard navigation across the swatch grid.

See Notion Color Picker extension for the underlying schema and theme tokens, and the Notion Mode guide for the full Notion-style editor setup.

Pair domternal-notion-color-picker with @domternal/extension-block-controls (drag handle, slash command, block context menu) and @domternal/extension-toc (table of contents) for the full Notion experience. The setup declares preset="notion" on the editor component (which paints the .dm-notion-mode class for you) and configures requireExplicitTrigger: true on the floating menu.

See the Notion Mode guide for the cross-framework setup with code examples per wrapper, or build it step by step in the Angular tutorial.

The editor component implements ControlValueAccessor, so it works with ngModel and reactive forms. For a complete worked example (required validation, character limit, disable and reset), follow the reactive forms tutorial.

@Component({
imports: [FormsModule, DomternalEditorComponent],
template: `
<domternal-editor
[extensions]="extensions"
[(ngModel)]="content"
/>
<pre>{{ content }}</pre>
`,
})
export class MyComponent {
extensions = [StarterKit];
content = '<p>Initial content</p>';
}
@Component({
imports: [ReactiveFormsModule, DomternalEditorComponent],
template: `
<domternal-editor
[extensions]="extensions"
[formControl]="editorControl"
/>
`,
})
export class MyComponent {
extensions = [StarterKit];
editorControl = new FormControl('<p>Initial content</p>');
}

By default, the form value is HTML. Set outputFormat="json" to get JSON instead:

<domternal-editor
[extensions]="extensions"
[(ngModel)]="content"
outputFormat="json"
/>

Reactive forms disable() and enable() automatically toggle the editor’s editable state:

this.editorControl.disable(); // Editor becomes read-only
this.editorControl.enable(); // Editor becomes editable

Disabling the control puts the editor in full read-only mode, not just a styling state: the toolbar disables every item that does not set allowReadOnly, and the bubble menu, floating menu, block handles, table chrome and image resize handles all stand down.

All components use Angular signals for state management. The recommended pattern:

@Component({
imports: [DomternalEditorComponent, DomternalToolbarComponent],
template: `
@if (editor(); as ed) {
<domternal-toolbar [editor]="ed" />
}
<domternal-editor
[extensions]="extensions"
(editorCreated)="editor.set($event)"
/>
<p>{{ isEmpty() ? 'Empty' : 'Has content' }}</p>
`,
})
export class MyComponent {
editor = signal<Editor | null>(null);
extensions = [StarterKit];
// Derived state from editor signals
isEmpty = computed(() => {
const ed = this.editor();
return ed ? ed.isEmpty : true;
});
}
  • No manual subscription management (no subscribe()/unsubscribe())
  • Works with OnPush change detection out of the box
  • Compatible with zoneless Angular
  • Editor events update signals automatically via NgZone.run()

Apply the theme class to a parent element:

<!-- Always dark -->
<div class="dm-theme-dark">
<domternal-toolbar [editor]="ed" />
<domternal-editor [extensions]="extensions" (editorCreated)="editor.set($event)" />
</div>
<!-- Follow system preference -->
<div class="dm-theme-auto">
<domternal-toolbar [editor]="ed" />
<domternal-editor [extensions]="extensions" (editorCreated)="editor.set($event)" />
</div>

Toggle at runtime:

toggleTheme() {
document.body.classList.toggle('dm-theme-dark');
}

Replace the built-in Phosphor icons with your own:

import type { IconSet } from '@domternal/core';
const customIcons: IconSet = {
textB: '<svg>...</svg>',
textItalic: '<svg>...</svg>',
};
<domternal-toolbar [editor]="ed" [icons]="customIcons" />

When icons is provided, domternal-toolbar uses only that set: names missing from it render nothing, there is no per-icon fallback. To override just a few icons, spread defaultIcons:

import { defaultIcons } from '@domternal/core';
const myIcons = { ...defaultIcons, textB: '<svg><!-- custom bold --></svg>' };

domternal-bubble-menu and domternal-floating-menu behave differently: their icons input is merged per icon, so any name you leave out still resolves to the built-in Phosphor icon.

ProseMirror events fire outside Angular’s zone. The editor component handles this internally by wrapping transaction handlers in NgZone.run(). You don’t need to manage this yourself.

If you listen to editor events directly (via editor.on()), wrap your handler:

private ngZone = inject(NgZone);
ngAfterViewInit() {
this.editor()?.on('update', ({ editor }) => {
this.ngZone.run(() => {
// Update Angular state here
this.mySignal.set(editor.getHTML());
});
});
}

All components are standalone (no NgModule required). Import them directly:

import {
DomternalEditorComponent,
DomternalToolbarComponent,
DomternalBubbleMenuComponent,
DomternalFloatingMenuComponent,
DomternalEmojiPickerComponent,
DomternalNotionColorPickerComponent,
} from '@domternal/angular';

Re-exported types from @domternal/core for convenience:

import type { Content, AnyExtension, FocusPosition, JSONContent } from '@domternal/angular';
import { Editor } from '@domternal/angular';

The EmojiPickerItem type is also exported:

import type { EmojiPickerItem } from '@domternal/angular';

Also exported:

import { DEFAULT_EXTENSIONS } from '@domternal/angular';
// [Document, Paragraph, Text, BaseKeymap, History]
  • Angular 17.1.0 or later
  • @domternal/core 0.15.0 or later
  • All components use ChangeDetectionStrategy.OnPush and ViewEncapsulation.None