Skip to content
Domphy

Extensions

Everything the editor can do comes from extensions. Three kinds, same as Tiptap:

  • Node — a block or inline node in the document (paragraph, heading, bulletList).
  • Mark — inline formatting applied to text (bold, code, link).
  • Extension — no schema of its own, just commands/shortcuts/rules (undoRedo, starterKit()).

starterKit()

starterKit() is an aggregate: it contributes no schema itself, it just returns the essential set from addExtensions().

import { starterKit } from "@domphy/editor"
import { createEditor } from "@domphy/editor/domphy"

const editor = createEditor({ extensions: [starterKit()] })
ExtensionCommandsShortcutsInput rules
Document———
Text———
ParagraphsetParagraph()Mod-Alt-0—
HeadingsetHeading({ level }), toggleHeading({ level })Mod-Alt-1…Mod-Alt-6# … ######
BoldsetBold(), toggleBold(), unsetBold()Mod-b**text**, __text__
ItalicsetItalic(), toggleItalic(), unsetItalic()Mod-i*text*, _text_
StrikesetStrike(), toggleStrike(), unsetStrike()Mod-Shift-s~~text~~
UnderlinesetUnderline(), toggleUnderline(), unsetUnderline()Mod-u—
CodesetCode(), toggleCode(), unsetCode()Mod-e`text`
BlockquotesetBlockquote(), toggleBlockquote(), unsetBlockquote()Mod-Shift-b>
BulletListtoggleBulletList()Mod-Shift-8- , + , *
OrderedListtoggleOrderedList()Mod-Shift-71.
ListItem—Enter splits, Tab sinks, Shift-Tab lifts—
HardBreaksetHardBreak()Mod-Enter, Shift-Enter—
HorizontalRulesetHorizontalRule()—---, ***
CodeBlocksetCodeBlock({ language }), toggleCodeBlock({ language })Mod-Alt-c```lang, ~~~lang
LinksetLink({ href }), toggleLink({ href }), unsetLink()——
UndoRedoundo(), redo()Mod-z, Shift-Mod-z, Mod-y—
TrailingNode———

Mod is Ctrl on Windows and Linux, Cmd on macOS.

Two Enter/Backspace behaviours come from the engine rather than an extension, so they apply to any node with the matching schema flag:

  • Inside a code textblock Enter inserts a newline instead of splitting the block (prosemirror-commands newlineInCode). The triple-Enter exit in CodeBlock still ends it.
  • Backspace at the start of a block with nothing before it lifts that block out of its parent (prosemirror-commands joinBackward) — pressing it in the first list item outdents the item; in a top-level paragraph it does nothing.

Two names are worth knowing because they differ from what you would guess:

  • Document's schema name is "doc", not "document" — the starterKit() option key is document, but anything addressing the node type (isActive, content expressions) uses "doc".
  • UndoRedo registers no commands of its own. undo() and redo() are generic engine commands that exist regardless; the extension only carries depth/newGroupDelay and the keymap, so dropping it with undoRedo: false removes the shortcuts, not the commands.
  • TrailingNode has no commands and no shortcuts. It keeps an empty paragraph at the end of the document so there is always somewhere to click below a table or code block. Its options are node (default "paragraph") and notAfter; the appended node is tagged addToHistory: false, so undo never stops on it.

Configuring the kit

Every key of the starterKit() options object takes that sub-extension's options, or false to leave it out entirely:

starterKit({
  heading: { levels: [1, 2, 3] },   // only H1-H3, so only Mod-Alt-1..3 bind
  codeBlock: false,                 // drop code blocks (and their input rules)
  undoRedo: { depth: 50, newGroupDelay: 300 },
  link: { HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" } },
})

Individual extensions are also exported by name, so you can skip the kit and assemble the set yourself. Document, Text and Paragraph are the minimum a working document needs:

import { Bold, Document, Italic, Paragraph, Text } from "@domphy/editor"

const editor = createEditor({ extensions: [Document, Paragraph, Text, Bold, Italic] })

Tables

Tables are not in starterKit() — import the four node extensions yourself. All four are required: a Table holds TableRows, and a row holds cells of either type.

import { starterKit, Table, TableCell, TableHeader, TableRow } from "@domphy/editor"

const editor = createEditor({
  extensions: [starterKit(), Table, TableRow, TableHeader, TableCell],
})
CommandNotes
insertTable({ rows, cols, withHeaderRow })Defaults to a 3×3 with a header row.
deleteTable()
addRowAfter() / deleteRow()Relative to the cell holding the cursor — for a merged cell, to the bottom/whole height of its rectangle.
addColumnAfter() / deleteColumn()Same, on the horizontal axis. Both refuse when the cell's rectangle spans the entire table (use deleteTable()).
goToNextCell() / goToPreviousCell()

Tab moves to the next cell and Shift-Tab to the previous one. Tab in the last cell appends a row and moves into it, the same as Tiptap.

Schema: Table is a block containing tableRow+; TableRow contains tableCell+, which is a group both TableCell (td) and TableHeader (th) belong to — so a row accepts either. Cells carry colspan, rowspan and colwidth attributes and contain block+.

colspan/rowspan are honoured: the commands build a table map — a port of prosemirror-tables' TableMap — so inserting a column next to a merged cell widens that cell instead of splitting the grid, deleting a row shortens or pushes down the cells that span across it, and goToNextCell() visits every cell exactly once. Every case is checked against prosemirror-tables 1.8.5 itself in tests/extensions/tableMap.test.ts.

This is still a subset, not a full port: there is no cell selection (a command acts on the cell holding the caret), no cell merge/split commands and no column resizing. See Deviations from Tiptap.

configure() vs extend()

configure(options) deep-merges over the result of addOptions() and returns a new instance — the config hooks are untouched. extend(config) replaces hooks; inside a replaced hook, this.parent is the same hook from the extended config, which is how you add to a hook instead of overwriting it:

const HeadingWithBigShortcut = Heading.extend({
  addKeyboardShortcuts() {
    return {
      ...this.parent?.(),
      "Mod-Alt-9": ({ editor }) => editor.commands.toggleHeading({ level: 1 }),
    }
  },
})

Every config hook is called with this bound to { name, options, storage, editor, parent }.

Lifecycle hooks: onCreate fires once at construction — note the view may not exist yet, since createEditor() mounts later. onMount fires every time the view is mounted into a host element (at construction when element was passed, and on each later editor.mount(host)), so it is the right place to bind DOM listeners to the host — pair it with onDestroy for teardown. onUpdate, onSelectionUpdate, onFocus and onBlur mirror the editor options of the same name.

Writing an extension

A Mark needs a name, how it parses out of HTML, how it renders back, and the commands it contributes:

import { Mark, mergeAttributes } from "@domphy/editor"

const Highlight = Mark.create({
  name: "highlight",

  parseHTML() {
    return [{ tag: "mark" }]
  },

  renderHTML({ HTMLAttributes }) {
    return ["mark", mergeAttributes(HTMLAttributes), 0]
  },

  addCommands() {
    return {
      toggleHighlight:
        () =>
        ({ commands }) =>
          commands.toggleMark(this.name),
    }
  },

  addKeyboardShortcuts() {
    return { "Mod-Shift-h": ({ editor }) => editor.commands.toggleHighlight() }
  },
})

Commands are curried: the outer function takes the caller's arguments, the inner one takes CommandProps and returns whether it applied. A command must check dispatch before mutating the transaction — can() runs commands with dispatch: undefined precisely so it can ask "would this work?" without changing anything:

addCommands() {
  return {
    clearHighlight:
      () =>
      ({ tr, state, dispatch }) => {
        const { from, to } = state.selection
        if (from === to) return false
        if (dispatch) tr.removeMark(from, to, this.name)
        return true
      },
  }
}

Input rules

addInputRules() returns the Markdown-style shortcuts an extension contributes. The four builders that every built-in uses are exported from the main entry, so a custom extension writes its rules the same way:

import { Mark, markInputRule } from "@domphy/editor"

const Highlight = Mark.create({
  name: "highlight",
  // ...parseHTML / renderHTML

  addInputRules() {
    // `==text==` → highlighted. The LAST capture group is the marked text.
    return [markInputRule({ find: /(?:^|\s)(==([^=]+)==)$/, type: this.name })]
  },
})
BuilderProducesBuilt-in using it
textblockTypeInputRule({ find, type, getAttributes? })retypes the current textblock# → heading, ``` → code block
wrappingInputRule({ find, type, getAttributes? })wraps the current textblock> → blockquote, - → bullet list
markInputRule({ find, type, getAttributes? })marks the last capture group and strips the delimiters**bold**, *italic*, `code`
nodeInputRule({ find, type, getAttributes? })replaces the match with a node--- → horizontal rule

find is matched against the text before the caret inside the current textblock, so anchor it with $ (and ^ for the textblock/wrapping rules). getAttributes is either an object or a function of the RegExpMatchArray; returning false or null from it cancels the rule — that is how Heading rejects a level outside its levels option. For anything the builders do not cover, return a raw InputRule: { find, handler({ editor, range, match, chain }) }, where chain() gives the same command chain as a command body (one undo step for the whole rule).

Node extensions add content, group, and addAttributes. Attributes declare a default and are omitted from JSON when every attribute is at its default:

import { Node, mergeAttributes } from "@domphy/editor"

const Callout = Node.create({
  name: "callout",
  group: "block",
  content: "block+",
  defining: true,

  addAttributes() {
    return { tone: { default: "info" } }
  },

  parseHTML() {
    return [{ tag: "aside[data-callout]" }]
  },

  renderHTML({ HTMLAttributes }) {
    return ["aside", mergeAttributes({ "data-callout": "" }, HTMLAttributes), 0]
  },
})

priority (default 100, higher runs first) decides ordering when two extensions register the same shortcut or parse rule — Paragraph and Link both use 1000.

Next steps