livebook/lib/live_book/delta.ex

156 lines
4.3 KiB
Elixir

defmodule LiveBook.Delta do
@moduledoc false
# Delta is a format used to represent a set of changes
# introduced to a text document.
#
# By design, delta is suitable for Operational Transformation
# and is hence our primary building block in collaborative text editing.
#
# For a detailed write-up see https://quilljs.com/docs/delta
# and https://quilljs.com/guides/designing-the-delta-format.
# The specification covers rich-text editing, while we only
# need to work with plain-text, so we use a subset of the specification
# with operations listed in `LiveBook.Delta.Operation`.
#
# Also see https://hexdocs.pm/text_delta/TextDelta.html
# for a complete implementation of the Delta specification.
defstruct ops: []
alias LiveBook.Delta
alias LiveBook.Delta.{Operation, Transformation}
@type t :: %Delta{ops: list(Operation.t())}
@doc """
Creates a new delta, optionally taking a list of operations.
"""
@spec new(list(Operation.t())) :: t()
def new(opts \\ [])
def new([]), do: %Delta{}
def new(ops), do: Enum.reduce(ops, new(), &append(&2, &1))
@doc """
Appends a new `:insert` operation to the given delta.
"""
@spec insert(t(), String.t()) :: t()
def insert(delta, string) do
append(delta, Operation.insert(string))
end
@doc """
Appends a new `:retain` operation to the given delta.
"""
@spec retain(t(), non_neg_integer()) :: t()
def retain(delta, length) do
append(delta, Operation.retain(length))
end
@doc """
Appends a new `:delete` operation to the given delta.
"""
@spec delete(t(), non_neg_integer()) :: t()
def delete(delta, length) do
append(delta, Operation.delete(length))
end
@doc """
Appends an operation to the given delta.
The specification imposes two constraints:
1. Delta must be *compact* - there must be no shorter equivalent delta.
2. Delta must be *canonical* - there is just a single valid representation of the given change.
To satisfy these constraints we follow two rules:
1. Delete followed by insert is swapped to ensure that insert goes first.
2. Operations of the same type are merged.
"""
@spec append(t(), Operation.t()) :: t()
def append(delta, op) do
Map.update!(delta, :ops, fn ops ->
ops
|> Enum.reverse()
|> compact(op)
|> Enum.reverse()
end)
end
defp compact(ops, {:insert, ""}), do: ops
defp compact(ops, {:retain, 0}), do: ops
defp compact(ops, {:delete, 0}), do: ops
defp compact([], new_op), do: [new_op]
defp compact([{:delete, _} = del | ops_remainder], {:insert, _} = ins) do
ops_remainder
|> compact(ins)
|> compact(del)
end
defp compact([{:retain, len_a} | ops_remainder], {:retain, len_b}) do
[Operation.retain(len_a + len_b) | ops_remainder]
end
defp compact([{:insert, str_a} | ops_remainder], {:insert, str_b}) do
[Operation.insert(str_a <> str_b) | ops_remainder]
end
defp compact([{:delete, len_a} | ops_remainder], {:delete, len_b}) do
[Operation.delete(len_a + len_b) | ops_remainder]
end
defp compact(ops, new_op), do: [new_op | ops]
@doc """
Removes trailing retain operations from the given delta.
"""
@spec trim(t()) :: t()
def trim(%Delta{ops: []} = delta), do: delta
def trim(delta) do
case List.last(delta.ops) do
{:retain, _} ->
Map.update!(delta, :ops, fn ops ->
ops |> Enum.reverse() |> tl() |> Enum.reverse()
end)
_ ->
delta
end
end
@doc """
Converts the given delta to a compact representation,
suitable for sending over the network.
## Examples
iex> delta = %LiveBook.Delta{ops: [retain: 2, insert: "hey", delete: 3]}
iex> LiveBook.Delta.to_compressed(delta)
[2, "hey", -3]
"""
@spec to_compressed(t()) :: list(Operation.compressed_t())
def to_compressed(delta) do
Enum.map(delta.ops, &Operation.to_compressed/1)
end
@doc """
Builds a new delta from the given compact representation.
## Examples
iex> LiveBook.Delta.from_compressed([2, "hey", -3])
%LiveBook.Delta{ops: [retain: 2, insert: "hey", delete: 3]}
"""
@spec from_compressed(list(Operation.compressed_t())) :: t()
def from_compressed(list) do
list
|> Enum.map(&Operation.from_compressed/1)
|> new()
end
defdelegate transform(left, right, priority), to: Transformation
end