rich-text 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2ccc9aaba7e28826fd5559ee6a2fdf8bd358faa5d6cf55bf5f9b2b3819dc33ed
4
+ data.tar.gz: be7c87956e6e8c06f60b96f05c115b63430ce431eeba5faf4e9e15f66fa3e478
5
+ SHA512:
6
+ metadata.gz: f3619d730230b49cb083f6866479848c0c2bdfb8a9b7c6095163f6d54eef6aca227052ffd0f64334800725e42410b0f04d27f2f52d588454a6b50cda636221fe
7
+ data.tar.gz: 57148b5ce1ffa60035391a8f7aeed6ba7bde5a6f9c4774f52b7375d72d2332ca25157b10e058edf2171971de76d2cc27e33e0c52d37bf61c1e10a88e4b0b5aba
data/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /Gemfile.lock
4
+ /_yardoc/
5
+ /coverage/
6
+ /doc/
7
+ /pkg/
8
+ /spec/reports/
9
+ /tmp/
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.2.5
data/.travis.yml ADDED
@@ -0,0 +1,5 @@
1
+ sudo: false
2
+ language: ruby
3
+ rvm:
4
+ - 2.2.5
5
+ before_install: gem install bundler -v 1.13.6
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --embed-mixins --markup=markdown
data/CHANGELOG.md ADDED
File without changes
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in rich-text.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2014 Jason Chen
4
+ Copyright (c) 2016 Blake Thomson
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # Rich Text
2
+
3
+ Ported from https://github.com/quilljs/delta, this library provides an elegant way of creating, manipulating, iterating, and transforming rich-text deltas and documents with a ruby-native API.
4
+
5
+ rich-text (aka Quill delta) is a format for representing attributed text. It aims to be intuitive and human readable with the ability to express any possible document or diff between documents.
6
+
7
+ This format is suitable for [operational transformation](https://en.wikipedia.org/wiki/Operational_transformation) and defines several functions ([`compose`](#compose), [`transform`](#transform), [`transform_position`](#transform-position), and [`diff`](#diff)) to support this use case.
8
+
9
+ For more information on the format itself, please consult the original README: https://github.com/quilljs/delta
10
+
11
+ ## Docs
12
+
13
+ Please see the generated [API docs](https://www.rubydoc.info/gems/rich-text) for more details on all of the available classes and methods.
14
+
15
+ ## TODO
16
+
17
+ - Implement `Delta#include?(other)`
18
+ - Finish writing tests
19
+
20
+ ## Quick Example
21
+
22
+ ```ruby
23
+ gandalf = RichText::Delta.new([
24
+ { insert: 'Gandalf', attributes: { bold: true } },
25
+ { insert: ' the ' },
26
+ { insert: 'Grey', attributes: { color: '#ccc' } }
27
+ ])
28
+ # => #<RichText::Delta [insert="Gandalf" {"bold"=>true}, insert=" the ", insert="Grey" {"color"=>"#ccc"}]>
29
+
30
+ # Keep the first 12 characters, delete the next 4, and insert a white 'White'
31
+ death = RichText::Delta.new
32
+ .retain(12)
33
+ .delete(4)
34
+ .insert('White', { color: '#fff' })
35
+ # => #<RichText::Delta [retain=12, delete=4, insert="White" {:color=>"#fff"}]>
36
+
37
+ gandalf.compose(death)
38
+ # => #<RichText::Delta [insert="Gandalf" {"bold"=>true}, insert=" the ", insert="White" {:color=>"#fff"}]>
39
+ ```
40
+
41
+ ## Operations
42
+
43
+ ### Insert Operation
44
+
45
+ Insert operations have an `insert` key defined. A String value represents inserting text. Any other type represents inserting an embed (however only one level of object comparison will be performed for equality).
46
+
47
+ In both cases of text and embeds, an optional `attributes` key can be defined with an Hash to describe additonal formatting information. Formats can be changed by the [retain](#retain) operation.
48
+
49
+ ```ruby
50
+ # Insert a bolded "Text"
51
+ { insert: "Text", attributes: { bold: true } }
52
+
53
+ # Insert a link
54
+ { insert: "Google", attributes: { link: 'https://www.google.com' } }
55
+
56
+ # Insert an embed
57
+ {
58
+ insert: { image: 'https://octodex.github.com/images/labtocat.png' },
59
+ attributes: { alt: "Lab Octocat" }
60
+ }
61
+
62
+ # Insert another embed
63
+ {
64
+ insert: { video: 'https://www.youtube.com/watch?v=dMH0bHeiRNg' },
65
+ attributes: {
66
+ width: 420,
67
+ height: 315
68
+ }
69
+ }
70
+ ```
71
+
72
+ ### Delete Operation
73
+
74
+ Delete operations have a Number `delete` key defined representing the number of characters to delete. All embeds have a length of 1.
75
+
76
+ ```ruby
77
+ # Delete the next 10 characters
78
+ { delete: 10 }
79
+ ```
80
+
81
+ ### Retain Operation
82
+
83
+ Retain operations have a Number `retain` key defined representing the number of characters to keep (other libraries might use the name keep or skip). An optional `attributes` key can be defined with an Hash to describe formatting changes to the character range. A value of `null` in the `attributes` Hash represents removal of that key.
84
+
85
+ *Note: It is not necessary to retain the last characters of a document as this is implied.*
86
+
87
+ ```ruby
88
+ # Keep the next 5 characters
89
+ { retain: 5 }
90
+
91
+ # Keep and bold the next 5 characters
92
+ { retain: 5, attributes: { bold: true } }
93
+
94
+ # Keep and unbold the next 5 characters
95
+ # More specifically, remove the bold key in the attributes Hash
96
+ # in the next 5 characters
97
+ { retain: 5, attributes: { bold: null } }
98
+ ```
99
+
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ require "bundler/gem_tasks"
2
+ require "rake/testtask"
3
+ require "yard"
4
+
5
+ Rake::TestTask.new(:test) do |t|
6
+ t.libs << "test"
7
+ t.libs << "lib"
8
+ t.test_files = FileList['test/**/*_test.rb']
9
+ end
10
+
11
+ YARD::Rake::YardocTask.new(:doc)
12
+
13
+ task :default => :test
data/bin/console ADDED
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "rich-text"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "irb"
14
+ IRB.start
data/bin/rake ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rake' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rake", "rake")
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/lib/rich-text.rb ADDED
@@ -0,0 +1,31 @@
1
+ module RichText
2
+ def self.config
3
+ @config ||= Config.new
4
+ end
5
+
6
+ def self.configure
7
+ yield config
8
+ end
9
+ end
10
+
11
+ require 'rich-text/version'
12
+ require 'rich-text/config'
13
+ require 'rich-text/delta'
14
+ require 'rich-text/html'
15
+
16
+ RichText.configure do |c|
17
+ c.safe_mode = true
18
+ c.html_default_block_tag = 'p'
19
+ c.html_block_tags = {
20
+ firstheader: 'h1',
21
+ secondheader: 'h2',
22
+ thirdheader: 'h3',
23
+ list: ->(content, value) { %(<ol><li>#{value}</li></ol>) },
24
+ bullet: ->(content, value) { %(<ul><li>#{value}</li></ul>) }
25
+ }
26
+ c.html_inline_tags = {
27
+ bold: 'strong',
28
+ italic: 'em',
29
+ link: ->(content, value) { %(<a href="#{value}">#{content}</a>) }
30
+ }
31
+ end
@@ -0,0 +1,29 @@
1
+ module RichText
2
+ # @api private
3
+ module Attributes
4
+ class << self
5
+ def compose(a, b, keep_nil)
6
+ return b if a.nil?
7
+ return a if b.nil?
8
+ result = b.merge(a) { |k,vb,va| vb }
9
+ result.delete_if { |k,v| v.nil? } unless keep_nil
10
+ result
11
+ end
12
+
13
+ def diff(a, b)
14
+ return b if a.nil?
15
+ return a if b.nil?
16
+ (a.keys | b.keys).each_with_object({}) do |key, memo|
17
+ memo[key] = b[key] if a[key] != b[key]
18
+ end
19
+ end
20
+
21
+ def transform(a, b, priority)
22
+ return b if a.nil? || a.empty? || b.nil? || b.empty? || !priority
23
+ (b.keys - a.keys).each_with_object({}) do |key, memo|
24
+ memo[key] = b[key]
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,6 @@
1
+ module RichText
2
+ # @api private
3
+ class Config
4
+ attr_accessor :safe_mode, :html_default_block_tag, :html_block_tags, :html_inline_tags
5
+ end
6
+ end
@@ -0,0 +1,490 @@
1
+ require 'json'
2
+ require 'rich-text/diff'
3
+ require 'rich-text/iterator'
4
+ require 'rich-text/op'
5
+ require 'rich-text/attributes'
6
+
7
+ module RichText
8
+ # A Delta is made up of an array of operations. All methods maintain the property that Deltas are represented in the most compact form. For example two consecutive insert operations with the same attributes will be merged into one. Thus a vanilla deep Hash/Array comparison can be used to determine Delta equality.
9
+ #
10
+ # A Delta with only insert operations can be used to represent a fully formed document. This can be thought of as a Delta applied to an empty document.
11
+ class Delta
12
+ # @return [Array<Op>]
13
+ attr_reader :ops
14
+
15
+ # Parses a new Delta object from incoming data.
16
+ # @param data [String, Array, Hash] String, Array of operations, or a Hash with an `:ops` or `'ops'` key set to an array of operations
17
+ # @raise [ArgumentError] if an argument other than a String, Array, or Hash is passed, or if any of the contained operations cannot be parsed by {Op.parse}
18
+ # @example
19
+ # # All equivalent
20
+ # RichText::Delta.new("abc")
21
+ # RichText::Delta.new([{ insert: 'abc' }])
22
+ # RichText::Delta.new({ ops: [{ insert: 'abc' }] })
23
+ def initialize(data = [])
24
+ if data.is_a?(String)
25
+ @ops = [Op.new(:insert, data)]
26
+ elsif data.is_a?(Array)
27
+ @ops = data.map { |h| Op.parse(h) }
28
+ elsif data.is_a?(Hash) && (data.key?('ops') || data.key?(:ops))
29
+ @ops = (data['ops'] || data[:ops]).map { |h| Op.parse(h) }
30
+ else
31
+ ArgumentError.new("Please provide either String, Array or Hash with an 'ops' key containing an Array")
32
+ end
33
+
34
+ @ops
35
+ end
36
+
37
+ # Appends an insert operation. A no-op if the provided value is the empty string.
38
+ # @param value [String|{ String => Object }] the value to insert, either a String or a Hash with a single String or Symbol key
39
+ # @param attributes [Hash]
40
+ # @return [Delta] `self` for chainability
41
+ # @example
42
+ # delta.insert('abc').insert('xyz', { bold: true })
43
+ # delta.insert({ image: 'http://i.imgur.com/FUCb95Y.gif' })
44
+ def insert(value, attributes = {})
45
+ return self if value.is_a?(String) && value.length == 0
46
+ push(Op.new(:insert, value, attributes))
47
+ end
48
+
49
+ # Appends a delete operation. A no-op if value <= 0.
50
+ # @param value [Integer] the number of characters to delete
51
+ # @return [Delta] `self` for chainability
52
+ # @example
53
+ # delta.delete(5)
54
+ def delete(value)
55
+ return self if value <= 0
56
+ push(Op.new(:delete, value))
57
+ end
58
+
59
+ # Appends a retain operation. A no-op if value <= 0.
60
+ # @param value [Integer] the number of characters to skip or change attributes for
61
+ # @param attributes [Hash] leave blank to leave attributes unchanged
62
+ # @return [Delta] `self` for chainability
63
+ # @example
64
+ # delta.retain(4).retain(5, { color: '#0c6' })
65
+ def retain(value, attributes = {})
66
+ return self if value <= 0
67
+ push(Op.new(:retain, value, attributes))
68
+ end
69
+
70
+ # Adds a new operation to the end of the delta, possibly merging it with the previously-last op if the types and attributes match, and ensuring that inserts always come before deletes.
71
+ # @param op [Op] the operation to add
72
+ # @return [Delta] `self` for chainability
73
+ def push(op)
74
+ index = @ops.length
75
+ last_op = @ops[index - 1]
76
+
77
+ if last_op
78
+ if last_op.delete? && op.delete?
79
+ @ops[index - 1] = Op.new(:delete, last_op.value + op.value)
80
+ return self
81
+ end
82
+
83
+ # Since it does not matter if we insert before or after deleting at the
84
+ # same index, always prefer to insert first
85
+ if last_op.delete? && op.insert?
86
+ index -= 1
87
+ last_op = @ops[index - 1]
88
+ if !last_op
89
+ @ops.unshift(op)
90
+ return self
91
+ end
92
+ end
93
+
94
+ if last_op.attributes == op.attributes
95
+ if last_op.insert?(String) && op.insert?(String)
96
+ @ops[index - 1] = Op.new(:insert, last_op.value + op.value, last_op.attributes)
97
+ return self
98
+ elsif last_op.retain? && op.retain?
99
+ @ops[index - 1] = Op.new(:retain, last_op.value + op.value, last_op.attributes)
100
+ return self
101
+ end
102
+ end
103
+ end
104
+
105
+ if index == @ops.length
106
+ @ops.push(op)
107
+ else
108
+ @ops[index, 0] = op
109
+ end
110
+
111
+ return self
112
+ end
113
+ alias :<< :push
114
+
115
+ # Modifies self by removing the last op if it was a retain without attributes.
116
+ # @return [Delta] `self` for chainability
117
+ def chop!
118
+ last_op = @ops.last
119
+ if last_op && last_op.retain? && !last_op.attributes?
120
+ @ops.pop
121
+ end
122
+ return self
123
+ end
124
+
125
+ # Returns true if all operations are inserts, i.e. a fully-composed document
126
+ # @return [Boolean]
127
+ def insert_only?
128
+ @ops.all?(&:insert?)
129
+ end
130
+ alias :document? :insert_only?
131
+
132
+ # Returns true if the last operation is a string insert that ends with a `\n` character.
133
+ # @return [Boolean]
134
+ def trailing_newline?
135
+ return false unless @ops.last && @ops.last.insert?(String)
136
+ @ops.last.value.end_with?("\n")
137
+ end
138
+
139
+ # Returns true if `other` is a substring of `self`
140
+ # @param other [Delta]
141
+ # @return [Boolean]
142
+ # @todo Not implemented yet
143
+ def include?(other)
144
+ raise NotImplementedError.new("TODO")
145
+ end
146
+
147
+ # Yields ops of at most `size` length to the block, or returns an enumerator which will do the same
148
+ # @param size [Integer]
149
+ # @yield [op] an {Op} object
150
+ # @return [Enumerator, Delta] if no block given, returns an {Enumerator}, else returns `self` for chainability
151
+ # @example
152
+ # delta = RichText::Delta.new.insert('abc')
153
+ # delta.each_slice(2).to_a # => [#<RichText::Op insert="ab">, #<RichText::Op insert="c">]
154
+ def each_slice(size = 1)
155
+ return enum_for(:each_slice, size) unless block_given?
156
+ Iterator.new(@ops).each(size) { |op| yield op }
157
+ self
158
+ end
159
+
160
+ # Yields char + attribute pairs of at most length = 1 to the block, or returns an enumerator which will do the same.
161
+ # Non-string inserts will result in that value being yielded instead of a string.
162
+ # The behavior is not defined with non-insert operations.
163
+ # @yield [char, attributes]
164
+ # @return [Enumerator, Delta] if no block given, returns an {Enumerator}, else returns `self` for chainability
165
+ # @example
166
+ # delta = RichText::Delta.new.insert('a', { bold: true }).insert('b').insert({ image: 'http://i.imgur.com/YtQPTnw.gif' })
167
+ # delta.each_char.to_a # => [["a", { bold: true }], ["b", {}], [{ image: "http://i.imgur.com/YtQPTnw.gif" }, {}]]
168
+ def each_char
169
+ return enum_for(:each_char) unless block_given?
170
+ each_slice(1) { |op| yield op.value, op.attributes }
171
+ self
172
+ end
173
+
174
+ # Yields {Delta} objects corresponding to each `\n`-separated line in the document, each including a trailing newline (except for the last if no trailing newline is present overall).
175
+ # The behavior is not defined with non-insert operations.
176
+ # @yield [delta]
177
+ # @return [Enumerator, Delta] if no block given, returns an {Enumerator}, else returns `self` for chainability
178
+ # @example
179
+ # delta = RichText::Delta.new.insert("abc\n123\n")
180
+ # delta.each_line.to_a # => [#<RichText::Delta [insert="abc\n"]>, #<RichText::Delta [insert="123\n"]>]
181
+ def each_line
182
+ return enum_for(:each_line) unless block_given?
183
+
184
+ iter = Iterator.new(@ops)
185
+ line = Delta.new
186
+
187
+ while iter.next?
188
+ op = iter.next
189
+ if !op.insert?(String)
190
+ line.push(op)
191
+ next
192
+ end
193
+
194
+ offset = 0
195
+ while idx = op.value.index("\n", offset)
196
+ line.push op.slice(offset, idx - offset + 1)
197
+ yield line
198
+ line = Delta.new
199
+ offset = idx + 1
200
+ end
201
+
202
+ if offset < op.value.length
203
+ line.push op.slice(offset)
204
+ end
205
+ end
206
+
207
+ yield line if line.length > 0
208
+ end
209
+
210
+ # Yields each operation in the delta, as-is.
211
+ # @yield [op] an {Op} object
212
+ # @return [Enumerator, Delta] if no block given, returns an {Enumerator}, else returns `self` for chainability
213
+ def each_op
214
+ return enum_for(:each_op) unless block_given?
215
+ @ops.each { |op| yield op }
216
+ self
217
+ end
218
+
219
+ # @return [Integer] the sum of the lengths of each operation.
220
+ # @example
221
+ # RichText::Delta.new.insert('Hello').length # => 5
222
+ # RichText::Delta.new.insert('A').retain(2).delete(1).length # => 4
223
+ def length
224
+ @ops.reduce(0) { |sum, op| sum + op.length }
225
+ end
226
+
227
+ # Returns a copy containing a subset of operations, measured in number of characters.
228
+ # An operation may be subdivided if needed to return just the requested length. Non-string inserts cannot be subdivided (naturally, as they have length 1).
229
+ # @param start [Integer] starting offset
230
+ # @param len [Integer] how many characters
231
+ # @return [Delta] whose length is at most `len`
232
+ # @example
233
+ # delta = RichText::Delta.new.insert('Hello', { bold: true }).insert(' World')
234
+ # copy = delta.slice() # => #<RichText::Delta [insert="Hello" {:bold=>true}, insert=" World"]>
235
+ # world = delta.slice(6) # => #<RichText::Delta [insert="World"]>
236
+ # space = delta.slice(5, 1) # => #<RichText::Delta [insert=" "]>
237
+ def slice(start = 0, len = length)
238
+ if start.is_a?(Range)
239
+ len = start.size
240
+ start = start.first
241
+ end
242
+
243
+ delta = Delta.new
244
+ start = [0, length + start].max if start < 0
245
+ finish = start + len
246
+ iter = Iterator.new(@ops)
247
+ idx = 0
248
+ while idx < finish && iter.next?
249
+ if idx < start
250
+ op = iter.next(start - idx)
251
+ else
252
+ op = iter.next(finish - idx)
253
+ delta.push(op)
254
+ end
255
+ idx += op.length
256
+ end
257
+ return delta
258
+ end
259
+ alias :[] :slice
260
+
261
+ # Returns a Delta that is equivalent to first applying the operations of `self`, then applying the operations of `other` on top of that.
262
+ # @param other [Delta]
263
+ # @return [Delta]
264
+ # @example
265
+ # a = RichText::Delta.new.insert('abc')
266
+ # b = RichText::Delta.new.retain(1).delete(1)
267
+ # a.compose(b) # => #<RichText::Delta [insert="ac"]>
268
+ def compose(other)
269
+ iter_a = Iterator.new(@ops)
270
+ iter_b = Iterator.new(other.ops)
271
+ delta = Delta.new
272
+ while iter_a.next? || iter_b.next?
273
+ if iter_b.peek.insert?
274
+ delta.push(iter_b.next)
275
+ elsif iter_a.peek.delete?
276
+ delta.push(iter_a.next)
277
+ else
278
+ len = [iter_a.peek.length, iter_b.peek.length].min
279
+ op_a = iter_a.next(len)
280
+ op_b = iter_b.next(len)
281
+ if op_b.retain?
282
+ if op_a.retain?
283
+ attrs = Attributes.compose(op_a.attributes, op_b.attributes, true)
284
+ delta.push(Op.new(:retain, len, attrs))
285
+ else
286
+ attrs = Attributes.compose(op_a.attributes, op_b.attributes, false)
287
+ delta.push(Op.new(:insert, op_a.value, attrs))
288
+ end
289
+ elsif op_b.delete? && op_a.retain?
290
+ delta.push(op_b)
291
+ end
292
+ end
293
+ end
294
+ delta.chop!
295
+ end
296
+ alias :| :compose
297
+
298
+ # Modifies `self` by the concatenating this and another document Delta's operations.
299
+ # Correctly handles the case of merging the last operation of `self` with the first operation of `other`, if possible.
300
+ # The behavior is not defined when either `self` or `other` has non-insert operations.
301
+ # @param other [Delta]
302
+ # @return [Delta] `self`
303
+ # @example
304
+ # a = RichText::Delta.new.insert('Hello')
305
+ # b = RichText::Delta.new.insert(' World!')
306
+ # a.concat(b) # => #<RichText::Delta [insert="Hello World!"]>
307
+ def concat(other)
308
+ if other.length > 0
309
+ push(other.ops.first)
310
+ @ops.concat(other.ops.slice(1..-1))
311
+ end
312
+ self
313
+ end
314
+
315
+ # The non-destructive version of {#concat}
316
+ # @see #concat
317
+ def +(other)
318
+ dup.concat(other)
319
+ end
320
+
321
+ # Returns a Delta representing the difference between two documents.
322
+ # The behavior is not defined when either `self` or `other` has non-insert operations.
323
+ # @param other [Delta]
324
+ # @return [Delta]
325
+ # @example
326
+ # a = RichText::Delta.new.insert('Hello')
327
+ # b = RichText::Delta.new.insert('Hello!')
328
+ # a.diff(b) # => #<RichText::Delta [retain=5, insert="!"]>
329
+ # a.compose(a.diff(b)) == b # => true
330
+ def diff(other)
331
+ delta = Delta.new
332
+ return delta if self == other
333
+
334
+ iter = Iterator.new(@ops)
335
+ other_iter = Iterator.new(other.ops)
336
+
337
+ Diff.new(self, other) do |kind, len|
338
+ while len > 0
339
+ case kind
340
+ when :insert
341
+ op_len = [len, other_iter.peek.length].min
342
+ delta.push(other_iter.next(op_len))
343
+ when :delete
344
+ op_len = [len, iter.peek.length].min
345
+ iter.next(op_len)
346
+ delta.delete(op_len)
347
+ when :retain
348
+ op_len = [iter.peek.length, other_iter.peek.length, len].min
349
+ this_op = iter.next(op_len)
350
+ other_op = other_iter.next(op_len)
351
+ if this_op.value == other_op.value
352
+ delta.retain(op_len, Attributes.diff(this_op.attributes, other_op.attributes))
353
+ else
354
+ delta.push(other_op).delete(op_len)
355
+ end
356
+ end
357
+ len -= op_len
358
+ end
359
+ end
360
+
361
+ delta.chop!
362
+ end
363
+ alias :- :diff
364
+
365
+ # Transform other Delta against own operations, such that [transformation property 1 (TP1)](https://en.wikipedia.org/wiki/Operational_transformation#Convergence_properties) holds:
366
+ #
367
+ # self.compose(self.transform(other, true)) == other.compose(other.transform(self, false))
368
+ #
369
+ # If called with a number, then acts as an alias for {#transform_position}
370
+ # @param other [Delta, Integer] the Delta to be transformed, or a number to pass along to {#transform_position}
371
+ # @param priority [Boolean] used to break ties; if true, then operations from `self` are seen as having priority over operations from `other`:
372
+ #
373
+ # - when inserts from `self` and `other` occur at the same index, `other`'s insert is shifted over in order for `self`'s to come first
374
+ # - retained attributes from `other` can be obsoleted by retained attributes in `self`
375
+ # @example
376
+ # a = RichText::Delta.new.insert('a')
377
+ # b = RichText::Delta.new.insert('b')
378
+ # a.transform(b, true) # => #<RichText::Delta [retain=1, insert="b"]>
379
+ # a.transform(b, false) # => #<RichText::Delta [insert="b"]>
380
+ #
381
+ # a = RichText::Delta.new.retain(1, { color: '#bbb' })
382
+ # b = RichText::Delta.new.retain(1, { color: '#fff', bold: true })
383
+ # a.transform(b, true) # => #<RichText::Delta [retain=1 {:bold=>true}]>
384
+ # a.transform(b, false) # => #<RichText::Delta [retain=1 {:color=>"#fff", :bold=>true}]>
385
+ def transform(other, priority)
386
+ return transform_position(other, priority) if other.is_a?(Integer)
387
+ iter = Iterator.new(@ops)
388
+ other_iter = Iterator.new(other.ops)
389
+ delta = Delta.new
390
+ while iter.next? || other_iter.next?
391
+ if iter.peek.insert? && (priority || !other_iter.peek.insert?)
392
+ delta.retain iter.next.length
393
+ elsif other_iter.peek.insert?
394
+ delta.push other_iter.next
395
+ else
396
+ len = [iter.peek.length, other_iter.peek.length].min
397
+ op = iter.next(len)
398
+ other_op = other_iter.next(len)
399
+ if op.delete?
400
+ # Our delete makes their delete redundant, or removes their retain
401
+ next
402
+ elsif other_op.delete?
403
+ delta.push(other_op)
404
+ else
405
+ # We either retain their retain or insert
406
+ delta.retain(len, Attributes.transform(op.attributes, other_op.attributes, priority))
407
+ end
408
+ end
409
+ end
410
+ delta.chop!
411
+ end
412
+ alias :^ :transform
413
+
414
+ # Transform an index against the current delta. Useful for shifting cursor & selection positions in response to remote changes.
415
+ # @param index [Integer] an offset position that may be shifted by inserts and deletes happening beforehand
416
+ # @param priority [Boolean] used to break ties
417
+ #
418
+ # - if true, then an insert happening exactly at `index` does not impact the return value
419
+ # - if false, then an insert happening exactly at `index` results in the return value being incremented by that insert's length
420
+ # @return [Integer]
421
+ # @example
422
+ # delta = RichText::Delta.new.retain(3).insert('def')
423
+ # delta.transform_position(3, true) # => 3
424
+ # delta.transform_position(3, false) # => 6
425
+ def transform_position(index, priority)
426
+ iter = Iterator.new(@ops)
427
+ offset = 0
428
+ while iter.next? && offset <= index
429
+ op = iter.next
430
+ if op.delete?
431
+ index -= [op.length, index - offset].min
432
+ next
433
+ elsif op.insert? && (offset < index || !priority)
434
+ index += op.length
435
+ end
436
+ offset += op.length
437
+ end
438
+ return index
439
+ end
440
+
441
+ # @return [Hash] the Hash representation of this object, by converting each contained op into a Hash
442
+ def to_h
443
+ { :ops => @ops.map(&:to_h) }
444
+ end
445
+
446
+ # @return [String] the JSON representation of this object, by delegating to {#to_h}
447
+ def to_json(*args)
448
+ to_h.to_json(*args)
449
+ end
450
+
451
+ # Returns a plain text representation of this delta (lossy).
452
+ # The behavior is not defined with non-insert operations.
453
+ # @param embed_str [String] the string to use in place of non-string insert operations
454
+ # @return [String]
455
+ def to_plaintext(embed_str: '!')
456
+ @ops.each_with_object('') do |op, str|
457
+ if op.insert?(String)
458
+ str << op.value
459
+ elsif embed_str
460
+ str << embed_str
461
+ end
462
+ end
463
+ end
464
+
465
+ # Returns an HTML representation of this delta.
466
+ # @see {HTML.render}
467
+ # @todo Support options that control how rich-text attributes are converted into HTML tags and attributes.
468
+ def to_html(options = {})
469
+ HTML.render(self, options)
470
+ end
471
+
472
+ # Returns a String useful for debugging that includes details of each contained operation.
473
+ # @return [String]
474
+ # @example
475
+ # '#<RichText::Delta [retain=3, delete=1, insert="abc" {:bold=>true}, insert={:image=>"http://i.imgur.com/vwGN6.gif"}]>'
476
+ def inspect
477
+ str = "#<#{self.class.name} ["
478
+ str << @ops.map { |o| o.inspect(false) }.join(", ")
479
+ str << "]>"
480
+ end
481
+
482
+ # A Delta is equal to another if all the ops are equal.
483
+ # @param other [Delta]
484
+ # @return [Boolean]
485
+ def ==(other)
486
+ other.is_a?(RichText::Delta) && @ops == other.ops
487
+ end
488
+ alias_method :eql?, :==
489
+ end
490
+ end
@@ -0,0 +1,34 @@
1
+ require 'diff-lcs'
2
+
3
+ module RichText
4
+ # @api private
5
+ class Diff
6
+ attr_reader :chunks
7
+
8
+ def initialize(left, right)
9
+ @chunks = []
10
+ ::Diff::LCS.traverse_sequences(left.to_plaintext, right.to_plaintext, self)
11
+ @chunks.each { |c| yield c } if block_given?
12
+ end
13
+
14
+ def push(type)
15
+ if @chunks.any? && @chunks[-1][0] == type
16
+ @chunks[-1][1] += 1
17
+ else
18
+ @chunks.push [type, 1]
19
+ end
20
+ end
21
+
22
+ def match(args)
23
+ push :retain
24
+ end
25
+
26
+ def discard_a(args)
27
+ push :delete
28
+ end
29
+
30
+ def discard_b(args)
31
+ push :insert
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,66 @@
1
+ require 'delegate'
2
+ require 'singleton'
3
+
4
+ module RichText
5
+ # @todo Work in progress
6
+ class HTML
7
+ ConfigError = Class.new(StandardError)
8
+
9
+ attr_reader :config
10
+
11
+ def self.render(delta, options)
12
+ new(RichText.config).render(delta)
13
+ end
14
+
15
+ def initialize(config)
16
+ @default_block_tag = config.html_default_block_tag
17
+ @block_tags = config.html_block_tags
18
+ @inline_tags = config.html_inline_tags
19
+ end
20
+
21
+ def render(delta)
22
+ raise TypeError.new("cannot convert retain or delete ops to html") unless delta.insert_only?
23
+ html = delta.each_line.inject('') do |html, line|
24
+ html << render_line(line)
25
+ end
26
+ normalize(html)
27
+ end
28
+
29
+ private
30
+
31
+ def render_line(delta)
32
+ # TODO: handle a delta without a trailing "\n"
33
+ line = ''
34
+ delta.slice(0, delta.length - 1).each_op do |op|
35
+ line << apply_tags(@inline_tags, op.value, op.attributes)
36
+ end
37
+ delta.slice(delta.length - 1, 1).each_op do |op|
38
+ if op.attributes?
39
+ line = apply_tags(@block_tags, line, op.attributes)
40
+ else
41
+ line = apply_tag(@default_block_tag, line, true)
42
+ end
43
+ end
44
+ line
45
+ end
46
+
47
+ def apply_tags(tags, text, attributes)
48
+ attributes.inject(text) do |content, (key, value)|
49
+ apply_tag(tags[key], content, value)
50
+ end
51
+ end
52
+
53
+ def apply_tag(tag, content, value)
54
+ if tag.respond_to?(:call)
55
+ tag.call(content, value)
56
+ elsif tag
57
+ "<#{tag}>#{content}</#{tag}>"
58
+ end
59
+ end
60
+
61
+ def normalize(html)
62
+ # merge sibling tags
63
+ # standardize nesting order
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,49 @@
1
+ module RichText
2
+ # @api private
3
+ class Iterator
4
+ def initialize(ops)
5
+ @ops = ops
6
+ reset
7
+ end
8
+
9
+ def each(size = 1)
10
+ return enum_for(:each, size) unless block_given?
11
+ yield self.next(size) while next?
12
+ end
13
+
14
+ def peek
15
+ if op = @ops[@index]
16
+ op.slice(@offset)
17
+ else
18
+ Op.new(:retain, Float::INFINITY)
19
+ end
20
+ end
21
+
22
+ def next?
23
+ peek.length < Float::INFINITY
24
+ end
25
+
26
+ def next(length = Float::INFINITY)
27
+ next_op = @ops[@index]
28
+ offset = @offset
29
+ if next_op
30
+ if length >= next_op.length - offset
31
+ length = next_op.length - offset
32
+ @index += 1
33
+ @offset = 0
34
+ else
35
+ @offset += length
36
+ end
37
+
38
+ next_op.slice(offset, length)
39
+ else
40
+ return Op.new(:retain, Float::INFINITY)
41
+ end
42
+ end
43
+
44
+ def reset
45
+ @index = 0
46
+ @offset = 0
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,153 @@
1
+ require 'active_support/core_ext/hash/indifferent_access'
2
+
3
+ module RichText
4
+ # Operations are the immutable units of rich-text deltas and documents. As such, we have a class that wraps these values and provides convenient methods for querying type and contents, and for subdividing as needed by {Delta#slice}.
5
+ class Op
6
+ TYPES = [:insert, :retain, :delete].freeze
7
+
8
+ # @return [Symbol] one of {TYPES}
9
+ attr_reader :type
10
+ # @return [String, Integer, Hash] value depends on type
11
+ attr_reader :value
12
+ # @return [Hash]
13
+ attr_reader :attributes
14
+
15
+ # Creates a new Op object from a Hash. Used by {Delta#initialize} to parse raw data into a convenient form.
16
+ # @param data [Hash] containing exactly one of {TYPES} as a key, and optionally an `:attributes` key. munged to provide indifferent access via String or Symbol keys
17
+ # @return [Op]
18
+ # @raise [ArgumentError] if `data` contains invalid keys, i.e. zero or more than one of {TYPES}
19
+ # @example
20
+ # RichText::Op.parse({ insert: 'abc', attributes: { bold: true } })
21
+ # # => #<RichText::Op insert="abc" {"bold"=>true}>
22
+ #
23
+ # RichText::Op.parse({ insert: 'abc', retain: 3 })
24
+ # # => ArgumentError: must be a Hash containing exactly one of the following keys: [:insert, :retain, :delete]
25
+ def self.parse(data)
26
+ data = data.to_h.with_indifferent_access
27
+ type_keys = (data.keys & TYPES.map(&:to_s))
28
+ if type_keys.length != 1
29
+ raise ArgumentError.new("must be a Hash containing exactly one of the following keys: #{TYPES.inspect}")
30
+ end
31
+
32
+ type = type_keys.first.to_sym
33
+ value = data[type]
34
+ if [:retain, :delete].include?(type) && !value.is_a?(Integer)
35
+ raise ArgumentError.new("value must be an Integer when type is #{type.inspect}")
36
+ end
37
+
38
+ attributes = data[:attributes]
39
+ if attributes && !attributes.is_a?(Hash)
40
+ raise ArgumentError.new("attributes must be a Hash")
41
+ end
42
+
43
+ self.new(type, value, attributes)
44
+ end
45
+
46
+ # Creates a new Op object, based on a type, value, and attributes. No sanity checking is performed on the arguments; please use {Op.parse} for dealing with untrusted user input.
47
+ # @param type [Symbol] one of {TYPES}
48
+ # @param value [Integer, String, Hash] various values corresponding to type
49
+ # @param attributes [Hash]
50
+ # @return [Op]
51
+ def initialize(type, value, attributes = nil)
52
+ @type = type.to_sym
53
+ @value = value.freeze
54
+ @attributes = (attributes || {}).freeze
55
+ end
56
+
57
+ # @return [Boolean] whether any attributes are present; `false` when attributes is empty, `true` otherwise.
58
+ # @example
59
+ # RichText::Op.new(:insert, 'abc').attributes? # => false
60
+ # RichText::Op.new(:insert, 'abc', {}).attributes? # => false
61
+ # RichText::Op.new(:insert, 'abc', { bold: true }).attributes? # => true
62
+ def attributes?
63
+ !attributes.empty?
64
+ end
65
+
66
+ # Returns whether type is `:insert`, and value is an instance of `kind`
67
+ # @param kind [Class] pass a class to perform an additional `is_a?` check on {value}
68
+ # @return [Boolean]
69
+ # @example
70
+ # RichText::Op.new(:insert, 'abc').insert? # => true
71
+ # RichText::Op.new(:insert, 'abc').insert?(String) # => true
72
+ # RichText::Op.new(:insert, { image: 'http://i.imgur.com/y6Eo48A.gif' }).insert?(String) # => false
73
+ def insert?(kind = Object)
74
+ type == :insert && value.is_a?(kind)
75
+ end
76
+
77
+ # Returns whether type is `:retain` or not
78
+ # @return [Boolean]
79
+ def retain?
80
+ type == :retain
81
+ end
82
+
83
+ # Returns whether type is `:delete` or not
84
+ # @returns [Boolean]
85
+ def delete?
86
+ type == :delete
87
+ end
88
+
89
+ # Returns a number indicating the length of this op, depending of the type:
90
+ #
91
+ # - for `:insert`, returns `value.length` if a String, 1 otherwise
92
+ # - for `:retain` and `:delete`, returns value
93
+ # @return [Integer]
94
+ def length
95
+ case type
96
+ when :insert
97
+ value.is_a?(String) ? value.length : 1
98
+ when :retain, :delete
99
+ value
100
+ end
101
+ end
102
+
103
+ # Returns a copy of the op with a subset of the value, measured in number of characters.
104
+ # An op may be subdivided if needed to return at most the requested length. Non-string inserts cannot be subdivided (naturally, as they have length 1).
105
+ # @param start [Integer] starting offset
106
+ # @param len [Integer] how many characters
107
+ # @return [Op] whose length is at most `len`
108
+ def slice(start = 0, len = length)
109
+ if insert?(String)
110
+ Op.new(:insert, value.slice(start, len), attributes)
111
+ elsif insert?
112
+ unless start == 0 && len == 1
113
+ raise ArgumentError.new("cannot subdivide a non-string insert")
114
+ end
115
+ dup
116
+ else
117
+ Op.new(type, [value - start, len].min, attributes)
118
+ end
119
+ end
120
+
121
+ # @return [Hash] the Hash representation of this object, the inverse of {Op.parse}
122
+ def to_h
123
+ { type => value }.tap do |json|
124
+ json[:attributes] = attributes if attributes?
125
+ end
126
+ end
127
+
128
+ # @return [String] the JSON representation of this object, by delegating to {#to_h}
129
+ def to_json(*args)
130
+ to_h.to_json(*args)
131
+ end
132
+
133
+ # A string useful for debugging, that includes type, value, and attributes.
134
+ # @param wrap [Boolean] pass false to avoid including the class name (used by {Delta#inspect})
135
+ # @return [String]
136
+ # @example
137
+ # RichText::Op.new(:insert, 'abc', { bold: true }).inspect # => '#<RichText::Op insert="abc" {:bold=>true}>'
138
+ # RichText::Op.new(:insert, 'abc', { bold: true }).inspect(false) => 'insert="abc" {:bold=>true}'
139
+ def inspect(wrap = true)
140
+ str = "#{type}=#{value.inspect}"
141
+ str << " #{attributes.inspect}" if attributes?
142
+ wrap ? "#<#{self.class.name} #{str}>" : str
143
+ end
144
+
145
+ # An Op is equal to another if type, value, and attributes all match
146
+ # @param other [Op]
147
+ # @return [Boolean]
148
+ def ==(other)
149
+ other.is_a?(Op) && type == other.type && value == other.value && attributes == other.attributes
150
+ end
151
+ alias :eql? :==
152
+ end
153
+ end
@@ -0,0 +1,3 @@
1
+ module RichText
2
+ VERSION = "0.2.0"
3
+ end
data/rich-text.gemspec ADDED
@@ -0,0 +1,30 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'rich-text/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "rich-text"
8
+ spec.version = RichText::VERSION
9
+ spec.authors = ["Blake Thomson"]
10
+ spec.email = ["thomsbg@gmail.com"]
11
+
12
+ spec.summary = %q{A ruby wrapper and utilities for rich text JSON documents.}
13
+ spec.homepage = "https://github.com/voxmedia/rich-text-ruby"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
17
+ f.match(%r{^(test|spec|features)/})
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_dependency "diff-lcs", "~> 1.2.5"
24
+ spec.add_dependency "activesupport", ">= 3.0.0"
25
+ # spec.add_dependency "nokogiri", ">= 1.0.0"
26
+ spec.add_development_dependency "bundler", "~> 1.13"
27
+ spec.add_development_dependency "rake", "~> 10.0"
28
+ spec.add_development_dependency "minitest", "~> 5.0"
29
+ spec.add_development_dependency "yard"
30
+ end
metadata ADDED
@@ -0,0 +1,150 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rich-text
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Blake Thomson
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2018-06-13 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: diff-lcs
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.2.5
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.2.5
27
+ - !ruby/object:Gem::Dependency
28
+ name: activesupport
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: 3.0.0
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: 3.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: bundler
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rake
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '10.0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '10.0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: minitest
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '5.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '5.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: yard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ description:
98
+ email:
99
+ - thomsbg@gmail.com
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - ".gitignore"
105
+ - ".ruby-version"
106
+ - ".travis.yml"
107
+ - ".yardopts"
108
+ - CHANGELOG.md
109
+ - Gemfile
110
+ - LICENSE.txt
111
+ - README.md
112
+ - Rakefile
113
+ - bin/console
114
+ - bin/rake
115
+ - bin/setup
116
+ - lib/rich-text.rb
117
+ - lib/rich-text/attributes.rb
118
+ - lib/rich-text/config.rb
119
+ - lib/rich-text/delta.rb
120
+ - lib/rich-text/diff.rb
121
+ - lib/rich-text/html.rb
122
+ - lib/rich-text/iterator.rb
123
+ - lib/rich-text/op.rb
124
+ - lib/rich-text/version.rb
125
+ - rich-text.gemspec
126
+ homepage: https://github.com/voxmedia/rich-text-ruby
127
+ licenses:
128
+ - MIT
129
+ metadata: {}
130
+ post_install_message:
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubyforge_project:
146
+ rubygems_version: 2.7.6
147
+ signing_key:
148
+ specification_version: 4
149
+ summary: A ruby wrapper and utilities for rich text JSON documents.
150
+ test_files: []