├── .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 | --------------------------------------------------------------------------------