diff options
Diffstat (limited to 'actiontext/lib')
24 files changed, 1078 insertions, 0 deletions
diff --git a/actiontext/lib/action_text.rb b/actiontext/lib/action_text.rb new file mode 100644 index 0000000000..0dd6318f89 --- /dev/null +++ b/actiontext/lib/action_text.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "active_support" +require "active_support/rails" + +require "nokogiri" + +module ActionText + extend ActiveSupport::Autoload + + autoload :Attachable + autoload :AttachmentGallery + autoload :Attachment + autoload :Attribute + autoload :Content + autoload :Fragment + autoload :HtmlConversion + autoload :PlainTextConversion + autoload :Serialization + autoload :TrixAttachment + + module Attachables + extend ActiveSupport::Autoload + + autoload :ContentAttachment + autoload :MissingAttachable + autoload :RemoteImage + end + + module Attachments + extend ActiveSupport::Autoload + + autoload :Caching + autoload :Minification + autoload :TrixConversion + end +end diff --git a/actiontext/lib/action_text/attachable.rb b/actiontext/lib/action_text/attachable.rb new file mode 100644 index 0000000000..38cd24aa8d --- /dev/null +++ b/actiontext/lib/action_text/attachable.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module ActionText + module Attachable + extend ActiveSupport::Concern + + LOCATOR_NAME = "attachable" + + class << self + def from_node(node) + if attachable = attachable_from_sgid(node["sgid"]) + attachable + elsif attachable = ActionText::Attachables::ContentAttachment.from_node(node) + attachable + elsif attachable = ActionText::Attachables::RemoteImage.from_node(node) + attachable + else + ActionText::Attachables::MissingAttachable + end + end + + def from_attachable_sgid(sgid, options = {}) + method = sgid.is_a?(Array) ? :locate_many_signed : :locate_signed + record = GlobalID::Locator.public_send(method, sgid, options.merge(for: LOCATOR_NAME)) + record || raise(ActiveRecord::RecordNotFound) + end + + private + def attachable_from_sgid(sgid) + from_attachable_sgid(sgid) + rescue ActiveRecord::RecordNotFound + nil + end + end + + class_methods do + def from_attachable_sgid(sgid) + ActionText::Attachable.from_attachable_sgid(sgid, only: self) + end + end + + def attachable_sgid + to_sgid(expires_in: nil, for: LOCATOR_NAME).to_s + end + + def attachable_content_type + try(:content_type) || "application/octet-stream" + end + + def attachable_filename + filename.to_s if respond_to?(:filename) + end + + def attachable_filesize + try(:byte_size) || try(:filesize) + end + + def attachable_metadata + try(:metadata) || {} + end + + def previewable_attachable? + false + end + + def as_json(*) + super.merge(attachable_sgid: attachable_sgid) + end + + def to_rich_text_attributes(attributes = {}) + attributes.dup.tap do |attrs| + attrs[:sgid] = attachable_sgid + attrs[:content_type] = attachable_content_type + attrs[:previewable] = true if previewable_attachable? + attrs[:filename] = attachable_filename + attrs[:filesize] = attachable_filesize + attrs[:width] = attachable_metadata[:width] + attrs[:height] = attachable_metadata[:height] + end.compact + end + end +end diff --git a/actiontext/lib/action_text/attachables/content_attachment.rb b/actiontext/lib/action_text/attachables/content_attachment.rb new file mode 100644 index 0000000000..804f74713f --- /dev/null +++ b/actiontext/lib/action_text/attachables/content_attachment.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ActionText + module Attachables + class ContentAttachment + include ActiveModel::Model + + def self.from_node(node) + if node["content-type"] + if matches = node["content-type"].match(/vnd\.rubyonrails\.(.+)\.html/) + attachment = new(name: matches[1]) + attachment if attachment.valid? + end + end + end + + attr_accessor :name + validates_inclusion_of :name, in: %w( horizontal-rule ) + + def attachable_plain_text_representation(caption) + case name + when "horizontal-rule" + " ┄ " + else + " " + end + end + + def to_partial_path + "action_text/attachables/content_attachment" + end + + def to_trix_content_attachment_partial_path + "action_text/attachables/content_attachments/#{name.underscore}" + end + end + end +end diff --git a/actiontext/lib/action_text/attachables/missing_attachable.rb b/actiontext/lib/action_text/attachables/missing_attachable.rb new file mode 100644 index 0000000000..2f3bd40563 --- /dev/null +++ b/actiontext/lib/action_text/attachables/missing_attachable.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module ActionText + module Attachables + module MissingAttachable + extend ActiveModel::Naming + + def self.to_partial_path + "action_text/attachables/missing_attachable" + end + end + end +end diff --git a/actiontext/lib/action_text/attachables/remote_image.rb b/actiontext/lib/action_text/attachables/remote_image.rb new file mode 100644 index 0000000000..650b11862b --- /dev/null +++ b/actiontext/lib/action_text/attachables/remote_image.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module ActionText + module Attachables + class RemoteImage + extend ActiveModel::Naming + + class << self + def from_node(node) + if node["url"] && content_type_is_image?(node["content-type"]) + new(attributes_from_node(node)) + end + end + + private + def content_type_is_image?(content_type) + content_type.to_s =~ /^image(\/.+|$)/ + end + + def attributes_from_node(node) + { url: node["url"], + content_type: node["content-type"], + width: node["width"], + height: node["height"] } + end + end + + attr_reader :url, :content_type, :width, :height + + def initialize(attributes = {}) + @url = attributes[:url] + @content_type = attributes[:content_type] + @width = attributes[:width] + @height = attributes[:height] + end + + def attachable_plain_text_representation(caption) + "[#{caption || "Image"}]" + end + + def to_partial_path + "action_text/attachables/remote_image" + end + end + end +end diff --git a/actiontext/lib/action_text/attachment.rb b/actiontext/lib/action_text/attachment.rb new file mode 100644 index 0000000000..e90a3e7d48 --- /dev/null +++ b/actiontext/lib/action_text/attachment.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module ActionText + class Attachment + include Attachments::TrixConversion, Attachments::Minification, Attachments::Caching + + TAG_NAME = "action-text-attachment" + SELECTOR = TAG_NAME + ATTRIBUTES = %w( sgid content-type url href filename filesize width height previewable presentation caption ) + + class << self + def fragment_by_canonicalizing_attachments(content) + fragment_by_minifying_attachments(fragment_by_converting_trix_attachments(content)) + end + + def from_node(node, attachable = nil) + new(node, attachable || ActionText::Attachable.from_node(node)) + end + + def from_attachables(attachables) + Array(attachables).map { |attachable| from_attachable(attachable) }.compact + end + + def from_attachable(attachable, attributes = {}) + if node = node_from_attributes(attachable.to_rich_text_attributes(attributes)) + new(node, attachable) + end + end + + def from_attributes(attributes, attachable = nil) + if node = node_from_attributes(attributes) + from_node(node, attachable) + end + end + + private + def node_from_attributes(attributes) + if attributes = process_attributes(attributes).presence + ActionText::HtmlConversion.create_element(TAG_NAME, attributes) + end + end + + def process_attributes(attributes) + attributes.transform_keys { |key| key.to_s.underscore.dasherize }.slice(*ATTRIBUTES) + end + end + + attr_reader :node, :attachable + + delegate :to_param, to: :attachable + delegate_missing_to :attachable + + def initialize(node, attachable) + @node = node + @attachable = attachable + end + + def caption + node_attributes["caption"].presence + end + + def full_attributes + node_attributes.merge(attachable_attributes).merge(sgid_attributes) + end + + def with_full_attributes + self.class.from_attributes(full_attributes, attachable) + end + + def to_plain_text + if respond_to?(:attachable_plain_text_representation) + attachable_plain_text_representation(caption) + else + caption.to_s + end + end + + def to_html + HtmlConversion.node_to_html(node) + end + + def to_s + to_html + end + + def inspect + "#<#{self.class.name} attachable=#{attachable.inspect}>" + end + + private + def node_attributes + @node_attributes ||= ATTRIBUTES.map { |name| [ name.underscore, node[name] ] }.to_h.compact + end + + def attachable_attributes + @attachable_attributes ||= (attachable.try(:to_rich_text_attributes) || {}).stringify_keys + end + + def sgid_attributes + @sgid_attributes ||= node_attributes.slice("sgid").presence || attachable_attributes.slice("sgid") + end + end +end diff --git a/actiontext/lib/action_text/attachment_gallery.rb b/actiontext/lib/action_text/attachment_gallery.rb new file mode 100644 index 0000000000..45afbff058 --- /dev/null +++ b/actiontext/lib/action_text/attachment_gallery.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module ActionText + class AttachmentGallery + include ActiveModel::Model + + class << self + def fragment_by_canonicalizing_attachment_galleries(content) + fragment_by_replacing_attachment_gallery_nodes(content) do |node| + "<#{TAG_NAME}>#{node.inner_html}</#{TAG_NAME}>" + end + end + + def fragment_by_replacing_attachment_gallery_nodes(content) + Fragment.wrap(content).update do |source| + find_attachment_gallery_nodes(source).each do |node| + node.replace(yield(node).to_s) + end + end + end + + def find_attachment_gallery_nodes(content) + Fragment.wrap(content).find_all(SELECTOR).select do |node| + node.children.all? do |child| + if child.text? + child.text =~ /\A(\n|\ )*\z/ + else + child.matches? ATTACHMENT_SELECTOR + end + end + end + end + + def from_node(node) + new(node) + end + end + + attr_reader :node + + def initialize(node) + @node = node + end + + def attachments + @attachments ||= node.css(ATTACHMENT_SELECTOR).map do |node| + ActionText::Attachment.from_node(node).with_full_attributes + end + end + + def size + attachments.size + end + + def inspect + "#<#{self.class.name} size=#{size.inspect}>" + end + + TAG_NAME = "div" + ATTACHMENT_SELECTOR = "#{ActionText::Attachment::SELECTOR}[presentation=gallery]" + SELECTOR = "#{TAG_NAME}:has(#{ATTACHMENT_SELECTOR} + #{ATTACHMENT_SELECTOR})" + + private_constant :TAG_NAME, :ATTACHMENT_SELECTOR, :SELECTOR + end +end diff --git a/actiontext/lib/action_text/attachments/caching.rb b/actiontext/lib/action_text/attachments/caching.rb new file mode 100644 index 0000000000..7c727bfc26 --- /dev/null +++ b/actiontext/lib/action_text/attachments/caching.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module ActionText + module Attachments + module Caching + def cache_key(*args) + [self.class.name, cache_digest, *attachable.cache_key(*args)].join("/") + end + + private + def cache_digest + Digest::SHA256.hexdigest(node.to_s) + end + end + end +end diff --git a/actiontext/lib/action_text/attachments/minification.rb b/actiontext/lib/action_text/attachments/minification.rb new file mode 100644 index 0000000000..edc8f876d6 --- /dev/null +++ b/actiontext/lib/action_text/attachments/minification.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActionText + module Attachments + module Minification + extend ActiveSupport::Concern + + class_methods do + def fragment_by_minifying_attachments(content) + Fragment.wrap(content).replace(ActionText::Attachment::SELECTOR) do |node| + node.tap { |n| n.inner_html = "" } + end + end + end + end + end +end diff --git a/actiontext/lib/action_text/attachments/trix_conversion.rb b/actiontext/lib/action_text/attachments/trix_conversion.rb new file mode 100644 index 0000000000..24937d6c22 --- /dev/null +++ b/actiontext/lib/action_text/attachments/trix_conversion.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActionText + module Attachments + module TrixConversion + extend ActiveSupport::Concern + + class_methods do + def fragment_by_converting_trix_attachments(content) + Fragment.wrap(content).replace(TrixAttachment::SELECTOR) do |node| + from_trix_attachment(TrixAttachment.new(node)) + end + end + + def from_trix_attachment(trix_attachment) + from_attributes(trix_attachment.attributes) + end + end + + def to_trix_attachment(content = trix_attachment_content) + attributes = full_attributes.dup + attributes["content"] = content if content + TrixAttachment.from_attributes(attributes) + end + + private + def trix_attachment_content + if partial_path = attachable.try(:to_trix_content_attachment_partial_path) + ActionText::Content.renderer.render(partial: partial_path, object: self, as: model_name.element) + end + end + end + end +end diff --git a/actiontext/lib/action_text/attribute.rb b/actiontext/lib/action_text/attribute.rb new file mode 100644 index 0000000000..f226dd21bd --- /dev/null +++ b/actiontext/lib/action_text/attribute.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module ActionText + module Attribute + extend ActiveSupport::Concern + + class_methods do + # Provides access to a dependent RichText model that holds the body and attachments for a single named rich text attribute. + # This dependent attribute is lazily instantiated and will be auto-saved when it's been changed. Example: + # + # class Message < ActiveRecord::Base + # has_rich_text :content + # end + # + # message = Message.create!(content: "<h1>Funny times!</h1>") + # message.content.to_s # => "<h1>Funny times!</h1>" + # message.content.to_plain_text # => "Funny times!" + # + # The dependent RichText model will also automatically process attachments links as sent via the Trix-powered editor. + # These attachments are associated with the RichText model using Active Storage. + # + # If you wish to preload the dependent RichText model, you can use the named scope: + # + # Message.all.with_rich_text_content # Avoids N+1 queries when you just want the body, not the attachments. + # Message.all.with_rich_text_content_and_embeds # Avoids N+1 queries when you just want the body and attachments. + def has_rich_text(name) + class_eval <<-CODE, __FILE__, __LINE__ + 1 + def #{name} + self.rich_text_#{name} ||= ActionText::RichText.new(name: "#{name}", record: self) + end + + def #{name}=(body) + self.#{name}.body = body + end + CODE + + has_one :"rich_text_#{name}", -> { where(name: name) }, class_name: "ActionText::RichText", as: :record, inverse_of: :record, dependent: :destroy + + scope :"with_rich_text_#{name}", -> { includes("rich_text_#{name}") } + scope :"with_rich_text_#{name}_and_embeds", -> { includes("rich_text_#{name}": { embeds_attachments: :blob }) } + + after_save do + public_send(name).save if public_send(name).changed? + end + end + end + end +end diff --git a/actiontext/lib/action_text/content.rb b/actiontext/lib/action_text/content.rb new file mode 100644 index 0000000000..16bc6fe031 --- /dev/null +++ b/actiontext/lib/action_text/content.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors_per_thread" + +module ActionText + class Content + include Serialization + + thread_cattr_accessor :renderer + + attr_reader :fragment + + delegate :blank?, :empty?, :html_safe, :present?, to: :to_html # Delegating to to_html to avoid including the layout + + class << self + def fragment_by_canonicalizing_content(content) + fragment = ActionText::Attachment.fragment_by_canonicalizing_attachments(content) + fragment = ActionText::AttachmentGallery.fragment_by_canonicalizing_attachment_galleries(fragment) + fragment + end + end + + def initialize(content = nil, options = {}) + options.with_defaults! canonicalize: true + + if options[:canonicalize] + @fragment = self.class.fragment_by_canonicalizing_content(content) + else + @fragment = ActionText::Fragment.wrap(content) + end + end + + def links + @links ||= fragment.find_all("a[href]").map { |a| a["href"] }.uniq + end + + def attachments + @attachments ||= attachment_nodes.map do |node| + attachment_for_node(node) + end + end + + def attachment_galleries + @attachment_galleries ||= attachment_gallery_nodes.map do |node| + attachment_gallery_for_node(node) + end + end + + def gallery_attachments + @gallery_attachments ||= attachment_galleries.flat_map(&:attachments) + end + + def attachables + @attachables ||= attachment_nodes.map do |node| + ActionText::Attachable.from_node(node) + end + end + + def append_attachables(attachables) + attachments = ActionText::Attachment.from_attachables(attachables) + self.class.new([self.to_s.presence, *attachments].compact.join("\n")) + end + + def render_attachments(**options, &block) + content = fragment.replace(ActionText::Attachment::SELECTOR) do |node| + block.call(attachment_for_node(node, **options)) + end + self.class.new(content, canonicalize: false) + end + + def render_attachment_galleries(&block) + content = ActionText::AttachmentGallery.fragment_by_replacing_attachment_gallery_nodes(fragment) do |node| + block.call(attachment_gallery_for_node(node)) + end + self.class.new(content, canonicalize: false) + end + + def to_plain_text + render_attachments(with_full_attributes: false, &:to_plain_text).fragment.to_plain_text + end + + def to_trix_html + render_attachments(&:to_trix_attachment).to_html + end + + def to_html + fragment.to_html + end + + def to_rendered_html_with_layout + renderer.render(partial: "action_text/content/layout", locals: { content: self }) + end + + def to_s + to_rendered_html_with_layout + end + + def as_json(*) + to_html + end + + def inspect + "#<#{self.class.name} #{to_s.truncate(25).inspect}>" + end + + def ==(other) + if other.is_a?(self.class) + to_s == other.to_s + end + end + + private + def attachment_nodes + @attachment_nodes ||= fragment.find_all(ActionText::Attachment::SELECTOR) + end + + def attachment_gallery_nodes + @attachment_gallery_nodes ||= ActionText::AttachmentGallery.find_attachment_gallery_nodes(fragment) + end + + def attachment_for_node(node, with_full_attributes: true) + attachment = ActionText::Attachment.from_node(node) + with_full_attributes ? attachment.with_full_attributes : attachment + end + + def attachment_gallery_for_node(node) + ActionText::AttachmentGallery.from_node(node) + end + end +end + +ActiveSupport.run_load_hooks :action_text_content, ActionText::Content diff --git a/actiontext/lib/action_text/engine.rb b/actiontext/lib/action_text/engine.rb new file mode 100644 index 0000000000..51ff5b575b --- /dev/null +++ b/actiontext/lib/action_text/engine.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails" +require "action_controller/railtie" +require "active_record/railtie" +require "active_storage/engine" + +require "action_text" + +module ActionText + class Engine < Rails::Engine + isolate_namespace ActionText + config.eager_load_namespaces << ActionText + + initializer "action_text.attribute" do + ActiveSupport.on_load(:active_record) do + include ActionText::Attribute + end + end + + initializer "action_text.attachable" do + ActiveSupport.on_load(:active_storage_blob) do + include ActionText::Attachable + + def previewable_attachable? + representable? + end + end + end + + initializer "action_text.helper" do + ActiveSupport.on_load(:action_controller_base) do + helper ActionText::Engine.helpers + end + end + + initializer "action_text.renderer" do |app| + app.executor.to_run { ActionText::Content.renderer = ApplicationController.renderer } + app.executor.to_complete { ActionText::Content.renderer = ApplicationController.renderer } + + ActiveSupport.on_load(:action_text_content) do + self.renderer = ApplicationController.renderer + end + + ActiveSupport.on_load(:action_controller_base) do + before_action { ActionText::Content.renderer = ApplicationController.renderer.new(request.env) } + end + end + end +end diff --git a/actiontext/lib/action_text/fragment.rb b/actiontext/lib/action_text/fragment.rb new file mode 100644 index 0000000000..af276b2b26 --- /dev/null +++ b/actiontext/lib/action_text/fragment.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module ActionText + class Fragment + class << self + def wrap(fragment_or_html) + case fragment_or_html + when self + fragment_or_html + when Nokogiri::HTML::DocumentFragment + new(fragment_or_html) + else + from_html(fragment_or_html) + end + end + + def from_html(html) + new(ActionText::HtmlConversion.fragment_for_html(html.to_s.strip)) + end + end + + attr_reader :source + + def initialize(source) + @source = source + end + + def find_all(selector) + source.css(selector) + end + + def update + yield source = self.source.clone + self.class.new(source) + end + + def replace(selector) + update do |source| + source.css(selector).each do |node| + node.replace(yield(node).to_s) + end + end + end + + def to_plain_text + @plain_text ||= PlainTextConversion.node_to_plain_text(source) + end + + def to_html + @html ||= HtmlConversion.node_to_html(source) + end + + def to_s + to_html + end + end +end diff --git a/actiontext/lib/action_text/gem_version.rb b/actiontext/lib/action_text/gem_version.rb new file mode 100644 index 0000000000..5a640de5c8 --- /dev/null +++ b/actiontext/lib/action_text/gem_version.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ActionText + # Returns the currently-loaded version of Action Text as a <tt>Gem::Version</tt>. + def self.gem_version + Gem::Version.new VERSION::STRING + end + + module VERSION + MAJOR = 6 + MINOR = 0 + TINY = 0 + PRE = "alpha" + + STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".") + end +end diff --git a/actiontext/lib/action_text/html_conversion.rb b/actiontext/lib/action_text/html_conversion.rb new file mode 100644 index 0000000000..1e1062ea3f --- /dev/null +++ b/actiontext/lib/action_text/html_conversion.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module ActionText + module HtmlConversion + extend self + + def node_to_html(node) + node.to_html(save_with: Nokogiri::XML::Node::SaveOptions::AS_HTML) + end + + def fragment_for_html(html) + document.fragment(html) + end + + def create_element(tag_name, attributes = {}) + document.create_element(tag_name, attributes) + end + + private + def document + Nokogiri::HTML::Document.new.tap { |doc| doc.encoding = "UTF-8" } + end + end +end diff --git a/actiontext/lib/action_text/plain_text_conversion.rb b/actiontext/lib/action_text/plain_text_conversion.rb new file mode 100644 index 0000000000..0eb4e2e7da --- /dev/null +++ b/actiontext/lib/action_text/plain_text_conversion.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module ActionText + module PlainTextConversion + extend self + + def node_to_plain_text(node) + remove_trailing_newlines(plain_text_for_node(node)) + end + + private + def plain_text_for_node(node, index = 0) + if respond_to?(plain_text_method_for_node(node), true) + send(plain_text_method_for_node(node), node, index) + else + plain_text_for_node_children(node) + end + end + + def plain_text_for_node_children(node) + node.children.each_with_index.map do |child, index| + plain_text_for_node(child, index) + end.compact.join("") + end + + def plain_text_method_for_node(node) + :"plain_text_for_#{node.name}_node" + end + + def plain_text_for_block(node, index = 0) + "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n" + end + + %i[ h1 p ul ol ].each do |element| + alias_method :"plain_text_for_#{element}_node", :plain_text_for_block + end + + def plain_text_for_br_node(node, index) + "\n" + end + + def plain_text_for_text_node(node, index) + remove_trailing_newlines(node.text) + end + + def plain_text_for_div_node(node, index) + "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n" + end + + def plain_text_for_figcaption_node(node, index) + "[#{remove_trailing_newlines(plain_text_for_node_children(node))}]" + end + + def plain_text_for_blockquote_node(node, index) + text = plain_text_for_block(node) + text.sub(/\A(\s*)(.+?)(\s*)\Z/m, '\1“\2”\3') + end + + def plain_text_for_li_node(node, index) + bullet = bullet_for_li_node(node, index) + text = remove_trailing_newlines(plain_text_for_node_children(node)) + "#{bullet} #{text}\n" + end + + def remove_trailing_newlines(text) + text.chomp("") + end + + def bullet_for_li_node(node, index) + if list_node_name_for_li_node(node) == "ol" + "#{index + 1}." + else + "•" + end + end + + def list_node_name_for_li_node(node) + node.ancestors.lazy.map(&:name).grep(/^[uo]l$/).first + end + end +end diff --git a/actiontext/lib/action_text/serialization.rb b/actiontext/lib/action_text/serialization.rb new file mode 100644 index 0000000000..8ecf8c9157 --- /dev/null +++ b/actiontext/lib/action_text/serialization.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ActionText + module Serialization + extend ActiveSupport::Concern + + class_methods do + def load(content) + new(content) if content + end + + def dump(content) + case content + when nil + nil + when self + content.to_html + else + new(content).to_html + end + end + end + + # Marshal compatibility + + class_methods do + alias_method :_load, :load + end + + def _dump(*) + self.class.dump(self) + end + end +end diff --git a/actiontext/lib/action_text/trix_attachment.rb b/actiontext/lib/action_text/trix_attachment.rb new file mode 100644 index 0000000000..c16c1c090d --- /dev/null +++ b/actiontext/lib/action_text/trix_attachment.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module ActionText + class TrixAttachment + TAG_NAME = "figure" + SELECTOR = "[data-trix-attachment]" + + COMPOSED_ATTRIBUTES = %w( caption presentation ) + ATTRIBUTES = %w( sgid contentType url href filename filesize width height previewable content ) + COMPOSED_ATTRIBUTES + ATTRIBUTE_TYPES = { + "previewable" => ->(value) { value.to_s == "true" }, + "filesize" => ->(value) { Integer(value.to_s) rescue value }, + "width" => ->(value) { Integer(value.to_s) rescue nil }, + "height" => ->(value) { Integer(value.to_s) rescue nil }, + :default => ->(value) { value.to_s } + } + + class << self + def from_attributes(attributes) + attributes = process_attributes(attributes) + + trix_attachment_attributes = attributes.except(*COMPOSED_ATTRIBUTES) + trix_attributes = attributes.slice(*COMPOSED_ATTRIBUTES) + + node = ActionText::HtmlConversion.create_element(TAG_NAME) + node["data-trix-attachment"] = JSON.generate(trix_attachment_attributes) + node["data-trix-attributes"] = JSON.generate(trix_attributes) if trix_attributes.any? + + new(node) + end + + private + def process_attributes(attributes) + typecast_attribute_values(transform_attribute_keys(attributes)) + end + + def transform_attribute_keys(attributes) + attributes.transform_keys { |key| key.to_s.underscore.camelize(:lower) } + end + + def typecast_attribute_values(attributes) + attributes.map do |key, value| + typecast = ATTRIBUTE_TYPES[key] || ATTRIBUTE_TYPES[:default] + [key, typecast.call(value)] + end.to_h + end + end + + attr_reader :node + + def initialize(node) + @node = node + end + + def attributes + @attributes ||= attachment_attributes.merge(composed_attributes).slice(*ATTRIBUTES) + end + + def to_html + ActionText::HtmlConversion.node_to_html(node) + end + + def to_s + to_html + end + + private + def attachment_attributes + read_json_object_attribute("data-trix-attachment") + end + + def composed_attributes + read_json_object_attribute("data-trix-attributes") + end + + def read_json_object_attribute(name) + read_json_attribute(name) || {} + end + + def read_json_attribute(name) + if value = node[name] + begin + JSON.parse(value) + rescue => e + Rails.logger.error "[#{self.class.name}] Couldn't parse JSON #{value} from NODE #{node.inspect}" + Rails.logger.error "[#{self.class.name}] Failed with #{e.class}: #{e.backtrace}" + nil + end + end + end + end +end diff --git a/actiontext/lib/action_text/version.rb b/actiontext/lib/action_text/version.rb new file mode 100644 index 0000000000..ed72859fa4 --- /dev/null +++ b/actiontext/lib/action_text/version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require_relative "gem_version" + +module ActionText + # Returns the currently-loaded version of Action Text as a <tt>Gem::Version</tt>. + def self.version + gem_version + end +end diff --git a/actiontext/lib/tasks/actiontext.rake b/actiontext/lib/tasks/actiontext.rake new file mode 100644 index 0000000000..4f90e4930c --- /dev/null +++ b/actiontext/lib/tasks/actiontext.rake @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +namespace :action_text do + # Prevent migration installation task from showing up twice. + Rake::Task["install:migrations"].clear_comments + + desc "Copy over the migration, stylesheet, and JavaScript files" + task install: %w( environment run_installer copy_migrations ) + + task :run_installer do + installer_template = File.expand_path("../templates/installer.rb", __dir__) + system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{installer_template}" + end + + task :copy_migrations do + Rake::Task["active_storage:install:migrations"].invoke + Rake::Task["railties:install:migrations"].reenable # Otherwise you can't run 2 migration copy tasks in one invocation + Rake::Task["action_text:install:migrations"].invoke + end +end diff --git a/actiontext/lib/templates/actiontext.scss b/actiontext/lib/templates/actiontext.scss new file mode 100644 index 0000000000..7cb26e74ac --- /dev/null +++ b/actiontext/lib/templates/actiontext.scss @@ -0,0 +1,36 @@ +// +// Provides a drop-in pointer for the default Trix stylesheet that will format the toolbar and +// the trix-editor content (whether displayed or under editing). Feel free to incorporate this +// inclusion directly in any other asset bundle and remove this file. +// +//= require trix/dist/trix + +// We need to override trix.css’s image gallery styles to accommodate the +// <action-text-attachment> element we wrap around attachments. Otherwise, +// images in galleries will be squished by the max-width: 33%; rule. +.trix-content { + .attachment-gallery { + > action-text-attachment, + > .attachment { + flex: 1 0 33%; + padding: 0 0.5em; + max-width: 33%; + } + + &.attachment-gallery--2, + &.attachment-gallery--4 { + > action-text-attachment, + > .attachment { + flex-basis: 50%; + max-width: 50%; + } + } + } + + action-text-attachment { + .attachment { + padding: 0 !important; + max-width: 100% !important; + } + } +} diff --git a/actiontext/lib/templates/fixtures.yml b/actiontext/lib/templates/fixtures.yml new file mode 100644 index 0000000000..8b371ea604 --- /dev/null +++ b/actiontext/lib/templates/fixtures.yml @@ -0,0 +1,4 @@ +# one: +# record: name_of_fixture (ClassOfFixture) +# name: content +# body: <p>In a <i>million</i> stars!</p> diff --git a/actiontext/lib/templates/installer.rb b/actiontext/lib/templates/installer.rb new file mode 100644 index 0000000000..ee5a5af75b --- /dev/null +++ b/actiontext/lib/templates/installer.rb @@ -0,0 +1,22 @@ +say "Copying actiontext.scss to app/assets/stylesheets" +copy_file "#{__dir__}/actiontext.scss", "app/assets/stylesheets/actiontext.scss" + +say "Copying fixtures to test/fixtures/action_text/rich_texts.yml" +copy_file "#{__dir__}/fixtures.yml", "test/fixtures/action_text/rich_texts.yml" + +say "Copying blob rendering partial to app/views/active_storage/blobs/_blob.html.erb" +copy_file "#{__dir__}/../../app/views/active_storage/blobs/_blob.html.erb", + "app/views/active_storage/blobs/_blob.html.erb" + +# FIXME: Replace with release version on release +say "Installing JavaScript dependency" +run "yarn add https://github.com/rails/actiontext" + +APPLICATION_PACK_PATH = "app/javascript/packs/application.js" + +if File.exists?(APPLICATION_PACK_PATH) && File.read(APPLICATION_PACK_PATH) !~ /import "actiontext"/ + say "Adding import to default JavaScript pack" + append_to_file APPLICATION_PACK_PATH, <<-EOS +import "actiontext" +EOS +end |