Skip to content

Configuration

Every feature in Domternal is an extension. This guide covers how to configure them, override behavior, create custom extensions, and understand how the extension system works.

The fastest way to set up an editor. StarterKit bundles 27 extensions you can configure or disable individually.

import { Editor, StarterKit } from '@domternal/core';
const editor = new Editor({
element: document.getElementById('editor')!,
extensions: [StarterKit],
});

Nodes (13): Document, Text, Paragraph, Heading, Blockquote, CodeBlock, BulletList, OrderedList, ListItem, TaskList, TaskItem, HorizontalRule, HardBreak

Marks (6): Bold, Italic, Underline, Strike, Code, Link

Behavior (8): BaseKeymap, History, Dropcursor, Gapcursor, TrailingNode, ListKeymap, LinkPopover, SelectionDecoration

Pass an options object for any included extension:

StarterKit.configure({
heading: { levels: [1, 2, 3] },
history: { depth: 50 },
link: { openOnClick: false },
})

Set any extension to false to exclude it:

StarterKit.configure({
codeBlock: false, // No code blocks
strike: false, // No strikethrough
taskList: false, // No task lists
gapcursor: false, // No gapcursor
})

Disable it in StarterKit and add your own version:

import { StarterKit, CodeBlock } from '@domternal/core';
import { CodeBlockLowlight } from '@domternal/extension-code-block-lowlight';
const editor = new Editor({
extensions: [
StarterKit.configure({ codeBlock: false }),
CodeBlockLowlight.configure({ lowlight }),
],
});

Every extension accepts options via configure(). Options are shallow-merged with defaults.

import { Heading, Placeholder, CharacterCount } from '@domternal/core';
const editor = new Editor({
extensions: [
Heading.configure({ levels: [1, 2] }),
Placeholder.configure({ placeholder: 'Start writing...' }),
CharacterCount.configure({ limit: 5000 }),
],
});

Use extend() to override any aspect of an existing extension. The original is not modified.

const CustomBold = Bold.extend({
addKeyboardShortcuts() {
return {
'Mod-b': () => this.editor?.commands.toggleBold() ?? false,
'Mod-Shift-b': () => this.editor?.commands.toggleBold() ?? false,
};
},
});
const BoldNoShortcut = Bold.extend({
addKeyboardShortcuts() {
return {};
},
});
const HeadingCustomRules = Heading.extend({
addInputRules() {
// Only allow # and ## (not ### or ####)
return [
textblockTypeInputRule({
find: /^(#{1,2})\s$/,
type: this.nodeTypeOrThrow,
getAttributes: (match) => ({ level: match[1]!.length }),
}),
];
},
});
const BoldNoInputRule = Bold.extend({
addInputRules() {
return [];
},
});

Use this.parent?.() to preserve existing attributes while adding new ones:

const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
...this.parent?.(),
customClass: {
default: null,
parseHTML: (element) => element.getAttribute('data-class'),
renderHTML: (attributes) => {
if (!attributes['customClass']) return null;
return { 'data-class': attributes['customClass'] as string };
},
},
};
},
});
const CustomParagraph = Paragraph.extend({
addCommands() {
return {
...this.parent?.(),
setCustomClass: (className: string) => ({ tr, dispatch }) => {
if (!dispatch) return true;
// ... implementation
return true;
},
};
},
});
import { Plugin, PluginKey } from '@domternal/pm/state';
const CustomExtension = Extension.create({
name: 'customPlugin',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('custom'),
// ... plugin implementation
}),
];
},
});

Extension names must be camelCase starting with a lowercase letter (e.g., myExtension, wordCounter). The name must match /^[a-z][a-zA-Z0-9]*$/ or the constructor throws.

For behavior that doesn’t add schema nodes or marks:

import { Extension } from '@domternal/core';
const WordCounter = Extension.create({
name: 'wordCounter',
addOptions() {
return {
limit: null as number | null,
};
},
addStorage() {
return {
words: 0,
};
},
onUpdate() {
const doc = this.editor?.view.state.doc;
if (!doc) return;
const text = doc.textContent;
this.storage.words = text.split(/\s+/).filter(Boolean).length;
},
});

For new document structure:

import { Node } from '@domternal/core';
const Callout = Node.create({
name: 'callout',
group: 'block',
content: 'inline*',
addAttributes() {
return {
type: { default: 'info' },
};
},
parseHTML() {
return [{ tag: 'div[data-callout]' }];
},
renderHTML({ node, HTMLAttributes }) {
return ['div', { ...HTMLAttributes, 'data-callout': node.attrs['type'] }, 0];
},
addCommands() {
return {
setCallout: (type?: string) => ({ commands }) => {
return commands.setBlockType('callout', { type: type ?? 'info' });
},
};
},
});

For new text-level formatting:

