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.
StarterKit
Section titled “StarterKit”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],});What’s included
Section titled “What’s included”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
Configure individual extensions
Section titled “Configure individual extensions”Pass an options object for any included extension:
StarterKit.configure({ heading: { levels: [1, 2, 3] }, history: { depth: 50 }, link: { openOnClick: false },})Disable extensions
Section titled “Disable extensions”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})Replace an extension
Section titled “Replace an extension”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 }), ],});Configuring extensions
Section titled “Configuring extensions”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 }), ],});Extending extensions
Section titled “Extending extensions”Use extend() to override any aspect of an existing extension. The original is not modified.
Override keyboard shortcuts
Section titled “Override keyboard shortcuts”const CustomBold = Bold.extend({ addKeyboardShortcuts() { return { 'Mod-b': () => this.editor?.commands.toggleBold() ?? false, 'Mod-Shift-b': () => this.editor?.commands.toggleBold() ?? false, }; },});Disable keyboard shortcuts
Section titled “Disable keyboard shortcuts”const BoldNoShortcut = Bold.extend({ addKeyboardShortcuts() { return {}; },});Override input rules
Section titled “Override input rules”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 }), }), ]; },});Disable input rules
Section titled “Disable input rules”const BoldNoInputRule = Bold.extend({ addInputRules() { return []; },});Add custom attributes
Section titled “Add custom attributes”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 }; }, }, }; },});Add commands
Section titled “Add commands”const CustomParagraph = Paragraph.extend({ addCommands() { return { ...this.parent?.(), setCustomClass: (className: string) => ({ tr, dispatch }) => { if (!dispatch) return true; // ... implementation return true; }, }; },});Add ProseMirror plugins
Section titled “Add ProseMirror plugins”import { Plugin, PluginKey } from '@domternal/pm/state';
const CustomExtension = Extension.create({ name: 'customPlugin',
addProseMirrorPlugins() { return [ new Plugin({ key: new PluginKey('custom'), // ... plugin implementation }), ]; },});Creating extensions
Section titled “Creating extensions”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.
Extension (pure functionality)
Section titled “Extension (pure functionality)”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; },});Node (schema node)
Section titled “Node (schema node)”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' }); }, }; },});Mark (inline formatting)
Section titled “Mark (inline formatting)”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, }; },});Extension configuration hooks
Section titled “Extension configuration hooks”Every extension can define these hooks. All are optional.
Options and storage
Section titled “Options and storage”| Hook | Returns | Description |
|---|---|---|
addOptions() | Options | Default options. Merged shallowly via configure(). |
addStorage() | Storage | Mutable state accessible via editor.storage[name]. |
Functionality
Section titled “Functionality”| Hook | Returns | Description |
|---|---|---|
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. |
Schema (Node and Mark only)
Section titled “Schema (Node and Mark only)”| Hook | Returns | Description |
|---|---|---|
addAttributes() | AttributeSpecs | Node/mark attributes with defaults, parsing, and rendering. |
parseHTML() | ParseRule[] | Rules for parsing HTML elements into this node/mark. |
renderHTML() | DOMOutputSpec | How to render this node/mark to the DOM. |
addNodeView() | NodeViewConstructor | Custom interactive node view (Node only). |
Lifecycle
Section titled “Lifecycle”| Hook | Arguments | Description |
|---|---|---|
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. |
Attributes
Section titled “Attributes”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 }, };}AttributeSpec
Section titled “AttributeSpec”| Property | Type | Default | Description |
|---|---|---|---|
default | unknown | - | Default value when attribute is not explicitly set. |
rendered | boolean | true | Whether the attribute is rendered to the DOM. |
keepOnSplit | boolean | true | Whether to preserve when splitting a node (e.g., pressing Enter in a heading keeps the level). |
validate | string | ((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. |
Global attributes
Section titled “Global attributes”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']}` }; }, }, }, }]; },});Node schema properties
Section titled “Node schema properties”Nodes define their schema behavior with these properties:
| Property | Type | Default | Description |
|---|---|---|---|
group | string | - | Schema group(s): 'block', 'inline', 'block list', etc. |
content | string | - | Content expression: 'inline*' (any inline), 'block+' (one or more blocks), etc. |
inline | boolean | false | Whether this is an inline node. |
atom | boolean | - | Atomic node (no direct editing inside, cursor moves around it). |
selectable | boolean | - | Whether the node can be selected as a whole. |
draggable | boolean | false | Whether the node can be dragged. |
code | boolean | - | Whether this represents code content. |
whitespace | 'pre' | 'normal' | 'normal' | Whitespace handling. |
isolating | boolean | - | Marks don’t extend across this node’s boundary. |
defining | boolean | - | Content doesn’t leak out of this node. |
marks | string | - | Allowed marks: '' (none), '_' (all), or space-separated names. |
tableRole | string | - | For table integration: 'table', 'row', 'cell', 'header_cell'. |
Mark schema properties
Section titled “Mark schema properties”Marks define their schema behavior with these properties:
| Property | Type | Default | Description |
|---|---|---|---|
inclusive | boolean | true | Whether typing at the mark boundary extends the mark. |
excludes | string | - | Marks that cannot coexist: '_' (all others), space-separated names. |
group | string | - | Mark group for the node marks property. |
spanning | boolean | true | Whether the mark can span multiple nodes. |
isFormatting | boolean | true | Whether 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 FormattingLink.configure({ isFormatting: true })Priority
Section titled “Priority”Extensions have a priority that controls their load order. Higher priority runs first.
| Range | Used by |
|---|---|
| 900 - 1000 | Core nodes (Document, Text, Paragraph) |
| 500 - 899 | Standard extensions |
| 100 - 499 | User extensions (default: 100) |
| 0 - 99 | Low 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
Dependencies
Section titled “Dependencies”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}Extension resolution
Section titled “Extension resolution”The ExtensionManager processes extensions in this order:
- Flatten - Recursively expand
addExtensions()(e.g., StarterKit becomes 26 individual extensions) - Deduplicate - Keep the last occurrence of each name. This lets explicit configurations override auto-included defaults.
- Sort - Order by priority (descending)
- Detect conflicts - Throw if duplicate names remain after deduplication
- Check dependencies - Throw if any declared dependency is missing
- Build schema - Create ProseMirror schema from Node and Mark extensions
- Collect plugins, commands, shortcuts - Gather functionality from all extensions
Deduplication example
Section titled “Deduplication example”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
Section titled “Input rules”Input rules are Markdown-style shortcuts that trigger during typing.
Built-in input rules
Section titled “Built-in input rules”Marks:
| Input | Result |
|---|---|
**text** or __text__ | Bold |
*text* or _text_ | Italic |
~~text~~ | |
`text` | Inline code |
==text== | Highlight (default color) |
Blocks:
| Input | Result |
|---|---|
# through #### | Heading level 1 - 4 |
``` or ```language | Code block |
> | Blockquote |
- , * , + | Bullet list |
1. | Ordered list |
[ ] or [x] | Task list |
---, ___, *** | Horizontal rule |
Typography (if Typography extension is enabled):
| Input | Result |
|---|---|
-- | Em dash (—) |
... | Ellipsis (…) |
-> | Right arrow (→) |
<- | Left arrow (←) |
=> | Double arrow (⇒) |
1/2, 1/4, 3/4, 1/3, 2/3 | Fractions (½, ¼, ¾, ⅓, ⅔) |
(c), (r), (tm) | Symbols (©, ®, ™) |
+/-, !=, <=, >= | Math (±, ≠, ≤, ≥) |
<<, >> | Guillemets («, ») |
All input rules are undoable by pressing Backspace immediately after they trigger.
Input rule helpers
Section titled “Input rule helpers”| Function | Description |
|---|---|
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.
Keyboard shortcuts
Section titled “Keyboard shortcuts”Built-in shortcuts
Section titled “Built-in shortcuts”Formatting:
| Shortcut | Command |
|---|---|
Mod-B | Toggle bold |
Mod-I | Toggle italic |
Mod-U | Toggle underline |
Mod-Shift-S | Toggle strikethrough |
Mod-E | Toggle inline code |
Mod-K | Open link popover |
Mod-Shift-H | Toggle highlight |
Blocks:
| Shortcut | Command |
|---|---|
Mod-Alt-0 | Set paragraph |
Mod-Alt-1 through Mod-Alt-4 | Set heading 1 - 4 |
Mod-Alt-C | Toggle code block |
Mod-Shift-B | Toggle blockquote |
Mod-Shift-8 | Toggle bullet list |
Mod-Shift-7 | Toggle ordered list |
Mod-Shift-9 | Toggle task list |
Navigation and editing:
| Shortcut | Command |
|---|---|
Mod-Z | Undo |
Mod-Shift-Z / Mod-Y | Redo |
Tab | Indent list item / insert spaces in code block |
Shift-Tab | Outdent list item / remove indent in code block |
Mod-Enter / Shift-Enter | Insert hard break |
Enter | Split block, exit code block (triple Enter), split list item |
Backspace | Lift list item at start, delete selection |
Other:
| Shortcut | Command |
|---|---|
Mod-Shift-L | Align left |
Mod-Shift-E | Align center |
Mod-Shift-R | Align right |
Mod-Shift-J | Justify |
Mod-Shift-I | Toggle invisible characters |
Mod-. | Toggle superscript |
Mod-, | Toggle subscript |
Shortcut conflict resolution
Section titled “Shortcut conflict resolution”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.
The this context
Section titled “The this context”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