rich-text 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.ruby-version +1 -0
- data/.travis.yml +5 -0
- data/.yardopts +1 -0
- data/CHANGELOG.md +0 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +99 -0
- data/Rakefile +13 -0
- data/bin/console +14 -0
- data/bin/rake +29 -0
- data/bin/setup +8 -0
- data/lib/rich-text.rb +31 -0
- data/lib/rich-text/attributes.rb +29 -0
- data/lib/rich-text/config.rb +6 -0
- data/lib/rich-text/delta.rb +490 -0
- data/lib/rich-text/diff.rb +34 -0
- data/lib/rich-text/html.rb +66 -0
- data/lib/rich-text/iterator.rb +49 -0
- data/lib/rich-text/op.rb +153 -0
- data/lib/rich-text/version.rb +3 -0
- data/rich-text.gemspec +30 -0
- metadata +150 -0
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
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.2.5
|
data/.travis.yml
ADDED
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--embed-mixins --markup=markdown
|
data/CHANGELOG.md
ADDED
File without changes
|
data/Gemfile
ADDED
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
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,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
|
data/lib/rich-text/op.rb
ADDED
@@ -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
|
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: []
|