Skip to content

Extract ActionText::Editor base class and ActionText::TrixEditor adapter - #51238

Merged
jeremy merged 1 commit into
rails:mainfrom
seanpdoyle:action-text-trix-adapter
Dec 5, 2025
Merged

jeremy merged 1 commit into
rails:mainfrom
seanpdoyle:action-text-trix-adapter

Conversation

@seanpdoyle

@seanpdoyle seanpdoyle commented Mar 2, 2024 •

Copy link
Copy Markdown
Contributor

Related to rails/actiontext#41

The introduction of the ActionText::TrixEditor class enables the
deprecation of a variety of class- and instance-level methods across the
ActionText namespace. Action Text benefits from deprecating methods in
order to reduce its public API.

There are two categories of deprecations in this changeset:
module/class deprecations and method deprecations.

The module and class deprecations target modules that are internal
and "private" in intent, but public in Ruby and not marked as :nodoc:.
They're either particular to Trix, or their responsibilities have been
given to ActionText::Editor.

The method deprecations also target methods that are internal and
"private" in intent, but public in Ruby and not marked as :nodoc:.
In general, determining whether or not to deprecate a method hinged on
whether its name or arguments mentioned "trix", or if it lacked
method-level documentation or comments of any kind.

When possible, the previous code paths have been deprecated and
re-implemented in terms of an ActionText::TrixEditor instance.

Additions

The main aim of this changeset is to provide a single, extensible
entrypoint for third-party editors (that are not Trix) to integrate with
Action Text.

The initial responsibilities of the ActionText::TrixEditor have been
determined by consolidating a variety of (class- and instance-) methods
across a variety of classes to reduce the number of touchpoints for
applications and engines that provide third-party editor integrations.
The vast majority of its method definitions are direct copy-and-pastes
from their original sources. Any method that previously mentioned trix
in its name or argument list has replaced and generalized that
occurrence with editor.

Changes

These changes aim to be considered implementation details, and intend
to remain "private" from an API perspective.
Future commits can be made
to expand upon and document the responsibilities of the adapter's
interface.

Checklist

Before submitting the PR make sure the following are checked:

  • This Pull Request is related to one change. Changes that are unrelated should be opened in separate PRs.
  • Commit message has a detailed description of what changed and why. If this PR fixes a related issue include it in the commit message. Ex: [Fix #issue-number]
  • Tests are added or updated if you fix a bug or add a feature.
  • CHANGELOG files are updated for the changed libraries if there is a behavior change or additional feature. Minor bug fixes and documentation changes should not be included.

@rails-bot rails-bot Bot added the actiontext label Mar 2, 2024
@seanpdoyle

Copy link
Copy Markdown
Contributor Author

This change aims to both introduce the ActionText::Editor class as the central hub for all rich text processing and rendering and deprecate the old methods.

If that's too ambitious, risky, or the code review diff is too noisy, I'm happy to open a separate PR to deprecate the methods, then rebase this branch off that PR.

@seanpdoyle seanpdoyle changed the title Introduce ActionText::Editor to support WYSIWYG Editors Extract ActionText::Editor to support WYSIWYG Editors Mar 7, 2024
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch 2 times, most recently from 45ed3fb to 9c32f81 Compare March 8, 2024 13:30
@swanson

swanson commented Mar 8, 2024 •

Copy link
Copy Markdown
Contributor

For example, this proposal draws inspiration from the ActiveStorage::Service class and its related Registry and Configurator infrastructural classes to store per-record information about the editor used to create its constituent data.

What would be a use-case for having this kind of configuration for ActionText? I can understand the case for ActiveStorage having multiple different "services" (mirroring, different regions, public vs private files, etc) but struggling to think of a case where a single Rails app would use multiple different text editor libraries. In my own codebase we have different "instances" of trix (for things like removing some formatting, changing styles, etc) but those are configured at the view-layer or with options passed to the underlying library.

@seanpdoyle

seanpdoyle commented Mar 8, 2024 •

Copy link
Copy Markdown
Contributor Author

struggling to think of a case where a single Rails app would use multiple different text editor libraries.

I think one potential use case is migration from one editor to another. For example, this diff's migration would backfill existing records to set editor_name = "trix", then any new records could set editor_name = "prosemirror".

For some period of time, the application could serve assets for both editors and use the editor_name column to determine which context to render the content in. It could render edit forms with Trix, then do some transformation when Trix submits the Trix-specific HTML content into the shape the new editor expects.

For some editors, there might not be any significant difference. Since the WYSIWYG landscape is vast, supporting an open-ended migration felt safest.

In my own codebase we have different "instances" of trix (for things like removing some formatting, changing styles, etc) but those are configured at the view-layer or with options passed to the underlying library.

This is another case I was imagining. Some consumer-facing pages might require real-time collaboration features, while other pages (back of house administrative pages, for example) might have different needs. While I agree with your point that using a consistent tool across the entire application would be the simplest approach, the spirit of this proposal is to provide flexibility when that isn't possible (for a variety of reasons, both in and out of teams' control).

