Class: RichText::Delta

Inherits:
Object
  • Object
show all
Defined in:
lib/rich-text/delta.rb

Overview

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.

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.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(data = []) ⇒ Delta

Parses a new Delta object from incoming data.

Examples:

# All equivalent
RichText::Delta.new("abc")
RichText::Delta.new([{ insert: 'abc' }])
RichText::Delta.new({ ops: [{ insert: 'abc' }] })

Parameters:

  • data (String, Array, Hash) (defaults to: [])

    String, Array of operations, or a Hash with an :ops or 'ops' key set to an array of operations

Raises:

  • (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



23
24
25
26
27
28
29
30
31
32
33
34
35
# File 'lib/rich-text/delta.rb', line 23

def initialize(data = [])
  if data.is_a?(String)
    @ops = [Op.new(:insert, data)]
  elsif data.is_a?(Array)
    @ops = data.map { |h| Op.parse(h) }
  elsif data.is_a?(Hash) && (data.key?('ops') || data.key?(:ops))
    @ops = (data['ops'] || data[:ops]).map { |h| Op.parse(h) }
  else
    ArgumentError.new("Please provide either String, Array or Hash with an 'ops' key containing an Array")
  end

  @ops
end

Instance Attribute Details

#opsArray<Op> (readonly)

Returns:

  • (Array<Op>)


13
14
15
# File 'lib/rich-text/delta.rb', line 13

def ops
  @ops
end

Instance Method Details

#+(other) ⇒ Object

The non-destructive version of #concat

See Also:



317
318
319
# File 'lib/rich-text/delta.rb', line 317

def +(other)
  dup.concat(other)
end

#==(other) ⇒ Boolean Also known as: eql?

A Delta is equal to another if all the ops are equal.

Parameters:

Returns:

  • (Boolean)


485
486
487
# File 'lib/rich-text/delta.rb', line 485

def ==(other)
  other.is_a?(RichText::Delta) && @ops == other.ops
end

#chop!Delta

Modifies self by removing the last op if it was a retain without attributes.

Returns:

  • (Delta)

    self for chainability



117
118
119
120
121
122
123
# File 'lib/rich-text/delta.rb', line 117

def chop!
  last_op = @ops.last
  if last_op && last_op.retain? && !last_op.attributes?
    @ops.pop
  end
  return self
end

#compose(other) ⇒ Delta Also known as: |

Returns a Delta that is equivalent to first applying the operations of self, then applying the operations of other on top of that.

Examples:

a = RichText::Delta.new.insert('abc')
b = RichText::Delta.new.retain(1).delete(1)
a.compose(b) # => #<RichText::Delta [insert="ac"]>

Parameters:

Returns:



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
# File 'lib/rich-text/delta.rb', line 268

def compose(other)
  iter_a = Iterator.new(@ops)
  iter_b = Iterator.new(other.ops)
  delta = Delta.new
  while iter_a.next? || iter_b.next?
    if iter_b.peek.insert?
      delta.push(iter_b.next)
    elsif iter_a.peek.delete?
      delta.push(iter_a.next)
    else
      len = [iter_a.peek.length, iter_b.peek.length].min
      op_a = iter_a.next(len)
      op_b = iter_b.next(len)
      if op_b.retain?
        if op_a.retain?
          attrs = Attributes.compose(op_a.attributes, op_b.attributes, true)
          delta.push(Op.new(:retain, len, attrs))
        else
          attrs = Attributes.compose(op_a.attributes, op_b.attributes, false)
          delta.push(Op.new(:insert, op_a.value, attrs))
        end
      elsif op_b.delete? && op_a.retain?
        delta.push(op_b)
      end
    end
  end
  delta.chop!
end

#concat(other) ⇒ Delta

Modifies self by the concatenating this and another document Delta's operations. Correctly handles the case of merging the last operation of self with the first operation of other, if possible. The behavior is not defined when either self or other has non-insert operations.

Examples:

a = RichText::Delta.new.insert('Hello')
b = RichText::Delta.new.insert(' World!')
a.concat(b) # => #<RichText::Delta [insert="Hello World!"]>

Parameters:

Returns:



307
308
309
310
311
312
313
# File 'lib/rich-text/delta.rb', line 307

def concat(other)
  if other.length > 0
    push(other.ops.first)
    @ops.concat(other.ops.slice(1..-1))
  end
  self
end

#delete(value) ⇒ Delta

Appends a delete operation. A no-op if value <= 0.

Examples:

delta.delete(5)

Parameters:

  • value (Integer)

    the number of characters to delete

Returns:

  • (Delta)

    self for chainability



54
55
56
57
# File 'lib/rich-text/delta.rb', line 54

def delete(value)
  return self if value <= 0
  push(Op.new(:delete, value))
end

#diff(other) ⇒ Delta Also known as: -

Returns a Delta representing the difference between two documents. The behavior is not defined when either self or other has non-insert operations.

Examples:

a = RichText::Delta.new.insert('Hello')
b = RichText::Delta.new.insert('Hello!')
a.diff(b) # => #<RichText::Delta [retain=5, insert="!"]>
a.compose(a.diff(b)) == b # => true

Parameters:

Returns:



330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
# File 'lib/rich-text/delta.rb', line 330

def diff(other)
  delta = Delta.new
  return delta if self == other

  iter = Iterator.new(@ops)
  other_iter = Iterator.new(other.ops)

  Diff.new(self, other) do |kind, len|
    while len > 0
      case kind
      when :insert
        op_len = [len, other_iter.peek.length].min
        delta.push(other_iter.next(op_len))
      when :delete
        op_len = [len, iter.peek.length].min
        iter.next(op_len)
        delta.delete(op_len)
      when :retain
        op_len = [iter.peek.length, other_iter.peek.length, len].min
        this_op = iter.next(op_len)
        other_op = other_iter.next(op_len)
        if this_op.value == other_op.value
          delta.retain(op_len, Attributes.diff(this_op.attributes, other_op.attributes))
        else
          delta.push(other_op).delete(op_len)
        end
      end
      len -= op_len
    end
  end

  delta.chop!
end

#each_char {|char, attributes| ... } ⇒ Enumerator, Delta

Yields char + attribute pairs of at most length = 1 to the block, or returns an enumerator which will do the same. Non-string inserts will result in that value being yielded instead of a string. The behavior is not defined with non-insert operations.

Examples:

delta = RichText::Delta.new.insert('a', { bold: true }).insert('b').insert({ image: 'http://i.imgur.com/YtQPTnw.gif' })
delta.each_char.to_a # => [["a", { bold: true }], ["b", {}], [{ image: "http://i.imgur.com/YtQPTnw.gif" }, {}]]

Yields:

  • (char, attributes)

Returns:

  • (Enumerator, Delta)

    if no block given, returns an Enumerator, else returns self for chainability



168
169
170
171
172
# File 'lib/rich-text/delta.rb', line 168

def each_char
  return enum_for(:each_char) unless block_given?
  each_slice(1) { |op| yield op.value, op.attributes }
  self
end

#each_line {|delta| ... } ⇒ Enumerator, Delta

Yields RichText::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). The behavior is not defined with non-insert operations.

