defmodule Livebook.Text.Delta.Operation do # An operation represents an atomic change applicable to a text. # # For plain-text (our use case) an operation can be either of: # # * `{:insert, string}` - insert the given text at the current # position # # * `{:retain, length}` - preserve the given number of characters # (effectively moving the cursor) # # * `{:delete, number}` - delete the given number of characters # starting from the current position # import Kernel, except: [length: 1] @type t :: insert | retain | delete @type insert :: {:insert, String.t()} @type retain :: {:retain, non_neg_integer()} @type delete :: {:delete, non_neg_integer()} @type compressed_t :: String.t() | non_neg_integer() | neg_integer() @spec insert(String.t()) :: t() def insert(string), do: {:insert, string} @spec retain(non_neg_integer()) :: t() def retain(length), do: {:retain, length} @spec delete(non_neg_integer()) :: t() def delete(length), do: {:delete, length} @doc """ Returns length of text affected by a given operation. """ @spec length(t()) :: non_neg_integer() def length({:insert, string}), do: Livebook.Text.JS.length(string) def length({:retain, length}), do: length def length({:delete, length}), do: length @doc """ Splits the given operation into two at the specified offset. """ @spec split_at(t(), non_neg_integer()) :: {t(), t()} def split_at(op, position) def split_at({:insert, string}, position) do {part_one, part_two} = Livebook.Text.JS.split_at(string, position) {insert(part_one), insert(part_two)} end def split_at({:retain, length}, position) do {retain(position), retain(length - position)} end def split_at({:delete, length}, position) do {delete(position), delete(length - position)} end @doc """ Converts the given operation to a basic type uniquely identifying it. """ @spec to_compressed(t()) :: compressed_t() def to_compressed({:insert, string}), do: string def to_compressed({:retain, length}), do: length def to_compressed({:delete, length}), do: -length @doc """ Converts the given basic type to the corresponding operation. """ @spec from_compressed(compressed_t()) :: t() def from_compressed(string) when is_binary(string), do: {:insert, string} def from_compressed(length) when is_integer(length) and length >= 0, do: {:retain, length} def from_compressed(length) when is_integer(length) and length < 0, do: {:delete, -length} @doc """ Modifies the given operation lists, so that their heads have the same operation length. ## Examples iex> left = [{:insert, "cat"}] iex> right = [{:retain, 2}, {:delete, 2}] iex> Livebook.Text.Delta.Operation.align_heads(left, right) { [{:insert, "ca"}, {:insert, "t"}], [{:retain, 2}, {:delete, 2}] } """ @spec align_heads(list(t()), list(t())) :: {list(t()), list(t())} def align_heads([head_a | tail_a], [head_b | tail_b]) do len_a = length(head_a) len_b = length(head_b) cond do len_a > len_b -> {left_a, right_a} = split_at(head_a, len_b) {[left_a, right_a | tail_a], [head_b | tail_b]} len_a < len_b -> {left_b, right_b} = split_at(head_b, len_a) {[head_a | tail_a], [left_b, right_b | tail_b]} true -> {[head_a | tail_a], [head_b | tail_b]} end end end