Otzel (otzel v0.3.2)
View SourceOtzel is an Elixir library for Operational Transformation (OT).
Operational Transformation is a technique for maintaining consistency in collaborative editing systems. When multiple users edit a shared document simultaneously, OT ensures that all users see the same final result regardless of the order in which edits are received.
The Delta Format
Otzel implements the Delta format, representing documents
and changes as lists of operations. A delta is simply a list of Otzel.Op.t/0 operations.
There are three types of operations:
- Insert (
Otzel.Op.Insert) - Adds new content - Retain (
Otzel.Op.Retain) - Keeps existing content (optionally modifying attributes) - Delete (
Otzel.Op.Delete) - Removes content
Documents vs Changes
The same delta format represents both:
- Documents: Deltas consisting only of insert operations
- Changes: Deltas that may include retain and delete operations
Example Usage
# Create a document
doc = [Otzel.insert("Hello World")]
# Create a change that makes "World" bold
change = [Otzel.retain(6), Otzel.retain(5, %{"bold" => true})]
# Apply the change
new_doc = Otzel.compose(doc, change)Core Operations
compose/2- Combine two deltas into onetransform/3- Adjust a delta for concurrent editsinvert/2- Create an undo deltadiff/2- Compute the delta between two documents
Configuration
The default string module can be configured:
config :otzel, :string_module, Otzel.Content.IomemoAvailable string modules:
Otzel.Content.Iomemo(default) - Efficient IO-list based strings with O(1) size lookups and structural sharing for split/concatenate operationsString- Plain Elixir strings (via the BitString protocol implementation), simpler but less efficient for large documents with frequent edits
Summary
Types
Priority determines which concurrent operation "wins" during transformation.
A delta is a list of operations representing a document or change.
Functions
Performs semantic cleanup on a diff, simplifying interleaved delete/insert sequences into cleaner delete-then-insert patterns when the common text is minimal.
Compacts Delta to satisfy compactness requirement.
Composes two deltas into a single delta with the same effect as applying them sequentially.
Concatenates two transformations into one
Creates a Delete operation with the given count.
Computes the difference between two transformations, returning a new transformation that represents the changes needed to convert src into dst.
Both src and dst should be strictly lists of Insert operations.
Parses a delta from JSON format.
Creates an Insert operation with the given content and attributes.
Computes an inverted transformation transformation, that has the opposite effect against a base transformation.
Creates a Retain operation with the given target and attributes.
Takes operations beyond count operations from the list of operations.
Returns the total size of the operations in the transformation. For strings, the size is the number of codepoints in the string.
Returns a slice of operations starting from start with a given count
splits a transformation into two parts at the supplied index
Takes the first count operations from the list of operations.
Transforms a delta against another concurrent delta.
Transforms a cursor/selection index against a delta.
Types
@type priority() :: :left | :right
Priority determines which concurrent operation "wins" during transformation.
:left- The first operation has priority:right- The second operation has priority
@type split_fun() :: (Otzel.Op.t() -> :cont | non_neg_integer())
@type split_fun_ctx() :: (Otzel.Op.t(), context :: term() -> :cont | {:cont, context :: term()} | non_neg_integer())
@type t() :: [Otzel.Op.t()]
A delta is a list of operations representing a document or change.
Documents are deltas containing only insert operations. Changes may contain insert, retain, and delete operations.
Functions
@spec cleanup_semantic(t(), Otzel.Content.t() | nil, list() | nil) :: t()
Performs semantic cleanup on a diff, simplifying interleaved delete/insert sequences into cleaner delete-then-insert patterns when the common text is minimal.
This makes diffs more human-readable by avoiding overly granular changes.
The optional dst_content parameter provides the destination content,
which is needed to extract retained content when converting retains to inserts.
The optional dst_attrs_index parameter provides the destination attributes index,
which maps positions to their attributes for proper attr handling.
Compacts Delta to satisfy compactness requirement.
iex> delta = [Otzel.insert("Hel"), Otzel.insert("lo"), Otzel.insert("World", %{"bold" => true})]
iex> Otzel.compact(delta) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "Hello"}, %{"insert" => "World", "attributes" => %{"bold" => true}}]
Composes two deltas into a single delta with the same effect as applying them sequentially.
Given deltas A and B, compose(A, B) returns a delta C such that applying C to a document
has the same effect as applying A then B.
Examples
iex> doc = [Otzel.insert("Hello")]
iex> change = [Otzel.retain(5), Otzel.insert(" World")]
iex> result = Otzel.compose(doc, change)
iex> result |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "Hello World"}]
iex> a = [Otzel.insert("abc")]
iex> b = [Otzel.retain(1), Otzel.delete(1), Otzel.retain(1)]
iex> Otzel.compose(a, b) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "ac"}]Properties
Compose is associative: compose(compose(a, b), c) == compose(a, compose(b, c))
Concatenates two transformations into one
iex> Otzel.concat([Otzel.insert("Hel")], [Otzel.insert("lo")]) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "Hello"}]
@spec delete(non_neg_integer()) :: Otzel.Op.Delete.t()
Creates a Delete operation with the given count.
Computes the difference between two transformations, returning a new transformation that represents the changes needed to convert src into dst.
Both src and dst should be strictly lists of Insert operations.
Note that the following invariant holds for compact transformation a: a == Otzel.compose(b, Otzel.diff(a, b))
iex> Otzel.diff([Otzel.insert("Hello")], [Otzel.insert("Hello!")])
[Otzel.retain(5), Otzel.insert("!")]
Parses a delta from JSON format.
Accepts a list of operation maps in the Quill Delta JSON format.
Options
:embed_encoder- A{module, function}tuple that will be called with the raw content to convert embedded content back to structs. The function should return the appropriate struct or pass through the content unchanged.
Examples
iex> json = [%{"insert" => "Hello"}, %{"insert" => " World", "attributes" => %{"bold" => true}}]
iex> delta = Otzel.from_json(json)
iex> length(delta)
2
@spec insert(String.t() | Otzel.Content.t(), attrs :: nil | map()) :: Otzel.Op.Insert.t()
Creates an Insert operation with the given content and attributes.
Computes an inverted transformation transformation, that has the opposite effect against a base transformation.
Note that the following invariant holds for base a list of inserts and change a transformation with retains and
deletes fewer than the size of base:
base == compose(compose(base, change), compose(invert(change, base)))
iex> alias Otzel.Op.{Insert, Retain, Delete}
iex> Otzel.invert([%Retain{target: 6, attrs: %{"bold" => true}}, %Delete{count: 5}, %Insert{content: "!"}], [%Insert{content: "Hello\nWorld"}])
[
%Retain{target: 6, attrs: %{"bold" => nil}},
%Insert{content: "World"},
%Delete{count: 1}
]
@spec retain(non_neg_integer() | Otzel.Content.t(), attrs :: nil | map()) :: Otzel.Op.Retain.t()
Creates a Retain operation with the given target and attributes.
Note: if a the target is an embedded Otzel.Content.t/0 then the "retain" operation
signifies that the target should be treated as a delta for the embedded content.
@spec seek(t(), non_neg_integer()) :: t()
Takes operations beyond count operations from the list of operations.
iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.seek([%Insert{content: "Hello"}, %Retain{target: 3}], 3)
[%Insert{content: "lo"}, %Retain{target: 3}]
@spec size(t()) :: non_neg_integer()
Returns the total size of the operations in the transformation. For strings, the size is the number of codepoints in the string.
iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.size([%Insert{content: "Hello"}])
5
iex> Otzel.size([%Insert{content: "Howdy🤠!"}])
7
iex> Otzel.size([%Insert{content: "Hello"}, %Retain{target: 3}])
8
@spec slice(t(), start :: non_neg_integer(), count :: non_neg_integer()) :: t()
Returns a slice of operations starting from start with a given count
iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.slice([%Insert{content: "Hello"}, %Retain{target: 3}], 2, 5)
[%Insert{content: "llo"}, %Retain{target: 2}]
@spec split( t(), non_neg_integer() | split_fun() | {split_fun_ctx(), context :: term()} ) :: {t(), t()}
splits a transformation into two parts at the supplied index
iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.split([%Insert{content: "Hello"}, %Retain{target: 3}], 2)
{[%Insert{content: "He"}], [%Insert{content: "llo"}, %Retain{target: 3}]}
Takes the first count operations from the list of operations.
iex> alias Otzel.Op.{Insert, Retain}
iex> Otzel.take([%Insert{content: "Hello"}, %Retain{target: 3}], 6)
[%Insert{content: "Hello"}, %Retain{target: 1}]
Transforms a delta against another concurrent delta.
When two users make edits concurrently, their deltas need to be transformed against each other to maintain consistency. Given deltas A and B that were created from the same base document:
transform(A, B, :right)returns B' that can be applied after Atransform(B, A, :left)returns A' that can be applied after B
The result satisfies: compose(A, B') == compose(B, A')
Parameters
from- The delta that was applied firstinto- The delta to transformpriority- Which delta wins when both insert at the same position
Examples
iex> a = [Otzel.insert("A")]
iex> b = [Otzel.insert("B")]
iex> Otzel.transform(a, b, :right) |> JSON.encode!() |> JSON.decode!()
[%{"insert" => "B"}]Priority
The priority parameter determines what happens when both deltas insert at the same position:
:right- Theintodelta's insert comes after:left- Theintodelta's insert comes before
@spec transform_index(non_neg_integer(), t(), priority()) :: non_neg_integer()
Transforms a cursor/selection index against a delta.
When a delta is applied to a document, cursor positions need to be adjusted. This function computes where an index should move to after the delta is applied.
Parameters
index- The original cursor positiondelta- The delta being appliedpriority- Whether the cursor should be pushed by inserts at the same position
Examples
iex> Otzel.transform_index(5, [Otzel.insert("abc")], :right)
8
iex> Otzel.transform_index(5, [Otzel.retain(3), Otzel.delete(2)], :right)
3