Examples:

delta = RichText::Delta.new.insert("abc\n123\n")
delta.each_line.to_a # => [#<RichText::Delta [insert="abc\n"]>, #<RichText::Delta [insert="123\n"]>]

Yields:

  • (delta)

Returns:

  • (Enumerator, Delta)

    if no block given, returns an Enumerator, else returns self for chainability



181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/rich-text/delta.rb', line 181

def each_line
  return enum_for(:each_line) unless block_given?

  iter = Iterator.new(@ops)
  line = Delta.new

  while iter.next?
    op = iter.next
    if !op.insert?(String)
      line.push(op)
      next
    end

    offset = 0
    while idx = op.value.index("\n", offset)
      line.push op.slice(offset, idx - offset + 1)
      yield line
      line = Delta.new
      offset = idx + 1
    end

    if offset < op.value.length
      line.push op.slice(offset)
    end
  end

  yield line if line.length > 0
end

#each_op {|op| ... } ⇒ Enumerator, Delta

Yields each operation in the delta, as-is.

Yields:

  • (op)

    an Op object

Returns:

  • (Enumerator, Delta)

    if no block given, returns an Enumerator, else returns self for chainability



213
214
215
216
217
# File 'lib/rich-text/delta.rb', line 213

def each_op
  return enum_for(:each_op) unless block_given?
  @ops.each { |op| yield op }
  self
end

#each_slice(size = 1) {|op| ... } ⇒ Enumerator, Delta

Yields ops of at most size length to the block, or returns an enumerator which will do the same

Examples:

delta = RichText::Delta.new.insert('abc')
delta.each_slice(2).to_a # => [#<RichText::Op insert="ab">, #<RichText::Op insert="c">]

Parameters:

  • size (Integer) (defaults to: 1)

Yields:

  • (op)

    an Op object

Returns:

  • (Enumerator, Delta)

    if no block given, returns an Enumerator, else returns self for chainability



154
155
156
157
158
# File 'lib/rich-text/delta.rb', line 154

def each_slice(size = 1)
  return enum_for(:each_slice, size) unless block_given?
  Iterator.new(@ops).each(size) { |op| yield op }
  self
end

#include?(other) ⇒ Boolean

TODO:

Not implemented yet

Returns true if other is a substring of self

Parameters:

Returns:

  • (Boolean)

Raises:

  • (NotImplementedError)


143
144
145
# File 'lib/rich-text/delta.rb', line 143

def include?(other)
  raise NotImplementedError.new("TODO")
end

#insert(value, attributes = {}) ⇒ Delta

Appends an insert operation. A no-op if the provided value is the empty string.

Examples:

delta.insert('abc').insert('xyz', { bold: true })
delta.insert({ image: 'http://i.imgur.com/FUCb95Y.gif' })

Parameters:

  • value (String|{ String => Object })

    the value to insert, either a String or a Hash with a single String or Symbol key

  • attributes (Hash) (defaults to: {})

Returns:

  • (Delta)

    self for chainability



44
45
46
47
# File 'lib/rich-text/delta.rb', line 44

def insert(value, attributes = {})
  return self if value.is_a?(String) && value.length == 0
  push(Op.new(:insert, value, attributes))
end

#insert_only?Boolean Also known as: document?

Returns true if all operations are inserts, i.e. a fully-composed document

Returns:

  • (Boolean)


127
128
129
# File 'lib/rich-text/delta.rb', line 127

def insert_only?
  @ops.all?(&:insert?)
end

#inspectString

Returns a String useful for debugging that includes details of each contained operation.

Examples:

'#<RichText::Delta [retain=3, delete=1, insert="abc" {:bold=>true}, insert={:image=>"http://i.imgur.com/vwGN6.gif"}]>'

Returns:

  • (String)


476
477
478
479
480
# File 'lib/rich-text/delta.rb', line 476

def inspect
  str = "#<#{self.class.name} ["
  str << @ops.map { |o| o.inspect(false) }.join(", ")
  str << "]>"
end

#lengthInteger

Returns the sum of the lengths of each operation.

Examples:

RichText::Delta.new.insert('Hello').length # => 5
RichText::Delta.new.insert('A').retain(2).delete(1).length # => 4

Returns:

  • (Integer)

    the sum of the lengths of each operation.



223
224
225
# File 'lib/rich-text/delta.rb', line 223

def length
  @ops.reduce(0) { |sum, op| sum + op.length }
end

#push(op) ⇒ Delta Also known as: <<

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.

Parameters:

  • op (Op)

    the operation to add

Returns:

  • (Delta)

    self for chainability



73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'lib/rich-text/delta.rb', line 73

def push(op)
  index = @ops.length
  last_op = @ops[index - 1]

  if last_op
    if last_op.delete? && op.delete?
      @ops[index - 1] = Op.new(:delete, last_op.value + op.value)
      return self
    end

    # Since it does not matter if we insert before or after deleting at the
    # same index, always prefer to insert first
    if last_op.delete? && op.insert?
      index -= 1
      last_op = @ops[index - 1]
      if !last_op
        @ops.unshift(op)
        return self
      end
    end

    if last_op.attributes == op.attributes
      if last_op.insert?(String) && op.insert?(String)
        @ops[index - 1] = Op.new(:insert, last_op.value + op.value, last_op.attributes)
        return self
      elsif last_op.retain? && op.retain?
        @ops[index - 1] = Op.new(:retain, last_op.value + op.value, last_op.attributes)
        return self
      end
    end
  end

  if index == @ops.length
    @ops.push(op)
  else
    @ops[index, 0] = op
  end

  return self
end

#retain(value, attributes = {}) ⇒ Delta

Appends a retain operation. A no-op if value <= 0.

Examples:

delta.retain(4).retain(5, { color: '#0c6' })

Parameters:

  • value (Integer)

    the number of characters to skip or change attributes for

  • attributes (Hash) (defaults to: {})

    leave blank to leave attributes unchanged

Returns:

  • (Delta)

    self for chainability



65
66
67
68
# File 'lib/rich-text/delta.rb', line 65

def retain(value, attributes = {})
  return self if value <= 0
  push(Op.new(:retain, value, attributes))
end

#slice(start = 0, len = length) ⇒ Delta Also known as: []

Returns a copy containing a subset of operations, measured in number of characters. 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).

Examples:

delta = RichText::Delta.new.insert('Hello', { bold: true }).insert(' World')
copy = delta.slice() # => #<RichText::Delta [insert="Hello" {:bold=>true}, insert=" World"]>
world = delta.slice(6) # => #<RichText::Delta [insert="World"]>
space = delta.slice(5, 1) # => #<RichText::Delta [insert=" "]>

Parameters:

  • start (Integer) (defaults to: 0)

    starting offset

  • len (Integer) (defaults to: length)

    how many characters

Returns:

  • (Delta)

    whose length is at most len



237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
# File 'lib/rich-text/delta.rb', line 237

def slice(start = 0, len = length)
  if start.is_a?(Range)
    len = start.size
    start = start.first
  end

  delta = Delta.new
  start = [0, length + start].max if start < 0
  finish = start + len
  iter = Iterator.new(@ops)
  idx = 0
  while idx < finish && iter.next?
    if idx < start
      op = iter.next(start - idx)
    else
      op = iter.next(finish - idx)
      delta.push(op)
    end
    idx += op.length
  end
  return delta
end

#to_hHash

Returns the Hash representation of this object, by converting each contained op into a Hash.

Returns:

  • (Hash)

    the Hash representation of this object, by converting each contained op into a Hash



442
443
444
# File 'lib/rich-text/delta.rb', line 442

def to_h
  { :ops => @ops.map(&:to_h) }
end

#to_html(options = {}) ⇒ Object

TODO:

Support options that control how rich-text attributes are converted into HTML tags and attributes.

Returns an HTML representation of this delta.

See Also:

  • RichText::Delta.{HTML{HTML.render}


468
469
470
# File 'lib/rich-text/delta.rb', line 468

def to_html(options = {})
  HTML.render(self, options)
end

#to_json(*args) ⇒ String

Returns the JSON representation of this object, by delegating to #to_h.

Returns:

  • (String)

    the JSON representation of this object, by delegating to #to_h



447
448
449
# File 'lib/rich-text/delta.rb', line 447

def to_json(*args)
  to_h.to_json(*args)
end

#to_plaintext(embed_str: '!') ⇒ String

Returns a plain text representation of this delta (lossy). The behavior is not defined with non-insert operations.

Parameters:

  • embed_str (String) (defaults to: '!')

    the string to use in place of non-string insert operations

Returns:

  • (String)


455
456
457
458
459
460
461
462
463
# File 'lib/rich-text/delta.rb', line 455

def to_plaintext(embed_str: '!')
  @ops.each_with_object('') do |op, str|
    if op.insert?(String)
      str << op.value
    elsif embed_str
      str << embed_str
    end
  end
end

#trailing_newline?Boolean

Returns true if the last operation is a string insert that ends with a \n character.

Returns:

  • (Boolean)


134
135
136
137
# File 'lib/rich-text/delta.rb', line 134

def trailing_newline?
  return false unless @ops.last && @ops.last.insert?(String)
  @ops.last.value.end_with?("\n")
end

#transform(other, priority) ⇒ Object Also known as: ^

Transform other Delta against own operations, such that transformation property 1 (TP1) holds:

self.compose(self.transform(other, true)) == other.compose(other.transform(self, false))

If called with a number, then acts as an alias for #transform_position

Examples:

a = RichText::Delta.new.insert('a')
b = RichText::Delta.new.insert('b')
a.transform(b, true) # => #<RichText::Delta [retain=1, insert="b"]>
a.transform(b, false) # => #<RichText::Delta [insert="b"]>

a = RichText::Delta.new.retain(1, { color: '#bbb' })
b = RichText::Delta.new.retain(1, { color: '#fff', bold: true })
a.transform(b, true) # => #<RichText::Delta [retain=1 {:bold=>true}]>
a.transform(b, false) # => #<RichText::Delta [retain=1 {:color=>"#fff", :bold=>true}]>

Parameters:

  • other (Delta, Integer)

    the Delta to be transformed, or a number to pass along to #transform_position

  • priority (Boolean)

    used to break ties; if true, then operations from self are seen as having priority over operations from other:

    • 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
    • retained attributes from other can be obsoleted by retained attributes in self


385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/rich-text/delta.rb', line 385

def transform(other, priority)
  return transform_position(other, priority) if other.is_a?(Integer)
  iter = Iterator.new(@ops)
  other_iter = Iterator.new(other.ops)
  delta = Delta.new
  while iter.next? || other_iter.next?
    if iter.peek.insert? && (priority || !other_iter.peek.insert?)
      delta.retain iter.next.length
    elsif other_iter.peek.insert?
      delta.push other_iter.next
    else
      len = [iter.peek.length, other_iter.peek.length].min
      op = iter.next(len)
      other_op = other_iter.next(len)
      if op.delete?
        # Our delete makes their delete redundant, or removes their retain
        next
      elsif other_op.delete?
        delta.push(other_op)
      else
        # We either retain their retain or insert
        delta.retain(len, Attributes.transform(op.attributes, other_op.attributes, priority))
      end
    end
  end
  delta.chop!
end

#transform_position(index, priority) ⇒ Integer

Transform an index against the current delta. Useful for shifting cursor & selection positions in response to remote changes.

Examples:

delta = RichText::Delta.new.retain(3).insert('def')
delta.transform_position(3, true) # => 3
delta.transform_position(3, false) # => 6

Parameters:

  • index (Integer)

    an offset position that may be shifted by inserts and deletes happening beforehand

  • priority (Boolean)

    used to break ties

    • if true, then an insert happening exactly at index does not impact the return value
    • if false, then an insert happening exactly at index results in the return value being incremented by that insert's length

Returns:

  • (Integer)


425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
# File 'lib/rich-text/delta.rb', line 425

def transform_position(index, priority)
  iter = Iterator.new(@ops)
  offset = 0
  while iter.next? && offset <= index
    op = iter.next
    if op.delete?
      index -= [op.length, index - offset].min
      next
    elsif op.insert? && (offset < index || !priority)
      index += op.length
    end
    offset += op.length
  end
  return index
end