actiontext 6.0.0.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +5 -0
  3. data/MIT-LICENSE +21 -0
  4. data/README.md +9 -0
  5. data/app/helpers/action_text/content_helper.rb +30 -0
  6. data/app/helpers/action_text/tag_helper.rb +75 -0
  7. data/app/javascript/actiontext/attachment_upload.js +45 -0
  8. data/app/javascript/actiontext/index.js +10 -0
  9. data/app/models/action_text/rich_text.rb +29 -0
  10. data/app/views/action_text/attachables/_missing_attachable.html.erb +1 -0
  11. data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
  12. data/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb +3 -0
  13. data/app/views/action_text/content/_layout.html.erb +3 -0
  14. data/app/views/active_storage/blobs/_blob.html.erb +14 -0
  15. data/db/migrate/201805281641_create_action_text_tables.rb +14 -0
  16. data/lib/action_text.rb +37 -0
  17. data/lib/action_text/attachable.rb +82 -0
  18. data/lib/action_text/attachables/content_attachment.rb +38 -0
  19. data/lib/action_text/attachables/missing_attachable.rb +13 -0
  20. data/lib/action_text/attachables/remote_image.rb +46 -0
  21. data/lib/action_text/attachment.rb +103 -0
  22. data/lib/action_text/attachment_gallery.rb +65 -0
  23. data/lib/action_text/attachments/caching.rb +16 -0
  24. data/lib/action_text/attachments/minification.rb +17 -0
  25. data/lib/action_text/attachments/trix_conversion.rb +34 -0
  26. data/lib/action_text/attribute.rb +48 -0
  27. data/lib/action_text/content.rb +132 -0
  28. data/lib/action_text/engine.rb +50 -0
  29. data/lib/action_text/fragment.rb +57 -0
  30. data/lib/action_text/gem_version.rb +17 -0
  31. data/lib/action_text/html_conversion.rb +24 -0
  32. data/lib/action_text/plain_text_conversion.rb +81 -0
  33. data/lib/action_text/serialization.rb +34 -0
  34. data/lib/action_text/trix_attachment.rb +92 -0
  35. data/lib/action_text/version.rb +10 -0
  36. data/lib/tasks/actiontext.rake +20 -0
  37. data/lib/templates/actiontext.scss +36 -0
  38. data/lib/templates/fixtures.yml +4 -0
  39. data/lib/templates/installer.rb +32 -0
  40. data/package.json +29 -0
  41. metadata +158 -0
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ module Attachables
5
+ module MissingAttachable
6
+ extend ActiveModel::Naming
7
+
8
+ def self.to_partial_path
9
+ "action_text/attachables/missing_attachable"
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ module Attachables
5
+ class RemoteImage
6
+ extend ActiveModel::Naming
7
+
8
+ class << self
9
+ def from_node(node)
10
+ if node["url"] && content_type_is_image?(node["content-type"])
11
+ new(attributes_from_node(node))
12
+ end
13
+ end
14
+
15
+ private
16
+ def content_type_is_image?(content_type)
17
+ content_type.to_s =~ /^image(\/.+|$)/
18
+ end
19
+
20
+ def attributes_from_node(node)
21
+ { url: node["url"],
22
+ content_type: node["content-type"],
23
+ width: node["width"],
24
+ height: node["height"] }
25
+ end
26
+ end
27
+
28
+ attr_reader :url, :content_type, :width, :height
29
+
30
+ def initialize(attributes = {})
31
+ @url = attributes[:url]
32
+ @content_type = attributes[:content_type]
33
+ @width = attributes[:width]
34
+ @height = attributes[:height]
35
+ end
36
+
37
+ def attachable_plain_text_representation(caption)
38
+ "[#{caption || "Image"}]"
39
+ end
40
+
41
+ def to_partial_path
42
+ "action_text/attachables/remote_image"
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ class Attachment
5
+ include Attachments::TrixConversion, Attachments::Minification, Attachments::Caching
6
+
7
+ TAG_NAME = "action-text-attachment"
8
+ SELECTOR = TAG_NAME
9
+ ATTRIBUTES = %w( sgid content-type url href filename filesize width height previewable presentation caption )
10
+
11
+ class << self
12
+ def fragment_by_canonicalizing_attachments(content)
13
+ fragment_by_minifying_attachments(fragment_by_converting_trix_attachments(content))
14
+ end
15
+
16
+ def from_node(node, attachable = nil)
17
+ new(node, attachable || ActionText::Attachable.from_node(node))
18
+ end
19
+
20
+ def from_attachables(attachables)
21
+ Array(attachables).map { |attachable| from_attachable(attachable) }.compact
22
+ end
23
+
24
+ def from_attachable(attachable, attributes = {})
25
+ if node = node_from_attributes(attachable.to_rich_text_attributes(attributes))
26
+ new(node, attachable)
27
+ end
28
+ end
29
+
30
+ def from_attributes(attributes, attachable = nil)
31
+ if node = node_from_attributes(attributes)
32
+ from_node(node, attachable)
33
+ end
34
+ end
35
+
36
+ private
37
+ def node_from_attributes(attributes)
38
+ if attributes = process_attributes(attributes).presence
39
+ ActionText::HtmlConversion.create_element(TAG_NAME, attributes)
40
+ end
41
+ end
42
+
43
+ def process_attributes(attributes)
44
+ attributes.transform_keys { |key| key.to_s.underscore.dasherize }.slice(*ATTRIBUTES)
45
+ end
46
+ end
47
+
48
+ attr_reader :node, :attachable
49
+
50
+ delegate :to_param, to: :attachable
51
+ delegate_missing_to :attachable
52
+
53
+ def initialize(node, attachable)
54
+ @node = node
55
+ @attachable = attachable
56
+ end
57
+
58
+ def caption
59
+ node_attributes["caption"].presence
60
+ end
61
+
62
+ def full_attributes
63
+ node_attributes.merge(attachable_attributes).merge(sgid_attributes)
64
+ end
65
+
66
+ def with_full_attributes
67
+ self.class.from_attributes(full_attributes, attachable)
68
+ end
69
+
70
+ def to_plain_text
71
+ if respond_to?(:attachable_plain_text_representation)
72
+ attachable_plain_text_representation(caption)
73
+ else
74
+ caption.to_s
75
+ end
76
+ end
77
+
78
+ def to_html
79
+ HtmlConversion.node_to_html(node)
80
+ end
81
+
82
+ def to_s
83
+ to_html
84
+ end
85
+
86
+ def inspect
87
+ "#<#{self.class.name} attachable=#{attachable.inspect}>"
88
+ end
89
+
90
+ private
91
+ def node_attributes
92
+ @node_attributes ||= ATTRIBUTES.map { |name| [ name.underscore, node[name] ] }.to_h.compact
93
+ end
94
+
95
+ def attachable_attributes
96
+ @attachable_attributes ||= (attachable.try(:to_rich_text_attributes) || {}).stringify_keys
97
+ end
98
+
99
+ def sgid_attributes
100
+ @sgid_attributes ||= node_attributes.slice("sgid").presence || attachable_attributes.slice("sgid")
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ class AttachmentGallery
5
+ include ActiveModel::Model
6
+
7
+ class << self
8
+ def fragment_by_canonicalizing_attachment_galleries(content)
9
+ fragment_by_replacing_attachment_gallery_nodes(content) do |node|
10
+ "<#{TAG_NAME}>#{node.inner_html}</#{TAG_NAME}>"
11
+ end
12
+ end
13
+
14
+ def fragment_by_replacing_attachment_gallery_nodes(content)
15
+ Fragment.wrap(content).update do |source|
16
+ find_attachment_gallery_nodes(source).each do |node|
17
+ node.replace(yield(node).to_s)
18
+ end
19
+ end
20
+ end
21
+
22
+ def find_attachment_gallery_nodes(content)
23
+ Fragment.wrap(content).find_all(SELECTOR).select do |node|
24
+ node.children.all? do |child|
25
+ if child.text?
26
+ child.text =~ /\A(\n|\ )*\z/
27
+ else
28
+ child.matches? ATTACHMENT_SELECTOR
29
+ end
30
+ end
31
+ end
32
+ end
33
+
34
+ def from_node(node)
35
+ new(node)
36
+ end
37
+ end
38
+
39
+ attr_reader :node
40
+
41
+ def initialize(node)
42
+ @node = node
43
+ end
44
+
45
+ def attachments
46
+ @attachments ||= node.css(ATTACHMENT_SELECTOR).map do |node|
47
+ ActionText::Attachment.from_node(node).with_full_attributes
48
+ end
49
+ end
50
+
51
+ def size
52
+ attachments.size
53
+ end
54
+
55
+ def inspect
56
+ "#<#{self.class.name} size=#{size.inspect}>"
57
+ end
58
+
59
+ TAG_NAME = "div"
60
+ ATTACHMENT_SELECTOR = "#{ActionText::Attachment::SELECTOR}[presentation=gallery]"
61
+ SELECTOR = "#{TAG_NAME}:has(#{ATTACHMENT_SELECTOR} + #{ATTACHMENT_SELECTOR})"
62
+
63
+ private_constant :TAG_NAME, :ATTACHMENT_SELECTOR, :SELECTOR
64
+ end
65
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ module Attachments
5
+ module Caching
6
+ def cache_key(*args)
7
+ [self.class.name, cache_digest, *attachable.cache_key(*args)].join("/")
8
+ end
9
+
10
+ private
11
+ def cache_digest
12
+ Digest::SHA256.hexdigest(node.to_s)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ module Attachments
5
+ module Minification
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def fragment_by_minifying_attachments(content)
10
+ Fragment.wrap(content).replace(ActionText::Attachment::SELECTOR) do |node|
11
+ node.tap { |n| n.inner_html = "" }
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ module Attachments
5
+ module TrixConversion
6
+ extend ActiveSupport::Concern
7
+
8
+ class_methods do
9
+ def fragment_by_converting_trix_attachments(content)
10
+ Fragment.wrap(content).replace(TrixAttachment::SELECTOR) do |node|
11
+ from_trix_attachment(TrixAttachment.new(node))
12
+ end
13
+ end
14
+
15
+ def from_trix_attachment(trix_attachment)
16
+ from_attributes(trix_attachment.attributes)
17
+ end
18
+ end
19
+
20
+ def to_trix_attachment(content = trix_attachment_content)
21
+ attributes = full_attributes.dup
22
+ attributes["content"] = content if content
23
+ TrixAttachment.from_attributes(attributes)
24
+ end
25
+
26
+ private
27
+ def trix_attachment_content
28
+ if partial_path = attachable.try(:to_trix_content_attachment_partial_path)
29
+ ActionText::Content.renderer.render(partial: partial_path, object: self, as: model_name.element)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionText
4
+ module Attribute
5
+ extend ActiveSupport::Concern
6
+
7
+ class_methods do
8
+ # Provides access to a dependent RichText model that holds the body and attachments for a single named rich text attribute.
9
+ # This dependent attribute is lazily instantiated and will be auto-saved when it's been changed. Example:
10
+ #
11
+ # class Message < ActiveRecord::Base
12
+ # has_rich_text :content
13
+ # end
14
+ #
15
+ # message = Message.create!(content: "<h1>Funny times!</h1>")
16
+ # message.content.to_s # => "<h1>Funny times!</h1>"
17
+ # message.content.to_plain_text # => "Funny times!"
18
+ #
19
+ # The dependent RichText model will also automatically process attachments links as sent via the Trix-powered editor.
20
+ # These attachments are associated with the RichText model using Active Storage.
21
+ #
22
+ # If you wish to preload the dependent RichText model, you can use the named scope:
23
+ #
24
+ # Message.all.with_rich_text_content # Avoids N+1 queries when you just want the body, not the attachments.
25
+ # Message.all.with_rich_text_content_and_embeds # Avoids N+1 queries when you just want the body and attachments.
26
+ def has_rich_text(name)
27
+ class_eval <<-CODE, __FILE__, __LINE__ + 1
28
+ def #{name}
29
+ self.rich_text_#{name} ||= ActionText::RichText.new(name: "#{name}", record: self)
30
+ end
31
+
32
+ def #{name}=(body)
33
+ self.#{name}.body = body
34
+ end
35
+ CODE
36
+
37
+ has_one :"rich_text_#{name}", -> { where(name: name) }, class_name: "ActionText::RichText", as: :record, inverse_of: :record, dependent: :destroy
38
+
39
+ scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") }
40
+ scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) }
41
+
42
+ after_save do
43
+ public_send(name).save if public_send(name).changed?
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/attribute_accessors_per_thread"
4
+
5
+ module ActionText
6
+ class Content
7
+ include Serialization
8
+
9
+ thread_cattr_accessor :renderer
10
+
11
+ attr_reader :fragment
12
+
13
+ delegate :blank?, :empty?, :html_safe, :present?, to: :to_html # Delegating to to_html to avoid including the layout
14
+
15
+ class << self
16
+ def fragment_by_canonicalizing_content(content)
17
+ fragment = ActionText::Attachment.fragment_by_canonicalizing_attachments(content)
18
+ fragment = ActionText::AttachmentGallery.fragment_by_canonicalizing_attachment_galleries(fragment)
19
+ fragment
20
+ end
21
+ end
22
+
23
+ def initialize(content = nil, options = {})
24
+ options.with_defaults! canonicalize: true
25
+
26
+ if options[:canonicalize]
27
+ @fragment = self.class.fragment_by_canonicalizing_content(content)
28
+ else
29
+ @fragment = ActionText::Fragment.wrap(content)
30
+ end
31
+ end
32
+
33
+ def links
34
+ @links ||= fragment.find_all("a[href]").map { |a| a["href"] }.uniq
35
+ end
36
+
37
+ def attachments
38
+ @attachments ||= attachment_nodes.map do |node|
39
+ attachment_for_node(node)
40
+ end
41
+ end
42
+
43
+ def attachment_galleries
44
+ @attachment_galleries ||= attachment_gallery_nodes.map do |node|
45
+ attachment_gallery_for_node(node)
46
+ end
47
+ end
48
+
49
+ def gallery_attachments
50
+ @gallery_attachments ||= attachment_galleries.flat_map(&:attachments)
51
+ end
52
+
53
+ def attachables
54
+ @attachables ||= attachment_nodes.map do |node|
55
+ ActionText::Attachable.from_node(node)
56
+ end
57
+ end
58
+
59
+ def append_attachables(attachables)
60
+ attachments = ActionText::Attachment.from_attachables(attachables)
61
+ self.class.new([self.to_s.presence, *attachments].compact.join("\n"))
62
+ end
63
+
64
+ def render_attachments(**options, &block)
65
+ content = fragment.replace(ActionText::Attachment::SELECTOR) do |node|
66
+ block.call(attachment_for_node(node, **options))
67
+ end
68
+ self.class.new(content, canonicalize: false)
69
+ end
70
+
71
+ def render_attachment_galleries(&block)
72
+ content = ActionText::AttachmentGallery.fragment_by_replacing_attachment_gallery_nodes(fragment) do |node|
73
+ block.call(attachment_gallery_for_node(node))
74
+ end
75
+ self.class.new(content, canonicalize: false)
76
+ end
77
+
78
+ def to_plain_text
79
+ render_attachments(with_full_attributes: false, &:to_plain_text).fragment.to_plain_text
80
+ end
81
+
82
+ def to_trix_html
83
+ render_attachments(&:to_trix_attachment).to_html
84
+ end
85
+
86
+ def to_html
87
+ fragment.to_html
88
+ end
89
+
90
+ def to_rendered_html_with_layout
91
+ renderer.render(partial: "action_text/content/layout", locals: { content: self })
92
+ end
93
+
94
+ def to_s
95
+ to_rendered_html_with_layout
96
+ end
97
+
98
+ def as_json(*)
99
+ to_html
100
+ end
101
+
102
+ def inspect
103
+ "#<#{self.class.name} #{to_s.truncate(25).inspect}>"
104
+ end
105
+
106
+ def ==(other)
107
+ if other.is_a?(self.class)
108
+ to_s == other.to_s
109
+ end
110
+ end
111
+
112
+ private
113
+ def attachment_nodes
114
+ @attachment_nodes ||= fragment.find_all(ActionText::Attachment::SELECTOR)
115
+ end
116
+
117
+ def attachment_gallery_nodes
118
+ @attachment_gallery_nodes ||= ActionText::AttachmentGallery.find_attachment_gallery_nodes(fragment)
119
+ end
120
+
121
+ def attachment_for_node(node, with_full_attributes: true)
122
+ attachment = ActionText::Attachment.from_node(node)
123
+ with_full_attributes ? attachment.with_full_attributes : attachment
124
+ end
125
+
126
+ def attachment_gallery_for_node(node)
127
+ ActionText::AttachmentGallery.from_node(node)
128
+ end
129
+ end
130
+ end
131
+
132
+ ActiveSupport.run_load_hooks :action_text_content, ActionText::Content