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
| Method | Returns | Notes |
|---|---|---|
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[]) | void | Applies 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:
text | Emits | Use |
|---|---|---|
"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 setTextGranularity 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.
op | Fields | Effect |
|---|---|---|
setAttr | path, key, value | Sets an attribute on the node at path. |
removeAttr | path, key | Removes an attribute. |
setText | path, text | Sets the node's text; null removes it. |
spliceText | path, from, lenDel, insert | Replaces lenDel scalars at from with insert. |
setMarks | path, marks | Replaces the node's whole mark list; null removes it. |
setExtra | path, key, value | Sets an unknown top-level field on the node. |
removeExtra | path, key | Removes an unknown top-level field. |
insert | path, index, node | Inserts node as child index of the node at path. |
remove | path, index | Removes child index of the node at path. |
replace | path, node | Replaces the node at path wholesale. |
move | path, from, to | Moves 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,setTextand the other field changes, andreplace, use the path of the node they change.insert,removeandmoveuse the path of the parent, withindex,fromandtoselecting 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 atfromis removed first, then inserted so it ends up attoin 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(); // trueApplying 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.