Skip to content

Editor API

Editor is framework-agnostic. It owns EditorState, the extension list, the command map, and CommentStore. It does not create EditorView until mount.

ts
import { Editor, schemaFromExtensions } from '@deditor/core'
import { docsPreset } from '@deditor/preset-docs'

Editor.create algorithm

plugins() / commands() / keymap() receive editor before editor.state exists. Accessing editor.state during those factories throws Error('Editor.create: state is not initialized').

1. Assert options.extensions non-empty; throw on duplicate extension / node / mark names.
2. editor = new Editor(); editor.#extensions = options.extensions
3. editor.schema = schemaFromExtensions(options.extensions)
4. editor.comments = new CommentStore()
5. editor.#editable = true
6. plugins = []
   rawCommands = {}
   for ext of extensions:
     ctx = { schema: editor.schema, editor }
     plugins.push(...(ext.plugins?.(ctx) ?? []))
     if ext.keymap: plugins.push(keymap(ext.keymap(ctx)))
     Object.assign(rawCommands, ext.commands?.(ctx) ?? {})
     throw on command-name collision
7. editor.#commands = wrap thunks around rawCommands
8. doc = options.content
     ? editor.schema.nodeFromJSON(options.content)   // throws on invalid JSON
     : editor.schema.node('doc', null, [editor.schema.node('paragraph')])
9. editor.#state = EditorState.create({ schema, doc, plugins })
10. return editor

Default empty document is doc > paragraph with no text. Never call Editor.create({}) without extensions: create always seeds the paragraph when content is omitted, but it still requires a non-empty extensions array.

Editor.create wraps each command thunk so editable === false short-circuits to false without calling the inner function. Keymaps call those thunks ('Mod-b': () => editor.commands.toggleStrong()).

Extension fields

ts
interface Extension {
  name: string
  nodes?: Record<string, NodeSpec>
  marks?: Record<string, MarkSpec>
  plugins?: (ctx: { schema: Schema; editor: Editor }) => Plugin[]
  commands?: (ctx: { schema: Schema; editor: Editor }) => Partial<DocsCommands>
  keymap?: (ctx: { schema: Schema; editor: Editor }) => {
    [key: string]: Command | (() => boolean)
  }
  onTransaction?: (ctx: { editor: Editor; tr: Transaction; oldState: EditorState }) => void
  prepareSnapshotReset?: (snap: Snapshot) => void
  destroy?: (ctx: { editor: Editor }) => void
}

Registry rules:

  • Duplicate name throws. 'collab' is reserved for collabExtension.
  • Node and mark names must be unique across extensions; collision throws.
  • Command names must be unique; collision throws.
  • Plugin order = extension order. For each extension, plugins() results are pushed, then that extension’s keymap is wrapped in one keymap() plugin and pushed immediately after. First plugin that handles a key wins.
  • baseKeymap must be last in docsPreset(). collabExtension must be last in the extensions array.

onTransaction runs after the new state is committed and after CommentStore.deriveDetached, before view update.

schemaFromExtensions

Public so apps/collab-server and tests can build a Schema without constructing Editor (no plugins, no keymaps, no NodeViews).

ts
function schemaFromExtensions(extensions: Extension[]): Schema {
  const nodes: Record<string, NodeSpec> = {}
  const marks: Record<string, MarkSpec> = {}
  for (const ext of extensions) {
    Object.assign(nodes, ext.nodes)
    Object.assign(marks, ext.marks)
  }
  if (!nodes.text) nodes.text = { group: 'inline' }
  if (!nodes.doc) throw new Error('schemaFromExtensions: no doc node')
  return new Schema({ nodes, marks })
}

docsSchema() is schemaFromExtensions(docsPreset()).

Editor.emit events

emit is public so @deditor/collab and @deditor/preset-docs can fire events without a reverse import. on returns an unsubscribe function.

EventPayloadWhen
transaction{ tr, state, remote }After every dispatch. remote is !!tr.getMeta('deditor-remote').
selectionSelectionWhen tr.selectionSet.
commentsCommentThread[]After every dispatch (comments.list()).
sync{ status: 'ok' | 'syncing' | 'disconnected' | 'error'; error?: Error }Collab send/ack, disconnect, resync.
openFindundefinedMod-f in the preset keymap.
openComment{ from: number; to: number }Mod-Alt-m with a non-empty selection.
openLink{ from: number; to: number }Mod-k with a non-empty selection. Toolbar prompts URL then setLink.

Search is not on Editor. Query state lives in searchPluginKey (@deditor/preset-docs). UI calls editor.commands.setSearchQuery(q) and findAll(editor.state).

resetFromSnapshot

Used by collab fatal-resync. Do not destroy() + recreate the Editor (that would provider.disconnect() and drop subscribe-once listeners).

1. for ext of extensions: ext.prepareSnapshotReset?.(snap)
2. comments.replaceAll(snap.comments)
3. Rebuild plugins by calling each ext.plugins() / keymap() again
4. #state = EditorState.create({ schema, doc: schema.nodeFromJSON(snap.doc), plugins })
5. comments.deriveDetached(#state.doc)
6. view?.updateState(#state)
7. emit('comments', comments.list()); emit('sync', { status: 'ok' })

Collab’s prepareSnapshotReset writes snap.version into its mutable config, clears SendLock, and empties pendingCommentOps / pendingCreates before plugins() rebuilds a new collab({ version }) instance.

setContent throws with collab

ts
editor.setContent(doc)

If any extension is named 'collab', this throws (setContent: collab is attached). Single-user setContent replaces the doc with EditorState.create({ schema, doc, plugins: this.state.plugins }).

setEditable(false) is the only read-only switch.

Mount / unmount / destroy

ts
editor.mount(place: HTMLElement)
editor.unmount()
editor.destroy()

mount constructs EditorView with state: editor.state, editable: () => editor.editable, and dispatchTransaction: tr => editor.dispatch(tr). unmount destroys the view only. destroy unmounts, calls each extension’s optional destroy, and drops listeners. The collab extension’s destroy calls provider.disconnect().

getJSON() returns ProseMirror node JSON. getHTML() serializes for print/export and omits comment marks (see Security).

Released under the MIT License.