actiontext 6.0.1
Sign up to get free protection for your applications and to get access to all the features.
Potentially problematic release.
This version of actiontext might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/CHANGELOG.md +35 -0
- data/MIT-LICENSE +21 -0
- data/README.md +9 -0
- data/app/helpers/action_text/content_helper.rb +37 -0
- data/app/helpers/action_text/tag_helper.rb +79 -0
- data/app/javascript/actiontext/attachment_upload.js +45 -0
- data/app/javascript/actiontext/index.js +10 -0
- data/app/models/action_text/rich_text.rb +29 -0
- data/app/views/action_text/attachables/_missing_attachable.html.erb +1 -0
- data/app/views/action_text/attachables/_remote_image.html.erb +8 -0
- data/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb +3 -0
- data/app/views/action_text/content/_layout.html.erb +3 -0
- data/app/views/active_storage/blobs/_blob.html.erb +14 -0
- data/db/migrate/20180528164100_create_action_text_tables.rb +13 -0
- data/lib/action_text.rb +37 -0
- data/lib/action_text/attachable.rb +86 -0
- data/lib/action_text/attachables/content_attachment.rb +38 -0
- data/lib/action_text/attachables/missing_attachable.rb +13 -0
- data/lib/action_text/attachables/remote_image.rb +46 -0
- data/lib/action_text/attachment.rb +103 -0
- data/lib/action_text/attachment_gallery.rb +65 -0
- data/lib/action_text/attachments/caching.rb +16 -0
- data/lib/action_text/attachments/minification.rb +17 -0
- data/lib/action_text/attachments/trix_conversion.rb +34 -0
- data/lib/action_text/attribute.rb +45 -0
- data/lib/action_text/content.rb +132 -0
- data/lib/action_text/engine.rb +54 -0
- data/lib/action_text/fragment.rb +57 -0
- data/lib/action_text/gem_version.rb +17 -0
- data/lib/action_text/html_conversion.rb +24 -0
- data/lib/action_text/plain_text_conversion.rb +81 -0
- data/lib/action_text/serialization.rb +34 -0
- data/lib/action_text/trix_attachment.rb +92 -0
- data/lib/action_text/version.rb +10 -0
- data/lib/tasks/actiontext.rake +20 -0
- data/lib/templates/actiontext.scss +36 -0
- data/lib/templates/fixtures.yml +4 -0
- data/lib/templates/installer.rb +45 -0
- data/package.json +29 -0
- metadata +161 -0
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionText
|
4
|
+
module Attachables
|
5
|
+
class ContentAttachment
|
6
|
+
include ActiveModel::Model
|
7
|
+
|
8
|
+
def self.from_node(node)
|
9
|
+
if node["content-type"]
|
10
|
+
if matches = node["content-type"].match(/vnd\.rubyonrails\.(.+)\.html/)
|
11
|
+
attachment = new(name: matches[1])
|
12
|
+
attachment if attachment.valid?
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
attr_accessor :name
|
18
|
+
validates_inclusion_of :name, in: %w( horizontal-rule )
|
19
|
+
|
20
|
+
def attachable_plain_text_representation(caption)
|
21
|
+
case name
|
22
|
+
when "horizontal-rule"
|
23
|
+
" ┄ "
|
24
|
+
else
|
25
|
+
" "
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def to_partial_path
|
30
|
+
"action_text/attachables/content_attachment"
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_trix_content_attachment_partial_path
|
34
|
+
"action_text/attachables/content_attachments/#{name.underscore}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
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,45 @@
|
|
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
|
+
rich_text_#{name} || build_rich_text_#{name}
|
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) },
|
38
|
+
class_name: "ActionText::RichText", as: :record, inverse_of: :record, autosave: true, dependent: :destroy
|
39
|
+
|
40
|
+
scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") }
|
41
|
+
scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) }
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
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
|