import { Mark } from '@domternal/core';
const Spoiler = Mark.create({
name: 'spoiler',
parseHTML() {
return [{ tag: 'span[data-spoiler]' }];
},
renderHTML({ HTMLAttributes }) {
return ['span', { ...HTMLAttributes, 'data-spoiler': '' }, 0];
},
addCommands() {
return {
toggleSpoiler: () => ({ commands }) => commands.toggleMark('spoiler'),
};
},
addKeyboardShortcuts() {
return {
'Mod-Shift-p': () => this.editor?.commands.toggleSpoiler() ?? false,
};
},
});

Every extension can define these hooks. All are optional.

HookReturnsDescription
addOptions()OptionsDefault options. Merged shallowly via configure().
addStorage()StorageMutable state accessible via editor.storage[name].
HookReturnsDescription
addCommands()Record<string, CommandSpec>Commands accessible via editor.commands.*.
addKeyboardShortcuts()Record<string, KeyboardShortcutCommand>Keyboard shortcuts mapped to handlers.
addInputRules()InputRule[]Markdown-style text shortcuts.
addProseMirrorPlugins()Plugin[]ProseMirror plugins.
addExtensions()AnyExtension[]Nested extensions (for bundles like StarterKit).
addGlobalAttributes()GlobalAttributes[]Inject attributes into other node/mark types.
addToolbarItems()ToolbarItem[]Toolbar buttons and dropdowns.
HookReturnsDescription
addAttributes()AttributeSpecsNode/mark attributes with defaults, parsing, and rendering.
parseHTML()ParseRule[]Rules for parsing HTML elements into this node/mark.
renderHTML()DOMOutputSpecHow to render this node/mark to the DOM.
addNodeView()NodeViewConstructorCustom interactive node view (Node only).
HookArgumentsDescription
onBeforeCreate()-Before the editor is created.
onCreate()-After the editor is fully initialized.
onUpdate()-When the document content changes.
onSelectionUpdate()-When the selection changes (no content change).
onTransaction(){ transaction }On every transaction.
onFocus(){ event }When the editor receives focus.
onBlur(){ event }When the editor loses focus.
onDestroy()-When the editor is destroyed. Use for cleanup.

Nodes and marks define attributes via addAttributes().

addAttributes() {
return {
level: {
default: 1,
parseHTML: (element) => parseInt(element.tagName.replace('H', ''), 10),
renderHTML: (attributes) => ({}), // Level is in the tag name, not an attribute
},
};
}
PropertyTypeDefaultDescription
defaultunknown-Default value when attribute is not explicitly set.
renderedbooleantrueWhether the attribute is rendered to the DOM.
keepOnSplitbooleantrueWhether to preserve when splitting a node (e.g., pressing Enter in a heading keeps the level).
validatestring | ((value: unknown) => void)-Validate attribute value. As a string: pipe-separated primitive types ('number', 'string|null'). As a function: throw if invalid.
parseHTML(element: HTMLElement) => unknown-Extract the attribute value from an HTML element.
renderHTML(attributes) => Record | null-Return HTML attributes to add to the element. Return null to skip.

Extensions can inject attributes into existing node/mark types without modifying them. This is how TextAlign adds alignment to paragraphs and headings:

const TextAlign = Extension.create({
name: 'textAlign',
addGlobalAttributes() {
return [{
types: ['heading', 'paragraph'],
attributes: {
textAlign: {
default: 'left',
parseHTML: (element) => element.style.textAlign || 'left',
renderHTML: (attributes) => {
if (attributes['textAlign'] === 'left') return null;
return { style: `text-align: ${attributes['textAlign']}` };
},
},
},
}];
},
});

Nodes define their schema behavior with these properties:

PropertyTypeDefaultDescription
groupstring-Schema group(s): 'block', 'inline', 'block list', etc.
contentstring-Content expression: 'inline*' (any inline), 'block+' (one or more blocks), etc.
inlinebooleanfalseWhether this is an inline node.
atomboolean-Atomic node (no direct editing inside, cursor moves around it).
selectableboolean-Whether the node can be selected as a whole.
draggablebooleanfalseWhether the node can be dragged.
codeboolean-Whether this represents code content.
whitespace'pre' | 'normal''normal'Whitespace handling.
isolatingboolean-Marks don’t extend across this node’s boundary.
definingboolean-Content doesn’t leak out of this node.
marksstring-Allowed marks: '' (none), '_' (all), or space-separated names.
tableRolestring-For table integration: 'table', 'row', 'cell', 'header_cell'.

Marks define their schema behavior with these properties:

PropertyTypeDefaultDescription
inclusivebooleantrueWhether typing at the mark boundary extends the mark.
excludesstring-Marks that cannot coexist: '_' (all others), space-separated names.
groupstring-Mark group for the node marks property.
spanningbooleantrueWhether the mark can span multiple nodes.
isFormattingbooleantrueWhether unsetAllMarks removes this mark. Set to false for semantic marks like Link.

The isFormatting flag can be overridden via configure():

// Make Link removable by Clear Formatting
Link.configure({ isFormatting: true })

Extensions have a priority that controls their load order. Higher priority runs first.

RangeUsed by
900 - 1000Core nodes (Document, Text, Paragraph)
500 - 899Standard extensions
100 - 499User extensions (default: 100)
0 - 99Low priority

