livebook/lib/live_book/delta/transformation.ex
Jonatan Kłosko 3e6a4adce2
Implement collaborative text editing (#10)
* Set up editor and client side delta handling

* Synchronize deltas on the server

* Document the client code, add more tests

* Implement delta on the server, use compact representation when transmitting changes

* Simplify transformation implementation and add documentation

* Add session and data tests

* Add more delta tests

* Clean up evaluator tests wait timeout
2021-01-21 13:11:45 +01:00

109 lines
4.4 KiB
Elixir

defmodule LiveBook.Delta.Transformation do
@moduledoc false
# Implementation of the Operational Transformation concept for deltas.
#
# The transformation allows for conflict resolution in concurrent editing.
# Consider delta `Oa` and delta `Ob` that occurred at the same time against the same text state `S`.
# The resulting new text states are `S ∘ Oa` and `S ∘ Ob` respectively.
# Now for each text state we would like to apply the other delta,
# so that both texts converge to the same state, i.e.:
#
# `S ∘ Oa ∘ transform(Oa, Ob) = S ∘ Ob ∘ transform(Ob, Oa)`
#
# That's the high-level idea.
# To actually achieve convergence we have to introduce a linear order of operations.
# This way we can resolve conflicts - e.g. if two deltas insert a text at the same
# position, we have to unambiguously determine which takes precedence.
# A reasonable solution is to have a server process where all
# the clients send deltas, as it naturally imposes the necessary ordering.
alias LiveBook.Delta
alias LiveBook.Delta.Operation
@type priority :: :left | :right
@doc """
Transforms `right` delta against the `left` delta.
Assuming both deltas represent changes applied to the same
document state, this operation results in modified `right` delta
that represents effectively the same changes (preserved intent),
but works on the document with `left` delta already applied.
The `priority` indicates which delta is considered to have
happened first and is used for conflict resolution.
"""
@spec transform(Delta.t(), Delta.t(), priority()) :: Delta.t()
def transform(left, right, priority) do
do_transform(left.ops, right.ops, priority, Delta.new())
|> Delta.trim()
end
defp do_transform(_ops_a, [] = _ops_b, _priority, result) do
result
end
defp do_transform([], ops_b, _priority, result) do
Enum.reduce(ops_b, result, &Delta.append(&2, &1))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:insert, _} | _] = ops_b, :left, result) do
[ins_a | remainder_a] = ops_a
retain = make_retain(ins_a)
do_transform(remainder_a, ops_b, :left, Delta.append(result, retain))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:insert, _} | _] = ops_b, :right, result) do
[ins_b | remainder_b] = ops_b
do_transform(ops_a, remainder_b, :right, Delta.append(result, ins_b))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:retain, _} | _] = ops_b, priority, result) do
[ins_a | remainder_a] = ops_a
retain = make_retain(ins_a)
do_transform(remainder_a, ops_b, priority, Delta.append(result, retain))
end
defp do_transform([{:insert, _} | _] = ops_a, [{:delete, _} | _] = ops_b, priority, result) do
[ins_a | remainder_a] = ops_a
retain = make_retain(ins_a)
do_transform(remainder_a, ops_b, priority, Delta.append(result, retain))
end
defp do_transform([{:delete, _} | _] = ops_a, [{:insert, _} | _] = ops_b, priority, result) do
[ins_b | remainder_b] = ops_b
do_transform(ops_a, remainder_b, priority, Delta.append(result, ins_b))
end
defp do_transform([{:delete, _} | _] = ops_a, [{:retain, _} | _] = ops_b, priority, result) do
{[_del_a | remainder_a], [_ret_b | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, result)
end
defp do_transform([{:delete, _} | _] = ops_a, [{:delete, _} | _] = ops_b, priority, result) do
{[_del_a | remainder_a], [_del_b | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, result)
end
defp do_transform([{:retain, _} | _] = ops_a, [{:insert, _} | _] = ops_b, priority, result) do
[ins_b | remainder_b] = ops_b
do_transform(ops_a, remainder_b, priority, Delta.append(result, ins_b))
end
defp do_transform([{:retain, _} | _] = ops_a, [{:retain, _} | _] = ops_b, priority, result) do
{[ret | remainder_a], [ret | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, Delta.append(result, ret))
end
defp do_transform([{:retain, _} | _] = ops_a, [{:delete, _} | _] = ops_b, priority, result) do
{[_ret_a | remainder_a], [del_b | remainder_b]} = Operation.align_heads(ops_a, ops_b)
do_transform(remainder_a, remainder_b, priority, Delta.append(result, del_b))
end
defp make_retain(op) do
op
|> Operation.length()
|> Operation.retain()
end
end