@swanson

swanson commented Mar 8, 2024 •

Copy link
Copy Markdown
Contributor

My gut reaction was that I was expecting this to be configured/designed more like ActiveJob (different adapters, a per-has_rich_text editor option, config.action_text options) and not like ActiveStorage. I have not explored that path in any depth, strictly speaking as a consumer of ActionText. Either way, very excited about this! It's a big undertaking!

@seanpdoyle

Copy link
Copy Markdown
Contributor Author

a per-has_rich_text editor option

This is an angle I hadn't considered. I think supporting editor: :trix and editor: :prosemirror could have value. Also supporting a callable could help with migrating from one to another. Something like:

has_rich_text :body # editor defaults to `ActionText::RichText.editor`
has_rich_text :body, editor: :trix
has_rich_text :body, editor: ActionText::RichText.editors[:trix]
has_rich_text :body, editor: ->(rich_text) { rich_text.method_to_determine_editor_during_migration } 

@ideasasylum

Copy link
Copy Markdown

We migrated from Trix to TipTap over the course of 2023. Our app pre-dated ActionText but this type of PR could help other apps in a similar situation to ours:

  1. We needed to re-create our own version of the RichText model and ActiveStorage integration. This was necessary because ActionText was tied to Trix so we couldn't just adopt ActionText but our implementation doesn't really do anything different.
  2. It was a year-long process so we were concurrently running Trix and TipTap in different parts of the app, and even within the same part of the app as the migration process ran.

@MatheusRich

Copy link
Copy Markdown
Contributor

@dhh shedding some light on this PR since you've mentioned working on a possible Markdown new editor for Rails and you were open to this idea at first.

@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from 9c32f81 to d9621a9 Compare October 8, 2025 23:46
@seanpdoyle seanpdoyle changed the title Extract ActionText::Editor to support WYSIWYG Editors Extract ActionText::TrixEditor Oct 8, 2025

@jorgemanrubia jorgemanrubia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks amazing @seanpdoyle 👏

Comment thread actiontext/lib/action_text/engine.rb Resolved
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch 2 times, most recently from af66fc2 to 0c1332c Compare October 9, 2025 15:42
Comment thread actiontext/lib/action_text/editor.rb Resolved
Comment thread actiontext/lib/action_text/editor.rb Resolved
seanpdoyle added a commit to seanpdoyle/rails that referenced this pull request Oct 10, 2025
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from 0c1332c to 0a89f39 Compare October 10, 2025 20:55
Comment thread actiontext/lib/action_text/content.rb Resolved
Comment thread actiontext/lib/action_text/editor.rb Resolved
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)

@jorgemanrubia jorgemanrubia Oct 12, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would leave this out of this PR to keep the size under control, but some thoughts about multi-editor support.