Priority affects:

  • Schema node/mark ordering (higher priority appears first)
  • Keyboard shortcut precedence (same key in multiple extensions: higher priority runs first, lower runs if higher returns false)
  • Plugin ordering

Extensions can declare required dependencies:

const TextColor = Extension.create({
name: 'textColor',
dependencies: ['textStyle'],
// ...
});

If textStyle is not in the extensions array, the editor throws:

Extension "textColor" requires "textStyle" extension.
Please add it to your extensions array.

Some extensions auto-include their dependencies via addExtensions():

addExtensions() {
return [TextStyle]; // Auto-included if not already present
}

The ExtensionManager processes extensions in this order:

  1. Flatten - Recursively expand addExtensions() (e.g., StarterKit becomes 26 individual extensions)
  2. Deduplicate - Keep the last occurrence of each name. This lets explicit configurations override auto-included defaults.
  3. Sort - Order by priority (descending)
  4. Detect conflicts - Throw if duplicate names remain after deduplication
  5. Check dependencies - Throw if any declared dependency is missing
  6. Build schema - Create ProseMirror schema from Node and Mark extensions
  7. Collect plugins, commands, shortcuts - Gather functionality from all extensions
const editor = new Editor({
extensions: [
StarterKit, // Includes Heading with default options
Heading.configure({ levels: [1, 2] }), // Overrides StarterKit's Heading
],
});

StarterKit’s Heading appears first, then the explicit Heading. Deduplication keeps the last occurrence, so Heading.configure({ levels: [1, 2] }) wins.

Input rules are Markdown-style shortcuts that trigger during typing.

Marks:

InputResult
**text** or __text__Bold
*text* or _text_Italic
~~text~~Strikethrough
`text`Inline code
==text==Highlight (default color)

Blocks:

InputResult
# through #### Heading level 1 - 4
``` or ```languageCode block
> Blockquote
- , * , + Bullet list
1. Ordered list
[ ] or [x] Task list
---, ___, ***Horizontal rule

Typography (if Typography extension is enabled):

InputResult
--Em dash (—)
...Ellipsis (…)
->Right arrow (→)
<-Left arrow (←)
=>Double arrow (⇒)
1/2, 1/4, 3/4, 1/3, 2/3Fractions (½, ¼, ¾, ⅓, ⅔)
(c), (r), (tm)Symbols (©, ®, ™)
+/-, !=, <=, >=Math (±, ≠, ≤, ≥)
<<, >>Guillemets («, »)

All input rules are undoable by pressing Backspace immediately after they trigger.

FunctionDescription
markInputRule({ find, type })Applies a mark to matched text, removing delimiters
wrappingInputRule({ find, type, guard? })Wraps a textblock in a node type
textblockTypeInputRule({ find, type, getAttributes? })Changes the textblock type
textInputRule({ find, replace })Simple text replacement
nodeInputRule({ find, type, getAttributes? })Replaces text with a node

The guard option on wrappingInputRule prevents the rule from firing in certain contexts. The notInsideList helper prevents list input rules from firing inside existing list items.

Formatting:

ShortcutCommand
Mod-BToggle bold
Mod-IToggle italic
Mod-UToggle underline
Mod-Shift-SToggle strikethrough
Mod-EToggle inline code
Mod-KOpen link popover
Mod-Shift-HToggle highlight

Blocks:

ShortcutCommand
Mod-Alt-0Set paragraph
Mod-Alt-1 through Mod-Alt-4Set heading 1 - 4
Mod-Alt-CToggle code block
Mod-Shift-BToggle blockquote
Mod-Shift-8Toggle bullet list
Mod-Shift-7Toggle ordered list
Mod-Shift-9Toggle task list

Navigation and editing:

ShortcutCommand
Mod-ZUndo
Mod-Shift-Z / Mod-YRedo
TabIndent list item / insert spaces in code block
Shift-TabOutdent list item / remove indent in code block
Mod-Enter / Shift-EnterInsert hard break
EnterSplit block, exit code block (triple Enter), split list item
BackspaceLift list item at start, delete selection

Other:

ShortcutCommand
Mod-Shift-LAlign left
Mod-Shift-EAlign center
Mod-Shift-RAlign right
Mod-Shift-JJustify
Mod-Shift-IToggle invisible characters
Mod-.Toggle superscript
Mod-,Toggle subscript

When multiple extensions register the same shortcut, handlers are chained by priority. The higher-priority handler runs first. If it returns false, the next handler gets a chance.

Inside extension config methods, this provides access to the extension context:

Extension.create({
name: 'myExtension',
addCommands() {
// this.name → 'myExtension'
// this.options → current options
// this.storage → mutable storage
// this.editor → editor instance (null until editor is created)
// this.parent?.() → parent implementation (in extend only)
return {};
},
});

For Node extensions, this also provides:

  • this.nodeType / this.nodeTypeOrThrow - ProseMirror NodeType

For Mark extensions:

  • this.markType / this.markTypeOrThrow - ProseMirror MarkType