From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
UNPUBLISHED
  • Overview
  • Why This Fork
  • Examples
Walkthroughs
  • Installing Plite
  • Adding Event Handlers
  • Defining Custom Elements
  • Applying Custom Formatting
  • Executing Commands
  • Saving to a Database
  • Canonical Change Substrate
  • Improving Performance
Concepts
  • Interfaces
  • Nodes
  • Locations
  • Transforms
  • Document Changes
  • Commands
  • Editor
  • Extensions
  • Rendering
  • Serializing
  • Normalizing
  • Using TypeScript
  • Roots
  • Document State
  • Editing Behavior
  • Selection And DOM
  • Clipboard And Paste
  • Projection And Overlays
  • Schema
API
  • Anchor API
  • Location API
  • Path API
  • PointEntry API
  • Point API
  • Range API
  • Selection API
  • Location Types APIs
  • Span API
  • Editor
  • Element API
  • NodeEntry API
  • Node API
  • Node Types APIs
  • Text API
  • Debug Value Scrubbing
  • Transforms API
Libraries
  • Plite DOM
  • History Editor API
  • History Extension Setup
  • History
  • Plite History
  • Plite Hyperscript
  • Plite Layout
  • Annotations
  • DOM Coverage Boundaries
  • Editable Component
  • Plite React Event Handling
  • Virtualized Rendering
  • Plite React Hooks
  • React Editor Setup
  • React Editor
  • Plite React
  • Plite Component
  • Plite Yjs
  • Plite
General
  • Migration
  • Contributing
  • Docs Proof Map
  • FAQ
  • Resources

Editor

PreviousNext

Core editor runtime API for reads, transactions, roots, state, schema, and extension APIs.

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.

Public Editor Shape

interface BaseEditor<
  V extends Value = Value,
  TExtensions extends readonly unknown[] = readonly []
> {
  readonly api: Readonly<
    EditorCoreApiGroups & EditorInstalledApiGroups<TExtensions>
  >;
























Span APIElement API

On This Page

Public Editor ShapeOn This PageCreating an editorcreateEditor(options?) => EditorReading stateeditor.read.<group>.<method>()editor.read(fn) => TLocating nodesUpdating stateeditor.update.<group>.<method>()editor.update(policy).<group>.<method>()editor.update(fn) => voideditor.update(policy, fn) => voidDocument rootsDocument metaSchema behaviorRuntime APIsSubscribing to commitseditor.subscribe(listener) => () => voideditor.subscribeCommit(listener) => () => voidInstalling and reconfiguring extensionseditor.install(extension, options?) => () => voidImmutable node and location utilities
Build your editor
Production-ready AI template and reusable components.
Get all-access
readonly
anchor
:
EditorAnchorApi
;
readonly id: string;
extend(
extension: EditorExtensionInput,
options?: EditorExtensionReconfigureOptions
): () => void;
extension<const TExtension extends EditorExtensionReference>(
extension: TExtension,
...guard: EditorInstalledExtensionGuard<
TExtension,
TExtensions
> extends never
? [never]
: []
): EditorExtensionPortal<TExtension>;
read: EditorRead<V, TExtensions>;
subscribe(listener: SnapshotListener): () => void;
subscribeCommit(listener: (commit: EditorCommit) => void): () => void;
update: EditorUpdate<V, TExtensions>;
}
type Editor<
V extends Value = Value,
TExtensions extends readonly unknown[] = readonly []
> = BaseEditor<V, TExtensions>;
interface BaseEditor<
  V extends Value = Value,
  TExtensions extends readonly unknown[] = readonly []
> {
  readonly api: Readonly<
    EditorCoreApiGroups & EditorInstalledApiGroups<TExtensions>
  >;
  readonly anchor: EditorAnchorApi;
  readonly id: string;
  extend(
    extension: EditorExtensionInput,
    options?: EditorExtensionReconfigureOptions
  ): () => void;
  extension<const TExtension extends EditorExtensionReference>(
    extension: TExtension,
    ...guard: EditorInstalledExtensionGuard<
      TExtension,
      TExtensions
    > extends never
      ? [never]
      : []
  ): EditorExtensionPortal<TExtension>;
  read: EditorRead<V, TExtensions>;
  subscribe(listener: SnapshotListener): () => void;
  subscribeCommit(listener: (commit: EditorCommit) => void): () => void;
  update: EditorUpdate<V, TExtensions>;
}
 
type Editor<
  V extends Value = Value,
  TExtensions extends readonly unknown[] = readonly []
> = BaseEditor<V, TExtensions>;

On This Page

  • Creating an editor
  • Reading state
  • Locating nodes
  • Updating state
  • Document roots
  • Document meta
  • Schema behavior
  • Runtime APIs
  • Subscribing to commits
  • Installing and reconfiguring extensions
  • Pure node and location helpers