To support using multiple editors in the same app, we should be able to create thread-safe contexts where you can configure the editor. So that here, in the helpers you use in your app, we could support an editor: option that let you opt out the default. With that option, you could create contexts where ActionText::RichText.editor is resolved differently. So, some API like:

ActionText.with_editor :trix do
   # ...
end

ActionText.with_editor :lexxy do
   # ...
end

That way, within the helpers, we could set the right "editor context" to interpret the editor param. To do this, we would need to make the editor variable a thread one.

We need this because through the code we rely on ActionText::RichText.editor resolving to the configured editor, which is handy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this involve an implementation that leverages Object#with like:

module ActionText
  singleton_class.delegate :editor, :editor=, to: "ActionText::RichText"

  def self.with_editor(editor_name)
    with editor: ActionText::RichText.editors.fetch(editor_name) do
      yield
    end
  end
end

I think the singleton_class.delegate bit is necessary since ActionText::RichText is an Active Record model, and Active Record overrides Object#with with ActiveRecord::QueryMethods.with for common-table expression support.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jorgemanrubia I'd declared the RichText.editors and RichText.editor class attributes because I thought they read as self-descriptive (since they read aloud as "rich text editors" and "rich text editor").

Other than hooking into an ActiveSupport.on_load(:action_text_rich_text) { … } block in the engine, there isn't much benefit to declaring the attributes on RichText, rather than ActionText itself.

Is that decision worth reconsidering before merging?

seanpdoyle added a commit to seanpdoyle/rails that referenced this pull request Oct 12, 2025
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from 0a89f39 to c8f75e9 Compare October 12, 2025 19:35
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from c8f75e9 to 28f148c Compare October 22, 2025 21:19
@seanpdoyle

Copy link
Copy Markdown
Contributor Author

This current changeset combines two units of work:

  1. one unit that deprecates any method that mentions trix in its name, and replaces it with a more generic editor version
  2. one unit that creates new classes in support of pluggable editors

@jorgemanrubia now that the Editor interface feels more settled, I wonder if it would be more productive to split out the deprecation unit (1.) into its own PR to be reviewed and merged on its own. I find that the renames and deprecations add a lot of noise to the diff that makes it more substantial than it needs to be.

We can keep this PR intact to serve as the pluggable editor unit of work (2.).

What do you think?

@jorgemanrubia jorgemanrubia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle this looks great 👍👍. I think in a single unit is fine.

Thanks for the fantastic work here.

Comment thread actiontext/lib/action_text/editor.rb Resolved
Comment thread actiontext/lib/action_text/editor.rb Resolved
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from 7615c1f to 9778d1d Compare November 13, 2025 20:30
@seanpdoyle seanpdoyle changed the title Extract ActionText::TrixEditor Extract ActionText::Editor base class and ActionText::TrixEditor adapter Nov 13, 2025
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from 9778d1d to 27fe49a Compare November 13, 2025 20:31
@flavorjones flavorjones added the ready PRs ready to merge label Nov 13, 2025
end

def render_in(view_context)
options[:class] ||= "#{editor_name}-content"

