Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Overview
    • Why This Fork
    • Examples
    • Installing Plite
    • Adding Event Handlers
    • Defining Custom Elements
    • Applying Custom Formatting
    • Executing Commands
    • Saving to a Database
    • Canonical Change Substrate
    • Improving Performance
    • Interfaces
    • Nodes
    • Locations
    • Transforms
    • Document Changes
    • Commands
    • Editor
    • Plugins
    • Rendering
    • Serializing
    • Normalizing
    • Using TypeScript
    • Roots
    • Document State
    • Editing Behavior
    • Selection And DOM
    • Clipboard And Paste
    • Decorations, annotations, and widgets
    • Schema
    • 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
    • Plite DOM
    • History Editor API
    • History Plugin Setup
    • History
    • Plite History
    • Plite Hyperscript
    • Plite PaginationExperimental
    • Annotations
    • DOM Coverage Boundaries
    • Editable Component
    • External Text Views
    • Plite React Event Handling
    • Virtualized Rendering
    • Plite React Hooks
    • React Editor Setup
    • React Editor
    • Plite React
    • Plite Component
    • Plite Yjs
    • Plite
    • Migration
    • Contributing
    • FAQ
    • Resources

Document Changes

PreviousNext

Use root-aware document changes as Plite's canonical mutation, mapping, and replay law.

Every document-changing update publishes one DocumentChange. It maps an immutable before value to an immutable after value across the primary document and any named roots.

const commit = editor.read.lastCommit();
const nextValue = commit ? commit.changes.apply(previousValue) : previousValue;
const commit = editor.read.lastCommit();
const nextValue = commit ? commit.changes.apply(previousValue) : previousValue;
TransformsCommands

On This Page

AlgebraApply a stored or remote changeCommit invalidationFit external content
Build your editor
Production-ready AI template and reusable components.
Get all-access

Algebra

DocumentChange owns the algebra required by history, collaboration, anchors, corrections, and view invalidation:

  • apply(value)
  • compose(other)
  • invert(value)
  • mapPosition(position, { root, association, track })
  • iterChangedRanges(visit)
  • toJSON() and DocumentChange.fromJSON(json)

The compact per-root representation is private. Omit root when mapping a primary-document position. Explicit root strings always address named secondary roots.

const mapped = change.mapPosition(position, {
  association: "forward",
});
 
const mappedHeader = change.mapPosition(position, {
  association: "backward",
  root: "header",
});
const mapped = change.mapPosition(position, {
  association: "forward",
});
 
const mappedHeader = change.mapPosition(position, {
  association: "backward",
  root: "header",
});

toJSON() writes version 3 with optional primary and roots fields. An omitted roots object and an empty roots object describe the same document change.

iterChangedRanges reports root: null for the primary document and a string for a named root.

DocumentChange.transform(a, b, value) rebases two changes from the same value for pairwise convergence, including history rebasing. Multi-peer ordering belongs to a collaboration adapter such as Yjs.

Apply a stored or remote change

editor.update({ tags: "remote-import" }, (tx) => {
  tx.changes.apply(DocumentChange.fromJSON(message.change));
});
editor.update({ tags: "remote-import" }, (tx) => {
  tx.changes.apply(DocumentChange.fromJSON(message.change));
});

Use semantic transaction groups for ordinary editing. tx.changes.apply is the adapter, history, and durable replay boundary.

Commit invalidation

Consumers inspect the canonical change through lazy commit.changed queries. They do not reconstruct impact from a parallel operation list.

const unsubscribe = editor.subscribeCommit((commit) => {
  if (!commit.changed.has("document")) return;
 
  const changedBlocks = commit.changed.topLevelRanges();
  const changedNodeKeys = commit.changed.nodeKeys("node");
 
  refreshDocumentConsumers(changedBlocks, changedNodeKeys);
});
const unsubscribe = editor.subscribeCommit((commit) => {
  if (!commit.changed.has("document")) return;
 
  const changedBlocks = commit.changed.topLevelRanges();
  const changedNodeKeys = commit.changed.nodeKeys("node");
 
  refreshDocumentConsumers(changedBlocks, changedNodeKeys);
});

commit.changed.has(kind) and nodeKeys(kind) query the primary document. Pass a named root for one secondary document. Use hasAny(kind) and nodeKeysAll(kind) only when a consumer intentionally spans every root. The queries derive their answers from DocumentChange plus the retained before and after snapshot indexes.

Choose the change kind your consumer needs. nodeKeys("presence") returns identities added to or removed from a document root. Moving a node within that root changes its path, not its presence. Use "node" for changed node values, "path" for path changes, and "decoration" for transient range-paint impact. Presence queries inspect changed ranges without enumerating shifted paths. Whole-value replacement compares the replaced roots.

Aggregate key lists are immutable and reused within a commit. hasNodeKey(key, "path") caches each key's answer without collecting every changed path. When a consumer requests the complete path list, membership queries reuse that result. Other change kinds share cached aggregate membership.

Fit external content

Parsing, paste, and import boundaries carry open content as an immutable ContentSlice. Replace through tx.slice so the compiled schema fits the slice before one canonical change is published.

import { ContentSlice } from "plitejs";
 
const slice = ContentSlice.fromJSON({
  content: parsedContent,
  openEnd: 1,
  openStart: 1,
});
 
editor.update.slice.replace(slice);
import { ContentSlice } from "plitejs";
 
const slice = ContentSlice.fromJSON({
  content: parsedContent,
  openEnd: 1,
  openStart: 1,
});
 
editor.update.slice.replace(slice);

Use ContentSlice.closed(content) when a transport explicitly needs a closed slice, ContentSlice.empty for the frozen empty slice, and ContentSlice.withContent(slice, content, { open }) when a codec or plugin rewrites content while either preserving or closing its boundaries.

For ordinary closed application content, skip the transport wrapper.

editor.update.fragment.replace([
  { type: "paragraph", children: [{ text: "Closed content" }] },
]);
editor.update.fragment.replace([
  { type: "paragraph", children: [{ text: "Closed content" }] },
]);

Pure commands preview the same atomic replacement with state.slice.fit(slice, options?). Editor-level code can call editor.read.slice.fit(...); it returns false or a frozen TransactionSpec without publishing.

Detached structures use the same compiled grammar without pretending to be in the live document.

const tableCell = {
  type: "tableCell",
  children: [{ type: "paragraph", children: [{ text: "" }] }],
};
const fittedChildren = editor.read.slice.fitContent(slice, {
  parent: tableCell,
});
 
if (fittedChildren === null) {
  throw new Error("The slice does not fit this table cell.");
}
const tableCell = {
  type: "tableCell",
  children: [{ type: "paragraph", children: [{ text: "" }] }],
};
const fittedChildren = editor.read.slice.fitContent(slice, {
  parent: tableCell,
});
 
if (fittedChildren === null) {
  throw new Error("The slice does not fit this table cell.");
}

fitContent returns immutable children or null. It does not mutate the parent, selection, document, or commit stream. Omit root for the primary root; root-scoped editor views inherit their root automatically.