Tiptap Studio

Diffing

Compare two documents with diff and diffWith, and apply the resulting change list.

diff and diffWith compare two TiptapDocs and return a Change[]: the edits that turn the first document into the second. You can store that list, send it over the network, apply it with applyChanges, and undo it with invert.

import { TiptapDoc } from "@tiptap-studio/rusty-parser";

const para = (text: string) => ({
  type: "doc",
  content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});

const a = TiptapDoc.fromJSON(para("the quick brown fox"));
const b = TiptapDoc.fromJSON(para("the quick red fox"));

a.diff(b);
// [{ op: "setText", path: [0, 0], text: "the quick red fox" }]

Methods

MethodReturnsNotes
diff(other: TiptapDoc)Change[]Text changes replace the whole text node.
diffWith(other: TiptapDoc, options: DiffOptions)Change[]Choose how changed text is described. undefined or null behaves like diff.
applyChanges(changes: Change[])voidApplies a change list in place.

Neither diff method modifies the documents. Both read other without consuming it, so it stays usable.

Options

DiffOptions has one optional field, text, the granularity for changed text nodes:

textEmitsUse
"block"One setText with the new text. The default.Smallest change lists; review at node level.
"inline"spliceText changes at character level.Showing inserted and deleted characters, such as suggestions.
{ smart: { replaceThreshold: number } }spliceText while the change is small, setText once it is not.Character-level review for small edits, a clean replacement for rewrites.

An unknown value throws, for example Error: unknown variant `chars`, expected one of `block`, `inline`, `smart` .

a.diffWith(b, { text: "inline" });
// [
//   { op: "spliceText", path: [0, 0], from: 12, lenDel: 3, insert: "ed" },
//   { op: "spliceText", path: [0, 0], from: 10, lenDel: 1, insert: "" },
// ]

spliceText offsets count Unicode scalar values. Splices on one text node come last offset first, so each one's offsets are still valid when it is applied.

Smart threshold

For smart, the diff computes the character-level segments, then the fraction of changed characters: inserted plus deleted characters, divided by the length of the old text plus the length of the new text. When that fraction is at most replaceThreshold (between 0 and 1), it emits the splices. Above it, it emits one setText.

a.diffWith(b, { text: { smart: { replaceThreshold: 0.5 } } }); // two spliceText changes
a.diffWith(b, { text: { smart: { replaceThreshold: 0.1 } } }); // one setText

Granularity only affects text nodes whose text changed on both sides. Everything else, attributes, marks and structure, is diffed the same way in every mode.

Change objects

Every change is an object tagged with op. Paths are index paths.

opFieldsEffect
setAttrpath, key, valueSets an attribute on the node at path.
removeAttrpath, keyRemoves an attribute.
setTextpath, textSets the node's text; null removes it.
spliceTextpath, from, lenDel, insertReplaces lenDel scalars at from with insert.
setMarkspath, marksReplaces the node's whole mark list; null removes it.
setExtrapath, key, valueSets an unknown top-level field on the node.
removeExtrapath, keyRemoves an unknown top-level field.
insertpath, index, nodeInserts node as child index of the node at path.
removepath, indexRemoves child index of the node at path.
replacepath, nodeReplaces the node at path wholesale.
movepath, from, toMoves a child within the node at path.

Two conventions matter when you read or write change lists by hand:

  • Node changes address the node, list changes address the parent. setAttr, setText and the other field changes, and replace, use the path of the node they change. insert, remove and move use the path of the parent, with index, from and to selecting the child.
  • Indices are live. Changes apply in order, and each one sees the document as the earlier ones left it. For move, the child at from is removed first, then inserted so it ends up at to in the shortened list.
const x = TiptapDoc.fromJSON({ type: "doc", content: [
  { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: "A" }] },
  { type: "paragraph", content: [{ type: "text", text: "b" }] },
  { type: "paragraph", content: [{ type: "text", text: "c" }] },
] });
const y = TiptapDoc.fromJSON({ type: "doc", content: [
  { type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "A", marks: [{ type: "bold" }] }] },
  { type: "paragraph", content: [{ type: "text", text: "c" }] },
  { type: "paragraph", content: [{ type: "text", text: "b" }] },
] });

x.diff(y);
// [
//   { op: "move", path: [], from: 1, to: 2 },
//   { op: "setAttr", path: [0], key: "level", value: 2 },
//   { op: "setMarks", path: [0, 0], marks: [{ type: "bold" }] },
// ]

A reordered child is detected as a move, without copying its subtree into the change list. A node whose type changes is replaced wholesale. A difference between an empty list and a missing field, such as "content": [] against no content, is kept with a replace, so a diff reproduces the target exactly.

Apply a change list

applyChanges runs the changes in order against the document.

const copy = TiptapDoc.fromJSON(x.toJSON());
copy.applyChanges(x.diff(y));
copy.toJSONString() === y.toJSONString(); // true

Applying a.diff(b) to a copy of a always reproduces b. A list written by hand can fail: a path that does not resolve, or a child index or splice out of range, throws apply: no node at path [...].

applyChanges is not atomic. When a change fails, the changes before it stay applied. Apply untrusted lists to a copy first, or use applyPosEdits, which leaves the document unchanged on failure.

To undo a list, see Transactions and undo. To combine or shorten lists, see Change algebra. The Rust side of the same diff is described in Diffing.

On this page