├── .formatter.exs
├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .iex.exs
├── .tool-versions
├── CHANGELOG.md
├── LICENSE
├── README.md
├── lib
├── delta.ex
└── delta
│ ├── attr.ex
│ ├── embed_handler.ex
│ ├── op.ex
│ └── utils.ex
├── mix.exs
├── mix.lock
└── test
├── delta
├── attr_test.exs
├── delta
│ ├── compose_test.exs
│ ├── diff_test.exs
│ ├── invert_test.exs
│ ├── transform_position_test.exs
│ └── transform_test.exs
├── delta_test.exs
└── op_test.exs
├── support
├── case.ex
├── quote_embed.ex
└── test_embed.ex
└── test_helper.exs
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: [".formatter.exs", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [ master ]
6 | pull_request:
7 | branches: [ master ]
8 |
9 | jobs:
10 | build:
11 | name: Build and test
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | include:
17 | - elixir: '1.10'
18 | otp: '22.0'
19 | - elixir: '1.11'
20 | otp: '23.0'
21 | - elixir: '1.12'
22 | otp: '23.0'
23 |
24 | steps:
25 | - uses: actions/checkout@v2
26 | - name: Set up Elixir
27 | uses: erlef/setup-beam@v1
28 | with:
29 | otp-version: ${{matrix.otp}}
30 | elixir-version: ${{matrix.elixir}}
31 |
32 | - name: Restore dependencies cache
33 | uses: actions/cache@v2
34 | with:
35 | path: deps
36 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
37 | restore-keys: ${{ runner.os }}-mix-
38 |
39 | - run: mix deps.get
40 | - run: mix test
41 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # The directory Mix will write compiled artifacts to.
2 | /_build/
3 |
4 | # If you run "mix test --cover", coverage assets end up here.
5 | /cover/
6 |
7 | # The directory Mix downloads your dependencies sources to.
8 | /deps/
9 |
10 | # Where third-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | delta-*.tar
24 |
25 |
--------------------------------------------------------------------------------
/.iex.exs:
--------------------------------------------------------------------------------
1 | alias Delta.{
2 | Op,
3 | Attr,
4 | EmbedHandler
5 | }
6 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.13.4-otp-24
2 | erlang 24.3.4.2
3 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## v0.4.1 (2024-11-28)
4 |
5 | ### Maintenances
6 | * Address Elixir 1.17 warnings
7 |
8 | ## v0.4.0 (2024-04-22)
9 |
10 | ### Enhancements
11 | * Add `Delta.diff/2`
12 | * Add `Delta.Attr.diff/2`
13 | * Add `c:Delta.EmbedHandler.diff/2`
14 |
15 | ### Bug Fixes
16 | * Fix `Delta.split/2` when index goes beyond end of delta #12
17 | * Fix `Delta.split/2` when splitter returns 0 index #13
18 |
19 | ## v0.3.0 (2022-09-19)
20 |
21 | ### Enhancements
22 | * Add support for attributes in delete operations
23 |
24 | ## v0.2.0 (2022-05-30)
25 |
26 | ### Enhancements
27 | * Add `Delta.slice_max/3`
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The 3-Clause BSD License (BSD-3-Clause)
2 |
3 | Copyright (c) 2022 Slab, Inc.
4 |
5 | Redistribution and use in source and binary forms, with or without
6 | modification, are permitted provided that the following conditions
7 | are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright
10 | notice, this list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright
13 | notice, this list of conditions and the following disclaimer in the
14 | documentation and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
21 | IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
22 | TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
23 | PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
24 | HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Delta
2 |
3 | [![Build Status][badge-github]][github-build]
4 | [![Version][badge-version]][hexpm]
5 | [![Downloads][badge-downloads]][hexpm]
6 | [![License][badge-license]][github-license]
7 |
8 | > Simple yet expressive format to describe documents' contents and changes 🗃
9 |
10 | Deltas are a simple, yet expressive format that can be used to describe contents and changes.
11 | The format is a strict subset of JSON, is human readable, and easily parsible by machines.
12 | Deltas can describe any rich-text document, includes all text and formatting information,
13 | without the ambiguity and complexity of HTML.
14 |
15 | The Delta format is suitable for [Operational Transform][wiki-ot] and can be used in real-time,
16 | collaborative document editors (e.g. Slab, Google Docs). A walkthough of the motivation and
17 | design thinking behind Deltas are on [Designing the Delta Format][quill-delta].
18 |
19 | See the [Documentation][docs].
20 |
21 |
22 |
23 | ## Installation
24 |
25 | Add `delta` to your project dependencies in `mix.exs`:
26 |
27 | ```elixir
28 | def deps do
29 | [{:delta, "~> 0.4.1"}]
30 | end
31 | ```
32 |
33 |
34 |
35 | ## Usage
36 |
37 | A Delta is made up of a list of operations, which describe changes to a document. These can be
38 | `insert`, `delete` or `retain`. These operations do not take an index, but instead describe the
39 | change at the current index. Retains are used to "keep" parts of the document.
40 |
41 | ### Quick Example
42 |
43 | ```elixir
44 | alias Delta.Op
45 |
46 | # Document with text "Gandalf the Grey", with "Gandalf" bolded
47 | # and "Grey" in grey
48 | delta = [
49 | Op.insert("Gandalf", %{"bold" => true}),
50 | Op.insert(" the "),
51 | Op.insert("Grey", %{"color" => "#ccc"}),
52 | ]
53 |
54 | # Define change intended to be applied to above:
55 | # Keep the first 12 characters, delete the next 4,
56 | # and insert a white "White"
57 | death = [
58 | Op.retain(12),
59 | Op.delete(4),
60 | Op.insert("White", %{"color" => "#fff"}),
61 | ]
62 |
63 |
64 | # Applying the change:
65 | Delta.compose(delta, death)
66 | # => [
67 | # %{"insert" => "Gandalf", "attributes" => %{"bold" => true}},
68 | # %{"insert" => " the "},
69 | # %{"insert" => "White", "attributes" => %{"color" => "#fff"}},
70 | # ]
71 | ```
72 |
73 |
74 |
75 | ## Operations
76 |
77 | ### Insert
78 |
79 | Insert operations have an `insert` key defined. A String value represents inserting text. Any
80 | other type represents inserting an embed (however only one level of object comparison will be
81 | performed for equality).
82 |
83 | In both cases of text and embeds, an optional `attributes` key can be defined with a `map` to
84 | describe additonal formatting information. Formats can be changed by the `retain` operation.
85 |
86 | ```elixir
87 | # Insert a text
88 | Op.insert("Some Text")
89 |
90 | # Insert a bolded text
91 | Op.insert("Bolded Text", %{"bold" => true})
92 |
93 | # Insert a link
94 | Op.insert("Google", %{"link" => "https://google.com"})
95 |
96 | # Insert an embed
97 | Op.insert(%{"image" => "https://app.com/logo.png"}, %{"alt" => "App Logo"})
98 |
99 | # Insert another embed
100 | Op.insert(%{"video" => "https://www.youtube.com/watch?v=dQw4w9WgXcQ"}, %{"width" => 420, "height" => 315})
101 | ```
102 |
103 | ### Delete
104 |
105 | Delete operations have a positive integer `delete` key defined representing the number of
106 | characters to delete. All embeds have a length of 1.
107 |
108 | ```elixir
109 | # Delete the next 10 characters
110 | Op.delete(10)
111 | ```
112 |
113 | ### Retain
114 |
115 | Retain operations have a positive integer `retain` key defined representing the number of
116 | characters to keep (other libraries might use the name keep or skip). An optional `attributes`
117 | key can be defined with a `map` to describe formatting changes to the character range. A
118 | value of `nil` in the attributes map represents removal of that key.
119 |
120 | Note: It is not necessary to retain the last characters of a document as this is implied.
121 |
122 | ```elixir
123 | # Keep the next 5 characters
124 | Op.retain(5)
125 |
126 | # Keep and bold the next 5 characters
127 | Op.retain(5, %{"bold" => true})
128 |
129 | # Keep and unbold the next 5 characters
130 | Op.retain(5, %{"bold" => nil})
131 | ```
132 |
133 |
134 |
135 | ## Operational Transform
136 |
137 | Operational Transform (OT) is a technology for building collabortive experiences, and is
138 | especially useful in application sharing and building real-time document editors that support
139 | multi-user collaboration (e.g. Google Docs, Slab).
140 |
141 | Delta supports OT out of the box and can be very useful in employing Operational Transform
142 | techniques in Elixir. It supports the following properties:
143 |
144 | ### Compose
145 |
146 | Returns a new Delta that is equivalent to applying the operations of one Delta, followed
147 | by another Delta:
148 |
149 | ```elixir
150 | a = [Op.insert("abc")]
151 | b = [Op.retain(1), Op.delete(1)]
152 |
153 | Delta.compose(a, b)
154 | # => [%{"insert" => "ac"}]
155 | ```
156 |
157 | ### Transform
158 |
159 | Transforms given delta against another's operations. This accepts an optional `priority`
160 | argument (default: `false`), used to break ties. If `true`, the first delta takes priority
161 | over other, that is, its actions are considered to happen "first."
162 |
163 | ```elixir
164 | a = [Op.insert("a")]
165 | b = [Op.insert("b"), Op.retain(5), Op.insert("c")]
166 |
167 | Delta.transform(a, b, true)
168 | # => [
169 | # %{"retain" => 1},
170 | # %{"insert" => "b"},
171 | # %{"retain" => 5},
172 | # %{"insert" => "c"},
173 | # ]
174 |
175 | Delta.transform(a, b)
176 | # => [
177 | # %{"insert" => "b"},
178 | # %{"retain" => 6},
179 | # %{"insert" => "c"},
180 | # ]
181 | ```
182 |
183 | Note that even though `delete` operations support attributes, it is only safe to
184 | use `transform` with deletes that don't have them.
185 |
186 | ### Invert
187 |
188 | Returns an inverted delta that has the opposite effect of against a base document delta.
189 | That is `base |> Delta.compose(change) |> Delta.compose(inverted) == base`.
190 |
191 | ```elixir
192 | base = [Op.insert("Hello\nWorld")]
193 |
194 | change = [
195 | Op.retain(6, %{"bold" => true}),
196 | Op.delete(5),
197 | Op.insert("!"),
198 | ]
199 |
200 | inverted = Delta.invert(change, base)
201 | # => [
202 | # %{"retain" => 6, "attributes" => %{"bold" => nil}},
203 | # %{"insert" => "World"},
204 | # %{"delete" => 1},
205 | # ]
206 |
207 | base |> Delta.compose(change) |> Delta.compose(inverted) == base
208 | # => true
209 | ```
210 |
211 |
212 |
213 | ## Contributing
214 |
215 | - [Fork][github-fork], Enhance, Send PR
216 | - Lock issues with any bugs or feature requests
217 | - Implement something from Roadmap
218 | - Spread the word :heart:
219 |
220 |
221 |
222 | [badge-github]: https://github.com/slab/delta-elixir/actions/workflows/ci.yml/badge.svg
223 | [badge-version]: https://img.shields.io/hexpm/v/delta.svg
224 | [badge-license]: https://img.shields.io/hexpm/l/delta.svg
225 | [badge-downloads]: https://img.shields.io/hexpm/dt/delta.svg
226 | [hexpm]: https://hex.pm/packages/delta
227 | [github-build]: https://github.com/slab/delta-elixir/actions/workflows/ci.yml
228 | [github-license]: https://github.com/slab/delta-elixir/blob/master/LICENSE
229 | [github-fork]: https://github.com/slab/delta-elixir/fork
230 | [docs]: https://hexdocs.pm/delta
231 | [wiki-ot]: https://en.wikipedia.org/wiki/Operational_transformation
232 | [quill-delta]: https://quilljs.com/guides/designing-the-delta-format/
233 |
--------------------------------------------------------------------------------
/lib/delta.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta do
2 | alias Delta.{Attr, Op}
3 |
4 | @type t :: list(Op.t())
5 |
6 | @spec get_handler(atom) :: module | nil
7 | def get_handler(embed_type) do
8 | :delta
9 | |> Application.get_env(:custom_embeds, [])
10 | |> Enum.find(&(&1.name() == embed_type))
11 | end
12 |
13 | @spec get_handler!(atom) :: module
14 | def get_handler!(embed_type) do
15 | case get_handler(embed_type) do
16 | nil -> raise("no embed handler configured for #{inspect(embed_type)}")
17 | handler -> handler
18 | end
19 | end
20 |
21 | @doc ~S"""
22 | Returns a new Delta that is equivalent to applying the operations of one Delta, followed by another Delta
23 |
24 | ## Examples
25 | iex> a = [Op.insert("abc")]
26 | iex> b = [Op.retain(1), Op.delete(1)]
27 | iex> Delta.compose(a, b)
28 | [%{"insert" => "ac"}]
29 | """
30 | @spec compose(t, t) :: t
31 | def compose(left, right) do
32 | [] |> do_compose(left, right) |> chop() |> Enum.reverse()
33 | end
34 |
35 | @doc ~S"""
36 | Returns a new Delta that is equivalent to applying Deltas one by one
37 |
38 | ## Examples
39 | iex> a = [Op.insert("ac")]
40 | iex> b = [Op.retain(1), Op.insert("b")]
41 | iex> c = [Op.delete(1)]
42 | iex> Delta.compose_all([a, b, c])
43 | [%{"insert" => "bc"}]
44 | """
45 | @spec compose_all([t]) :: t
46 | def compose_all(deltas) do
47 | Enum.reduce(deltas, [], &compose(&2, &1))
48 | end
49 |
50 | defp do_compose(result, [], []), do: result
51 |
52 | defp do_compose(result, [], [op | delta]) do
53 | Enum.reverse(delta, push(result, op))
54 | end
55 |
56 | defp do_compose(result, [op | delta], []) do
57 | Enum.reverse(delta, push(result, op))
58 | end
59 |
60 | defp do_compose(result, [op1 | delta1], [op2 | delta2]) do
61 | {op, delta1, delta2} =
62 | cond do
63 | Op.insert?(op2) ->
64 | {op2, [op1 | delta1], delta2}
65 |
66 | Op.delete?(op1) ->
67 | {op1, delta1, [op2 | delta2]}
68 |
69 | true ->
70 | {composed, op1, op2} = Op.compose(op1, op2)
71 | delta1 = push(delta1, op1)
72 | delta2 = push(delta2, op2)
73 | {composed, delta1, delta2}
74 | end
75 |
76 | result
77 | |> push(op)
78 | |> do_compose(delta1, delta2)
79 | end
80 |
81 | @spec chop(t) :: t
82 | defp chop([%{"retain" => n} = op | delta]) when is_number(n) and map_size(op) == 1, do: delta
83 | defp chop(delta), do: delta
84 |
85 | @doc ~S"""
86 | Compacts Delta to satisfy [compactness](https://quilljs.com/guides/designing-the-delta-format/#compact) requirement.
87 |
88 | ## Examples
89 | iex> delta = [Op.insert("Hel"), Op.insert("lo"), Op.insert("World", %{"bold" => true})]
90 | iex> Delta.compact(delta)
91 | [%{"insert" => "Hello"}, %{"insert" => "World", "attributes" => %{"bold" => true}}]
92 | """
93 | @spec compact(t) :: t
94 | def compact(delta) do
95 | delta
96 | |> Enum.reduce([], &push(&2, &1))
97 | |> Enum.reverse()
98 | end
99 |
100 | @doc ~S"""
101 | Concatenates two Deltas.
102 |
103 | ## Examples
104 | iex> a = [Op.insert("Hel")]
105 | iex> b = [Op.insert("lo")]
106 | iex> Delta.concat(a, b)
107 | [%{"insert" => "Hello"}]
108 | """
109 | @spec concat(t, t) :: t
110 | def concat(left, []), do: left
111 | def concat([], right), do: right
112 |
113 | def concat(left, [first | right]) do
114 | left =
115 | left
116 | |> Enum.reverse()
117 | |> push(first)
118 | |> Enum.reverse()
119 |
120 | left ++ right
121 | end
122 |
123 | @doc ~S"""
124 | Pushes an operation to a reversed Delta honouring semantics.
125 |
126 | Note: that reversed delta does not represent a reversed text, but rather a list
127 | of operations that was naively reversed during programmatic manipulations.
128 | This function is normally only used by other functions which reverse the list
129 | back in the end.
130 |
131 | ## Examples
132 | iex> delta = [Op.insert("World", %{"italic" => true}), Op.insert("Hello", %{"bold" => true})]
133 | iex> op = Op.insert("!")
134 | iex> Delta.push(delta, op)
135 | [%{"insert" => "!"}, %{"insert" => "World", "attributes" => %{"italic" => true}}, %{"insert" => "Hello", "attributes" => %{"bold" => true}}]
136 |
137 | iex> delta = [Op.insert("World"), Op.insert("Hello", %{"bold" => true})]
138 | iex> op = Op.insert("!")
139 | iex> Delta.push(delta, op)
140 | [%{"insert" => "World!"}, %{"insert" => "Hello", "attributes" => %{"bold" => true}}]
141 | """
142 | @spec push(t, false) :: t
143 | @spec push(t, Op.t()) :: t
144 | def push(delta, false), do: delta
145 |
146 | def push([], op) do
147 | if Op.size(op) > 0, do: [op], else: []
148 | end
149 |
150 | # Adds op to the beginning of delta (we expect a reverse)
151 | # TODO: Handle inserts after delete (should move insert before delete)
152 | def push(delta, op) do
153 | [last_op | partial_delta] = delta
154 | merged_op = do_push(last_op, op)
155 |
156 | if is_nil(merged_op) do
157 | [op | delta]
158 | else
159 | [merged_op | partial_delta]
160 | end
161 | end
162 |
163 | defp do_push(op, %{"delete" => 0}), do: op
164 | defp do_push(op, %{"insert" => ""}), do: op
165 | defp do_push(op, %{"retain" => 0}), do: op
166 |
167 | defp do_push(%{"delete" => left, "attributes" => attr}, %{
168 | "delete" => right,
169 | "attributes" => attr
170 | }) do
171 | Op.delete(left + right, attr)
172 | end
173 |
174 | defp do_push(%{"delete" => left} = last_op, %{"delete" => right} = op)
175 | when map_size(last_op) == 1 and map_size(op) == 1 do
176 | Op.delete(left + right)
177 | end
178 |
179 | defp do_push(%{"retain" => left, "attributes" => attr}, %{
180 | "retain" => right,
181 | "attributes" => attr
182 | })
183 | when is_integer(left) and is_integer(right) do
184 | Op.retain(left + right, attr)
185 | end
186 |
187 | defp do_push(%{"retain" => left} = last_op, %{"retain" => right} = op)
188 | when map_size(last_op) == 1 and map_size(op) == 1 and
189 | is_integer(left) and is_integer(right) do
190 | Op.retain(left + right)
191 | end
192 |
193 | defp do_push(%{"insert" => left, "attributes" => attr}, %{
194 | "insert" => right,
195 | "attributes" => attr
196 | })
197 | when is_bitstring(left) and is_bitstring(right) do
198 | Op.insert(left <> right, attr)
199 | end
200 |
201 | defp do_push(%{"insert" => left} = last_op, %{"insert" => right} = op)
202 | when is_bitstring(left) and is_bitstring(right) and map_size(last_op) == 1 and
203 | map_size(op) == 1 do
204 | Op.insert(left <> right)
205 | end
206 |
207 | defp do_push(_, _), do: nil
208 |
209 | @doc ~S"""
210 | Returns the size of delta.
211 |
212 | ## Examples
213 | iex> delta = [Op.insert("abc"), Op.retain(2), Op.delete(1)]
214 | iex> Delta.size(delta)
215 | 6
216 | """
217 | @spec size(t) :: non_neg_integer
218 | def size(delta) do
219 | Enum.reduce(delta, 0, fn op, sum ->
220 | sum + Op.size(op)
221 | end)
222 | end
223 |
224 | @doc ~S"""
225 | Attempts to take `len` characters starting from `index`.
226 |
227 | Note: note that due to the way it's implemented this operation can potentially
228 | raise if the resulting text isn't a valid UTF-8 encoded string
229 |
230 | ## Examples
231 | iex> delta = [Op.insert("Hello World")]
232 | iex> Delta.slice(delta, 6, 3)
233 | [%{"insert" => "Wor"}]
234 |
235 | iex> delta = [Op.insert("01🙋45")]
236 | iex> Delta.slice(delta, 1, 2)
237 | ** (RuntimeError) Encoding failed in take_partial {"1🙋45", %{"insert" => "1🙋45"}, 2, {:incomplete, "1", <<216, 61>>}, {:error, "", <<222, 75, 0, 52, 0, 53>>}}
238 | """
239 | @spec slice(t, non_neg_integer, non_neg_integer) :: t
240 | def slice(delta, index, len) do
241 | {_left, right} = split(delta, index)
242 | {middle, _rest} = split(right, len)
243 | middle
244 | end
245 |
246 | @doc ~S"""
247 | Takes `len` or fewer characters from `index` position. Variable `len` allows
248 | to not cut things like emojis in half.
249 |
250 | ## Examples
251 | iex> delta = [Op.insert("Hello World")]
252 | iex> Delta.slice_max(delta, 6, 3)
253 | [%{"insert" => "Wor"}]
254 |
255 | iex> delta = [Op.insert("01🙋45")]
256 | iex> Delta.slice_max(delta, 1, 2)
257 | [%{"insert" => "1"}]
258 | """
259 | @doc since: "0.2.0"
260 | @spec slice_max(t, non_neg_integer, non_neg_integer) :: t
261 | def slice_max(delta, index, len) do
262 | {_left, right} = split(delta, index, align: true)
263 | {middle, _rest} = split(right, len, align: true)
264 | middle
265 | end
266 |
267 | @doc ~S"""
268 | Splits delta at the given index.
269 |
270 | ## Options
271 |
272 | * `:align` - when `true`, allow moving index left if
273 | we're likely to split a grapheme otherwise.
274 |
275 | ## Examples
276 | iex> delta = [Op.insert("Hello World")]
277 | iex> Delta.split(delta, 5)
278 | {[%{"insert" => "Hello"}], [%{"insert" => " World"}]}
279 |
280 | iex> delta = [Op.insert("01🙋45")]
281 | iex> Delta.split(delta, 3, align: true)
282 | {[%{"insert" => "01"}], [%{"insert" => "🙋45"}]}
283 |
284 | iex> delta = [Op.insert("a"), Op.insert("b", %{"bold" => true})]
285 | iex> Delta.split(delta, 3)
286 | {[%{"insert" => "a"}, %{"insert" => "b", "attributes" => %{"bold" => true}}], []}
287 | """
288 | @spec split(t, non_neg_integer | fun, Keyword.t()) :: {t, t}
289 | def split(delta, index, opts \\ [])
290 |
291 | def split(delta, 0, _), do: {[], delta}
292 |
293 | def split(delta, index, opts) when is_integer(index) do
294 | do_split(
295 | [],
296 | delta,
297 | fn op, index ->
298 | op_size = Op.size(op)
299 |
300 | if index <= op_size do
301 | index
302 | else
303 | {:cont, index - op_size}
304 | end
305 | end,
306 | index,
307 | opts
308 | )
309 | end
310 |
311 | def split(delta, func, opts) when is_function(func) do
312 | do_split([], delta, func, nil, opts)
313 | end
314 |
315 | defp do_split(passed, [], _, _, _), do: {Enum.reverse(passed), []}
316 |
317 | defp do_split(passed, remaining, func, context, opts) when is_function(func, 1) do
318 | do_split(passed, remaining, fn op, _ -> func.(op) end, context, opts)
319 | end
320 |
321 | defp do_split(passed, remaining, func, context, opts) when is_function(func, 2) do
322 | [first | remaining] = remaining
323 |
324 | case func.(first, context) do
325 | :cont ->
326 | do_split([first | passed], remaining, func, context, opts)
327 |
328 | {:cont, context} ->
329 | do_split([first | passed], remaining, func, context, opts)
330 |
331 | 0 ->
332 | {Enum.reverse(passed), [first | remaining]}
333 |
334 | index ->
335 | case Op.take(first, index, opts) do
336 | {left, false} ->
337 | {Enum.reverse([left | passed]), remaining}
338 |
339 | {left, right} ->
340 | {Enum.reverse([left | passed]), [right | remaining]}
341 | end
342 | end
343 | end
344 |
345 | @doc ~S"""
346 | Transforms given delta against another's operations.
347 |
348 | This accepts an optional priority argument (default: false), used to break ties.
349 | If true, the first delta takes priority over other, that is, its actions are considered to happen "first."
350 |
351 | ## Examples
352 | iex> a = [Op.insert("a")]
353 | iex> b = [Op.insert("b"), Op.retain(5), Op.insert("c")]
354 | iex> Delta.transform(a, b, true)
355 | [
356 | %{"retain" => 1},
357 | %{"insert" => "b"},
358 | %{"retain" => 5},
359 | %{"insert" => "c"},
360 | ]
361 | iex> Delta.transform(a, b)
362 | [
363 | %{"insert" => "b"},
364 | %{"retain" => 6},
365 | %{"insert" => "c"},
366 | ]
367 | """
368 | @spec transform(t, t, boolean) :: t
369 | def transform(_, _, priority \\ false)
370 |
371 | def transform(index, delta, priority) when is_integer(index) do
372 | do_transform(0, index, delta, priority)
373 | end
374 |
375 | def transform(left, right, priority) do
376 | delta = do_transform([], left, right, priority)
377 | delta |> chop() |> Enum.reverse()
378 | end
379 |
380 | defp do_transform(offset, index, _, _) when is_integer(index) and offset > index, do: index
381 | defp do_transform(_, index, [], _) when is_integer(index), do: index
382 |
383 | defp do_transform(offset, index, [%{"delete" => length} | delta], priority)
384 | when is_integer(index) do
385 | do_transform(offset, index - min(length, index - offset), delta, priority)
386 | end
387 |
388 | defp do_transform(offset, index, [op | delta], priority) when is_integer(index) do
389 | {offset, index} = Op.transform(offset, index, op, priority)
390 | do_transform(offset, index, delta, priority)
391 | end
392 |
393 | defp do_transform(result, [], [], _), do: result
394 |
395 | defp do_transform(result, [], [op | delta], priority) do
396 | do_transform(result, [Op.retain(Op.size(op))], [op | delta], priority)
397 | end
398 |
399 | defp do_transform(result, [op | delta], [], priority) do
400 | do_transform(result, [op | delta], [Op.retain(Op.size(op))], priority)
401 | end
402 |
403 | defp do_transform(result, [op1 | delta1], [op2 | delta2], priority) do
404 | {op, delta1, delta2} =
405 | cond do
406 | Op.insert?(op1) and (priority or not Op.insert?(op2)) ->
407 | {Op.retain(Op.size(op1)), delta1, [op2 | delta2]}
408 |
409 | Op.insert?(op2) ->
410 | {op2, [op1 | delta1], delta2}
411 |
412 | true ->
413 | {transformed, op1, op2} = Op.transform(op1, op2, priority)
414 | delta1 = push(delta1, op1)
415 | delta2 = push(delta2, op2)
416 | {transformed, delta1, delta2}
417 | end
418 |
419 | result
420 | |> push(op)
421 | |> do_transform(delta1, delta2, priority)
422 | end
423 |
424 | @doc ~S"""
425 | Returns an inverted delta that has the opposite effect of against a base document delta.
426 |
427 | That is base |> Delta.compose(change) |> Delta.compose(inverted) == base.
428 |
429 | ## Examples
430 | iex> base = [Op.insert("Hello\nWorld")]
431 | iex> change = [
432 | ...> Op.retain(6, %{"bold" => true}),
433 | ...> Op.delete(5),
434 | ...> Op.insert("!"),
435 | ...> ]
436 | iex> inverted = Delta.invert(change, base)
437 | [
438 | %{"retain" => 6, "attributes" => %{"bold" => nil}},
439 | %{"insert" => "World"},
440 | %{"delete" => 1},
441 | ]
442 | iex> base |> Delta.compose(change) |> Delta.compose(inverted) == base
443 | true
444 | """
445 | @spec invert(t, t) :: t
446 | def invert(change, base) do
447 | change
448 | |> Enum.reduce({[], 0}, fn op, {inverted, base_index} ->
449 | length = Op.size(op)
450 |
451 | cond do
452 | Op.insert?(op) ->
453 | inverted = push(inverted, Op.delete(length))
454 | {inverted, base_index}
455 |
456 | Op.retain?(op, :number) && !Op.has_attributes?(op) ->
457 | inverted = push(inverted, Op.retain(length))
458 | {inverted, base_index + length}
459 |
460 | Op.retain?(op, :number) || Op.delete?(op) ->
461 | inverted =
462 | base
463 | |> slice(base_index, length)
464 | |> Enum.reduce(inverted, &do_invert_slice(op, &1, &2))
465 |
466 | {inverted, base_index + length}
467 |
468 | # Delegate to the embed handler when change op is an embed
469 | Op.retain?(op, :map) ->
470 | base_op =
471 | base
472 | |> slice(base_index, length)
473 | |> hd
474 |
475 | {embed_type, embed1, embed2} = Op.get_embed_data!(op["retain"], base_op["insert"])
476 | handler = get_handler!(embed_type)
477 |
478 | embed = %{embed_type => handler.invert(embed1, embed2)}
479 | attrs = Attr.invert(op["attributes"], base_op["attributes"])
480 | inverted = push(inverted, Op.retain(embed, attrs))
481 |
482 | {inverted, base_index + 1}
483 |
484 | true ->
485 | {inverted, base_index}
486 | end
487 | end)
488 | |> elem(0)
489 | |> chop()
490 | |> Enum.reverse()
491 | end
492 |
493 | defp do_invert_slice(op, base_op, inverted) do
494 | cond do
495 | Op.delete?(op) ->
496 | push(inverted, base_op)
497 |
498 | Op.retain?(op) && Op.has_attributes?(op) ->
499 | attrs = Attr.invert(op["attributes"], base_op["attributes"])
500 | retain_op = base_op |> Op.size() |> Op.retain(attrs)
501 | push(inverted, retain_op)
502 |
503 | true ->
504 | inverted
505 | end
506 | end
507 |
508 | @doc ~S"""
509 | Returns a delta representing the difference between two documents.
510 |
511 | ## Examples
512 | iex> a = [Op.insert("Hello")]
513 | iex> b = [Op.insert("Hello!")]
514 | iex> diff = Delta.diff(a, b)
515 | [
516 | %{"retain" => 5},
517 | %{"insert" => "!"}
518 | ]
519 | iex> Delta.compose(a, diff) == b
520 | true
521 | """
522 | @doc since: "0.4.0"
523 | @spec diff(t, t) :: t
524 | def diff(base, other)
525 |
526 | def diff(base, other) when base == other, do: []
527 |
528 | def diff(base, other) do
529 | base_string = diffable_string(base)
530 | other_string = diffable_string(other)
531 |
532 | diff =
533 | base_string
534 | |> :diffy.diff(other_string)
535 | |> Dmp.Diff.cleanup_semantic()
536 |
537 | do_diff(base, other, diff, [], nil, 0)
538 | end
539 |
540 | defp diffable_string(delta) do
541 | delta
542 | |> Enum.map(fn
543 | %{"insert" => str} when is_binary(str) ->
544 | str
545 |
546 | %{"insert" => data} when not is_nil(data) ->
547 | <<0>>
548 |
549 | _ ->
550 | raise "Delta.diff called with non-document"
551 | end)
552 | |> Enum.join()
553 | end
554 |
555 | defp do_diff(_, _, [], delta, _, 0) do
556 | delta
557 | |> chop()
558 | |> Enum.reverse()
559 | end
560 |
561 | defp do_diff(base, other, [{action, str} | rest_diffs], delta, _cur_action, 0) do
562 | do_diff(base, other, rest_diffs, delta, action, Op.text_size(str))
563 | end
564 |
565 | defp do_diff(base, [first | rest], diffs, delta, :insert, len) do
566 | op_len = min(Op.size(first), len)
567 | {op, remaining} = Op.take(first, op_len)
568 |
569 | do_diff(base, push(rest, remaining), diffs, push(delta, op), :insert, len - op_len)
570 | end
571 |
572 | defp do_diff([first | rest], other, diffs, delta, :delete, len) do
573 | op_len = min(Op.size(first), len)
574 | {_op, remaining} = Op.take(first, op_len)
575 |
576 | do_diff(
577 | push(rest, remaining),
578 | other,
579 | diffs,
580 | push(delta, Op.delete(op_len)),
581 | :delete,
582 | len - op_len
583 | )
584 | end
585 |
586 | defp do_diff([base_first | base_rest], [other_first | other_rest], diffs, delta, :equal, len) do
587 | op_len = Enum.min([Op.size(base_first), Op.size(other_first), len])
588 | {base_op, base_remaining} = Op.take(base_first, op_len)
589 | {other_op, other_remaining} = Op.take(other_first, op_len)
590 |
591 | base = push(base_rest, base_remaining)
592 | other = push(other_rest, other_remaining)
593 |
594 | ops_diff =
595 | with %{"insert" => %{} = base_insert} <- base_op,
596 | %{"insert" => %{} = other_insert} <- other_op,
597 | [{embed_type, _embed}] <- Map.to_list(base_insert),
598 | [{^embed_type, _embed}] <- Map.to_list(other_insert),
599 | handler = get_handler(embed_type),
600 | true <- function_exported?(handler, :diff, 2) do
601 | handler.diff(base_op, other_op)
602 | else
603 | _ ->
604 | diff_ops_generic(base_op, other_op)
605 | end
606 |
607 | delta = Enum.reduce(ops_diff, delta, fn op, acc -> push(acc, op) end)
608 |
609 | do_diff(base, other, diffs, delta, :equal, len - op_len)
610 | end
611 |
612 | defp diff_ops_generic(base_op = %{"insert" => ins}, other_op = %{"insert" => ins}) do
613 | attrs = Delta.Attr.diff(base_op["attributes"], other_op["attributes"])
614 | [Op.retain(Op.size(base_op), attrs)]
615 | end
616 |
617 | defp diff_ops_generic(base_op, other_op) do
618 | [Op.delete(Op.size(base_op)), other_op]
619 | end
620 | end
621 |
--------------------------------------------------------------------------------
/lib/delta/attr.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.Attr do
2 | @typep maybe_map :: map | nil
3 |
4 | @spec compose(a :: maybe_map, b :: maybe_map) :: boolean | map
5 | @spec compose(a :: maybe_map, b :: maybe_map, keepNil :: boolean) :: boolean | map
6 | def compose(a, b, keepNil \\ false) do
7 | attr = merge(a || %{}, b || %{}, keepNil)
8 |
9 | case map_size(attr) do
10 | 0 -> false
11 | _ -> attr
12 | end
13 | end
14 |
15 | @spec transform(a :: maybe_map, b :: maybe_map, priority :: boolean) :: boolean | map
16 | def transform(a, b, _) when not is_map(a), do: b
17 | def transform(_, b, _) when not is_map(b), do: false
18 | def transform(_, b, false), do: b
19 |
20 | def transform(a, b, _) do
21 | attr =
22 | b
23 | |> Map.keys()
24 | |> Enum.reduce([], fn k, list ->
25 | case Map.has_key?(a, k) do
26 | true -> list
27 | false -> [{k, b[k]} | list]
28 | end
29 | end)
30 | |> Map.new()
31 |
32 | case map_size(attr) do
33 | 0 -> false
34 | _ -> attr
35 | end
36 | end
37 |
38 | @spec invert(attr :: maybe_map, base :: maybe_map) :: map
39 | def invert(attr, base) do
40 | attr = attr || %{}
41 | base = base || %{}
42 |
43 | inverted =
44 | Enum.reduce(base, %{}, fn {key, value}, inverted ->
45 | case attr do
46 | %{^key => v} when v != value -> Map.put(inverted, key, value)
47 | _other -> inverted
48 | end
49 | end)
50 |
51 | Enum.reduce(attr, inverted, fn {key, value}, inverted ->
52 | if Map.has_key?(base, key) || is_nil(value) do
53 | inverted
54 | else
55 | Map.put(inverted, key, nil)
56 | end
57 | end)
58 | end
59 |
60 | @spec merge(a :: map, b :: map, boolean) :: map
61 | defp merge(a, b, false) do
62 | merged = Map.merge(a, b)
63 |
64 | keys =
65 | merged
66 | |> Map.keys()
67 | |> Enum.filter(fn k -> is_nil(merged[k]) end)
68 |
69 | Map.drop(merged, keys)
70 | end
71 |
72 | defp merge(a, b, true) do
73 | Map.merge(a, b)
74 | end
75 |
76 | @doc since: "0.4.0"
77 | @spec diff(a :: maybe_map, b :: maybe_map) :: map
78 | def diff(a, b) do
79 | a = a || %{}
80 | b = b || %{}
81 |
82 | keys = MapSet.new(Map.keys(a) ++ Map.keys(b))
83 |
84 | Enum.reduce(keys, %{}, fn key, attrs ->
85 | if a[key] != b[key] do
86 | Map.put(attrs, key, b[key])
87 | else
88 | attrs
89 | end
90 | end)
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/lib/delta/embed_handler.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.EmbedHandler do
2 | @moduledoc """
3 | A module implementing `EmbedHandler` behaviour is required to make `compose`, `transform` and `invert` operations possible for custom embeds.
4 |
5 | Suppose we want to have a custom `image` embed handler, that will always prefer image urls that start with `https://`
6 |
7 | defmodule ImageEmbed do
8 | @behaviour Delta.EmbedHandler
9 |
10 | @impl Delta.EmbedHandler
11 | def name, do: "image"
12 |
13 | @impl Delta.EmbedHandler
14 | def compose(url1, url2, _keep_nil?) do
15 | choose_https(url1, url2)
16 | end
17 |
18 | @impl Delta.EmbedHandler
19 | def transform(url1, url2, _priority? \\ false) do
20 | choose_https(url1, url2)
21 | end
22 |
23 | @impl Delta.EmbedHandler
24 | def invert(change, base) do
25 | choose_https(change, base)
26 | end
27 |
28 | defp choose_https(url1, url2) do
29 | case {url1, url2} do
30 | {"https" <> _ , _} -> url1
31 |
32 | {_, "https" <> _} -> url2
33 |
34 | {_, _} -> url1
35 | end
36 | end
37 | end
38 |
39 | Now we just need to add our module to config:
40 |
41 | config :delta, custom_embeds: [ImageEmbed]
42 |
43 | ## Examples
44 | iex> base = Op.insert(%{"image" => "update me"})
45 | iex> a = Op.retain(%{"image" => "http://quilljs.com/assets/images/icon.png"})
46 | iex> b = Op.retain(%{"image" => "https://quilljs.com/assets/images/icon.png"})
47 | iex> Delta.transform([base, a], [base, b])
48 | [
49 | %{"insert" => %{"image" => "update me"}},
50 | %{"retain" => 1},
51 | %{"retain" => %{"image" => "https://quilljs.com/assets/images/icon.png"}}
52 | ]
53 | """
54 | @type t :: module()
55 | @type embed :: map()
56 |
57 | @callback name() :: binary()
58 | @callback compose(any(), any(), keep_nil? :: boolean()) :: embed()
59 | @callback transform(any(), any(), priority? :: boolean()) :: embed()
60 | @callback invert(any(), any()) :: embed()
61 | @callback diff(Delta.Op.insert_op(), Delta.Op.insert_op()) :: Delta.t()
62 |
63 | @optional_callbacks diff: 2
64 | end
65 |
--------------------------------------------------------------------------------
/lib/delta/op.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.Op do
2 | alias Delta.Attr
3 | alias Delta.Utils
4 | alias Delta.EmbedHandler
5 |
6 | @type t :: insert_op | retain_op | delete_op
7 |
8 | @typedoc """
9 | Stand-in type while operators are keyed with String.t() instead of Atom.t()
10 |
11 | `%{insert: String.t() | EmbedHandler.embed(), ...}`
12 | """
13 | @type insert_op :: %{required(insert_key) => insert_val, optional(attributes) => attributes_val}
14 | @typep insert_key :: String.t()
15 | @typep insert_val :: String.t() | EmbedHandler.embed()
16 |
17 | @typedoc """
18 | Stand-in type while operators are keyed with String.t() instead of Atom.t()
19 |
20 | `%{retain: pos_integer() | EmbedHandler.embed()}`
21 | """
22 | @type retain_op :: %{required(retain_key) => retain_val, optional(attributes) => attributes_val}
23 | @typep retain_key :: String.t()
24 | @typep retain_val :: pos_integer() | EmbedHandler.embed()
25 |
26 | @typedoc """
27 | Stand-in type while operators are keyed with String.t() instead of Atom.t()
28 |
29 | `%{delete: pos_integer()}`
30 | """
31 | @type delete_op :: %{
32 | required(delete_key) => pos_integer,
33 | optional(attributes) => attributes_val
34 | }
35 | @typep delete_key :: String.t()
36 | @typep delete_val :: pos_integer()
37 |
38 | @typedoc """
39 | Stand-in type while operators are keyed with String.t() instead of Atom.t()
40 | """
41 | @type operation :: insert_key | retain_key | delete_key
42 | @typep operation_val :: insert_val | retain_val | delete_val
43 |
44 | @typedoc """
45 | Stand-in type while attributes is keyed with String.t() instead of Atom.t()
46 | """
47 | @type attributes :: %{required(String.t()) => attributes_val}
48 | @typep attributes_val :: map() | false
49 |
50 | @doc ~S"""
51 | Create a new operation.
52 |
53 | Note that operations _are_ maps, and not structs.
54 |
55 | ## Examples
56 | iex> Op.new("insert", "Hello", %{"bold" => true})
57 | %{"insert" => "Hello", "attributes" => %{"bold" => true}}
58 | """
59 | @spec new(action :: operation, value :: operation_val, attr :: attributes_val) :: t
60 | def new(action, value, attr \\ false)
61 |
62 | def new(action, value, %{} = attr) when map_size(attr) > 0 do
63 | %{action => value, "attributes" => attr}
64 | end
65 |
66 | def new(action, value, _attr), do: %{action => value}
67 |
68 | @doc ~S"""
69 | A shorthand for `new("insert", value, attributes)`. See `new/3`.
70 |
71 | ## Examples
72 | iex> Op.insert("Hello", %{"bold" => true})
73 | %{"insert" => "Hello", "attributes" => %{"bold" => true}}
74 | """
75 | @spec insert(value :: insert_val, attr :: attributes_val) :: insert_op
76 | def insert(value, attr \\ false), do: new("insert", value, attr)
77 |
78 | @doc ~S"""
79 | A shorthand for `new("retain", value, attributes)`. See `new/3`.
80 |
81 | ## Examples
82 | iex> Op.retain(1, %{"bold" => true})
83 | %{"retain" => 1, "attributes" => %{"bold" => true}}
84 | """
85 | @spec retain(value :: retain_val, attr :: attributes_val) :: retain_op
86 | def retain(value, attr \\ false), do: new("retain", value, attr)
87 |
88 | @doc ~S"""
89 | A shorthand for `new("delete", value, attributes)`. See `new/3`.
90 |
91 | ## Examples
92 | iex> Op.delete(1, %{"bold" => true})
93 | %{"delete" => 1, "attributes" => %{"bold" => true}}
94 | """
95 | @spec delete(value :: delete_val, attr :: attributes_val) :: delete_op
96 | def delete(value, attr \\ false), do: new("delete", value, attr)
97 |
98 | @doc ~S"""
99 | Returns true if operation has attributes
100 |
101 | ## Examples
102 | iex> Op.has_attributes?(%{"insert" => "Hello", "attributes" => %{"bool" => true}})
103 | true
104 |
105 | iex> Op.has_attributes?(%{"insert" => "Hello"})
106 | false
107 | """
108 | @spec has_attributes?(any) :: boolean
109 | def has_attributes?(%{"attributes" => %{}}), do: true
110 | def has_attributes?(_), do: false
111 |
112 | @doc ~S"""
113 | Returns true if operation is of type `type`. Optionally check against more specific `value_type`.
114 |
115 | ## Examples
116 | iex> Op.insert("Hello") |> Op.type?("insert")
117 | true
118 |
119 | iex> Op.insert("Hello") |> Op.type?("insert", :string)
120 | true
121 |
122 | iex> Op.insert("Hello") |> Op.type?("insert", :number)
123 | false
124 |
125 | iex> Op.retain(1) |> Op.type?("retain", :number)
126 | true
127 | """
128 | @spec type?(op :: t, action :: any, value_type :: any) :: boolean
129 | def type?(op, action, value_type \\ nil)
130 | def type?(%{} = op, action, nil) when is_map_key(op, action), do: true
131 | def type?(%{} = op, action, :map), do: is_map(op[action])
132 | def type?(%{} = op, action, :string), do: is_binary(op[action])
133 | def type?(%{} = op, action, :number), do: is_integer(op[action])
134 | def type?(%{}, _action, _value_type), do: false
135 |
136 | @doc ~S"""
137 | A shorthand for `type?(op, "insert", type)`. See `type?/3`.
138 |
139 | ## Examples
140 | iex> Op.insert("Hello") |> Op.insert?()
141 | true
142 | """
143 | @spec insert?(op :: t, type :: any) :: boolean
144 | def insert?(op, type \\ nil), do: type?(op, "insert", type)
145 |
146 | @doc ~S"""
147 | A shorthand for `type?(op, "delete", type)`. See `type?/3`.
148 |
149 | ## Examples
150 | iex> Op.delete(1) |> Op.delete?()
151 | true
152 | """
153 | @spec delete?(op :: t, type :: any) :: boolean
154 | def delete?(op, type \\ nil), do: type?(op, "delete", type)
155 |
156 | @doc ~S"""
157 | A shorthand for `type?(op, "insert", type)`. See `type?/3`.
158 |
159 | ## Examples
160 | iex> Op.retain(1) |> Op.retain?()
161 | true
162 | """
163 | @spec retain?(op :: t, type :: any) :: boolean
164 | def retain?(op, type \\ nil), do: type?(op, "retain", type)
165 |
166 | @doc ~S"""
167 | Returns text size.
168 |
169 | ## Examples
170 | iex> Op.text_size("Hello")
171 | 5
172 |
173 | iex> Op.text_size("🏴")
174 | 14
175 | """
176 | @spec text_size(text :: binary) :: non_neg_integer
177 | def text_size(text) do
178 | text
179 | |> :unicode.characters_to_binary(:utf8, :utf16)
180 | |> byte_size()
181 | |> div(2)
182 | end
183 |
184 | @doc ~S"""
185 | Returns operation size.
186 |
187 | ## Examples
188 | iex> Op.insert("Hello") |> Op.size()
189 | 5
190 |
191 | iex> Op.retain(3) |> Op.size()
192 | 3
193 | """
194 | @spec size(t) :: non_neg_integer
195 | def size(%{"insert" => text}) when is_binary(text), do: text_size(text)
196 | def size(%{"delete" => len}) when is_integer(len), do: len
197 | def size(%{"retain" => len}) when is_integer(len), do: len
198 | def size(_op), do: 1
199 |
200 | @doc ~S"""
201 | Takes `length` characters from an operation and returns it together with the
202 | remaining part in a tuple.
203 |
204 | ## Options
205 |
206 | * `:align` - when `true`, allow moving index left if
207 | we're likely to split a grapheme otherwise.
208 |
209 | ## Examples
210 | iex> Op.insert("Hello") |> Op.take(3)
211 | {%{"insert" => "Hel"}, %{"insert" => "lo"}}
212 |
213 | iex> assert_raise RuntimeError, fn -> Op.insert("🏴") |> Op.take(1) end
214 |
215 | iex> Op.insert("🏴") |> Op.take(1, align: true)
216 | {%{"insert" => ""}, %{"insert" => "🏴"}}
217 | """
218 | @spec take(op :: t, length :: non_neg_integer, opts :: Keyword.t()) :: {t, t | boolean}
219 | def take(op, length, opts \\ [])
220 |
221 | def take(op = %{"insert" => embed}, _length, _opts) when not is_bitstring(embed) do
222 | {op, false}
223 | end
224 |
225 | def take(op, length, opts) do
226 | case size(op) - length do
227 | 0 -> {op, false}
228 | _ -> take_partial(op, length, opts)
229 | end
230 | end
231 |
232 | @doc ~S"""
233 | Gets two embeds' data. An embed is always a [one-key map](https://quilljs.com/docs/delta/#embeds)
234 |
235 | ## Examples
236 | iex> Op.get_embed_data!(
237 | ...> %{"image" => "https://quilljs.com/assets/images/icon.png"},
238 | ...> %{"image" => "https://quilljs.com/assets/images/icon2.png"}
239 | ...> )
240 | {"image", "https://quilljs.com/assets/images/icon.png", "https://quilljs.com/assets/images/icon2.png"}
241 | """
242 | @spec get_embed_data!(map, map) :: {any, any, any}
243 | def get_embed_data!(a, b) do
244 | cond do
245 | not is_map(a) ->
246 | raise("cannot retain #{inspect(a)}")
247 |
248 | not is_map(b) ->
249 | raise("cannot retain #{inspect(b)}")
250 |
251 | map_size(a) != 1 and Map.keys(a) != Map.keys(b) ->
252 | raise("embeds not matched: #{inspect(a: a, b: b)}")
253 |
254 | true ->
255 | [type] = Map.keys(a)
256 | {type, a[type], b[type]}
257 | end
258 | end
259 |
260 | @spec compose(a :: t, b :: t) :: {t | false, t, t}
261 | def compose(a, b) do
262 | {op1, a, op2, b} = next(a, b)
263 |
264 | composed =
265 | case {info(op1), info(op2)} do
266 | {{"retain", _type}, {"delete", :number}} ->
267 | op2
268 |
269 | {{"retain", :map}, {"retain", :number}} ->
270 | attr = Attr.compose(op1["attributes"], op2["attributes"])
271 | retain(op1["retain"], attr)
272 |
273 | {{"retain", :number}, {"retain", _type}} ->
274 | attr = Attr.compose(op1["attributes"], op2["attributes"], true)
275 | retain(op2["retain"], attr)
276 |
277 | {{"insert", _type}, {"retain", :number}} ->
278 | attr = Attr.compose(op1["attributes"], op2["attributes"])
279 | insert(op1["insert"], attr)
280 |
281 | {{action, type}, {"retain", :map}} ->
282 | {embed_type, embed1, embed2} = get_embed_data!(op1[action], op2["retain"])
283 | handler = Delta.get_handler!(embed_type)
284 |
285 | composed_embed = %{embed_type => handler.compose(embed1, embed2, action == "retain")}
286 | keep_nil? = action == "retain" && type == :number
287 | attr = Attr.compose(op1["attributes"], op2["attributes"], keep_nil?)
288 |
289 | new(action, composed_embed, attr)
290 |
291 | _other ->
292 | false
293 | end
294 |
295 | {composed, a, b}
296 | end
297 |
298 | @spec transform(non_neg_integer, non_neg_integer, t, boolean) ::
299 | {non_neg_integer, non_neg_integer}
300 | def transform(offset, index, op, priority) when is_integer(index) do
301 | length = size(op)
302 |
303 | if insert?(op) and (offset < index or not priority) do
304 | {offset + length, index + length}
305 | else
306 | {offset + length, index}
307 | end
308 | end
309 |
310 | @spec transform(a :: t, b :: t, priority :: boolean) :: {t | false, t, t}
311 | def transform(a, b, priority) do
312 | {op1, a, op2, b} = next(a, b)
313 |
314 | transformed =
315 | cond do
316 | delete?(op1) ->
317 | false
318 |
319 | delete?(op2) ->
320 | op2
321 |
322 | # Delegate to embed handler if both are retain ops are
323 | # embeds of the same type
324 | retain?(op1, :map) && retain?(op2, :map) &&
325 | Map.keys(op1["retain"]) == Map.keys(op2["retain"]) ->
326 | {embed_type, embed1, embed2} = get_embed_data!(op1["retain"], op2["retain"])
327 | handler = Delta.get_handler!(embed_type)
328 |
329 | embed = %{embed_type => handler.transform(embed1, embed2, priority)}
330 | attrs = Attr.transform(op1["attributes"], op2["attributes"], priority)
331 | retain(embed, attrs)
332 |
333 | retain?(op1, :number) && retain?(op2, :map) ->
334 | attrs = Attr.transform(op1["attributes"], op2["attributes"], priority)
335 | retain(op2["retain"], attrs)
336 |
337 | true ->
338 | attrs = Attr.transform(op1["attributes"], op2["attributes"], priority)
339 | retain(size(op1), attrs)
340 | end
341 |
342 | {transformed, a, b}
343 | end
344 |
345 | @spec next(t, t) :: {t, t, t, t}
346 | defp next(a, b) do
347 | size = min(size(a), size(b))
348 | {op1, a} = take(a, size)
349 | {op2, b} = take(b, size)
350 | {op1, a, op2, b}
351 | end
352 |
353 | @spec take_partial(t, non_neg_integer, Keyword.t()) :: {t, t}
354 | defp take_partial(op, 0, _opts), do: {insert("", op["attributes"]), op}
355 |
356 | defp take_partial(%{"insert" => text} = op, len, opts) do
357 | binary = :unicode.characters_to_binary(text, :utf8, :utf16)
358 | binary_length = byte_size(binary)
359 |
360 | left =
361 | binary
362 | |> Kernel.binary_part(0, len * 2)
363 | |> :unicode.characters_to_binary(:utf16, :utf8)
364 |
365 | right =
366 | binary
367 | |> Kernel.binary_part(len * 2, binary_length - len * 2)
368 | |> :unicode.characters_to_binary(:utf16, :utf8)
369 |
370 | case {is_binary(left), is_binary(right), Keyword.get(opts, :align, false)} do
371 | {true, true, false} ->
372 | {insert(left, op["attributes"]), insert(right, op["attributes"])}
373 |
374 | {true, true, true} ->
375 | if Utils.slices_likely_cut_emoji?(left, right) do
376 | take_partial(op, len - 1, opts)
377 | else
378 | {insert(left, op["attributes"]), insert(right, op["attributes"])}
379 | end
380 |
381 | {_, _, true} ->
382 | take_partial(op, len - 1, opts)
383 |
384 | _ ->
385 | raise "Encoding failed in take_partial #{inspect({text, op, len, left, right})}"
386 | end
387 | end
388 |
389 | defp take_partial(%{"delete" => full} = op, length, _opts) do
390 | {delete(length, op["attributes"]), delete(full - length, op["attributes"])}
391 | end
392 |
393 | defp take_partial(%{"retain" => full} = op, length, _opts) do
394 | {retain(length, op["attributes"]), retain(full - length, op["attributes"])}
395 | end
396 |
397 | @spec info(t) :: {String.t(), :number | :string | :map}
398 | defp info(op) do
399 | action =
400 | case op do
401 | %{"insert" => _} -> "insert"
402 | %{"retain" => _} -> "retain"
403 | %{"delete" => _} -> "delete"
404 | end
405 |
406 | type =
407 | case op[action] do
408 | value when is_integer(value) -> :number
409 | value when is_binary(value) -> :string
410 | value when is_map(value) -> :map
411 | end
412 |
413 | {action, type}
414 | end
415 | end
416 |
--------------------------------------------------------------------------------
/lib/delta/utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.Utils do
2 | @moduledoc false
3 |
4 | @spec slices_likely_cut_emoji?(String.t(), String.t()) :: boolean()
5 | def slices_likely_cut_emoji?(left, right) do
6 | left
7 | |> to_charlist()
8 | |> Enum.reverse()
9 | |> do_slices_likely_cut_emoji?(to_charlist(right))
10 | end
11 |
12 | @zero_width_joiner 0x200D
13 | defp do_slices_likely_cut_emoji?([l | _], [r | _])
14 | when r == @zero_width_joiner or l == @zero_width_joiner,
15 | do: true
16 |
17 | @variation_selector_16 0xFE0F
18 | defp do_slices_likely_cut_emoji?(_, [r | _]) when r == @variation_selector_16, do: true
19 |
20 | # we don't have to account for hair modifiers as they use ZWJ
21 | @skin_tone_modifiers 0x1F3FB..0x1F3FF
22 | defp do_slices_likely_cut_emoji?(_, [r | _]) when r in @skin_tone_modifiers, do: true
23 |
24 | @tags 0xE0001..0xE007F
25 | defp do_slices_likely_cut_emoji?(_, [r | _]) when r in @tags, do: true
26 |
27 | defp do_slices_likely_cut_emoji?(_, _), do: false
28 | end
29 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Delta.MixProject do
2 | use Mix.Project
3 |
4 |
5 | @app :delta
6 | @name "Delta"
7 | @version "0.4.1"
8 | @github "https://github.com/slab/delta-elixir"
9 |
10 |
11 | def project do
12 | [
13 | # Project
14 | app: @app,
15 | version: @version,
16 | elixir: "~> 1.13",
17 | description: description(),
18 | package: package(),
19 | deps: deps(),
20 | elixirc_paths: elixirc_paths(Mix.env()),
21 |
22 | # ExDoc
23 | name: @name,
24 | docs: [
25 | main: @name,
26 | source_url: @github,
27 | homepage_url: @github,
28 | canonical: "https://hexdocs.pm/#{@app}",
29 | extras: ["README.md", "CHANGELOG.md"]
30 | ]
31 | ]
32 | end
33 |
34 |
35 | defp description do
36 | "Simple, yet expressive format to describe contents and changes"
37 | end
38 |
39 |
40 | # BEAM Application
41 | def application do
42 | [
43 | env: [custom_embeds: []],
44 | extra_applications: [:logger]
45 | ]
46 | end
47 |
48 |
49 | # Dependencies
50 | defp deps do
51 | [
52 | {:diff_match_patch, "~> 0.2"},
53 | {:diffy, "~> 1.1"},
54 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}
55 | ]
56 | end
57 |
58 |
59 | # Compilation Paths
60 | defp elixirc_paths(:dev), do: elixirc_paths(:test)
61 | defp elixirc_paths(:test), do: ["lib", "test/support"]
62 | defp elixirc_paths(_), do: ["lib"]
63 |
64 |
65 | # Package Information
66 | defp package do
67 | [
68 | name: @app,
69 | maintainers: ["Slab"],
70 | licenses: ["BSD-3-Clause"],
71 | files: ~w(mix.exs lib README.md CHANGELOG.md),
72 | links: %{
73 | "Github" => @github,
74 | "Delta.js" => "https://github.com/quilljs/delta",
75 | "Changelog" => "https://hexdocs.pm/delta/changelog.html"
76 | }
77 | ]
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "diff_match_patch": {:hex, :diff_match_patch, "0.3.0", "8741299f6e43d701dc160e28738440f53af0969e3cea79d2d8e57f8dcb631d26", [:mix], [], "hexpm", "cf377f0d7dc4d40cd68fc580c26047351f217be729565e944f8cea36b19013b7"},
3 | "diffy": {:hex, :diffy, "1.1.2", "7060e34fd512f210056b7e66e8d315cb90d06b97a6656d92fc299eb91692e36e", [:rebar3], [{:proper, "~> 1.2.0", [hex: :proper, repo: "hexpm", optional: false]}, {:zotonic_stdlib, "1.2.3", [hex: :zotonic_stdlib, repo: "hexpm", optional: false]}], "hexpm", "9a59bbdcafb6b115b5099711f7f114edb00be11658cef08ef26e04f8040c7149"},
4 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"},
5 | "ex_doc": {:hex, :ex_doc, "0.35.1", "de804c590d3df2d9d5b8aec77d758b00c814b356119b3d4455e4b8a8687aecaf", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2121c6402c8d44b05622677b761371a759143b958c6c19f6558ff64d0aed40df"},
6 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
7 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.0", "74bb8348c9b3a51d5c589bf5aebb0466a84b33274150e3b6ece1da45584afc82", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "49159b7d7d999e836bedaf09dcf35ca18b312230cf901b725a64f3f42e407983"},
8 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"},
9 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
10 | "proper": {:hex, :proper, "1.2.0", "1466492385959412a02871505434e72e92765958c60dba144b43863554b505a4", [:make, :mix, :rebar3], [], "hexpm", "cbc3766c08337806741343d330bf4bcb826155d2141be8514c4b02858aa19fd3"},
11 | "zotonic_stdlib": {:hex, :zotonic_stdlib, "1.2.3", "4a33b60c82379169c9934ccd1fc9e512ca16b922e131ad6b6d26e562f66df9cc", [:rebar3], [], "hexpm", "4712dd7a0c0c600afedafda738d40febf10cfc2485e62d109361fcc190f7381a"},
12 | }
13 |
--------------------------------------------------------------------------------
/test/delta/attr_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta.Attr do
2 | use Delta.Support.Case, async: false
3 | alias Delta.Attr
4 | doctest Attr
5 |
6 | @attr %{bold: true, color: "red"}
7 |
8 | test "compose left undefined" do
9 | assert Attr.compose(nil, @attr) == @attr
10 | end
11 |
12 | test "compose right undefined" do
13 | assert Attr.compose(@attr, nil) == @attr
14 | end
15 |
16 | test "both undefined" do
17 | assert Attr.compose(nil, nil) == false
18 | end
19 |
20 | test "compose missing" do
21 | param = %{italic: true}
22 |
23 | assert Attr.compose(@attr, param) == %{
24 | bold: true,
25 | italic: true,
26 | color: "red"
27 | }
28 | end
29 |
30 | test "compose overwrite" do
31 | param = %{bold: false, color: "blue"}
32 |
33 | assert Attr.compose(@attr, param) == %{
34 | bold: false,
35 | color: "blue"
36 | }
37 | end
38 |
39 | test "compose remove" do
40 | param = %{bold: nil}
41 | assert Attr.compose(@attr, param) == %{color: "red"}
42 | end
43 |
44 | test "compose keep removal" do
45 | param = %{bold: nil}
46 |
47 | assert Attr.compose(@attr, param, true) == %{
48 | bold: nil,
49 | color: "red"
50 | }
51 | end
52 |
53 | # TODO divergent behavior vs JS Delta
54 | test "compose remove to empty" do
55 | param = %{bold: nil, color: nil}
56 | assert Attr.compose(@attr, param) == false
57 | end
58 |
59 | test "compose remove missing" do
60 | param = %{italic: nil}
61 | assert Attr.compose(@attr, param) == @attr
62 | end
63 | end
64 |
--------------------------------------------------------------------------------
/test/delta/delta/compose_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta.Compose do
2 | use Delta.Support.Case, async: false
3 | doctest Delta, only: [compose: 2, compose_all: 1]
4 |
5 | describe ".compose/2 (basic)" do
6 | test "insert + insert" do
7 | a = [Op.insert("A")]
8 | b = [Op.insert("B")]
9 | expected = [Op.insert("BA")]
10 |
11 | assert Delta.compose(a, b) == expected
12 | end
13 |
14 | test "insert + insert (with attributes)" do
15 | a = [Op.insert("A", %{"bold" => true})]
16 | b = [Op.insert("B", %{"bold" => true})]
17 | expected = [Op.insert("BA", %{"bold" => true})]
18 |
19 | assert Delta.compose(a, b) == expected
20 | end
21 |
22 | test "insert + retain" do
23 | a = [Op.insert("A")]
24 | b = [Op.retain(1, %{"bold" => true, "color" => "red", "font" => nil})]
25 | expected = [Op.insert("A", %{"bold" => true, "color" => "red"})]
26 |
27 | assert Delta.compose(a, b) == expected
28 | end
29 |
30 | test "insert + delete" do
31 | a = [Op.insert("A")]
32 | b = [Op.delete(1)]
33 | expected = []
34 |
35 | assert Delta.compose(a, b) == expected
36 | end
37 |
38 | test "delete + insert" do
39 | a = [Op.delete(1)]
40 | b = [Op.insert("B")]
41 | expected = [Op.insert("B"), Op.delete(1)]
42 |
43 | assert Delta.compose(a, b) == expected
44 | end
45 |
46 | test "delete + retain" do
47 | a = [Op.delete(1)]
48 | b = [Op.retain(1, %{"bold" => true, "color" => "red"})]
49 | expected = [Op.delete(1), Op.retain(1, %{"bold" => true, "color" => "red"})]
50 |
51 | assert Delta.compose(a, b) == expected
52 | end
53 |
54 | test "delete + delete" do
55 | a = [Op.delete(1)]
56 | b = [Op.delete(1)]
57 | expected = [Op.delete(2)]
58 |
59 | assert Delta.compose(a, b) == expected
60 | end
61 |
62 | test "delete + delete different attributes" do
63 | a = [Op.delete(1, %{"foo" => true})]
64 | b = [Op.delete(1, %{"bar" => true})]
65 | expected = [Op.delete(1, %{"foo" => true}), Op.delete(1, %{"bar" => true})]
66 |
67 | assert Delta.compose(a, b) == expected
68 | end
69 |
70 | test "delete + delete same attributes" do
71 | a = [Op.delete(1, %{"foo" => true})]
72 | b = [Op.delete(1, %{"foo" => true})]
73 | expected = [Op.delete(2, %{"foo" => true})]
74 |
75 | assert Delta.compose(a, b) == expected
76 | end
77 |
78 | test "retain + insert" do
79 | a = [Op.retain(1, %{"color" => "blue"})]
80 | b = [Op.insert("B")]
81 | expected = [Op.insert("B"), Op.retain(1, %{"color" => "blue"})]
82 |
83 | assert Delta.compose(a, b) == expected
84 | end
85 |
86 | test "retain + retain (plain)" do
87 | a = b = [Op.retain(1)]
88 | assert Delta.compose(a, b) == []
89 | end
90 |
91 | test "retain + retain (with attributes)" do
92 | a = [Op.retain(1, %{"color" => "blue"})]
93 | b = [Op.retain(1, %{"bold" => true, "color" => "red", "font" => nil})]
94 | expected = [Op.retain(1, %{"bold" => true, "color" => "red", "font" => nil})]
95 |
96 | assert Delta.compose(a, b) == expected
97 | end
98 |
99 | test "retain + delete" do
100 | a = [Op.retain(1, %{"color" => "blue"})]
101 | b = [Op.delete(1)]
102 | expected = [Op.delete(1)]
103 |
104 | assert Delta.compose(a, b) == expected
105 | end
106 |
107 | test "insert in middle of text" do
108 | a = [Op.insert("Hello")]
109 | b = [Op.retain(3), Op.insert("X")]
110 | expected = [Op.insert("HelXlo")]
111 |
112 | assert Delta.compose(a, b) == expected
113 | end
114 |
115 | test "insert/delete ordering" do
116 | base = [Op.insert("Hello")]
117 | insert_first = [Op.retain(3), Op.insert("X"), Op.delete(1)]
118 | delete_first = [Op.retain(3), Op.delete(1), Op.insert("X")]
119 | expected = [Op.insert("HelXo")]
120 |
121 | assert Delta.compose(base, insert_first) == expected
122 | assert Delta.compose(base, delete_first) == expected
123 | end
124 |
125 | test "insert embed" do
126 | a = [Op.insert(%{"image" => "image.png"}, %{"width" => "300"})]
127 | b = [Op.retain(1, %{"height" => "200"})]
128 | expected = [Op.insert(%{"image" => "image.png"}, %{"width" => "300", "height" => "200"})]
129 |
130 | assert Delta.compose(a, b) == expected
131 | end
132 |
133 | test "delete entire text" do
134 | a = [Op.retain(4), Op.insert("Hello")]
135 | b = [Op.delete(9)]
136 | expected = [Op.delete(4)]
137 |
138 | assert Delta.compose(a, b) == expected
139 | end
140 |
141 | test "retain more than length of text" do
142 | a = [Op.insert("Hello")]
143 | b = [Op.retain(10)]
144 | expected = [Op.insert("Hello")]
145 |
146 | assert Delta.compose(a, b) == expected
147 | end
148 |
149 | test "retain empty embed" do
150 | a = [Op.insert(%{})]
151 | b = [Op.retain(1)]
152 |
153 | assert Delta.compose(a, b) == a
154 | end
155 |
156 | test "remove all attributes" do
157 | a = [Op.insert("A", %{"bold" => true})]
158 | b = [Op.retain(1, %{"bold" => nil})]
159 | expected = [Op.insert("A")]
160 |
161 | assert Delta.compose(a, b) == expected
162 | end
163 |
164 | test "remove all embed attributes" do
165 | a = [Op.insert(2, %{"bold" => true})]
166 | b = [Op.retain(1, %{"bold" => nil})]
167 | expected = [Op.insert(2)]
168 |
169 | assert Delta.compose(a, b) == expected
170 | end
171 |
172 | test "retain start optimization" do
173 | a = [
174 | Op.insert("A", %{"bold" => true}),
175 | Op.insert("B"),
176 | Op.insert("C", %{"bold" => true}),
177 | Op.delete(1)
178 | ]
179 |
180 | b = [
181 | Op.retain(3),
182 | Op.insert("D")
183 | ]
184 |
185 | expected = [
186 | Op.insert("A", %{"bold" => true}),
187 | Op.insert("B"),
188 | Op.insert("C", %{"bold" => true}),
189 | Op.insert("D"),
190 | Op.delete(1)
191 | ]
192 |
193 | assert Delta.compose(a, b) == expected
194 | end
195 |
196 | test "retain start optimization split" do
197 | a = [
198 | Op.insert("A", %{"bold" => true}),
199 | Op.insert("B"),
200 | Op.insert("C", %{"bold" => true}),
201 | Op.retain(5),
202 | Op.delete(1)
203 | ]
204 |
205 | b = [
206 | Op.retain(4),
207 | Op.insert("D")
208 | ]
209 |
210 | expected = [
211 | Op.insert("A", %{"bold" => true}),
212 | Op.insert("B"),
213 | Op.insert("C", %{"bold" => true}),
214 | Op.retain(1),
215 | Op.insert("D"),
216 | Op.retain(4),
217 | Op.delete(1)
218 | ]
219 |
220 | assert Delta.compose(a, b) == expected
221 | end
222 |
223 | test "retain end optimization" do
224 | a = [
225 | Op.insert("A", %{"bold" => true}),
226 | Op.insert("B"),
227 | Op.insert("C", %{"bold" => true})
228 | ]
229 |
230 | b = [Op.delete(1)]
231 | expected = [Op.insert("B"), Op.insert("C", %{"bold" => true})]
232 |
233 | assert Delta.compose(a, b) == expected
234 | end
235 |
236 | test "retain end optimization join" do
237 | a = [
238 | Op.insert("A", %{"bold" => true}),
239 | Op.insert("B"),
240 | Op.insert("C", %{"bold" => true}),
241 | Op.insert("D"),
242 | Op.insert("E", %{"bold" => true}),
243 | Op.insert("F")
244 | ]
245 |
246 | b = [
247 | Op.retain(1),
248 | Op.delete(1)
249 | ]
250 |
251 | expected = [
252 | Op.insert("AC", %{"bold" => true}),
253 | Op.insert("D"),
254 | Op.insert("E", %{"bold" => true}),
255 | Op.insert("F")
256 | ]
257 |
258 | assert Delta.compose(a, b) == expected
259 | end
260 |
261 | test "retain at boundary" do
262 | a = [Op.insert("ab"), Op.insert("cd")]
263 | b = [Op.retain(2), Op.delete(1)]
264 | expected = [Op.insert("abd")]
265 |
266 | assert Delta.compose(a, b) == expected
267 | end
268 |
269 | test "non-compact" do
270 | a = [
271 | Op.insert(""),
272 | Op.insert("2", %{"link" => "link"}),
273 | Op.insert("\n")
274 | ]
275 |
276 | b = [Op.retain(1), Op.delete(1)]
277 | expected = [Op.insert("2", %{"link" => "link"})]
278 |
279 | assert Delta.compose(a, b) == expected
280 | end
281 |
282 | test "overlapping delete and retain" do
283 | a = [
284 | Op.retain(1),
285 | Op.retain(2, %{"bold" => true, "author" => "user1"})
286 | ]
287 |
288 | b = [
289 | Op.retain(2),
290 | Op.delete(2, %{"author" => "user2"})
291 | ]
292 |
293 | assert Delta.compose(a, b) == [
294 | Op.retain(1),
295 | Op.retain(1, %{"bold" => true, "author" => "user1"}),
296 | Op.delete(2, %{"author" => "user2"})
297 | ]
298 | end
299 | end
300 |
301 | describe ".compose/2 (custom embeds)" do
302 | @describetag custom_embeds: [TestEmbed]
303 |
304 | test "retain an embed with a number" do
305 | a = [Op.insert(%{"delta" => [Op.insert("a")]})]
306 | b = [Op.retain(1, %{"bold" => true})]
307 | expected = [Op.insert(%{"delta" => [Op.insert("a")]}, %{"bold" => true})]
308 |
309 | assert Delta.compose(a, b) == expected
310 | end
311 |
312 | test "retain a number with an embed" do
313 | a = [Op.retain(10, %{"bold" => true})]
314 | b = [Op.retain(%{"delta" => [Op.insert("b")]})]
315 |
316 | expected = [
317 | Op.retain(%{"delta" => [Op.insert("b")]}, %{"bold" => true}),
318 | Op.retain(9, %{"bold" => true})
319 | ]
320 |
321 | assert Delta.compose(a, b) == expected
322 | end
323 |
324 | test "retain an embed with an embed" do
325 | a = [Op.retain(%{"delta" => [Op.insert("a")]})]
326 | b = [Op.retain(%{"delta" => [Op.insert("b")]})]
327 | expected = [Op.retain(%{"delta" => [Op.insert("ba")]})]
328 |
329 | assert Delta.compose(a, b) == expected
330 | end
331 |
332 | test "delete a retain" do
333 | a = [Op.retain(%{"delta" => [Op.insert("a")]})]
334 | b = [Op.delete(1)]
335 | expected = [Op.delete(1)]
336 |
337 | assert Delta.compose(a, b) == expected
338 | end
339 | end
340 | end
341 |
--------------------------------------------------------------------------------
/test/delta/delta/diff_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta.Diff do
2 | use Delta.Support.Case, async: false
3 | doctest Delta, only: [diff: 2]
4 |
5 | describe ".diff/2 (basic)" do
6 | test "insert" do
7 | a = [Op.insert("A")]
8 | b = [Op.insert("AB")]
9 |
10 | assert [Op.retain(1), Op.insert("B")] == Delta.diff(a, b)
11 | assert Delta.compose(a, Delta.diff(a, b)) == b
12 | end
13 |
14 | test "delete" do
15 | a = [Op.insert("AB")]
16 | b = [Op.insert("A")]
17 |
18 | assert [Op.retain(1), Op.delete(1)] == Delta.diff(a, b)
19 | assert Delta.compose(a, Delta.diff(a, b)) == b
20 | end
21 |
22 | test "retain" do
23 | a = [Op.insert("A")]
24 | b = [Op.insert("A")]
25 |
26 | assert [] == Delta.diff(a, b)
27 | assert Delta.compose(a, Delta.diff(a, b)) == b
28 | end
29 |
30 | test "format" do
31 | a = [Op.insert("A")]
32 | b = [Op.insert("A", %{"bold" => true})]
33 |
34 | assert [Op.retain(1, %{"bold" => true})] == Delta.diff(a, b)
35 | assert Delta.compose(a, Delta.diff(a, b)) == b
36 | end
37 |
38 | test "object attributes" do
39 | a = [Op.insert("A", %{"font" => %{"family" => "Helvetica", "size" => "15px"}})]
40 | b = [Op.insert("A", %{"font" => %{"family" => "Helvetica", "size" => "15px"}})]
41 |
42 | assert [] == Delta.diff(a, b)
43 | assert Delta.compose(a, Delta.diff(a, b)) == b
44 | end
45 |
46 | test "embed integer match" do
47 | a = [Op.insert(%{"embed" => 1})]
48 | b = [Op.insert(%{"embed" => 1})]
49 |
50 | assert [] == Delta.diff(a, b)
51 | assert Delta.compose(a, Delta.diff(a, b)) == b
52 | end
53 |
54 | test "embed integer mismatch" do
55 | a = [Op.insert(%{"embed" => 1})]
56 | b = [Op.insert(%{"embed" => 2})]
57 |
58 | assert [Op.delete(1), Op.insert(%{"embed" => 2})] == Delta.diff(a, b)
59 | assert Delta.compose(a, Delta.diff(a, b)) == b
60 | end
61 |
62 | test "embed object match" do
63 | a = [Op.insert(%{"image" => "http://example.com"})]
64 | b = [Op.insert(%{"image" => "http://example.com"})]
65 |
66 | assert [] == Delta.diff(a, b)
67 | assert Delta.compose(a, Delta.diff(a, b)) == b
68 | end
69 |
70 | test "embed object mismatch" do
71 | a = [Op.insert(%{"image" => %{"url" => "http://example.com", "alt" => "overwrite"}})]
72 | b = [Op.insert(%{"image" => %{"url" => "http://example.com"}})]
73 |
74 | assert [Op.delete(1), Op.insert(%{"image" => %{"url" => "http://example.com"}})] ==
75 | Delta.diff(a, b)
76 |
77 | assert Delta.compose(a, Delta.diff(a, b)) == b
78 | end
79 |
80 | test "embed object change" do
81 | a = [Op.insert(%{"image" => "http://example.com"})]
82 | b = [Op.insert(%{"image" => "http://example.org"})]
83 |
84 | assert [Op.delete(1), Op.insert(%{"image" => "http://example.org"})] == Delta.diff(a, b)
85 | assert Delta.compose(a, Delta.diff(a, b)) == b
86 | end
87 |
88 | test "error on non-documents" do
89 | a = [Op.insert("A")]
90 | b = [Op.retain(1), Op.insert("B")]
91 |
92 | assert_raise RuntimeError, fn -> Delta.diff(a, b) end
93 | assert_raise RuntimeError, fn -> Delta.diff(b, a) end
94 | end
95 |
96 | test "inconvenient indices" do
97 | a = [Op.insert("12", %{"bold" => true}), Op.insert("34", %{"italic" => true})]
98 | b = [Op.insert("123", %{"color" => "red"})]
99 |
100 | assert [
101 | Op.retain(2, %{"bold" => nil, "color" => "red"}),
102 | Op.retain(1, %{"italic" => nil, "color" => "red"}),
103 | Op.delete(1)
104 | ] == Delta.diff(a, b)
105 |
106 | assert Delta.compose(a, Delta.diff(a, b)) == b
107 | end
108 |
109 | test "combination" do
110 | a = [Op.insert("Bad", %{"color" => "red"}), Op.insert("cat", %{"color" => "blue"})]
111 | b = [Op.insert("Good", %{"bold" => true}), Op.insert("dog", %{"italic" => true})]
112 |
113 | # semantic cleanup simplifies this diff
114 | assert [
115 | Op.delete(6),
116 | Op.insert("Good", %{"bold" => true}),
117 | Op.insert("dog", %{"italic" => true})
118 | ] == Delta.diff(a, b)
119 |
120 | assert Delta.compose(a, Delta.diff(a, b)) == b
121 | end
122 | end
123 |
124 | describe ".diff/2 (custom embeds)" do
125 | @describetag custom_embeds: [TestEmbed]
126 |
127 | test "equal strings" do
128 | a = [Op.insert("A")]
129 | b = [Op.insert("A")]
130 |
131 | assert [] == Delta.diff(a, b)
132 | assert Delta.compose(a, Delta.diff(a, b)) == b
133 | end
134 |
135 | test "equal embeds" do
136 | a = [Op.insert(%{"delta" => [Op.insert("hello")]})]
137 | b = [Op.insert(%{"delta" => [Op.insert("hello")]})]
138 |
139 | assert [] == Delta.diff(a, b)
140 | assert Delta.compose(a, Delta.diff(a, b)) == b
141 | end
142 |
143 | test "basic embed diff" do
144 | a = [Op.insert(%{"delta" => [Op.insert("hello world")]})]
145 | b = [Op.insert(%{"delta" => [Op.insert("goodbye world")]})]
146 |
147 | assert [
148 | Op.retain(%{
149 | "delta" => [
150 | Op.delete(5),
151 | Op.insert("goodbye")
152 | ]
153 | })
154 | ] == Delta.diff(a, b)
155 |
156 | assert Delta.compose(a, Delta.diff(a, b)) == b
157 | end
158 |
159 | test "embed diff with attribute changes" do
160 | a = [
161 | Op.insert(
162 | %{"delta" => [Op.insert("hello world")]},
163 | %{"bold" => true, "color" => "red"}
164 | )
165 | ]
166 |
167 | b = [
168 | Op.insert(
169 | %{"delta" => [Op.insert("goodbye world")]},
170 | %{"italic" => true, "color" => "yellow"}
171 | )
172 | ]
173 |
174 | assert [
175 | Op.retain(
176 | %{
177 | "delta" => [
178 | Op.delete(5),
179 | Op.insert("goodbye")
180 | ]
181 | },
182 | %{
183 | "bold" => nil,
184 | "italic" => true,
185 | "color" => "yellow"
186 | }
187 | )
188 | ] == Delta.diff(a, b)
189 |
190 | assert Delta.compose(a, Delta.diff(a, b)) == b
191 | end
192 |
193 | @tag custom_embeds: [TestEmbed, QuoteEmbed]
194 | test "different embeds" do
195 | a = [Op.insert(%{"delta" => [Op.insert("hello world")]})]
196 | b = [Op.insert(%{"quote" => [Op.insert("goodbye world")]})]
197 |
198 | assert [
199 | Op.delete(1),
200 | Op.insert(%{"quote" => [Op.insert("goodbye world")]})
201 | ] == Delta.diff(a, b)
202 |
203 | assert Delta.compose(a, Delta.diff(a, b)) == b
204 | end
205 |
206 | @tag custom_embeds: [QuoteEmbed]
207 | test "embeds without handler diff attributes if equal" do
208 | a = [Op.insert(%{"quote" => [Op.insert("hello world")]}, %{"author" => "A"})]
209 | b = [Op.insert(%{"quote" => [Op.insert("hello world")]}, %{"author" => "B"})]
210 |
211 | assert [
212 | Op.retain(1, %{"author" => "B"})
213 | ] == Delta.diff(a, b)
214 |
215 | assert Delta.compose(a, Delta.diff(a, b)) == b
216 | end
217 |
218 | @tag custom_embeds: [QuoteEmbed]
219 | test "embeds without handler replaces whole operation if different content" do
220 | a = [Op.insert(%{"quote" => [Op.insert("foo")]}, %{"author" => "A"})]
221 | b = [Op.insert(%{"quote" => [Op.insert("bar")]}, %{"author" => "B"})]
222 |
223 | assert [
224 | Op.delete(1),
225 | Op.insert(%{"quote" => [Op.insert("bar")]}, %{"author" => "B"})
226 | ] == Delta.diff(a, b)
227 | end
228 |
229 | @tag custom_embeds: [__MODULE__.CaseInsensitiveEmbed]
230 | test "embeds consider operations equal" do
231 | # Let's imagine the embed is case-insensitive
232 | a = [Op.insert(%{"delta" => [Op.insert("HELLO")]})]
233 | b = [Op.insert(%{"delta" => [Op.insert("hello")]})]
234 |
235 | assert [] == Delta.diff(a, b)
236 |
237 | # The invariant breaks for obvious reasons
238 | refute Delta.compose(a, Delta.diff(a, b)) == b
239 | end
240 |
241 | @tag custom_embeds: [__MODULE__.HomographEmbed]
242 | test "embeds consider equal inserts as different" do
243 | # Let's imagine the embed considers homographs as different words based on
244 | # `meaning` attribute
245 | a = [Op.insert(%{"delta" => [Op.insert("football", %{"meaning" => "american"})]})]
246 | b = [Op.insert(%{"delta" => [Op.insert("football", %{"meaning" => "soccer"})]})]
247 |
248 | assert [
249 | Op.delete(1),
250 | Op.insert(%{"delta" => [Op.insert("football", %{"meaning" => "soccer"})]})
251 | ] == Delta.diff(a, b)
252 |
253 | assert Delta.compose(a, Delta.diff(a, b)) == b
254 | end
255 | end
256 |
257 | defmodule CaseInsensitiveEmbed do
258 | def name, do: "delta"
259 |
260 | def diff(%{"insert" => %{"delta" => [%{"insert" => "HELLO"}]}}, %{
261 | "insert" => %{"delta" => [%{"insert" => "hello"}]}
262 | }) do
263 | []
264 | end
265 | end
266 |
267 | defmodule HomographEmbed do
268 | def name, do: "delta"
269 |
270 | def diff(_, b) do
271 | [Delta.Op.delete(1), b]
272 | end
273 | end
274 | end
275 |
--------------------------------------------------------------------------------
/test/delta/delta/invert_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta.Invert do
2 | use Delta.Support.Case, async: false
3 | doctest Delta, only: [invert: 2]
4 |
5 | describe ".invert/2 (basic)" do
6 | test "insert" do
7 | change = [%{"retain" => 2}, %{"insert" => "A"}]
8 | base = [%{"insert" => "123456"}]
9 | expected = [%{"retain" => 2}, %{"delete" => 1}]
10 | inverted = Delta.invert(change, base)
11 |
12 | assert inverted == expected
13 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
14 | end
15 |
16 | test "delete" do
17 | change = [%{"retain" => 2}, %{"delete" => 3}]
18 | base = [%{"insert" => "123456"}]
19 | expected = [%{"retain" => 2}, %{"insert" => "345"}]
20 | inverted = Delta.invert(change, base)
21 |
22 | assert inverted == expected
23 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
24 | end
25 |
26 | test "retain" do
27 | change = [%{"retain" => 2}, %{"retain" => 3, "attributes" => %{"bold" => true}}]
28 | base = [%{"insert" => "123456"}]
29 | expected = [%{"retain" => 2}, %{"retain" => 3, "attributes" => %{"bold" => nil}}]
30 | inverted = Delta.invert(change, base)
31 |
32 | assert inverted == expected
33 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
34 | end
35 |
36 | test "retain on a delta with different attributes" do
37 | base = [%{"insert" => "123"}, %{"insert" => "4", "attributes" => %{"bold" => true}}]
38 | change = [%{"retain" => 4, "attributes" => %{"italic" => true}}]
39 | expected = [%{"retain" => 4, "attributes" => %{"italic" => nil}}]
40 | inverted = Delta.invert(change, base)
41 |
42 | assert inverted == expected
43 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
44 | end
45 |
46 | test "combined" do
47 | change = [
48 | %{"retain" => 2},
49 | %{"delete" => 2},
50 | %{"insert" => "AB", "attributes" => %{"italic" => true}},
51 | %{"retain" => 2, "attributes" => %{"italic" => nil, "bold" => true}},
52 | %{"retain" => 2, "attributes" => %{"color" => "red"}},
53 | %{"delete" => 1}
54 | ]
55 |
56 | base = [
57 | %{"insert" => "123", "attributes" => %{"bold" => true}},
58 | %{"insert" => "456", "attributes" => %{"italic" => true}},
59 | %{"insert" => "789", "attributes" => %{"bold" => true, "color" => "red"}}
60 | ]
61 |
62 | expected = [
63 | %{"retain" => 2},
64 | %{"insert" => "3", "attributes" => %{"bold" => true}},
65 | %{"insert" => "4", "attributes" => %{"italic" => true}},
66 | %{"delete" => 2},
67 | %{"retain" => 2, "attributes" => %{"italic" => true, "bold" => nil}},
68 | %{"retain" => 2},
69 | %{"insert" => "9", "attributes" => %{"bold" => true, "color" => "red"}}
70 | ]
71 |
72 | inverted = Delta.invert(change, base)
73 | assert inverted == expected
74 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
75 | end
76 | end
77 |
78 | describe ".invert/2 (custom embeds)" do
79 | @describetag custom_embeds: [TestEmbed]
80 |
81 | test "invert a normal change" do
82 | change = [Op.retain(1, %{"bold" => true})]
83 | base = [Op.insert(%{"delta" => [Op.insert("a")]})]
84 | expected = [Op.retain(1, %{"bold" => nil})]
85 | inverted = Delta.invert(change, base)
86 |
87 | assert inverted == expected
88 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
89 | end
90 |
91 | test "invert an embed change" do
92 | change = [Op.retain(%{"delta" => [Op.insert("b")]})]
93 | base = [Op.insert(%{"delta" => [Op.insert("a")]})]
94 | expected = [Op.retain(%{"delta" => [Op.delete(1)]})]
95 | inverted = Delta.invert(change, base)
96 |
97 | assert inverted == expected
98 | assert base == base |> Delta.compose(change) |> Delta.compose(inverted)
99 | end
100 |
101 | test "invert an embed change with numbers" do
102 | delta = [
103 | Op.retain(1),
104 | Op.retain(1, %{"bold" => true}),
105 | Op.retain(%{"delta" => [Op.insert("b")]})
106 | ]
107 |
108 | base = [Op.insert("\n\n"), Op.insert(%{"delta" => [Op.insert("a")]})]
109 |
110 | expected = [
111 | Op.retain(1),
112 | Op.retain(1, %{"bold" => nil}),
113 | Op.retain(%{"delta" => [Op.delete(1)]})
114 | ]
115 |
116 | inverted = Delta.invert(delta, base)
117 |
118 | assert inverted == expected
119 | assert base == base |> Delta.compose(delta) |> Delta.compose(inverted)
120 | end
121 |
122 | test "respects base attributes" do
123 | delta = [
124 | Op.delete(1),
125 | Op.retain(1, %{"header" => 2}),
126 | Op.retain(%{"delta" => [Op.insert("b")]}, %{"padding" => 10, "margin" => 0})
127 | ]
128 |
129 | base = [
130 | Op.insert("\n"),
131 | Op.insert("\n", %{"header" => 1}),
132 | Op.insert(%{"delta" => [Op.insert("a")]}, %{"margin" => 10})
133 | ]
134 |
135 | expected = [
136 | Op.insert("\n"),
137 | Op.retain(1, %{"header" => 1}),
138 | Op.retain(%{"delta" => [Op.delete(1)]}, %{"padding" => nil, "margin" => 10})
139 | ]
140 |
141 | inverted = Delta.invert(delta, base)
142 |
143 | assert inverted == expected
144 | assert base == base |> Delta.compose(delta) |> Delta.compose(inverted)
145 | end
146 |
147 | test "works with multiple embeds" do
148 | delta = [
149 | Op.retain(1),
150 | Op.retain(%{"delta" => [Op.delete(1)]}),
151 | Op.retain(%{"delta" => [Op.delete(1)]})
152 | ]
153 |
154 | base = [
155 | Op.insert("\n"),
156 | Op.insert(%{"delta" => [Op.insert("a")]}),
157 | Op.insert(%{"delta" => [Op.insert("b")]})
158 | ]
159 |
160 | expected = [
161 | Op.retain(1),
162 | Op.retain(%{"delta" => [Op.insert("a")]}),
163 | Op.retain(%{"delta" => [Op.insert("b")]})
164 | ]
165 |
166 | inverted = Delta.invert(delta, base)
167 |
168 | assert inverted == expected
169 | assert base == base |> Delta.compose(delta) |> Delta.compose(inverted)
170 | end
171 | end
172 | end
173 |
--------------------------------------------------------------------------------
/test/delta/delta/transform_position_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta.TransformPosition do
2 | use Delta.Support.Case, async: true
3 |
4 | test "insert before position" do
5 | delta = [%{"insert" => "A"}]
6 | assert Delta.transform(2, delta) == 3
7 | end
8 |
9 | test "insert after position" do
10 | delta = [%{"retain" => 2}, %{"insert" => "A"}]
11 | assert Delta.transform(1, delta) == 1
12 | end
13 |
14 | test "insert at position" do
15 | delta = [%{"retain" => 2}, %{"insert" => "A"}]
16 | assert Delta.transform(2, delta, true) == 2
17 | assert Delta.transform(2, delta, false) == 3
18 | end
19 |
20 | test "delete before position" do
21 | delta = [%{"delete" => 2}]
22 | assert Delta.transform(4, delta) == 2
23 | end
24 |
25 | test "delete after position" do
26 | delta = [%{"retain" => 4}, %{"delete" => 2}]
27 | assert Delta.transform(2, delta) == 2
28 | end
29 |
30 | test "delete across position" do
31 | delta = [%{"retain" => 1}, %{"delete" => 4}]
32 | assert Delta.transform(2, delta) == 1
33 | end
34 |
35 | test "insert and delete before position" do
36 | delta = [%{"retain" => 2}, %{"insert" => "A"}, %{"delete" => 2}]
37 | assert Delta.transform(4, delta) == 3
38 | end
39 |
40 | test "insert before and delete across position" do
41 | delta = [%{"retain" => 2}, %{"insert" => "A"}, %{"delete" => 4}]
42 | assert Delta.transform(4, delta) == 3
43 | end
44 |
45 | test "delete before and delete across position" do
46 | delta = [%{"delete" => 1}, %{"retain" => 1}, %{"delete" => 4}]
47 | assert Delta.transform(4, delta) == 1
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/test/delta/delta/transform_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta.Transform do
2 | use Delta.Support.Case, async: false
3 | doctest Delta, only: [transform: 3]
4 |
5 | describe ".transform/3 (basic)" do
6 | test "insert + insert" do
7 | a = [Op.insert("A")]
8 | b = [Op.insert("B")]
9 |
10 | assert Delta.transform(a, b, true) == [Op.retain(1), Op.insert("B")]
11 | assert Delta.transform(a, b, false) == [Op.insert("B")]
12 | end
13 |
14 | test "insert + retain" do
15 | a = [Op.insert("A")]
16 | b = [Op.retain(1, %{"bold" => true, "color" => "red"})]
17 |
18 | assert Delta.transform(a, b) == [Op.retain(1) | b]
19 | end
20 |
21 | test "insert + delete" do
22 | a = [Op.insert("A")]
23 | b = [Op.delete(1)]
24 |
25 | assert Delta.transform(a, b) == [Op.retain(1), Op.delete(1)]
26 | end
27 |
28 | test "delete + insert" do
29 | a = [Op.delete(1)]
30 | b = [Op.insert("B")]
31 |
32 | assert Delta.transform(a, b, true) == b
33 | end
34 |
35 | test "delete + retain" do
36 | a = [Op.delete(1)]
37 | b = [Op.retain(1, %{"bold" => true, "color" => "red"})]
38 |
39 | assert Delta.transform(a, b, true) == []
40 | end
41 |
42 | test "delete + delete" do
43 | a = b = [Op.delete(1)]
44 |
45 | assert Delta.transform(a, b) == []
46 | end
47 |
48 | test "retain + insert" do
49 | a = [Op.retain(1, %{"color" => "blue"})]
50 | b = [Op.insert("B")]
51 |
52 | assert Delta.transform(a, b, true) == b
53 | end
54 |
55 | test "retain + retain (with priority)" do
56 | a = [Op.retain(1, %{"color" => "blue"})]
57 | b = [Op.retain(1, %{"color" => "red", "bold" => true})]
58 |
59 | assert Delta.transform(a, b, true) == [Op.retain(1, %{"bold" => true})]
60 | assert Delta.transform(b, a, true) == []
61 | end
62 |
63 | test "retain + retain (without priority)" do
64 | a = [Op.retain(1, %{"color" => "blue"})]
65 | b = [Op.retain(1, %{"color" => "red", "bold" => true})]
66 |
67 | assert Delta.transform(a, b, false) == [Op.retain(1, %{"bold" => true, "color" => "red"})]
68 | assert Delta.transform(b, a, false) == [Op.retain(1, %{"color" => "blue"})]
69 | end
70 |
71 | test "retain + delete" do
72 | a = [Op.retain(1, %{"color" => "blue"})]
73 | b = [Op.delete(1)]
74 |
75 | assert Delta.transform(a, b, true) == b
76 | end
77 |
78 | test "retain + delete (with attributes)" do
79 | a = [Op.retain(1, %{"color" => "blue"})]
80 | b = [Op.delete(1, %{"foo" => true})]
81 |
82 | assert Delta.transform(a, b, true) == b
83 | end
84 |
85 | test "alternating edits" do
86 | a = [Op.retain(2), Op.insert("si"), Op.delete(5)]
87 | b = [Op.retain(1), Op.insert("e"), Op.delete(5), Op.retain(1), Op.insert("ow")]
88 |
89 | assert Delta.transform(a, b, false) == [
90 | Op.retain(1),
91 | Op.insert("e"),
92 | Op.delete(1),
93 | Op.retain(2),
94 | Op.insert("ow")
95 | ]
96 |
97 | assert Delta.transform(b, a, false) == [
98 | Op.retain(2),
99 | Op.insert("si"),
100 | Op.delete(1)
101 | ]
102 | end
103 |
104 | test "conflicting appends" do
105 | a = [Op.retain(3), Op.insert("aa")]
106 | b = [Op.retain(3), Op.insert("bb")]
107 |
108 | assert Delta.transform(a, b, true) == [Op.retain(5), Op.insert("bb")]
109 | assert Delta.transform(b, a, false) == [Op.retain(3), Op.insert("aa")]
110 | end
111 |
112 | test "prepend + append" do
113 | a = [Op.insert("aa")]
114 | b = [Op.retain(3), Op.insert("bb")]
115 |
116 | assert Delta.transform(a, b, false) == [Op.retain(5), Op.insert("bb")]
117 | assert Delta.transform(b, a, false) == [Op.insert("aa")]
118 | end
119 |
120 | test "trailing deletes with differing lengths" do
121 | a = [Op.retain(2), Op.delete(1)]
122 | b = [Op.delete(3)]
123 |
124 | assert Delta.transform(a, b, false) == [Op.delete(2)]
125 | assert Delta.transform(b, a, false) == []
126 | end
127 | end
128 |
129 | describe ".transform/3 (custom embeds)" do
130 | @describetag custom_embeds: [TestEmbed]
131 |
132 | test "transform an embed change with number" do
133 | a = [Op.retain(1)]
134 | b = [Op.retain(%{"delta" => [Op.insert("b")]})]
135 |
136 | expected = [Op.retain(%{"delta" => [Op.insert("b")]})]
137 |
138 | assert Delta.transform(a, b, true) == expected
139 | assert Delta.transform(a, b, false) == expected
140 | end
141 |
142 | test "transform an embed change" do
143 | a = [Op.retain(%{"delta" => [Op.insert("a")]})]
144 | b = [Op.retain(%{"delta" => [Op.insert("b")]})]
145 |
146 | with_priority = [Op.retain(%{"delta" => [Op.retain(1), Op.insert("b")]})]
147 | without_priority = [Op.retain(%{"delta" => [Op.insert("b")]})]
148 |
149 | assert Delta.transform(a, b, true) == with_priority
150 | assert Delta.transform(a, b, false) == without_priority
151 | end
152 | end
153 | end
154 |
--------------------------------------------------------------------------------
/test/delta/delta_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Delta do
2 | use Delta.Support.Case, async: true
3 |
4 | doctest Delta,
5 | only: [
6 | compact: 1,
7 | concat: 2,
8 | push: 2,
9 | size: 1,
10 | slice: 3,
11 | slice_max: 3,
12 | split: 3
13 | ]
14 |
15 | # NOTE: {compose, transform, invert, diff} tests are in their
16 | # dedicated test suites under delta/
17 |
18 | describe ".slice/3" do
19 | test "slice across" do
20 | delta = [
21 | %{"insert" => "ABC"},
22 | %{"insert" => "012", "attributes" => %{bold: true}},
23 | %{"insert" => "DEF"}
24 | ]
25 |
26 | assert Delta.slice(delta, 1, 7) == [
27 | %{"insert" => "BC"},
28 | %{"insert" => "012", "attributes" => %{bold: true}},
29 | %{"insert" => "DE"}
30 | ]
31 | end
32 |
33 | test "slice boundaries" do
34 | delta = [
35 | %{"insert" => "ABC"},
36 | %{"insert" => "012", "attributes" => %{bold: true}},
37 | %{"insert" => "DEF"}
38 | ]
39 |
40 | assert Delta.slice(delta, 3, 3) == [
41 | %{"insert" => "012", "attributes" => %{bold: true}}
42 | ]
43 | end
44 |
45 | test "slice middle" do
46 | delta = [
47 | %{"insert" => "ABC"},
48 | %{"insert" => "012", "attributes" => %{bold: true}},
49 | %{"insert" => "DEF"}
50 | ]
51 |
52 | assert Delta.slice(delta, 4, 1) == [
53 | %{"insert" => "1", "attributes" => %{bold: true}}
54 | ]
55 | end
56 |
57 | test "slice normal emoji" do
58 | delta = [%{"insert" => "01🙋45"}]
59 | assert Delta.slice(delta, 1, 4) == [%{"insert" => "1🙋4"}]
60 | end
61 |
62 | test "slice emoji with zero width joiner" do
63 | delta = [%{"insert" => "01🙋♂️78"}]
64 | assert Delta.slice(delta, 1, 7) == [%{"insert" => "1🙋♂️7"}]
65 | end
66 |
67 | test "slice emoji with joiner and modifer" do
68 | delta = [%{"insert" => "01🙋🏽♂️90"}]
69 | assert Delta.slice(delta, 1, 9) == [%{"insert" => "1🙋🏽♂️9"}]
70 | end
71 |
72 | test "slice with 0 index" do
73 | delta = [Op.insert("12")]
74 | assert Delta.slice(delta, 0, 1) == [%{"insert" => "1"}]
75 | end
76 |
77 | test "slice insert object with 0 index" do
78 | delta = [Op.insert(%{"id" => "1"}), Op.insert(%{"id" => "2"})]
79 | assert Delta.slice(delta, 0, 1) == [%{"insert" => %{"id" => "1"}}]
80 | end
81 | end
82 |
83 | describe ".slice_max/3" do
84 | test "slice across" do
85 | delta = [
86 | %{"insert" => "ABC"},
87 | %{"insert" => "012", "attributes" => %{bold: true}},
88 | %{"insert" => "DEF"}
89 | ]
90 |
91 | assert Delta.slice_max(delta, 1, 7) == [
92 | %{"insert" => "BC"},
93 | %{"insert" => "012", "attributes" => %{bold: true}},
94 | %{"insert" => "DE"}
95 | ]
96 | end
97 |
98 | test "slice boundaries" do
99 | delta = [
100 | %{"insert" => "ABC"},
101 | %{"insert" => "012", "attributes" => %{bold: true}},
102 | %{"insert" => "DEF"}
103 | ]
104 |
105 | assert Delta.slice_max(delta, 3, 3) == [
106 | %{"insert" => "012", "attributes" => %{bold: true}}
107 | ]
108 | end
109 |
110 | test "slice middle" do
111 | delta = [
112 | %{"insert" => "ABC"},
113 | %{"insert" => "012", "attributes" => %{bold: true}},
114 | %{"insert" => "DEF"}
115 | ]
116 |
117 | assert Delta.slice_max(delta, 4, 1) == [
118 | %{"insert" => "1", "attributes" => %{bold: true}}
119 | ]
120 | end
121 |
122 | test "slice normal emoji" do
123 | delta = [%{"insert" => "01🙋45"}]
124 | assert Delta.slice_max(delta, 1, 4) == [%{"insert" => "1🙋4"}]
125 | end
126 |
127 | test "slice emoji with zero width joiner" do
128 | delta = [%{"insert" => "01🙋♂️78"}]
129 | assert Delta.slice_max(delta, 1, 7) == [%{"insert" => "1🙋♂️7"}]
130 | end
131 |
132 | test "slice emoji with joiner and modifer" do
133 | delta = [%{"insert" => "01🙋🏽♂️90"}]
134 | assert Delta.slice_max(delta, 1, 9) == [%{"insert" => "1🙋🏽♂️9"}]
135 | end
136 |
137 | test "slice with 0 index" do
138 | delta = [Op.insert("12")]
139 | assert Delta.slice_max(delta, 0, 1) == [%{"insert" => "1"}]
140 | end
141 |
142 | test "slice insert object with 0 index" do
143 | delta = [Op.insert(%{"id" => "1"}), Op.insert(%{"id" => "2"})]
144 | assert Delta.slice_max(delta, 0, 1) == [%{"insert" => %{"id" => "1"}}]
145 | end
146 |
147 | test "slice emoji: codepoint + variation selector" do
148 | # "01☹️345"
149 | delta = [%{"insert" => "01\u2639\uFE0F345"}]
150 | assert Delta.slice_max(delta, 1, 2) == [%{"insert" => "1"}]
151 | assert Delta.slice_max(delta, 1, 3) == [%{"insert" => "1\u2639\uFE0F"}]
152 | end
153 |
154 | test "slice emoji: codepoint + skin tone modifier" do
155 | # "01🤵🏽345"
156 | delta = [%{"insert" => "01\u{1F935}\u{1F3FD}345"}]
157 | assert Delta.slice_max(delta, 1, 2) == [%{"insert" => "1"}]
158 | assert Delta.slice_max(delta, 1, 3) == [%{"insert" => "1"}]
159 | assert Delta.slice_max(delta, 1, 4) == [%{"insert" => "1"}]
160 | assert Delta.slice_max(delta, 1, 5) == [%{"insert" => "1\u{1F935}\u{1F3FD}"}]
161 | end
162 |
163 | test "slice emoji: codepoint + ZWJ + codepoint" do
164 | # "01👨🏭345"
165 | delta = [%{"insert" => "01\u{1F468}\u200D\u{1F3ED}345"}]
166 | assert Delta.slice_max(delta, 1, 2) == [%{"insert" => "1"}]
167 | assert Delta.slice_max(delta, 1, 3) == [%{"insert" => "1"}]
168 | assert Delta.slice_max(delta, 1, 4) == [%{"insert" => "1"}]
169 | assert Delta.slice_max(delta, 1, 5) == [%{"insert" => "1"}]
170 | assert Delta.slice_max(delta, 1, 6) == [%{"insert" => "1\u{1F468}\u200D\u{1F3ED}"}]
171 | end
172 |
173 | test "slice emoji: flags" do
174 | # "01🇦🇺345"
175 | delta = [%{"insert" => "01\u{1F1E6}\u{1F1FA}345"}]
176 | assert Delta.slice_max(delta, 1, 2) == [%{"insert" => "1"}]
177 | # "1🇦"
178 | assert Delta.slice_max(delta, 1, 3) == [%{"insert" => "1\u{1F1E6}"}]
179 | # "1🇦"
180 | assert Delta.slice_max(delta, 1, 4) == [%{"insert" => "1\u{1F1E6}"}]
181 | # "1🇦🇺"
182 | assert Delta.slice_max(delta, 1, 5) == [%{"insert" => "1\u{1F1E6}\u{1F1FA}"}]
183 | end
184 |
185 | test "slice emoji: tag sequence" do
186 | # "01🏴345"
187 | delta = [
188 | %{"insert" => "01\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}345"}
189 | ]
190 |
191 | for len <- 2..14 do
192 | assert Delta.slice_max(delta, 1, len) == [%{"insert" => "1"}]
193 | end
194 |
195 | assert Delta.slice_max(delta, 1, 15) == [
196 | %{"insert" => "1\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"}
197 | ]
198 | end
199 |
200 | test "slice complex emoji" do
201 | # "01🚵🏻♀️345"
202 | delta = [%{"insert" => "01\u{1F6B5}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}345"}]
203 |
204 | for len <- 2..7 do
205 | assert Delta.slice_max(delta, 1, len) == [%{"insert" => "1"}]
206 | end
207 |
208 | assert Delta.slice_max(delta, 1, 8) == [
209 | %{"insert" => "1\u{1F6B5}\u{1F3FB}\u{200D}\u{2640}\u{FE0F}"}
210 | ]
211 | end
212 | end
213 |
214 | describe ".split/3" do
215 | test "split at op boundary" do
216 | delta = [
217 | Op.insert("hello"),
218 | Op.insert(%{"code-embed" => []}),
219 | Op.insert("world")
220 | ]
221 |
222 | # this splitter should split the delta immediately before the first embed
223 | assert Delta.split(delta, fn
224 | %{"insert" => text}, _ when is_binary(text) -> :cont
225 | _, _ -> 0
226 | end) ==
227 | {[Op.insert("hello")],
228 | [
229 | Op.insert(%{"code-embed" => []}),
230 | Op.insert("world")
231 | ]}
232 | end
233 | end
234 |
235 | describe ".push/2" do
236 | test "push merge" do
237 | delta =
238 | []
239 | |> Delta.push(Op.insert("Hello"))
240 | |> Delta.push(Op.insert(" World!"))
241 |
242 | assert(delta == [%{"insert" => "Hello World!"}])
243 | end
244 |
245 | test "push redundant" do
246 | delta =
247 | []
248 | |> Delta.push(Op.insert("Hello"))
249 | |> Delta.push(Op.retain(0))
250 |
251 | assert(delta == [%{"insert" => "Hello"}])
252 | end
253 |
254 | @tag skip: true
255 | test "insert after delete" do
256 | flunk("implement this")
257 | end
258 | end
259 | end
260 |
--------------------------------------------------------------------------------
/test/delta/op_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Tests.Op do
2 | use ExUnit.Case, async: true
3 | alias Delta.Op
4 | doctest Op
5 |
6 | describe ".compose/2 : retain + delete" do
7 | test "retain + delete" do
8 | a = Op.retain(1)
9 | b = Op.delete(1)
10 |
11 | assert Op.compose(a, b) == {Op.delete(1), false, false}
12 | end
13 |
14 | test "retain + bigger delete" do
15 | a = Op.retain(1)
16 | b = Op.delete(2)
17 |
18 | assert Op.compose(a, b) == {Op.delete(1), false, Op.delete(1)}
19 | end
20 |
21 | test "retain + smaller delete" do
22 | a = Op.retain(2)
23 | b = Op.delete(1)
24 |
25 | assert Op.compose(a, b) == {Op.delete(1), Op.retain(1), false}
26 | end
27 |
28 | test "retain with attributes + bigger delete" do
29 | a = Op.retain(1, %{"foo" => true})
30 | b = Op.delete(2)
31 |
32 | assert Op.compose(a, b) == {Op.delete(1), false, Op.delete(1)}
33 | end
34 |
35 | test "retain with attributes + smaller delete" do
36 | a = Op.retain(2, %{"foo" => true})
37 | b = Op.delete(1)
38 |
39 | assert Op.compose(a, b) == {Op.delete(1), Op.retain(1, %{"foo" => true}), false}
40 | end
41 |
42 | test "retain with attributes + bigger delete with attributes" do
43 | a = Op.retain(1, %{"foo" => true})
44 | b = Op.delete(2, %{"bar" => true})
45 |
46 | assert Op.compose(a, b) ==
47 | {Op.delete(1, %{"bar" => true}), false, Op.delete(1, %{"bar" => true})}
48 | end
49 | end
50 |
51 | describe ".compose/2 : retain + retain" do
52 | test "retain + retain" do
53 | a = Op.retain(1)
54 | b = Op.retain(1)
55 |
56 | assert Op.compose(a, b) == {Op.retain(1), false, false}
57 | end
58 |
59 | test "retain + retain with attributes" do
60 | a = Op.retain(1, %{"foo" => true})
61 | b = Op.retain(1, %{"bar" => true})
62 |
63 | assert Op.compose(a, b) == {Op.retain(1, %{"foo" => true, "bar" => true}), false, false}
64 | end
65 |
66 | test "retain + bigger retain" do
67 | a = Op.retain(1)
68 | b = Op.retain(2)
69 |
70 | assert Op.compose(a, b) == {Op.retain(1), false, Op.retain(1)}
71 | end
72 |
73 | test "retain + smaller retain" do
74 | a = Op.retain(2)
75 | b = Op.retain(1)
76 |
77 | assert Op.compose(a, b) == {Op.retain(1), Op.retain(1), false}
78 | end
79 |
80 | test "retain + bigger retain with attributes" do
81 | a = Op.retain(1)
82 | b = Op.retain(2, %{"foo" => true})
83 |
84 | assert Op.compose(a, b) ==
85 | {Op.retain(1, %{"foo" => true}), false, Op.retain(1, %{"foo" => true})}
86 | end
87 |
88 | test "retain + smaller retain with attributes" do
89 | a = Op.retain(2)
90 | b = Op.retain(1, %{"foo" => true})
91 |
92 | assert Op.compose(a, b) == {Op.retain(1, %{"foo" => true}), Op.retain(1), false}
93 | end
94 |
95 | test "retain + bigger retain both with attributes" do
96 | a = Op.retain(1, %{"bar" => true})
97 | b = Op.retain(2, %{"foo" => true})
98 |
99 | assert Op.compose(a, b) ==
100 | {Op.retain(1, %{"foo" => true, "bar" => true}), false,
101 | Op.retain(1, %{"foo" => true})}
102 | end
103 | end
104 |
105 | describe ".compose/2 : insert + retain" do
106 | test "insert + retain" do
107 | a = Op.insert("A")
108 | b = Op.retain(1)
109 |
110 | assert Op.compose(a, b) == {Op.insert("A"), false, false}
111 | end
112 |
113 | test "insert + smaller retain" do
114 | a = Op.insert("Hello")
115 | b = Op.retain(4)
116 |
117 | assert Op.compose(a, b) == {Op.insert("Hell"), Op.insert("o"), false}
118 | end
119 |
120 | test "insert + bigger retain" do
121 | a = Op.insert("Hello")
122 | b = Op.retain(6)
123 |
124 | assert Op.compose(a, b) == {Op.insert("Hello"), false, Op.retain(1)}
125 | end
126 |
127 | test "insert + retain both with attributes" do
128 | a = Op.insert("A", %{"foo" => true})
129 | b = Op.retain(1, %{"bar" => true})
130 |
131 | assert Op.compose(a, b) == {Op.insert("A", %{"foo" => true, "bar" => true}), false, false}
132 | end
133 |
134 | test "insert + smaller retain with attributes" do
135 | a = Op.insert("Hello")
136 | b = Op.retain(4, %{"foo" => true})
137 |
138 | assert Op.compose(a, b) == {Op.insert("Hell", %{"foo" => true}), Op.insert("o"), false}
139 | end
140 |
141 | test "insert + bigger retain with attributes" do
142 | a = Op.insert("Hello")
143 | b = Op.retain(6, %{"foo" => true})
144 |
145 | assert Op.compose(a, b) ==
146 | {Op.insert("Hello", %{"foo" => true}), false, Op.retain(1, %{"foo" => true})}
147 | end
148 |
149 | test "insert + smaller retain both with attributes" do
150 | a = Op.insert("Hello", %{"foo" => true})
151 | b = Op.retain(4, %{"bar" => true})
152 |
153 | assert Op.compose(a, b) ==
154 | {Op.insert("Hell", %{"foo" => true, "bar" => true}),
155 | Op.insert("o", %{"foo" => true}), false}
156 | end
157 |
158 | test "insert + bigger retain both with attributes" do
159 | a = Op.insert("Hello", %{"foo" => true})
160 | b = Op.retain(6, %{"bar" => true})
161 |
162 | assert Op.compose(a, b) ==
163 | {Op.insert("Hello", %{"foo" => true, "bar" => true}), false,
164 | Op.retain(1, %{"bar" => true})}
165 | end
166 | end
167 |
168 | describe ".compose/2 unsupported" do
169 | test "delete on the left" do
170 | delete = Op.delete(1)
171 | retain = Op.retain(1)
172 | insert = Op.insert("A")
173 |
174 | assert Op.compose(delete, retain) == {false, false, false}
175 | assert Op.compose(delete, insert) == {false, false, false}
176 | end
177 |
178 | test "insert on the right" do
179 | retain = Op.retain(1)
180 | insert = Op.insert("A")
181 |
182 | assert Op.compose(retain, insert) == {false, false, false}
183 | assert Op.compose(insert, insert) == {false, false, false}
184 | end
185 | end
186 | end
187 |
--------------------------------------------------------------------------------
/test/support/case.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.Support.Case do
2 | @moduledoc false
3 | use ExUnit.CaseTemplate
4 |
5 | using do
6 | quote do
7 | alias Delta
8 | alias Delta.{Op, Attr}
9 | alias Delta.Support.{TestEmbed, QuoteEmbed}
10 | end
11 | end
12 |
13 | setup tags do
14 | setup_embeds(tags[:custom_embeds])
15 | :ok
16 | end
17 |
18 | defp setup_embeds(embeds) when is_list(embeds) do
19 | previous = Application.get_env(:delta, :custom_embeds, [])
20 | Application.put_env(:delta, :custom_embeds, embeds)
21 | on_exit(fn -> Application.put_env(:delta, :custom_embeds, previous) end)
22 | end
23 |
24 | defp setup_embeds(_other), do: :ok
25 | end
26 |
--------------------------------------------------------------------------------
/test/support/quote_embed.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.Support.QuoteEmbed do
2 | @moduledoc false
3 | @behaviour Delta.EmbedHandler
4 |
5 | @impl true
6 | def name, do: "quote"
7 |
8 | @impl true
9 | def compose(a, b, _keep_nil), do: Delta.compose(a, b)
10 |
11 | @impl true
12 | defdelegate transform(a, b, priority?), to: Delta
13 |
14 | @impl true
15 | defdelegate invert(a, b), to: Delta
16 | end
17 |
--------------------------------------------------------------------------------
/test/support/test_embed.ex:
--------------------------------------------------------------------------------
1 | defmodule Delta.Support.TestEmbed do
2 | @moduledoc false
3 | @behaviour Delta.EmbedHandler
4 |
5 | @impl true
6 | def name, do: "delta"
7 |
8 | @impl true
9 | def compose(a, b, _keep_nil), do: Delta.compose(a, b)
10 |
11 | @impl true
12 | defdelegate transform(a, b, priority?), to: Delta
13 |
14 | @impl true
15 | defdelegate invert(a, b), to: Delta
16 |
17 | @impl true
18 | def diff(a, b) do
19 | attr_diff = Delta.Attr.diff(a["attributes"], b["attributes"])
20 |
21 | diff =
22 | case Delta.diff(a["insert"]["delta"], b["insert"]["delta"]) do
23 | [] -> 1
24 | delta -> %{"delta" => delta}
25 | end
26 |
27 | [Delta.Op.retain(diff, attr_diff)]
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------