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~~
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

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.

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

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

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