Editor

PreviousNext

An editor owns the document runtime. It is the root node of the document, and its path is [].

The public editor object is intentionally small:

interface Editor<TExtensions extends readonly unknown[] = []> {
  api: Readonly<InstalledApiGroups<TExtensions>>
  getApi(extension: EditorExtension): unknown
  read<T>(fn: (state: EditorState) => T): T
  subscribe(listener: SnapshotListener): () => void
  subscribeCommit(listener: (commit: EditorCommit) => void): () => void
  update(
    fn: (tx: EditorTransaction, context: EditorUpdateContext) => void,
    options?: EditorUpdateOptions
  ): void
  extend(extension: EditorExtension | EditorExtension[]): () => void
}
interface Editor<TExtensions extends readonly unknown[] = []> {
  api: Readonly<InstalledApiGroups<TExtensions>>
  getApi(extension: EditorExtension): unknown
  read<T>(fn: (state: EditorState) => T): T
  subscribe(listener: SnapshotListener): () => void
  subscribeCommit(listener: (commit: EditorCommit) => void): () => void
  update(
    fn: (tx: EditorTransaction, context: EditorUpdateContext) => void,
    options?: EditorUpdateOptions
  ): void
  extend(extension: EditorExtension | EditorExtension[]): () => void
}

Creating an editor

createEditor(options?) => Editor

Create an editor.

const editor = createEditor({
  initialValue: [{ type: 'paragraph', children: [{ text: 'Body' }] }],
})
const editor = createEditor({
  initialValue: [{ type: 'paragraph', children: [{ text: 'Body' }] }],
})

Extensions define schema, normalizers, commit listeners, operation middleware, feature namespaces, and optional runtime registration.

Reading state

editor.read(fn) => T

Read a coherent snapshot of editor state.

const selection = editor.read(state => state.selection.get())
const selection = editor.read(state => state.selection.get())

Use state for editor-state queries:

editor.read(state => {
  const children = state.nodes.children()
  const marks = state.marks.get()
  const first = state.nodes.get([0])
  const start = state.points.start([])
  const range = state.ranges.get([])
 
  return { children, first, marks, range, start }
})
editor.read(state => {
  const children = state.nodes.children()
  const marks = state.marks.get()
  const first = state.nodes.get([0])
  const start = state.points.start([])
  const range = state.ranges.get([])
 
  return { children, first, marks, range, start }
})

Schema policy is read through state.schema:

const isInline = editor.read(state => state.schema.isInline(element))
const isInline = editor.read(state => state.schema.isInline(element))

Updating state

editor.update(fn, options?) => void

Run a transaction. The callback receives tx, which owns command reads and writes.

editor.update(tx => {
  tx.marks.toggle('bold')
})
editor.update(tx => {
  tx.marks.toggle('bold')
})

Use transaction groups for document changes:

editor.update(tx => {
  tx.nodes.set({ type: 'heading' })
  tx.text.insert('Title')
  tx.selection.move({ distance: 1 })
})
editor.update(tx => {
  tx.nodes.set({ type: 'heading' })
  tx.text.insert('Title')
  tx.selection.move({ distance: 1 })
})

Replay operations through the transaction boundary:

editor.update(
  tx => {
    tx.operations.replay(remoteOperations)
  },
  {
    tag: ['collaboration', 'remote-import'],
    metadata: {
      collab: { origin: 'remote', saveToHistory: false },
      history: { mode: 'skip' },
      selection: { dom: 'preserve' },
    },
  }
)
editor.update(
  tx => {
    tx.operations.replay(remoteOperations)
  },
  {
    tag: ['collaboration', 'remote-import'],
    metadata: {
      collab: { origin: 'remote', saveToHistory: false },
      history: { mode: 'skip' },
      selection: { dom: 'preserve' },
    },
  }
)

tag is the cheap lifecycle label. metadata is the typed policy channel for history, collaboration, and model/DOM selection behavior.

The update callback also receives a context object for local post-commit hooks:

editor.update((tx, { afterCommit }) => {
  tx.text.insert('Saved')
 
  afterCommit((change) => {
    analytics.track('editor-change', change.source)
  })
})
editor.update((tx, { afterCommit }) => {
  tx.text.insert('Saved')
 
  afterCommit((change) => {
    analytics.track('editor-change', change.source)
  })
})

