Packages

Elixir counter-part for the Quill.js Delta library. It provides a baseline for Operational Transformation of rich text.

Current section

Files

Jump to
text_delta lib operation.ex
Raw

lib/operation.ex

defmodule TextDelta.Operation do
@moduledoc """
Operations represent a smallest possible change applicable to the document.
In case of text, there are exactly 3 possible operations we might want to
perform:
- `insert`: insert a new piece of text or an embedded element
- `retain`: preserve given number of characters in sequence
- `delete`: delete given number of characters in sequence
Insert and retain operations can also have optional attributes attached to
them. This is how Delta manages rich text formatting without breaking the
[Operational Transformation][ot] paradigm.
[ot]: https://en.wikipedia.org/wiki/Operational_transformation
"""
alias TextDelta.Attributes
@typedoc """
Insert operation represents an intention to add a text or an
embedded element to a document. Text additions are represented with binary
strings and embedded elements are represented with either an integer or an
object.
Insert also allows us to attach arbitrary number of attributes to the element
being inserted.
"""
@type insert :: %{insert: element}
| %{insert: element, attributes: Attributes.t}
@typedoc """
Retain operation represents an intention to keep a sequence of characters
unchanged in the document. It is always a number and it is always positive.
In addition to indicating preservation of existing text, retain also allows us
to change formatting of retained text or element by providing optional
attributes.
"""
@type retain :: %{retain: non_neg_integer}
| %{retain: non_neg_integer, attributes: Attributes.t}
@typedoc """
Delete operation represents an intention to delete a sequence of characters
from the document. It is always a number and it is always positive.
"""
@type delete :: %{delete: non_neg_integer}
@typedoc """
An operation. Either insert, retain or delete.
"""
@type t :: insert | retain | delete
@typedoc """
Atom representing type of operation.
"""
@type type :: :insert | :retain | :delete
@typedoc """
The result of comparison operation.
"""
@type comparison :: :eq | :gt | :lt
@typedoc """
An insertable into text element. Either a piece of text or embed.
"""
@type element :: String.t | integer | map
@doc """
Creates a new insert operation.
Attributes are optional and are ignored if empty map or nil is provided.
## Examples
To indicate that we need to insert a text "hello" into the document, we can
use following insert:
iex> TextDelta.Operation.insert("hello")
%{insert: "hello"}
In addition, we can indicate that "hello" should be inserted with specific
attributes:
iex> TextDelta.Operation.insert("hello", %{bold: true, color: "magenta"})
%{insert: "hello", attributes: %{bold: true, color: "magenta"}}
We can also insert non-text objects, such as an image:
iex> TextDelta.Operation.insert(%{img: "me.png"}, %{alt: "My photo"})
%{insert: %{img: "me.png"}, attributes: %{alt: "My photo"}}
"""
@spec insert(element, Attributes.t) :: insert
def insert(el, attrs \\ %{})
def insert(el, nil), do: %{insert: el}
def insert(el, attrs) when map_size(attrs) == 0, do: %{insert: el}
def insert(el, attrs), do: %{insert: el, attributes: attrs}
@doc """
Creates a new retain operation.
Attributes are optional and are ignored if empty map or nil is provided.
## Examples
To keep 5 next characters inside the text, we can create a following retain:
iex> TextDelta.Operation.retain(5)
%{retain: 5}
To make those exact 5 characters bold while keeping them, we can use
attributes:
iex> TextDelta.Operation.retain(5, %{bold: true})
%{retain: 5, attributes: %{bold: true}}
"""
@spec retain(non_neg_integer, Attributes.t) :: retain
def retain(len, attrs \\ %{})
def retain(len, nil), do: %{retain: len}
def retain(len, attrs) when map_size(attrs) == 0, do: %{retain: len}
def retain(len, attrs), do: %{retain: len, attributes: attrs}
@doc """
Creates a new delete operation.
## Examples
To delete 3 next characters from the text, we can create a following
operation:
iex> TextDelta.Operation.delete(3)
%{delete: 3}
"""
@spec delete(non_neg_integer) :: delete
def delete(len)
def delete(len), do: %{delete: len}
@doc """
Returns atom representing type of the given operation.
## Examples
iex> TextDelta.Operation.type(%{retain: 5, attributes: %{bold: true}})
:retain
"""
@spec type(t) :: type
def type(op)
def type(%{insert: _}), do: :insert
def type(%{retain: _}), do: :retain
def type(%{delete: _}), do: :delete
@doc """
Returns length of text affected by a given operation.
Length for insert operations is calculated by counting the length of text
itself being inserted, length for retain and delete operations is a length of
sequence itself. Attributes have no effect over the length.
## Examples
For text inserts it is a length of text itself:
iex> TextDelta.Operation.length(%{insert: "hello!"})
6
For embed inserts, however, length is always 1:
iex> TextDelta.Operation.length(%{insert: 3})
1
For retain and deletes, the number itself is the length:
iex> TextDelta.Operation.length(%{retain: 4})
4
"""
@spec length(t) :: non_neg_integer
def length(op)
def length(%{insert: el}) when not is_bitstring(el), do: 1
def length(%{insert: str}), do: String.length(str)
def length(%{retain: len}), do: len
def length(%{delete: len}), do: len
@doc """
Compares the length of two operations.
## Examples
iex> TextDelta.Operation.compare(%{insert: "hello!"}, %{delete: 3})
:gt
"""
@spec compare(t, t) :: comparison
def compare(op_a, op_b) do
len_a = __MODULE__.length(op_a)
len_b = __MODULE__.length(op_b)
cond do
len_a > len_b -> :gt
len_a < len_b -> :lt
true -> :eq
end
end
@doc """
Splits operations into two halves around the given index.
Text inserts are split via slicing the text itself, retains and deletes are
sliced split by subtracting the sequence number. Attributes are preserved
during splitting. This is mostly used for normalisation and simplification of
iteration over deltas of operations.
## Examples
Text inserts are split via slicing the text itself:
iex> TextDelta.Operation.slice(%{insert: "hello"}, 3)
{%{insert: "hel"}, %{insert: "lo"}}
Retains and deletes are split by subtracting the sequence number itself:
iex> TextDelta.Operation.slice(%{retain: 5}, 2)
{%{retain: 2}, %{retain: 3}}
"""
@spec slice(t, non_neg_integer) :: {t, t}
def slice(op, idx)
def slice(%{insert: str} = op, idx) when is_bitstring(str) do
{Map.put(op, :insert, String.slice(str, 0, idx)),
Map.put(op, :insert, String.slice(str, idx..-1))}
end
def slice(%{insert: _} = op, _) do
{op, %{insert: ""}}
end
def slice(%{retain: op_len} = op, idx) do
{Map.put(op, :retain, idx),
Map.put(op, :retain, op_len - idx)}
end
def slice(%{delete: op_len} = op, idx) do
{Map.put(op, :delete, idx),
Map.put(op, :delete, op_len - idx)}
end
@doc """
Attempts to compact two given operations into one.
If successful, will return a list with just a single, compacted operation. In
any other case both operations will be returned back unchanged.
Compacting works by combining same operations with the same attributes
together. Easiest way to think about this function is that it produces an
exact opposite effect of slice function.
Text inserts are compacted by concatenating strings, retain and deletes are
compacted by adding their sequence numbers. Only operations with the same
attribute set are compacted. This is mostly used for composition,
transformation and other delta operations to keep deltas short and canonical.
## Examples
Text inserts are compacted into a single insert:
iex> TextDelta.Operation.compact(%{insert: "hel"}, %{insert: "lo"})
[%{insert: "hello"}]
Retains and deletes are compacted by adding their sequence numbers:
iex> TextDelta.Operation.compact(%{retain: 2}, %{retain: 3})
[%{retain: 5}]
"""
@spec compact(t, t) :: [t]
def compact(op_a, op_b)
def compact(%{retain: len_a, attributes: attrs_a},
%{retain: len_b, attributes: attrs_b})
when attrs_a == attrs_b do
[retain(len_a + len_b, attrs_a)]
end
def compact(%{retain: len_a} = a,
%{retain: len_b} = b)
when map_size(a) == 1 and map_size(b) == 1 do
[retain(len_a + len_b)]
end
def compact(%{insert: el_a} = op_a,
%{insert: _} = op_b)
when not is_bitstring(el_a) do
[op_a, op_b]
end
def compact(%{insert: _} = op_a,
%{insert: el_b} = op_b)
when not is_bitstring(el_b) do
[op_a, op_b]
end
def compact(%{insert: str_a, attributes: attrs_a},
%{insert: str_b, attributes: attrs_b})
when attrs_a == attrs_b do
[insert(str_a <> str_b, attrs_a)]
end
def compact(%{insert: str_a} = op_a,
%{insert: str_b} = op_b)
when map_size(op_a) == 1 and map_size(op_b) == 1 do
[insert(str_a <> str_b)]
end
def compact(%{delete: len_a}, %{delete: len_b}) do
[delete(len_a + len_b)]
end
def compact(op_a, op_b), do: [op_a, op_b]
@doc """
Checks if given operation is trimmable.
Technically only retains are trimmable, but the creator of this library didn't
feel comfortable exposing that knowledge outside of this module.
## Examples
iex> TextDelta.Operation.trimmable?(%{retain: 3})
true
"""
@spec trimmable?(t) :: boolean
def trimmable?(op) do
Map.has_key?(op, :retain) and !Map.has_key?(op, :attributes)
end
end