Creating an editor

createEditor(options?) => Editor

Create an editor.

const editor = createEditor({
  initialValue: [{ type: "paragraph", children: [{ text: "Body" }] }],
  maxLength: 1000,
});
const editor = createEditor({
  initialValue: [{ type: "paragraph", children: [{ text: "Body" }] }],
  maxLength: 1000,
});

Extensions define schema, corrections, commit listeners, owner-local read and update groups, and optional runtime APIs.

maxLength limits user-facing text, fragment, and node insertions. Adapter-owned canonical change application remains outside user insertion policy.

Reading state

editor.read.<group>.<method>()

Run one committed-state read.

const selection = editor.read.selection();
const isExpanded = editor.read.selection.isExpanded();
const spansBlocks = editor.read.selection.isAcrossBlocks();
const startsBlock = editor.read.selection.isAtBlockStart();
const containsTitle = editor.read.selection.contains([0]);
const endsWord = selection
  ? editor.read.points.isWordEnd(selection.anchor)
  : false;
const text = editor.read.text.string([]);
const isInline = editor.read.schema.isInline(element);
const selection = editor.read.selection();
const isExpanded = editor.read.selection.isExpanded();
const spansBlocks = editor.read.selection.isAcrossBlocks();
const startsBlock = editor.read.selection.isAtBlockStart();
const containsTitle = editor.read.selection.contains([0]);
const endsWord = selection
  ? editor.read.points.isWordEnd(selection.anchor)
  : false;
const text = editor.read.text.string([]);
const isInline = editor.read.schema.isInline(element);

Selection predicates accept explicit targets when a command should inspect a location other than the current selection.

const startsHeading = editor.read.selection.isAtBlockStart({
  at: point,
  type: "heading",
});
const startsHeading = editor.read.selection.isAtBlockStart({
  at: point,
  type: "heading",
});

editor.read(fn) => T

Read a coherent snapshot of editor state when several reads should share the same state view.

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

Use state for editor-state queries:

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

Schema policy is available through direct read methods for common one-shot checks and through state.schema for grouped reads:

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

Locating nodes

Public read and update methods with an at option accept a NodeTarget: a Path, Point, Range, or live descendant.

type NodeTarget<N extends Descendant = Descendant> = Location | N;
type NodeTarget<N extends Descendant = Descendant> = Location | N;

Resolve a node when the path itself matters:

const path = editor.read.nodes.path(element);
const path = editor.read.nodes.path(element);

The read returns undefined for an unresolved node. Resolution is scoped to the editor root, so nodes from another editor or another root do not resolve. Strict static helpers keep their non-optional contracts and treat a missing result as an internal invariant failure.

Node queries use type for structural selection and match for an optional predicate.

const callout = editor.read.nodes.find({ type: "callout" });
const tableNode = editor.read.nodes.find({
  type: ["table", "table_cell"],
});
const callout = editor.read.nodes.find({ type: "callout" });
const tableNode = editor.read.nodes.find({
  type: ["table", "table_cell"],
});

An array selects any listed type. Add match: (node, path) => ... for computed conditions and static type narrowing.

Updating state

editor.update.<group>.<method>()

Run one transaction write with the default policy.

editor.update.marks.toggle("bold");
editor.update.text.insert("Title");
editor.update.selection.move({ distance: 1 });
editor.update.nodes.set({ icon: "🔥" }, { at: calloutElement });
editor.update.marks.toggle("bold");
editor.update.text.insert("Title");
editor.update.selection.move({ distance: 1 });
editor.update.nodes.set({ icon: "🔥" }, { at: calloutElement });

editor.update(policy).<group>.<method>()

Configure one direct write.

editor.update({ history: "skip" }).text.insert("Imported");
editor.update({ tags: ["paste", "html"] }).slice.replace(importedSlice);
editor.update({ history: "skip" }).text.insert("Imported");
editor.update({ tags: ["paste", "html"] }).slice.replace(importedSlice);

The configured facade exposes core and installed extension update methods. A method marked with txOnly(...) is omitted because it requires an explicit transaction. editor.update(policy) returns that configured facade; a direct method returns the underlying method result.

editor.update(fn) => void

Run one atomic update with the default policy. The callback receives tx, which owns reads and writes for the active transaction.

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

editor.update(policy, fn) => void

Run one atomic update with a semantic policy.

editor.update({ history: "new-batch", tags: "paste" }, (tx) => {
  tx.slice.replace(importedSlice);
  tx.selection.collapse({ edge: "end" });
});
editor.update({ history: "new-batch", tags: "paste" }, (tx) => {
  tx.slice.replace(importedSlice);
  tx.selection.collapse({ edge: "end" });
});
type EditorUpdatePolicy = Readonly<{
  history?: "merge" | "new-batch" | "skip";
  tags?: EditorUpdateTag | readonly EditorUpdateTag[];
}>;
type EditorUpdatePolicy = Readonly<{
  history?: "merge" | "new-batch" | "skip";
  tags?: EditorUpdateTag | readonly EditorUpdateTag[];
}>;

EditorUpdatePolicyFor<E> narrows the policy to an editor's installed capabilities. history is a type error when E has no History transaction group, and an untyped runtime call fails before mutation. Tags are applied in input order before history; only the last history mode remains.

Inside the callback, tx.tags.add(tag) updates the final tag set and tx.tags.has(tag) inspects it. History adds tx.history.skip(), tx.history.merge(), and tx.history.newBatch() as transaction-only controls for decisions made after the update starts.

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", { tags: change.tags });
  });
});
editor.update((tx, { afterCommit }) => {
  tx.text.insert("Saved");
 
  afterCommit((change) => {
    analytics.track("editor-change", { tags: change.tags });
  });
});

All callback forms return void, must finish synchronously, and invalidate tx when they return. Thenable callbacks and escaped transactions are rejected. A public update cannot be nested; pass the active tx into helpers instead.

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 editor.read.children(). Read an extra root by key.

const body = editor.read.children();
const footer = editor.read.root("footer");
const body = editor.read.children();
const footer = editor.read.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 meta

editor.read.value() returns the persisted document value.

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

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

const documentValue = editor.read.value();
const documentValue = editor.read.value();

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 emit the field's typed effect. The commit lists the changed field key in commit.dirtyStateKeys and carries the effect in commit.effects. History inverts effects whose field policy is "add"; collaboration adapters export effects whose field policy is "shared".

editor.update((tx) => {
  tx.effects.emit(documentTitle.effect, {
    previousValue: tx.getField(documentTitle),
    value: remoteTitle,
  });
});
editor.update((tx) => {
  tx.effects.emit(documentTitle.effect, {
    previousValue: tx.getField(documentTitle),
    value: remoteTitle,
  });
});

See Document Meta for persistence patterns and comments ownership.

Schema behavior

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

import {
  createEditor,
  defineEditorSchema,
  type Element,
  property,
  schema,
} from "@platejs/plite";
 
const TableSchema = defineEditorSchema("schema:table-document", {
  elements: {
    tableCell: schema.element.textBlock({
      isolating: true,
      keyboardSelectable: true,
      properties: {
        colSpan: property.number({ default: 1, omitDefault: true }),
        rowSpan: property.number({ default: 1, omitDefault: true }),
      },
    }),
  },
  id: "table-document",
  root: schema.content.type("tableCell", {
    min: 1,
  }),
  version: 1,
});
const tableCell = schema.handle.element(TableSchema, "tableCell");
const colSpan = schema.handle.property(tableCell, "colSpan");
 
const editor = createEditor({
  extensions: [TableSchema] as const,
  initialValue: [{ type: "tableCell", children: [{ text: "" }] }],
});
const cell = editor.read.children()[0] as Element;
 
const behavior = editor.read((state) => ({
  allowsUnknownChildren:
    state.schema.element("tableCell")?.content?.allowsUnknownElements,
  colSpan: state.schema.getElementProperty(cell, colSpan),
  isolating: state.schema.isIsolating(cell),
  keyboardSelectable: state.schema.isKeyboardSelectable(cell),
}));
import {
  createEditor,
  defineEditorSchema,
  type Element,
  property,
  schema,
} from "@platejs/plite";
 
const TableSchema = defineEditorSchema("schema:table-document", {
  elements: {
    tableCell: schema.element.textBlock({
      isolating: true,
      keyboardSelectable: true,
      properties: {
        colSpan: property.number({ default: 1, omitDefault: true }),
        rowSpan: property.number({ default: 1, omitDefault: true }),
      },
    }),
  },





















Common schema checks include:

  • state.schema.allowsElementType(parentType, childType)
  • state.schema.create(type, properties?)
  • state.schema.createDefaultRootChild(root?)
  • state.schema.delta()
  • state.schema.element(type)
  • state.schema.findWrapping(parent, child)
  • state.schema.getElementBehavior(element)
  • state.schema.getElementContentRoots(element)
  • state.schema.getElementProperty(element, property)
  • state.schema.getElementSlicePolicy(element)
  • state.schema.getVocabulary()
  • state.schema.identity()
  • state.schema.isAtom(element)
  • state.schema.isEditableIsland(element)
  • state.schema.isElementTypeInGroup(type, group)
  • state.schema.isInline(element)
  • state.schema.isIsolating(element)
  • state.schema.isKeyboardSelectable(element)
  • state.schema.isReadOnly(element)
  • state.schema.isVoid(element)
  • state.schema.isMarkableVoid(element)
  • state.schema.isSelectable(element)
  • state.schema.property({ key, placement, type? })
  • state.schema.assertDocument(document)
  • state.schema.assertFragment(children)

state.schema.identity() always returns either a derived identity or a named identity. Derived schemas omit id and version; named schemas provide both for a durable lineage.

Resolved properties expose their value descriptor, target, placement, and lifecycle. Value descriptors provide canonical defaults and structural JSON equality. Resolved element content exposes allowedElementTypes, allowsText, allowsUnknownElements, cardinality, and its canonical default. Use schema.handle.element(...) and schema.handle.property(...) when the schema is known. The raw property query is for property keys discovered at runtime. Reading a default does not write that property into the document. The Plite value remains plain JSON until a transaction writes a field. See Schema for declaration, validation, and fitting.

Runtime APIs

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

editor.api.dom.focus();
editor.api.dom.clipboard.insertTextData(dataTransfer);
editor.api.dom.focus();
editor.api.dom.clipboard.insertTextData(dataTransfer);

Use api for services that are not transaction-scoped document mutations: DOM/React bridges, clipboard ingress, mounted overlay handles, measurements, or framework adapters. Do not put product editing commands there. If a feature changes Plite model state, expose it as an update group.

Use editor.extension(Extension).api when the call site owns the extension descriptor and needs its typed API.

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, commit) => {
  if (commit?.changed.has("document") || commit?.dirtyStateKeys.length) {
    const documentValue = editor.read.value();
 
    save(documentValue);
  }
});
const unsubscribe = editor.subscribe((_snapshot, commit) => {
  if (commit?.changed.has("document") || commit?.dirtyStateKeys.length) {
    const documentValue = editor.read.value();
 
    save(documentValue);
  }
});

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

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

const unsubscribe = editor.subscribeCommit((commit) => {
  if (commit.selectionChanged) {
    syncSelection(commit.selectionAfter);
  }
});
const unsubscribe = editor.subscribeCommit((commit) => {
  if (commit.selectionChanged) {
    syncSelection(commit.selectionAfter);
  }
});

Call the returned function to unsubscribe.

Installing and reconfiguring extensions

Install statically known extensions when the editor is created. This preserves their inferred read, update, and api groups.

const editor = createEditor({ extensions: [myExtension] as const });
const editor = createEditor({ extensions: [myExtension] as const });

editor.install(extension, options?) => () => void

Install a host-selected extension after creation. Plite compiles and validates a detached extension candidate, publishes and activates it atomically, then emits one commit. The commit includes the migrated document when options provides one. Calling the returned cleanup function publishes the corresponding removal through the same lifecycle.

const removeExtension = editor.install(myExtension);
 
removeExtension();
const removeExtension = editor.install(myExtension);
 
removeExtension();

When the extension introduces a schema that rejects the current document, pass options.migrate. It receives the immutable current document and candidate schema, and returns the complete document to publish with the configuration. Validation finishes before publication; failure publishes nothing.

Use creation-time installation when TypeScript should infer the extension's groups. Dynamic installation does not rewrite the editor's static type.

Use a named slot for runtime configuration. Reconfiguration is part of one atomic editor transaction rather than an immediate registry mutation.

const feature = defineExtensionSlot("feature");
const editor = createEditor({
  extensions: [feature.of(readMode)] as const,
});
 
editor.update((tx) => {
  tx.extensions.reconfigure(feature, writeMode);
});
const feature = defineExtensionSlot("feature");
const editor = createEditor({
  extensions: [feature.of(readMode)] as const,
});
 
editor.update((tx) => {
  tx.extensions.reconfigure(feature, writeMode);
});

Extensions add typed state and tx namespaces. Direct-safe transaction methods are also available through the matching editor.update group.

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

Wrap a transaction-only extension method with txOnly(...). TypeScript omits it from direct update groups, and dynamic direct dispatch rejects it at runtime.

Immutable node and location utilities

Read-only utilities stay on their own frozen namespaces because they never mutate the editor. They operate on the immutable node, editor, and location values passed to them.

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

Use editor.read(...) when several state-dependent reads must share one snapshot, and editor.update(...) for document changes.

id:
"table-document"
,
root: schema.content.type("tableCell", {
min: 1,
}),
version: 1,
});
const tableCell = schema.handle.element(TableSchema, "tableCell");
const colSpan = schema.handle.property(tableCell, "colSpan");
const editor = createEditor({
extensions: [TableSchema] as const,
initialValue: [{ type: "tableCell", children: [{ text: "" }] }],
});
const cell = editor.read.children()[0] as Element;
const behavior = editor.read((state) => ({
allowsUnknownChildren:
state.schema.element("tableCell")?.content?.allowsUnknownElements,
colSpan: state.schema.getElementProperty(cell, colSpan),
isolating: state.schema.isIsolating(cell),
keyboardSelectable: state.schema.isKeyboardSelectable(cell),
}));