diff options
Diffstat (limited to 'actiontext/app')
10 files changed, 215 insertions, 0 deletions
diff --git a/actiontext/app/helpers/action_text/content_helper.rb b/actiontext/app/helpers/action_text/content_helper.rb new file mode 100644 index 0000000000..b3335601f9 --- /dev/null +++ b/actiontext/app/helpers/action_text/content_helper.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module ActionText + module ContentHelper + SANITIZER = Rails::Html::Sanitizer.white_list_sanitizer + ALLOWED_TAGS = SANITIZER.allowed_tags + [ ActionText::Attachment::TAG_NAME, "figure", "figcaption" ] + ALLOWED_ATTRIBUTES = SANITIZER.allowed_attributes + ActionText::Attachment::ATTRIBUTES + + def render_action_text_content(content) + content = content.render_attachments do |attachment| + unless attachment.in?(content.gallery_attachments) + attachment.node.tap do |node| + node.inner_html = render(attachment, in_gallery: false).chomp + end + end + end + + content = content.render_attachment_galleries do |attachment_gallery| + render(layout: attachment_gallery, object: attachment_gallery) do + attachment_gallery.attachments.map do |attachment| + attachment.node.inner_html = render(attachment, in_gallery: true).chomp + attachment.to_html + end.join("").html_safe + end.chomp + end + + sanitize content.to_html, tags: ALLOWED_TAGS, attributes: ALLOWED_ATTRIBUTES + end + end +end diff --git a/actiontext/app/helpers/action_text/tag_helper.rb b/actiontext/app/helpers/action_text/tag_helper.rb new file mode 100644 index 0000000000..837b2264b1 --- /dev/null +++ b/actiontext/app/helpers/action_text/tag_helper.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module ActionText + module TagHelper + cattr_accessor(:id, instance_accessor: false) { 0 } + + # Returns a `trix-editor` tag that instantiates the Trix JavaScript editor as well as a hidden field + # that Trix will write to on changes, so the content will be sent on form submissions. + # + # ==== Options + # * <tt>:class</tt> - Defaults to "trix-content" which ensures default styling is applied. + # + # ==== Example + # + # rich_text_area_tag "content", message.content + # # <input type="hidden" name="content" id="trix_input_post_1"> + # # <trix-editor id="content" input="trix_input_post_1" class="trix-content" ...></trix-editor> + def rich_text_area_tag(name, value = nil, options = {}) + options = options.symbolize_keys + + options[:input] ||= "trix_input_#{ActionText::TagHelper.id += 1}" + options[:class] ||= "trix-content" + + options[:data] ||= {} + options[:data][:direct_upload_url] = main_app.rails_direct_uploads_url + options[:data][:blob_url_template] = main_app.rails_service_blob_url(":signed_id", ":filename") + + editor_tag = content_tag("trix-editor", "", options) + input_tag = hidden_field_tag(name, value, id: options[:input]) + + input_tag + editor_tag + end + end +end + +module ActionView::Helpers + class Tags::ActionText < Tags::Base + delegate :dom_id, to: ActionView::RecordIdentifier + + def render + options = @options.stringify_keys + add_default_name_and_id(options) + options["input"] ||= dom_id(object, [options["id"], :trix_input].compact.join("_")) if object + @template_object.rich_text_area_tag(options.delete("name"), editable_value, options) + end + + def editable_value + value&.body.try(:to_trix_html) + end + end + + module FormHelper + # Returns a `trix-editor` tag that instantiates the Trix JavaScript editor as well as a hidden field + # that Trix will write to on changes, so the content will be sent on form submissions. + # + # ==== Options + # * <tt>:class</tt> - Defaults to "trix-content" which ensures default styling is applied. + # + # ==== Example + # form_with(model: @message) do |form| + # form.rich_text_area :content + # end + # # <input type="hidden" name="message[content]" id="message_content_trix_input_message_1"> + # # <trix-editor id="content" input="message_content_trix_input_message_1" class="trix-content" ...></trix-editor> + def rich_text_area(object_name, method, options = {}) + Tags::ActionText.new(object_name, method, self, options).render + end + end + + class FormBuilder + def rich_text_area(method, options = {}) + @template.rich_text_area(@object_name, method, objectify_options(options)) + end + end +end diff --git a/actiontext/app/javascript/actiontext/attachment_upload.js b/actiontext/app/javascript/actiontext/attachment_upload.js new file mode 100644 index 0000000000..a716f1f589 --- /dev/null +++ b/actiontext/app/javascript/actiontext/attachment_upload.js @@ -0,0 +1,45 @@ +import { DirectUpload } from "activestorage" + +export class AttachmentUpload { + constructor(attachment, element) { + this.attachment = attachment + this.element = element + this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this) + } + + start() { + this.directUpload.create(this.directUploadDidComplete.bind(this)) + } + + directUploadWillStoreFileWithXHR(xhr) { + xhr.upload.addEventListener("progress", event => { + const progress = event.loaded / event.total * 100 + this.attachment.setUploadProgress(progress) + }) + } + + directUploadDidComplete(error, attributes) { + if (error) { + throw new Error(`Direct upload failed: ${error}`) + } + + this.attachment.setAttributes({ + sgid: attributes.attachable_sgid, + url: this.createBlobUrl(attributes.signed_id, attributes.filename) + }) + } + + createBlobUrl(signedId, filename) { + return this.blobUrlTemplate + .replace(":signed_id", signedId) + .replace(":filename", encodeURIComponent(filename)) + } + + get directUploadUrl() { + return this.element.dataset.directUploadUrl + } + + get blobUrlTemplate() { + return this.element.dataset.blobUrlTemplate + } +} diff --git a/actiontext/app/javascript/actiontext/index.js b/actiontext/app/javascript/actiontext/index.js new file mode 100644 index 0000000000..c149eda952 --- /dev/null +++ b/actiontext/app/javascript/actiontext/index.js @@ -0,0 +1,11 @@ +import * as Trix from "trix" +import { AttachmentUpload } from "./attachment_upload" + +addEventListener("trix-attachment-add", event => { + const { attachment, target } = event + + if (attachment.file) { + const upload = new AttachmentUpload(attachment, target) + upload.start() + } +}) diff --git a/actiontext/app/models/action_text/rich_text.rb b/actiontext/app/models/action_text/rich_text.rb new file mode 100644 index 0000000000..717e93599f --- /dev/null +++ b/actiontext/app/models/action_text/rich_text.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# The RichText record holds the content produced by the Trix editor in a serialized `body` attribute. +# It also holds all the references to the embedded files, which are stored using Active Storage. +# This record is then associated with the Active Record model the application desires to have +# rich text content using the `has_rich_text` class method. +class ActionText::RichText < ActiveRecord::Base + self.table_name = "action_text_rich_texts" + + serialize :body, ActionText::Content + delegate :to_s, :nil?, to: :body + + belongs_to :record, polymorphic: true, touch: true + has_many_attached :embeds + + before_save do + self.embeds = body.attachments.map(&:attachable) if body.present? + end + + def to_plain_text + body&.to_plain_text.to_s + end + + delegate :blank?, :empty?, :present?, to: :to_plain_text +end diff --git a/actiontext/app/views/action_text/attachables/_missing_attachable.html.erb b/actiontext/app/views/action_text/attachables/_missing_attachable.html.erb new file mode 100644 index 0000000000..5ffd93b89e --- /dev/null +++ b/actiontext/app/views/action_text/attachables/_missing_attachable.html.erb @@ -0,0 +1 @@ +<%= "☒" -%> diff --git a/actiontext/app/views/action_text/attachables/_remote_image.html.erb b/actiontext/app/views/action_text/attachables/_remote_image.html.erb new file mode 100644 index 0000000000..3372f8d940 --- /dev/null +++ b/actiontext/app/views/action_text/attachables/_remote_image.html.erb @@ -0,0 +1,8 @@ +<figure class="attachment attachment--preview"> + <%= image_tag(remote_image.url, width: remote_image.width, height: remote_image.height) %> + <% if caption = remote_image.try(:caption) %> + <figcaption class="attachment__caption"> + <%= caption %> + </figcaption> + <% end %> +</figure> diff --git a/actiontext/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb b/actiontext/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb new file mode 100644 index 0000000000..6bc8674dc5 --- /dev/null +++ b/actiontext/app/views/action_text/attachment_galleries/_attachment_gallery.html.erb @@ -0,0 +1,3 @@ +<div class="attachment-gallery attachment-gallery--<%= attachment_gallery.size %>"> + <%= yield %> +</div> diff --git a/actiontext/app/views/action_text/content/_layout.html.erb b/actiontext/app/views/action_text/content/_layout.html.erb new file mode 100644 index 0000000000..55cb708ac4 --- /dev/null +++ b/actiontext/app/views/action_text/content/_layout.html.erb @@ -0,0 +1,3 @@ +<div class="trix-content"> + <%= render_action_text_content(content) %> +</div> diff --git a/actiontext/app/views/active_storage/blobs/_blob.html.erb b/actiontext/app/views/active_storage/blobs/_blob.html.erb new file mode 100644 index 0000000000..049f57e804 --- /dev/null +++ b/actiontext/app/views/active_storage/blobs/_blob.html.erb @@ -0,0 +1,14 @@ +<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>"> + <% if blob.representable? %> + <%= image_tag blob.representation(resize_to_fit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %> + <% end %> + + <figcaption class="attachment__caption"> + <% if caption = blob.try(:caption) %> + <%= caption %> + <% else %> + <span class="attachment__name"><%= blob.filename %></span> + <span class="attachment__size"><%= number_to_human_size blob.byte_size %></span> + <% end %> + </figcaption> +</figure> |