actiontext 7.2.2.1 → 8.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,32 +1,86 @@
1
- import { DirectUpload } from "@rails/activestorage"
1
+ import { DirectUpload, dispatchEvent } from "@rails/activestorage"
2
2
 
3
3
  export class AttachmentUpload {
4
- constructor(attachment, element) {
4
+ constructor(attachment, element, file = attachment.file) {
5
5
  this.attachment = attachment
6
6
  this.element = element
7
- this.directUpload = new DirectUpload(attachment.file, this.directUploadUrl, this)
7
+ this.directUpload = new DirectUpload(file, this.directUploadUrl, this)
8
+ this.file = file
8
9
  }
9
10
 
10
11
  start() {
11
- this.directUpload.create(this.directUploadDidComplete.bind(this))
12
+ return new Promise((resolve, reject) => {
13
+ this.directUpload.create((error, attributes) => this.directUploadDidComplete(error, attributes, resolve, reject))
14
+ this.dispatch("start")
15
+ })
12
16
  }
13
17
 
14
18
  directUploadWillStoreFileWithXHR(xhr) {
15
19
  xhr.upload.addEventListener("progress", event => {
16
- const progress = event.loaded / event.total * 100
17
- this.attachment.setUploadProgress(progress)
20
+ // Scale upload progress to 0-90% range
21
+ const progress = (event.loaded / event.total) * 90
22
+ if (progress) {
23
+ this.dispatch("progress", { progress: progress })
24
+ }
25
+ })
26
+
27
+ // Start simulating progress after upload completes
28
+ xhr.upload.addEventListener("loadend", () => {
29
+ this.simulateResponseProgress(xhr)
18
30
  })
19
31
  }
20
32
 
21
- directUploadDidComplete(error, attributes) {
22
- if (error) {
23
- throw new Error(`Direct upload failed: ${error}`)
33
+ simulateResponseProgress(xhr) {
34
+ let progress = 90
35
+ const startTime = Date.now()
36
+
37
+ const updateProgress = () => {
38
+ // Simulate progress from 90% to 99% over estimated time
39
+ const elapsed = Date.now() - startTime
40
+ const estimatedResponseTime = this.estimateResponseTime()
41
+ const responseProgress = Math.min(elapsed / estimatedResponseTime, 1)
42
+ progress = 90 + (responseProgress * 9) // 90% to 99%
43
+
44
+ this.dispatch("progress", { progress })
45
+
46
+ // Continue until response arrives or we hit 99%
47
+ if (xhr.readyState !== XMLHttpRequest.DONE && progress < 99) {
48
+ requestAnimationFrame(updateProgress)
49
+ }
24
50
  }
25
51
 
26
- this.attachment.setAttributes({
27
- sgid: attributes.attachable_sgid,
28
- url: this.createBlobUrl(attributes.signed_id, attributes.filename)
52
+ // Stop simulation when response arrives
53
+ xhr.addEventListener("loadend", () => {
54
+ this.dispatch("progress", { progress: 100 })
29
55
  })
56
+
57
+ requestAnimationFrame(updateProgress)
58
+ }
59
+
60
+ estimateResponseTime() {
61
+ // Base estimate: 1 second for small files, scaling up for larger files
62
+ const fileSize = this.file.size
63
+ const MB = 1024 * 1024
64
+
65
+ if (fileSize < MB) {
66
+ return 1000 // 1 second for files under 1MB
67
+ } else if (fileSize < 10 * MB) {
68
+ return 2000 // 2 seconds for files 1-10MB
69
+ } else {
70
+ return 3000 + (fileSize / MB * 50) // 3+ seconds for larger files
71
+ }
72
+ }
73
+
74
+ directUploadDidComplete(error, attributes, resolve, reject) {
75
+ if (error) {
76
+ this.dispatchError(error, reject)
77
+ } else {
78
+ resolve({
79
+ sgid: attributes.attachable_sgid,
80
+ url: this.createBlobUrl(attributes.signed_id, attributes.filename)
81
+ })
82
+ this.dispatch("end")
83
+ }
30
84
  }
31
85
 
32
86
  createBlobUrl(signedId, filename) {
@@ -35,6 +89,18 @@ export class AttachmentUpload {
35
89
  .replace(":filename", encodeURIComponent(filename))
36
90
  }
37
91
 
92
+ dispatch(name, detail = {}) {
93
+ detail.attachment = this.attachment
94
+ return dispatchEvent(this.element, `direct-upload:${name}`, { detail })
95
+ }
96
+
97
+ dispatchError(error, reject) {
98
+ const event = this.dispatch("error", { error })
99
+ if (!event.defaultPrevented) {
100
+ reject(error)
101
+ }
102
+ }
103
+
38
104
  get directUploadUrl() {
39
105
  return this.element.dataset.directUploadUrl
40
106
  }
@@ -4,7 +4,16 @@ addEventListener("trix-attachment-add", event => {
4
4
  const { attachment, target } = event
5
5
 
6
6
  if (attachment.file) {
7
- const upload = new AttachmentUpload(attachment, target)
7
+ const upload = new AttachmentUpload(attachment, target, attachment.file)
8
+ const onProgress = event => attachment.setUploadProgress(event.detail.progress)
9
+
10
+ target.addEventListener("direct-upload:progress", onProgress)
11
+
8
12
  upload.start()
13
+ .then(attributes => attachment.setAttributes(attributes))
14
+ .catch(error => alert(error))
15
+ .finally(() => target.removeEventListener("direct-upload:progress", onProgress))
9
16
  }
10
17
  })
18
+
19
+ export { AttachmentUpload }
@@ -48,10 +48,13 @@ module ActionText
48
48
  ##
49
49
  # :method: embeds
50
50
  #
51
- # Returns the `ActiveStorage::Blob`s of the embedded files.
51
+ # Returns the ActiveStorage::Attachment records from the embedded files.
52
+ #
53
+ # Attached ActiveStorage::Blob records are extracted from the `body`
54
+ # in a {before_validation}[rdoc-ref:ActiveModel::Validations::Callbacks::ClassMethods#before_validation] callback.
52
55
  has_many_attached :embeds
53
56
 
54
- before_save do
57
+ before_validation do
55
58
  self.embeds = body.attachables.grep(ActiveStorage::Blob).uniq if body.present?
56
59
  end
57
60
 
@@ -41,13 +41,16 @@ module ActionText
41
41
  # `strict_loading:` will be set to the value of the
42
42
  # `strict_loading_by_default` class attribute (false by default).
43
43
  #
44
+ # * `:store_if_blank` - Pass false to not create RichText records with empty values,
45
+ # if a blank value is provided. Default: true.
46
+ #
44
47
  #
45
48
  # Note: Action Text relies on polymorphic associations, which in turn store
46
49
  # class names in the database. When renaming classes that use `has_rich_text`,
47
50
  # make sure to also update the class names in the
48
51
  # `action_text_rich_texts.record_type` polymorphic type column of the
49
52
  # corresponding rows.
50
- def has_rich_text(name, encrypted: false, strict_loading: strict_loading_by_default)
53
+ def has_rich_text(name, encrypted: false, strict_loading: strict_loading_by_default, store_if_blank: true)
51
54
  class_eval <<-CODE, __FILE__, __LINE__ + 1
52
55
  def #{name}
53
56
  rich_text_#{name} || build_rich_text_#{name}
@@ -56,12 +59,29 @@ module ActionText
56
59
  def #{name}?
57
60
  rich_text_#{name}.present?
58
61
  end
59
-
60
- def #{name}=(body)
61
- self.#{name}.body = body
62
- end
63
62
  CODE
64
63
 
64
+ if store_if_blank
65
+ class_eval <<-CODE, __FILE__, __LINE__ + 1
66
+ def #{name}=(body)
67
+ self.#{name}.body = body
68
+ end
69
+ CODE
70
+ else
71
+ class_eval <<-CODE, __FILE__, __LINE__ + 1
72
+ def #{name}=(body)
73
+ if body.present?
74
+ self.#{name}.body = body
75
+ else
76
+ if #{name}?
77
+ self.#{name}.body = body
78
+ self.#{name}.mark_for_destruction
79
+ end
80
+ end
81
+ end
82
+ CODE
83
+ end
84
+
65
85
  rich_text_class_name = encrypted ? "ActionText::EncryptedRichText" : "ActionText::RichText"
66
86
  has_one :"rich_text_#{name}", -> { where(name: name) },
67
87
  class_name: rich_text_class_name, as: :record, inverse_of: :record, autosave: true, dependent: :destroy,
@@ -56,7 +56,7 @@ module ActionText
56
56
  @links ||= fragment.find_all("a[href]").map { |a| a["href"] }.uniq
57
57
  end
58
58
 
59
- # Extracts +ActionText::Attachment+s from the HTML fragment:
59
+ # Extracts ActionText::Attachment objects from the HTML fragment:
60
60
  #
61
61
  # attachable = ActiveStorage::Blob.first
62
62
  # html = %Q(<action-text-attachment sgid="#{attachable.attachable_sgid}" caption="Captioned"></action-text-attachment>)
@@ -78,7 +78,7 @@ module ActionText
78
78
  @gallery_attachments ||= attachment_galleries.flat_map(&:attachments)
79
79
  end
80
80
 
81
- # Extracts +ActionText::Attachable+s from the HTML fragment:
81
+ # Extracts ActionText::Attachable objects from the HTML fragment:
82
82
  #
83
83
  # attachable = ActiveStorage::Blob.first
84
84
  # html = %Q(<action-text-attachment sgid="#{attachable.attachable_sgid}" caption="Captioned"></action-text-attachment>)
@@ -123,10 +123,11 @@ module ActionText
123
123
  # content.to_plain_text # => "safeunsafe"
124
124
  #
125
125
  # NOTE: that the returned string is not HTML safe and should not be rendered in
126
- # browsers.
126
+ # browsers without additional sanitization.
127
127
  #
128
128
  # content = ActionText::Content.new("&lt;script&gt;alert()&lt;/script&gt;")
129
129
  # content.to_plain_text # => "<script>alert()</script>"
130
+ # ActionText::ContentHelper.sanitizer.sanitize(content.to_plain_text) # => ""
130
131
  def to_plain_text
131
132
  render_attachments(with_full_attributes: false, &:to_plain_text).fragment.to_plain_text
132
133
  end
@@ -8,6 +8,7 @@ require "active_record/railtie"
8
8
  require "active_storage/engine"
9
9
 
10
10
  require "action_text"
11
+ require "action_text/trix"
11
12
 
12
13
  module ActionText
13
14
  class Engine < Rails::Engine
@@ -34,7 +35,7 @@ module ActionText
34
35
 
35
36
  initializer "action_text.asset" do
36
37
  if Rails.application.config.respond_to?(:assets)
37
- Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js trix.js trix.css )
38
+ Rails.application.config.assets.precompile += %w( actiontext.js actiontext.esm.js )
38
39
  end
39
40
  end
40
41
 
@@ -87,7 +88,9 @@ module ActionText
87
88
 
88
89
  config.after_initialize do |app|
89
90
  if klass = app.config.action_text.sanitizer_vendor
90
- ActionText::ContentHelper.sanitizer = klass.safe_list_sanitizer.new
91
+ ActiveSupport.on_load(:action_view) do
92
+ ActionText::ContentHelper.sanitizer = klass.safe_list_sanitizer.new
93
+ end
91
94
  end
92
95
  end
93
96
  end
@@ -62,7 +62,7 @@ module ActionText
62
62
  signed_global_id = ActiveRecord::FixtureSet.signed_global_id fixture_set_name, label,
63
63
  column_type: column_type, for: ActionText::Attachable::LOCATOR_NAME
64
64
 
65
- %(<action-text-attachment sgid="#{signed_global_id}"></action-text-attachment>)
65
+ %(<#{Attachment.tag_name} sgid="#{signed_global_id}"></#{Attachment.tag_name}>)
66
66
  end
67
67
  end
68
68
  end
@@ -9,10 +9,10 @@ module ActionText
9
9
  end
10
10
 
11
11
  module VERSION
12
- MAJOR = 7
13
- MINOR = 2
12
+ MAJOR = 8
13
+ MINOR = 1
14
14
  TINY = 2
15
- PRE = "1"
15
+ PRE = nil
16
16
 
17
17
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
18
18
  end
@@ -7,64 +7,70 @@ module ActionText
7
7
  extend self
8
8
 
9
9
  def node_to_plain_text(node)
10
- remove_trailing_newlines(plain_text_for_node(node))
10
+ BottomUpReducer.new(node).reduce do |n, child_values|
11
+ plain_text_for_node(n, child_values)
12
+ end.then(&method(:remove_trailing_newlines))
11
13
  end
12
14
 
13
15
  private
14
- def plain_text_for_node(node, index = 0)
16
+ def plain_text_for_node(node, child_values)
15
17
  if respond_to?(plain_text_method_for_node(node), true)
16
- send(plain_text_method_for_node(node), node, index)
18
+ send(plain_text_method_for_node(node), node, child_values)
17
19
  else
18
- plain_text_for_node_children(node)
20
+ plain_text_for_child_values(child_values)
19
21
  end
20
22
  end
21
23
 
22
- def plain_text_for_node_children(node)
23
- texts = []
24
- node.children.each_with_index do |child, index|
25
- texts << plain_text_for_node(child, index)
26
- end
27
- texts.join
28
- end
29
-
30
24
  def plain_text_method_for_node(node)
31
25
  :"plain_text_for_#{node.name}_node"
32
26
  end
33
27
 
34
- def plain_text_for_block(node, index = 0)
35
- "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n\n"
28
+ def plain_text_for_child_values(child_values)
29
+ child_values.join
30
+ end
31
+
32
+ def plain_text_for_unsupported_node(node, _child_values)
33
+ ""
34
+ end
35
+
36
+ %i[ script style].each do |element|
37
+ alias_method :"plain_text_for_#{element}_node", :plain_text_for_unsupported_node
38
+ end
39
+
40
+ def plain_text_for_block(node, child_values)
41
+ "#{remove_trailing_newlines(plain_text_for_child_values(child_values))}\n\n"
36
42
  end
37
43
 
38
44
  %i[ h1 p ].each do |element|
39
45
  alias_method :"plain_text_for_#{element}_node", :plain_text_for_block
40
46
  end
41
47
 
42
- def plain_text_for_list(node, index)
43
- "#{break_if_nested_list(node, plain_text_for_block(node))}"
48
+ def plain_text_for_list(node, child_values)
49
+ "#{break_if_nested_list(node, plain_text_for_block(node, child_values))}"
44
50
  end
45
51
 
46
52
  %i[ ul ol ].each do |element|
47
53
  alias_method :"plain_text_for_#{element}_node", :plain_text_for_list
48
54
  end
49
55
 
50
- def plain_text_for_br_node(node, index)
56
+ def plain_text_for_br_node(node, _child_values)
51
57
  "\n"
52
58
  end
53
59
 
54
- def plain_text_for_text_node(node, index)
60
+ def plain_text_for_text_node(node, _child_values)
55
61
  remove_trailing_newlines(node.text)
56
62
  end
57
63
 
58
- def plain_text_for_div_node(node, index)
59
- "#{remove_trailing_newlines(plain_text_for_node_children(node))}\n"
64
+ def plain_text_for_div_node(node, child_values)
65
+ "#{remove_trailing_newlines(plain_text_for_child_values(child_values))}\n"
60
66
  end
61
67
 
62
- def plain_text_for_figcaption_node(node, index)
63
- "[#{remove_trailing_newlines(plain_text_for_node_children(node))}]"
68
+ def plain_text_for_figcaption_node(node, child_values)
69
+ "[#{remove_trailing_newlines(plain_text_for_child_values(child_values))}]"
64
70
  end
65
71
 
66
- def plain_text_for_blockquote_node(node, index)
67
- text = plain_text_for_block(node)
72
+ def plain_text_for_blockquote_node(node, child_values)
73
+ text = plain_text_for_block(node, child_values)
68
74
  return "“”" if text.blank?
69
75
 
70
76
  text = text.dup
@@ -73,9 +79,9 @@ module ActionText
73
79
  text
74
80
  end
75
81
 
76
- def plain_text_for_li_node(node, index)
77
- bullet = bullet_for_li_node(node, index)
78
- text = remove_trailing_newlines(plain_text_for_node_children(node))
82
+ def plain_text_for_li_node(node, child_values)
83
+ bullet = bullet_for_li_node(node)
84
+ text = remove_trailing_newlines(plain_text_for_child_values(child_values))
79
85
  indentation = indentation_for_li_node(node)
80
86
 
81
87
  "#{indentation}#{bullet} #{text}\n"
@@ -85,8 +91,9 @@ module ActionText
85
91
  text.chomp("")
86
92
  end
87
93
 
88
- def bullet_for_li_node(node, index)
94
+ def bullet_for_li_node(node)
89
95
  if list_node_name_for_li_node(node) == "ol"
96
+ index = node.parent.elements.index(node)
90
97
  "#{index + 1}."
91
98
  else
92
99
  "•"
@@ -115,5 +122,33 @@ module ActionText
115
122
  text
116
123
  end
117
124
  end
125
+
126
+ class BottomUpReducer # :nodoc:
127
+ def initialize(node)
128
+ @node = node
129
+ @values = {}
130
+ end
131
+
132
+ def reduce(&block)
133
+ traverse_bottom_up(@node) do |n|
134
+ child_values = @values.values_at(*n.children)
135
+ @values[n] = block.call(n, child_values)
136
+ end
137
+ @values[@node]
138
+ end
139
+
140
+ private
141
+ def traverse_bottom_up(node, &block)
142
+ call_stack, processing_stack = [ node ], []
143
+
144
+ until call_stack.empty?
145
+ node = call_stack.pop
146
+ processing_stack.push(node)
147
+ call_stack.concat node.children
148
+ end
149
+
150
+ processing_stack.reverse_each(&block)
151
+ end
152
+ end
118
153
  end
119
154
  end
@@ -2,7 +2,6 @@
2
2
 
3
3
  # :markup: markdown
4
4
 
5
- require "active_support/concern"
6
5
  require "active_support/core_ext/module/attribute_accessors_per_thread"
7
6
 
8
7
  module ActionText
@@ -7,52 +7,68 @@ module ActionText
7
7
  # Locates a Trix editor and fills it in with the given HTML.
8
8
  #
9
9
  # The editor can be found by:
10
+ #
10
11
  # * its `id`
11
12
  # * its `placeholder`
12
13
  # * the text from its `label` element
13
14
  # * its `aria-label`
14
15
  # * the `name` of its input
15
16
  #
17
+ # Additional options are forwarded to Capybara as filters
16
18
  #
17
19
  # Examples:
18
20
  #
19
21
  # # <trix-editor id="message_content" ...></trix-editor>
20
- # fill_in_rich_text_area "message_content", with: "Hello <em>world!</em>"
22
+ # fill_in_rich_textarea "message_content", with: "Hello <em>world!</em>"
21
23
  #
22
24
  # # <trix-editor placeholder="Your message here" ...></trix-editor>
23
- # fill_in_rich_text_area "Your message here", with: "Hello <em>world!</em>"
25
+ # fill_in_rich_textarea "Your message here", with: "Hello <em>world!</em>"
24
26
  #
25
27
  # # <label for="message_content">Message content</label>
26
28
  # # <trix-editor id="message_content" ...></trix-editor>
27
- # fill_in_rich_text_area "Message content", with: "Hello <em>world!</em>"
29
+ # fill_in_rich_textarea "Message content", with: "Hello <em>world!</em>"
28
30
  #
29
31
  # # <trix-editor aria-label="Message content" ...></trix-editor>
30
- # fill_in_rich_text_area "Message content", with: "Hello <em>world!</em>"
32
+ # fill_in_rich_textarea "Message content", with: "Hello <em>world!</em>"
31
33
  #
32
34
  # # <input id="trix_input_1" name="message[content]" type="hidden">
33
35
  # # <trix-editor input="trix_input_1"></trix-editor>
34
- # fill_in_rich_text_area "message[content]", with: "Hello <em>world!</em>"
35
- def fill_in_rich_text_area(locator = nil, with:)
36
- find(:rich_text_area, locator).execute_script("this.editor.loadHTML(arguments[0])", with.to_s)
36
+ # fill_in_rich_textarea "message[content]", with: "Hello <em>world!</em>"
37
+ def fill_in_rich_textarea(locator = nil, with:, **)
38
+ find(:rich_textarea, locator, **).execute_script(<<~JS, with.to_s)
39
+ if ("value" in this) {
40
+ this.value = arguments[0]
41
+ } else {
42
+ this.editor.loadHTML(arguments[0])
43
+ }
44
+ JS
37
45
  end
46
+ alias_method :fill_in_rich_text_area, :fill_in_rich_textarea
38
47
  end
39
48
  end
40
49
 
41
- Capybara.add_selector :rich_text_area do
42
- label "rich-text area"
43
- xpath do |locator|
44
- if locator.nil?
45
- XPath.descendant(:"trix-editor")
46
- else
47
- input_located_by_name = XPath.anywhere(:input).where(XPath.attr(:name) == locator).attr(:id)
48
- input_located_by_label = XPath.anywhere(:label).where(XPath.string.n.is(locator)).attr(:for)
50
+ %i[rich_textarea rich_text_area].each do |rich_textarea|
51
+ Capybara.add_selector rich_textarea do
52
+ label "rich-text area"
53
+ xpath do |locator|
54
+ xpath = XPath.descendant[[
55
+ XPath.attribute(:role) == "textbox",
56
+ (XPath.attribute(:contenteditable) == "") | (XPath.attribute(:contenteditable) == "true")
57
+ ].reduce(:&)]
58
+
59
+ if locator.nil?
60
+ xpath
61
+ else
62
+ input_located_by_name = XPath.anywhere(:input).where(XPath.attr(:name) == locator).attr(:id)
63
+ input_located_by_label = XPath.anywhere(:label).where(XPath.string.n.is(locator)).attr(:for)
49
64
 
50
- XPath.descendant(:"trix-editor").where \
51
- XPath.attr(:id).equals(locator) |
52
- XPath.attr(:placeholder).equals(locator) |
53
- XPath.attr(:"aria-label").equals(locator) |
54
- XPath.attr(:input).equals(input_located_by_name) |
55
- XPath.attr(:id).equals(input_located_by_label)
65
+ xpath.where \
66
+ XPath.attr(:id).equals(locator) |
67
+ XPath.attr(:placeholder).equals(locator) |
68
+ XPath.attr(:"aria-label").equals(locator) |
69
+ XPath.attr(:input).equals(input_located_by_name) |
70
+ XPath.attr(:id).equals(input_located_by_label)
71
+ end
56
72
  end
57
73
  end
58
74
  end
@@ -36,20 +36,8 @@ module ActionText
36
36
  end
37
37
 
38
38
  def create_actiontext_files
39
- destination = Pathname(destination_root)
40
-
41
39
  template "actiontext.css", "app/assets/stylesheets/actiontext.css"
42
40
 
43
- unless destination.join("app/assets/application.css").exist?
44
- if (stylesheets = Dir.glob "#{destination_root}/app/assets/stylesheets/application.*.{scss,css}").length > 0
45
- insert_into_file stylesheets.first.to_s, %(@import 'actiontext.css';)
46
- else
47
- say <<~INSTRUCTIONS, :green
48
- To use the Trix editor, you must require 'app/assets/stylesheets/actiontext.css' in your base stylesheet.
49
- INSTRUCTIONS
50
- end
51
- end
52
-
53
41
  gem_root = "#{__dir__}/../../../.."
54
42
 
55
43
  copy_file "#{gem_root}/app/views/active_storage/blobs/_blob.html.erb",
@@ -59,18 +47,6 @@ module ActionText
59
47
  "app/views/layouts/action_text/contents/_content.html.erb"
60
48
  end
61
49
 
62
- def enable_image_processing_gem
63
- if (gemfile_path = Pathname(destination_root).join("Gemfile")).exist?
64
- say "Ensure image_processing gem has been enabled so image uploads will work (remember to bundle!)"
65
- image_processing_regex = /gem ["']image_processing["']/
66
- if File.readlines(gemfile_path).grep(image_processing_regex).any?
67
- uncomment_lines gemfile_path, image_processing_regex
68
- else
69
- run "bundle add --skip-install image_processing"
70
- end
71
- end
72
- end
73
-
74
50
  def create_migrations
75
51
  rails_command "railties:install:migrations FROM=active_storage,action_text", inline: true
76
52
  end