Document roots

A plain block array initializes the primary document. Pass initialValue.children plus initialValue.roots when one editor owns extra roots.

const editor = createEditor({
  initialValue: {
    children: [{ type: 'paragraph', children: [{ text: 'Body' }] }],
    roots: {
      header: [{ type: 'paragraph', children: [{ text: 'Draft' }] }],
      footer: [{ type: 'paragraph', children: [{ text: 'Internal' }] }],
    },
  },
})
const editor = createEditor({
  initialValue: {
    children: [{ type: 'paragraph', children: [{ text: 'Body' }] }],
    roots: {
      header: [{ type: 'paragraph', children: [{ text: 'Draft' }] }],
      footer: [{ type: 'paragraph', children: [{ text: 'Internal' }] }],
    },
  },
})

Read the primary document with state.value.root(). Read an extra root by key.

const body = editor.read((state) => state.value.root())
const footer = editor.read((state) => state.value.root('footer'))
const body = editor.read((state) => state.value.root())
const footer = editor.read((state) => state.value.root('footer'))

Create, replace, or delete extra roots with tx.roots.

editor.update((tx) => {
  tx.roots.create('aside:1', [
    { type: 'paragraph', children: [{ text: 'Aside' }] },
  ])
})
editor.update((tx) => {
  tx.roots.create('aside:1', [
    { type: 'paragraph', children: [{ text: 'Aside' }] },
  ])
})

Use normal node and text transforms for the primary document. See Roots for React rendering, root chrome, and content roots.

Document state

state.value.get() returns the persisted document value.

type EditorDocumentValue = {
  children: Descendant[]
  roots?: Record<string, Descendant[]>
  state?: Record<string, unknown>
}
type EditorDocumentValue = {
  children: Descendant[]
  roots?: Record<string, Descendant[]>
  state?: Record<string, unknown>
}

Use it for database persistence because it includes the primary document, extra roots, and persistent state fields.

const documentValue = editor.read((state) => state.value.get())
const documentValue = editor.read((state) => state.value.get())

State fields are registered with defineStateField and read through state.getField(field).

const title = editor.read((state) => state.getField(documentTitle))
const title = editor.read((state) => state.getField(documentTitle))

Write state fields with tx.setField.

editor.update((tx) => {
  tx.setField(documentTitle, 'Q3 Launch Brief')
})
editor.update((tx) => {
  tx.setField(documentTitle, 'Q3 Launch Brief')
})

State-field writes appear in commit.statePatches and commit.dirtyStateKeys. Collaboration adapters should export only shared state-patch keys. Replay remote state patches with tx.statePatches.replay(...).

editor.update(
  (tx) => {
    tx.statePatches.replay(remoteStatePatches)
  },
  {
    metadata: {
      collab: { origin: 'remote', saveToHistory: false },
      history: { mode: 'skip' },
      selection: { dom: 'preserve' },
    },
    tag: ['collaboration', 'remote-state'],
  }
)
editor.update(
  (tx) => {
    tx.statePatches.replay(remoteStatePatches)
  },
  {
    metadata: {
      collab: { origin: 'remote', saveToHistory: false },
      history: { mode: 'skip' },
      selection: { dom: 'preserve' },
    },
    tag: ['collaboration', 'remote-state'],
  }
)

See Document State for persistence patterns and comments ownership.

Schema behavior

Schema setup belongs to extensions. Read schema policy through state.schema or tx.schema.

import { defineEditorExtension, elementProperty } from '@platejs/slate'
 
const tables = defineEditorExtension({
  name: 'tables',
  elements: [
    {
      type: 'table-cell',
      isolating: true,
      keyboardSelectable: true,
      properties: {
        colSpan: elementProperty.number({ default: 1 }),
        rowSpan: elementProperty.number({ default: 1 }),
      },
    },
  ],
})
 
editor.extend(tables)
 
editor.read(state => state.schema.isVoid(element))
 
editor.update(tx => {
  if (tx.schema.isInline(element)) {
    tx.selection.move({ unit: 'character' })
  }
})
import { defineEditorExtension, elementProperty } from '@platejs/slate'
 
const tables = defineEditorExtension({
  name: 'tables',
  elements: [
    {
      type: 'table-cell',
      isolating: true,
      keyboardSelectable: true,
      properties: {
        colSpan: elementProperty.number({ default: 1 }),
        rowSpan: elementProperty.number({ default: 1 }),
      },
    },
  ],
})
 
editor.extend(tables)
 
editor.read(state => state.schema.isVoid(element))
 
editor.update(tx => {
  if (tx.schema.isInline(element)) {
    tx.selection.move({ unit: 'character' })
  }
})

Common schema checks include:

  • state.schema.getElementBehavior(element)
  • state.schema.getElementProperty(element, property)
  • state.schema.getElementPropertyDescriptor(type, property)
  • state.schema.isAtom(element)
  • state.schema.isEditableIsland(element)
  • state.schema.isInline(element)
  • state.schema.isIsolating(element)
  • state.schema.isKeyboardSelectable(element)
  • state.schema.isReadOnly(element)
  • state.schema.isVoid(element)
  • state.schema.markableVoid(element)
  • state.schema.isSelectable(element)
  • state.schema.isElementPropertyEqual(type, property, left, right)

Element property descriptors provide defaults and equality for extension-owned element fields. Reading a default does not write that property into the document. The Slate value remains plain JSON until your transaction writes a field.

Runtime APIs

Extensions expose mounted host and runtime services through editor.api.

editor.api.dom.focus()
editor.api.clipboard.insertTextData(dataTransfer)
editor.api.history.withoutSaving(() => {
  editor.update((tx) => {
    tx.text.insert('Imported')
  })
})
editor.api.dom.focus()
editor.api.clipboard.insertTextData(dataTransfer)
editor.api.history.withoutSaving(() => {
  editor.update((tx) => {
    tx.text.insert('Imported')
  })
})

Use api for services that are not transaction-scoped document mutations: DOM/React bridges, clipboard ingress, history batching, mounted overlay handles, measurements, or framework adapters. Do not put product editing commands there. If a feature changes Slate model state, expose it as a tx group and call it inside editor.update(...).

Use editor.getApi(extension) when the call site owns the extension token and needs the typed API for that extension.

Subscribing to commits

editor.subscribe(listener) => () => void

Subscribe to editor snapshots. The listener receives the current snapshot and an optional change summary.

const unsubscribe = editor.subscribe((_snapshot, change) => {
  if (change?.childrenChanged || change?.dirtyStateKeys.length) {
    const documentValue = editor.read((state) => state.value.get())
 
    save(documentValue)
  }
})
const unsubscribe = editor.subscribe((_snapshot, change) => {
  if (change?.childrenChanged || change?.dirtyStateKeys.length) {
    const documentValue = editor.read((state) => state.value.get())
 
    save(documentValue)
  }
})

editor.subscribeCommit(listener) => () => void

Subscribe only to committed changes. The listener receives the change summary for each commit.

const unsubscribe = editor.subscribeCommit((change) => {
  if (change.selectionChanged) {
    syncSelection(change.selection)
  }
})
const unsubscribe = editor.subscribeCommit((change) => {
  if (change.selectionChanged) {
    syncSelection(change.selection)
  }
})

Call the returned function to unsubscribe.

Extending the editor

editor.extend(extension) => () => void

Install an extension and return a cleanup function.

const removeExtension = editor.extend(myExtension)
const removeExtension = editor.extend(myExtension)

Extensions add typed state and tx namespaces. They should not add methods to the editor object.

editor.update(tx => {
  tx.links.toggle({ href })
})
editor.update(tx => {
  tx.links.toggle({ href })
})

Pure node and location helpers

Pure helpers stay on their own namespaces because they do not read editor runtime state.

NodeApi.string(node)
ElementApi.isElement(value)
TextApi.isText(value)
PathApi.next(path)
PointApi.equals(point, other)
RangeApi.isCollapsed(range)
OperationApi.isOperation(value)
NodeApi.string(node)
ElementApi.isElement(value)
TextApi.isText(value)
PathApi.next(path)
PointApi.equals(point, other)
RangeApi.isCollapsed(range)
OperationApi.isOperation(value)

Use editor.read(...) or editor.update(...) when a helper needs editor state.