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-1Mod-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.
ListItemEnter 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 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.
addColumnAfter() / deleteColumn()Refuses to delete the last remaining column.
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+.

This is a subset, not a port of prosemirror-tables. Spans round-trip through HTML but the row and column commands treat the table as a uniform grid and ignore them, so merged cells will drift; there is no cell selection 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
      },
  }
}

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