Change algebra
Combine, shorten and rebase change lists, and carry paths through them.
A change list from diff or a Transform is plain data. Five functions work on those
lists without touching a tree:
| Function | Use it to |
|---|---|
compose(a, b) | Merge two consecutive lists into one. |
compact(changes) | Drop redundant writes from a list. |
map_path(path, changes) | Find where a node ends up after the changes run. |
unmap_path(path, changes) | Find where a node was before the changes ran. |
rebase_change(change, prior) | Re-express one change from the middle of a list so it applies on its own. |
All five rely on one rule: change paths are live. Each change assumes every earlier change in its list has already run. See Diffing.
compose
compose(a, b) returns one list that has the same effect as applying a and then b.
Because b's paths already assume a ran, composing is concatenation, followed by
compact.
let base = doc([Node::text("x")]);
let a = vec![Change::SetText { path: vec![0], text: Some("y".into()) }];
let b = vec![Change::SetText { path: vec![0], text: Some("z".into()) }];
let composed = compose(&a, &b);
assert_eq!(composed, vec![Change::SetText { path: vec![0], text: Some("z".into()) }]);
let mut t = base.clone();
apply(&mut t, &composed).unwrap();
assert_eq!(t.text_content(), "z");Use it to squash several undo steps into one, or to batch edits before you send them.
compact
compact(changes) removes changes whose effect a later change overwrites, and keeps the
result of apply the same. It makes only two safe reductions:
- Repeated writes to the same field of the same node keep only the last one. A field is
the text, the marks, one attribute key or one extra key.
SetAttrandRemoveAttron the same key count as writes to the same field, and so doSetExtraandRemoveExtra. - An
Insertimmediately followed by aRemoveof the same child cancels out.
Every other change (Insert, Remove, Move, Replace and SpliceText) is a barrier:
writes are never merged across one, because after a structural change the same path may
point at a different node. The result is never longer than the input.
let set = |v: u64| Change::SetAttr { path: vec![0], key: "level".into(), value: v.into() };
let changes = vec![
set(1),
set(2),
Change::Insert { path: vec![], index: 0, node: Node::element("paragraph") },
set(3), // after the insert, [0] is a different node
];
assert_eq!(compact(&changes), vec![set(2), changes[2].clone(), set(3)]);compact assumes the list applies cleanly to its base, as lists from diff and
Transform do. On a list that would fail, the result can differ: an out-of-range Insert
followed by its Remove cancels out here, while applying the original would return an
error.
map_path
map_path(path, changes) answers: the node at path before the changes, where is it
after them? It returns None when the changes removed the node, removed or replaced one of
its ancestors, or replaced the node itself.
let changes = vec![Change::Insert { path: vec![], index: 0, node: Default::default() }];
assert_eq!(map_path(&[1, 0], &changes), Some(vec![2, 0])); // shifted right by the insert
let removed = vec![Change::Remove { path: vec![], index: 1 }];
assert_eq!(map_path(&[1, 0], &removed), None); // its parent was removedField changes never move a node. Insert, Remove and Move shift the index at the level
they act on. Use map_path to keep a selection, a highlight or a comment anchor on the
same node after an edit, an undo or a remote change. For flat positions, use a PosMap
instead; see Position editing.
unmap_path
unmap_path(path, changes) goes the other way: given a path in the tree after the
changes, where was that node before them? It returns None when the node did not exist
before, because a change inserted it or it lives inside a replaced subtree.
let changes = vec![Change::Insert { path: vec![], index: 0, node: Default::default() }];
assert_eq!(unmap_path(&[2, 0], &changes), Some(vec![1, 0]));
assert_eq!(unmap_path(&[0], &changes), None); // the inserted node itselfIts main use is to show a change from the middle of a list against the original tree.
Change i has live paths, so it addresses the tree after changes 0..i.
unmap_path(path, &changes[..i]) turns its path into a path in the original tree, where a
diff view can highlight it.
rebase_change
rebase_change(change, prior) takes a change that comes right after prior in a list and
re-expresses it against the tree before prior ran. You can then apply it on its own to
the base. That is how you cherry-pick one change out of a diff, for example when a reviewer
accepts the third suggestion but not the first two.
let p = |t: &str| Node::element("paragraph").with_text(t);
let base = doc([p("one"), p("two"), p("three")]);
let target = doc([p("two"), p("THREE")]);
let changes = base.diff(&target);
assert_eq!(
changes,
vec![
Change::Remove { path: vec![], index: 0 },
Change::SetText { path: vec![1, 0], text: Some("THREE".into()) },
]
);
// Accept only the text edit. Its live path [1, 0] assumes "one" is already gone.
let picked = rebase_change(&changes[1], &changes[..1]).unwrap();
assert_eq!(picked, Change::SetText { path: vec![2, 0], text: Some("THREE".into()) });
let mut merged = base.clone();
apply(&mut merged, &[picked]).unwrap();
assert_eq!(merged, doc([p("one"), p("two"), p("THREE")]));Applying changes[1] to base directly would have edited "two" instead.
How each kind of change is rebased:
- Node addresses go through
unmap_path. - The
indexof anInsertand thetoof aMoveare insertion slots. An earlier insert at the same slot does not drop them. - A
SpliceTextalso has its character range carried back through earlier splices of the same text node.
rebase_change returns None when the change cannot exist on its own in the base: its
target is inside a node that prior inserted or replaced, or a SpliceText touches text
that an earlier change inserted or replaced.
After you apply a cherry-picked change, the base has changed, so recompute the diff to get the remaining changes. In Tiptap Studio, Take B on a single change works this way; see Diff and merge.
rebase_change is not operational transformation. It moves one change back across the
changes before it in the same list. It does not merge two concurrent edit streams, which
would need stable node identity that the Change model does not have.
See the API reference.