Skip to content
Domphy

API Reference

createEditor(options)

Builds an editor without mounting it. The DOM host comes later from editorContent(), which owns the mount/unmount lifecycle of the element it is applied to — passing element here would mount twice.

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

const editor = createEditor({
  extensions: [starterKit()],
  content: "<p>Hello</p>",
  editable: true,
  onUpdate: ({ editor }) => console.log(editor.getJSON()),
})
OptionTypeNotes
contentstring | JSONContent | JSONContent[] | nullHTML string or Tiptap-shaped JSON.
extensionsAnyExtension[]Order does not matter; priority does.
editablebooleanDefaults to true.
autofocus"start" | "end" | "all" | number | boolean | nullWhere the cursor lands on mount.
onCreate / onUpdate / onSelectionUpdate({ editor }) => void
onFocus / onBlur({ editor, event }) => void
onDestroy() => void

Options can be changed after construction with editor.setOptions(partial).

Editor instance

MemberReturnsNotes
commandsSingleCommandsEach call runs on a fresh transaction and dispatches immediately.
chain()ChainedCommandsCollects commands into ONE transaction; run() dispatches.
can()CanCommandsSame commands with dispatch: undefined — never mutates.
state{ doc, selection, storedMarks }The current document and selection.
stateVersionState<number>Bumped once per transaction — the reactivity bridge.
isActive(name, attrs?)booleanAttribute matching is a subset test.
getAttributes(nameOrType)AttributesAttributes of the active node or mark.
getJSON() / getHTML() / getText(options?)JSON / string / stringgetText({ blockSeparator }) defaults to "\n\n".
isEmpty / isEditable / isFocused / isDestroyedboolean
setEditable(editable, emitUpdate?)void
mount(element) / unmount() / destroy()voideditorContent() calls the first two for you.
on(event, cb) / off(event, cb)voidEvents: create, update, selectionUpdate, focus, blur, destroy.

Commands

chain() builds one transaction shared by every link. Each link runs and pushes its boolean result; a failing link does not abort the chain. run() dispatches once and returns true only if every link returned true.

editor.chain().focus().toggleBold().run()          // one transaction, one history entry
editor.commands.toggleBold()                       // its own transaction, dispatched immediately
editor.can().toggleBold()                          // would it apply? nothing is mutated
editor.can().chain().toggleBold().toggleItalic().run()

Every extension command is built out of this generic set, which is always available:

GroupCommands
ContentsetContent(content, options?), insertContent(content), insertContentAt(position, content)
MarkssetMark(type, attrs?), toggleMark(type, attrs?), unsetMark(type)
NodessetNode(type, attrs?), toggleNode(type, toggleType, attrs?), updateAttributes(type, attrs), clearNodes()
WrappingwrapIn(type, attrs?), toggleWrap(type, attrs?), lift(type, attrs?)
ListstoggleList(listType, itemType, attrs?), splitListItem(itemType), sinkListItem(itemType), liftListItem(itemType)
SplittingsplitBlock(options?), exitCode()
SelectionsetTextSelection(position), selectAll(), deleteSelection(), deleteRange(range)
Focusfocus(position?), blur(), scrollIntoView()
Escape hatchescommand(fn), first(commands), setMeta(key, value)
Historyundo(), redo()

A command receives { editor, tr, state, dispatch, chain, can, commands } and returns whether it applied. dispatch is undefined during can(), so guard every mutation with it.

setMeta("preventUpdate", true) suppresses the update event for that transaction; the event is also skipped when the document did not actually change.

Document JSON

The JSON is Tiptap's, exactly:

{
  "type": "doc",
  "content": [
    { "type": "heading", "attrs": { "level": 2 }, "content": [{ "type": "text", "text": "Title" }] },
    { "type": "paragraph", "content": [{ "type": "text", "marks": [{ "type": "bold" }], "text": "Bold" }] },
    { "type": "paragraph" }
  ]
}

Marks live on text nodes, never on wrappers. attrs is omitted when every attribute is at its default, and an empty content array is omitted entirely — an empty paragraph is just { "type": "paragraph" }.

Positions are ProseMirror-style integer token positions: entering a non-leaf node costs 1, each character costs 1, a leaf node (hardBreak, horizontalRule) costs 1, leaving a node costs 1. This is what makes from/to and command semantics source-compatible with Tiptap.

Domphy adapter

editorContent(editor, props?)

Mounts the editor into the host element and styles the editing surface with theme tokens. Apply it to a div.

{ div: null, $: [editorContent(editor, { minHeight: 60 })] }
PropTypeDefault
colorValueOrState<ThemeColor>"neutral" — surface text and resting outline
accentColorValueOrState<ThemeColor>"primary" — focus ring
minHeightnumber40 — theme spacing units (10em)

The mount runs through a behavior(), so it happens exactly once for the real DOM node no matter how many times a reactive ancestor re-renders the host; passing a different editor later swaps the view in place, and unmount runs once when the element leaves the DOM.

Typography inside the editable area is left to the theme and the browser's tag defaults — the content is real p / h1-h6 / strong / em markup, so it inherits the document type scale. Only structural rhythm (block margins, list indent, quote and code chrome) is declared by the patch.

bubbleMenu(editor, props)

A floating menu anchored to the current text selection. Apply it to the same host element as editorContent().

{
  div: null,
  $: [
    editorContent(editor),
    bubbleMenu(editor, {
      shouldShow: (editor) => !editor.state.selection.empty && !editor.isActive("codeBlock"),
      children: { div: [boldButton, italicButton] },
    }),
  ],
}
PropTypeDefault
childrenDomphyElementrequired — the menu content
shouldShow(editor) => booleaneditable, with a non-empty selection

Positioning uses @domphy/floating against a virtual element wrapping the live selection rectangle: getBoundingClientRect() re-reads getSelection().getRangeAt(0) on every call, so scrolling and resizing reposition against the real current rect rather than a snapshot taken when the menu opened. The middleware chain is inline() (picks the right rect when a selection wraps across lines), offset(8), flip() and shift().

Show/hide is wired to the editor's selectionUpdate, update, focus and blur events from inside the behavior — not from a lifecycle hook — so it survives host re-renders. The panel is inserted next to the app root, escaping the editor's overflow and stacking context, and it swallows mousedown so pressing a button never blurs the editor and collapses the selection you are formatting.

editorState(editor)

Returns reader functions that re-evaluate on every transaction.

const { isActive, read } = editorState(editor)

isActive("bold")                        // (l) => boolean
isActive("heading", { level: 2 })       // (l) => boolean
read((editor) => editor.can().undo())   // (l) => boolean
read((editor) => editor.getText())      // (l) => string

There is nothing more to it than reading editor.stateVersion with the listener first, so anything not covered here can be written inline:

const wordCount = (l) => {
  editor.stateVersion.get(l)
  return editor.getText().split(/\s+/).filter(Boolean).length
}