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.

Reach for Configuration when you need:

  • A subset of StarterKit (disable an extension via StarterKit.configure({ bold: false }))
  • Per-extension options (e.g. Heading.configure({ levels: [1, 2, 3] }))
  • Custom HTML attributes on rendered elements (HTMLAttributes: { class: 'my-class' })
  • Custom keyboard shortcuts that override defaults

Notes:

  • Configuration applies at extension-creation time. Once the editor is created, options are frozen
  • For runtime changes (e.g. switching themes), recreate the editor with new options

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
})

listIndent is the one inverted toggle: ListIndent is not part of the default 27, so instead of disabling it you opt in with StarterKit.configure({ listIndent: true }). See List Indent for why it is off by default.

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.
addFloatingMenuItems()FloatingMenuItem[]Slash-menu entries. See Floating menu.
addBlockMenuItems()BlockMenuItem[]Block handle menu entries. Declared by @domternal/extension-block-controls, so it typechecks only where that package is installed and renders only when BlockContextMenu is loaded. See Contributed items.
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), or space-separated mark names or group names. A mark name listed here must exist in the compiled schema, otherwise schema construction throws SyntaxError: Unknown mark type: 'myMark'. Excluding by group name is the portable form for an extension that must work in minimal setups, as long as some mark in the compiled schema declares that group: a group with no members throws the same error, so the safest form is for the excluding mark to declare the group itself.
groupstring-Mark group for the node marks property.
keepOnDuplicatebooleantrueWhether the block context menu’s Duplicate keeps this mark on the copy. Set false for a mark carrying identity that lives outside the document (a comment thread anchor, a suggestion id): duplicating it would make one identity point at two unrelated places, so Duplicate strips it recursively from the copied fragment, node marks and descendants included. Forwarded onto the ProseMirror mark spec, so consumers read it as markType.spec.keepOnDuplicate.
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 - 1000Paragraph and Link
500 - 899Standard extensions
100 - 499User extensions (default: 100)
0 - 99Low priority

An extension that does not declare priority defaults to 100. In @domternal/core only Paragraph and Link (1000) and TextStyle (101) set an explicit priority; everything else, Document and Text included, sorts at the default.

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 27 individual extensions with default options). The count is 27 only with defaults: listIndent: true pushes a 28th extension (ListIndent), and each of the other options set to false removes one.
  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 (if the Highlight extension is enabled)

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.

ExportDescription
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
markInputRulePatternsReady-made regexes (bold, strike, code, highlight) for the common Markdown delimiters, so a custom mark rule can reuse the exact pattern the built-ins use

The guard option on wrappingInputRule prevents the rule from firing in certain contexts. The built-in list rules pass a guard that refuses to fire inside an existing listItem or taskItem; that guard is internal to @domternal/core and is not exported, so write your own predicate ((state) => boolean) when you need the same behavior.

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 (if the Highlight extension is enabled)

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 (none of these ship in StarterKit; add the extension that provides them):

ShortcutCommand
Mod-Shift-LAlign left (TextAlign)
Mod-Shift-EAlign center (TextAlign)
Mod-Shift-RAlign right (TextAlign)
Mod-Shift-JJustify (TextAlign)
Mod-Shift-IToggle invisible characters (InvisibleChars)
Mod-.Toggle superscript (Superscript)
Mod-,Toggle subscript (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.editor?.preset → 'classic' | 'notion' | undefined
// this.parent?.() → parent implementation (in extend only)
return {};
},
});

this.editor.preset is the resolved editing experience, available on both the extension and the node context. It is optional on the context type so minimal editor mocks keep compiling, so treat undefined as 'classic'. Reading it is how an extension varies its behavior by editing experience rather than by option: the Image extension uses it to decide whether its bubble menu offers float or align controls. See the preset option.

For Node extensions, this also provides:

  • this.nodeType / this.nodeTypeOrThrow - ProseMirror NodeType

For Mark extensions:

  • this.markType / this.markTypeOrThrow - ProseMirror MarkType
  • Editor Api - editor API: commands, chains, state, view
  • Theming - CSS custom properties for theming
  • Starter Kit - all-in-one preset of common nodes/marks