@jorgemanrubia jorgemanrubia Dec 2, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@seanpdoyle I am testing the new adapterized approach in Lexxy here](https://github.com/basecamp/lexxy/compare/rails-adapter).

The only thing I have missed is that I think we should inject the name attribute here by default, as it is a reasonable expectation: that the editor behaves like a first-class form control and can get a name attribute. I had to add it for Lexxy like this. What do you think?

@jorgemanrubia jorgemanrubia Dec 2, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or, alternatively, we could avoid deleting the property here https://github.com/rails/rails/pull/51238/files#diff-60630514a64ceee9e6d1e641d893af347eae7704d5262903407f36a83125a2deR65. Then we could remove for the Trix case in the adapter, which I think would be more the exception than the norm.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jorgemanrubia I've pushed up a commit to do so.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice thanks @seanpdoyle, could you please rebase on main to resolve the conflicts. We are merging this one...!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For reference, this is how the Lexxy adapter would look: https://github.com/basecamp/lexxy/pull/484/files#diff-299a739d1eca5793af10f6d15a5823ede8684a7b936ac31c929677f851411f0f. I think this is a pretty sweet setup for Rails. Thanks again for the work @seanpdoyle

Related to [rails/actiontext#41](rails/actiontext#41)

The introduction of the `ActionText::TrixEditor` class enables the
deprecation of a variety of class- and instance-level methods across the
`ActionText` namespace. Action Text benefits from deprecating methods in
order to reduce its public API.

There are two categories of deprecations in this changeset:
`module`/`class` deprecations and method deprecations.

The `module` and `class` deprecations target modules that are internal
and "private" in intent, but public in Ruby and not marked as `:nodoc:`.
They're either particular to Trix, or their responsibilities have been
given to `ActionText::Editor`.

The method deprecations also target methods that are internal and
"private" in intent, but `public` in Ruby and not marked as `:nodoc:`.
In general, determining whether or not to deprecate a method hinged on
whether its name or arguments mentioned `"trix"`, *or* if it lacked
method-level documentation or comments of any kind.

When possible, the previous code paths have been deprecated *and*
re-implemented in terms of an `ActionText::TrixEditor` instance.

Additions
---

The main aim of this changeset is to provide a single, extensible
entrypoint for third-party editors (that are not Trix) to integrate with
Action Text.

The initial responsibilities of the `ActionText::TrixEditor` have been
determined by consolidating a variety of (class- and instance-) methods
across a variety of classes to reduce the number of touchpoints for
applications and engines that provide third-party editor integrations.
The vast majority of its method definitions are direct copy-and-pastes
from their original sources. Any method that previously mentioned `trix`
in its name or argument list has replaced and generalized that
occurrence with `editor`.

Classes that inherit from `ActionText::Editor` can override two methods
that accept and return `Fragment` instances:

* `Editor#as_canonical` method accepts an editor-sourced
  `ActionText::Fragment` instance, transforms it, then returns an
  `ActionText::Fragment` instance to be stored.

* `Editor#as_editable` method accepts a storage-sourced
  `ActionText::Fragment` instance, transforms it, then returns an
  `ActionText::Fragment` instance to be edited.

The interface utilizes `Fragment` instances rather than `Content` or
String instances.

The `Fragment` class serves as an agnostic layer on top of HTML and
Plain Text string manipulation through Nokogiri. It has a simple
interface of transforms:

* `find_all(selector)`
* `update(&block)`
* `replace(selector, &block)`

Accepting **and** returning a transformation-ready `Fragment` enables
a more consistent adapter interface.

Changes
---

*These changes aim to be considered implementation details, and intend
to remain "private" from an API perspective.* Future commits can be made
to expand upon and document the responsibilities of the adapter's
interface.
@seanpdoyle
seanpdoyle force-pushed the action-text-trix-adapter branch from f046415 to 558d839 Compare December 5, 2025 13:02
@jeremy jeremy added this to the 8.2.0 milestone Dec 5, 2025
@jeremy
jeremy merged commit cb0fd22 into rails:main Dec 5, 2025
4 checks passed
@seanpdoyle
seanpdoyle deleted the action-text-trix-adapter branch December 5, 2025 16:34
jeremy added a commit to basecamp/lexxy that referenced this pull request Dec 9, 2025
Rails 8.2 removed the `dom_id` method from the ActionText tag in
rails/rails#51238, breaking Lexxy's override.

Use `@template_object.dom_id` instead which works in all releases.
(`@template_object` is the view context and always has access to
`RecordIdentifier` helpers, including `dom_id`.)

This is obviated by #484 but allows Rails 8.2 apps to not break before
adopting the adapter setup.
jeremy added a commit to basecamp/fizzy that referenced this pull request Dec 9, 2025
Rails does post-upload variant processing using the same file upload
in rails/rails#56327

Unrelated Lexxy bump:
Pulls in basecamp/lexxy#493 to work around `dom_id`
removal from ActionText tag in rails/rails#51238
lonexw pushed a commit to lonexw/kanban that referenced this pull request Dec 9, 2025
Rails does post-upload variant processing using the same file upload
in rails/rails#56327

Unrelated Lexxy bump:
Pulls in basecamp/lexxy#493 to work around `dom_id`
removal from ActionText tag in rails/rails#51238
drymar pushed a commit to drymar/rails that referenced this pull request Jan 5, 2026
Related to [rails/actiontext#41](rails/actiontext#41)

The introduction of the `ActionText::TrixEditor` class enables the
deprecation of a variety of class- and instance-level methods across the
`ActionText` namespace. Action Text benefits from deprecating methods in
order to reduce its public API.

There are two categories of deprecations in this changeset:
`module`/`class` deprecations and method deprecations.

The `module` and `class` deprecations target modules that are internal
and "private" in intent, but public in Ruby and not marked as `:nodoc:`.
They're either particular to Trix, or their responsibilities have been
given to `ActionText::Editor`.

The method deprecations also target methods that are internal and
"private" in intent, but `public` in Ruby and not marked as `:nodoc:`.
In general, determining whether or not to deprecate a method hinged on
whether its name or arguments mentioned `"trix"`, *or* if it lacked
method-level documentation or comments of any kind.

When possible, the previous code paths have been deprecated *and*
re-implemented in terms of an `ActionText::TrixEditor` instance.

Additions
---

The main aim of this changeset is to provide a single, extensible
entrypoint for third-party editors (that are not Trix) to integrate with
Action Text.

The initial responsibilities of the `ActionText::TrixEditor` have been
determined by consolidating a variety of (class- and instance-) methods
across a variety of classes to reduce the number of touchpoints for
applications and engines that provide third-party editor integrations.
The vast majority of its method definitions are direct copy-and-pastes
from their original sources. Any method that previously mentioned `trix`
in its name or argument list has replaced and generalized that
occurrence with `editor`.

Classes that inherit from `ActionText::Editor` can override two methods
that accept and return `Fragment` instances:

* `Editor#as_canonical` method accepts an editor-sourced
  `ActionText::Fragment` instance, transforms it, then returns an
  `ActionText::Fragment` instance to be stored.

* `Editor#as_editable` method accepts a storage-sourced
  `ActionText::Fragment` instance, transforms it, then returns an
  `ActionText::Fragment` instance to be edited.

The interface utilizes `Fragment` instances rather than `Content` or
String instances.

The `Fragment` class serves as an agnostic layer on top of HTML and
Plain Text string manipulation through Nokogiri. It has a simple
interface of transforms:

* `find_all(selector)`
* `update(&block)`
* `replace(selector, &block)`

Accepting **and** returning a transformation-ready `Fragment` enables
a more consistent adapter interface.

Changes
---

*These changes aim to be considered implementation details, and intend
to remain "private" from an API perspective.* Future commits can be made
to expand upon and document the responsibilities of the adapter's
interface.
jorgemanrubia added a commit to basecamp/rails that referenced this pull request Sep 11, 2026
New applications, and applications that set `config.load_defaults "8.2"`,
render `rich_textarea` with Lexxy instead of Trix. Upgraded applications
keep Trix until they opt in.

The change builds on the editor adapter from rails#51238:

* `config.action_text.editor` still defaults to `:trix`, and becomes
  `:lexxy` in `load_defaults "8.2"`, with a commented entry in
  `new_framework_defaults_8_2.rb`.
* Action Text registers a Lexxy editor adapter next to the Trix one, so
  applications on the new defaults boot before the `lexxy` gem is added.
* `bin/rails action_text:install` installs the configured editor, or the
  one passed with `--editor`. For Lexxy, it adds the `lexxy` gem, its
  JavaScript and stylesheet, and the `lexxy-content` layout class.
* The Action Text dummy application pins Trix, which its forms and system
  tests exercise.

Stored rich text needs no migration: Lexxy loads content saved by Trix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants