+ HTML
+
+ TRIX_DIALOGS_TEMPLATE = <<~HTML.html_safe.freeze
+
+
+
+
+
+
+
+
+
+
+
+ HTML
+
+ def render
+ content_tag("trix-toolbar", TRIX_BUTTON_ROW_TEMPLATE + TRIX_DIALOGS_TEMPLATE, **html_attributes)
+ end
+
+ def default_html_attributes
+ { data: { turbo_permanent: "" } }
+ end
+ end
+ end
+end
diff --git a/app/javascript/koi/application.js b/app/javascript/koi/application.js
index ca644bc15..ec35ac262 100644
--- a/app/javascript/koi/application.js
+++ b/app/javascript/koi/application.js
@@ -1,7 +1,8 @@
import "@hotwired/turbo-rails";
import { initAll } from "@katalyst/govuk-formbuilder";
import "@rails/actiontext";
-import "trix";
+import Lexxy from "./utils/lexxy";
+import Trix from "./utils/trix";
import "./controllers";
import "./elements";
@@ -18,3 +19,5 @@ function initGOVUK() {
window.addEventListener("turbo:load", initGOVUK);
if (window.Turbo) initGOVUK();
+
+export default { Lexxy, Trix };
diff --git a/app/javascript/koi/utils/lexxy.js b/app/javascript/koi/utils/lexxy.js
new file mode 100644
index 000000000..58e892bd6
--- /dev/null
+++ b/app/javascript/koi/utils/lexxy.js
@@ -0,0 +1,23 @@
+import * as Lexxy from "lexxy";
+
+Lexxy.configure({
+ global: {
+ authenticatedUploads: true,
+ },
+});
+
+// The link toolbar's URL input is type=url, whose checkValidity() rejects
+// schemeless values like #anchor and /relative that lexxy's link node happily
+// preserves. Switch it to type=text with the same pattern Koi applies to the
+// trix link dialog (see Koi::Tags::TrixToolbar::TRIX_DIALOGS_TEMPLATE) so both
+// editors validate link input identically.
+document.addEventListener("lexxy:initialize", ({ target }) => {
+ target
+ .querySelectorAll("lexxy-link-dropdown input[type=url]")
+ .forEach((input) => {
+ input.type = "text";
+ input.pattern = "(https?|mailto:|tel:|/|#).*?";
+ });
+});
+
+export default Lexxy;
diff --git a/app/javascript/koi/utils/trix.js b/app/javascript/koi/utils/trix.js
new file mode 100644
index 000000000..fbae85e68
--- /dev/null
+++ b/app/javascript/koi/utils/trix.js
@@ -0,0 +1,17 @@
+import "trix";
+
+// Trix does not provide an export
+const Trix = window.Trix;
+
+// Koi uses H4 for headings instead of H1
+Trix.config.blockAttributes["heading4"] = {
+ tagName: "h4",
+ terminal: true,
+ breakOnReturn: true,
+ group: false,
+};
+
+// Remove H1 from trix list of acceptable tags
+delete Trix.config.blockAttributes.heading1;
+
+export default Trix;
diff --git a/app/views/layouts/action_text/contents/_content.html.erb b/app/views/layouts/action_text/contents/_content.html.erb
new file mode 100644
index 000000000..12f1708b6
--- /dev/null
+++ b/app/views/layouts/action_text/contents/_content.html.erb
@@ -0,0 +1,3 @@
+
+ <%= yield -%>
+
diff --git a/config/importmap.rb b/config/importmap.rb
index e183b2c01..34ba55fbc 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -1,8 +1,10 @@
# frozen_string_literal: true
-pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
-pin "@hotwired/stimulus-loading", to: "stimulus-loading.js", preload: true
-pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
+pin "@hotwired/stimulus", to: "stimulus.min.js"
+pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
+pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@katalyst/koi", to: "katalyst/koi.min.js"
-pin "@rails/actiontext", to: "actiontext.js", preload: true
+pin "@rails/actiontext", to: "actiontext.js"
+pin "@rails/activestorage", to: "activestorage.esm.js"
+pin "lexxy"
pin "trix"
diff --git a/config/routes.rb b/config/routes.rb
index 299db7f97..54f7c7a50 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -16,6 +16,10 @@
resources :device_authorizations, param: :user_code, only: %i[create show update]
resources :device_tokens, only: %i[create]
+ scope :active_storage, module: :active_storage do
+ post :direct_uploads, to: "direct_uploads#create"
+ end
+
resource :profile, only: %i[show edit update], shallow: true do
resources :credentials, only: %i[show new create update destroy]
resource :otp, only: %i[new create destroy]
@@ -62,4 +66,9 @@
end
resources :well_knowns, path: ".well-known", param: :name, only: %i[show], name: /.+/
+
+ # Fallbacks for optional dependencies
+ unless Object.const_defined?("HotwireCombobox")
+ get "hotwire_combobox", to: ->(_) { [204, {}, []] }, as: nil
+ end
end
diff --git a/katalyst-koi.gemspec b/katalyst-koi.gemspec
index 5bd9ff30b..ffcbe23a2 100644
--- a/katalyst-koi.gemspec
+++ b/katalyst-koi.gemspec
@@ -10,7 +10,7 @@ Gem::Specification.new do |s|
s.homepage = "https://github.com/katalyst/koi"
s.license = "MIT"
- s.required_ruby_version = ">= 3.4"
+ s.required_ruby_version = ">= 4.0"
s.files = Dir["{app,config,db,lib}/**/*", "spec/factories/**/*", "MIT-LICENSE", "README.md", "Upgrade.md"]
.grep_v(%r{^lib/tasks})
@@ -34,12 +34,13 @@ Gem::Specification.new do |s|
s.add_dependency "webauthn", ">= 3.4.0"
# Third party libraries for admin pages
+ s.add_dependency "lexxy"
s.add_dependency "pagy", ">= 8.0"
s.add_dependency "view_component"
# Katalyst libraries
- s.add_dependency "katalyst-content", ">= 3.0.0.alpha.1"
- s.add_dependency "katalyst-govuk-formbuilder", "< 2"
+ s.add_dependency "katalyst-content", ">= 3.2"
+ s.add_dependency "katalyst-govuk-formbuilder", ">= 1.28.0", "< 2"
s.add_dependency "katalyst-html-attributes"
s.add_dependency "katalyst-navigation", ">= 2"
s.add_dependency "katalyst-tables", ">= 3.5"
diff --git a/lib/koi.rb b/lib/koi.rb
index 1b125df06..24fea91bc 100644
--- a/lib/koi.rb
+++ b/lib/koi.rb
@@ -1,14 +1,20 @@
# frozen_string_literal: true
module Koi
- extend self
+ class << self
+ def config
+ @config ||= Config.new
+ end
- def config
- @config ||= Config.new
- end
+ def configure
+ yield config
+ end
+
+ def action_text_editor
+ return config.action_text_editor if config.action_text_editor.present?
- def configure
- yield config
+ defined?(::Lexxy) && defined?(::Flipper) && Flipper.enabled?(:lexxy, Koi::Current.admin_user) ? :lexxy : :trix
+ end
end
end
diff --git a/lib/koi/action_text.rb b/lib/koi/action_text.rb
new file mode 100644
index 000000000..ed62db907
--- /dev/null
+++ b/lib/koi/action_text.rb
@@ -0,0 +1,112 @@
+# frozen_string_literal: true
+
+module Koi
+ module ActionText
+ module TagHelper
+ using Katalyst::HtmlAttributes::HasHtmlAttributes
+
+ def koi_trix_toolbar_tag(**)
+ Tags::TrixToolbar.new(**).render
+ end
+
+ def koi_rich_textarea_tag(name, value = nil, options = {}, &)
+ case Koi.action_text_editor
+ when :lexxy
+ lexxy_rich_textarea_tag(name, value, options, &)
+ when :trix
+ options = options.symbolize_keys
+ trix_id = options[:id]
+
+ toolbar = koi_trix_toolbar_tag(id: "trix-toolbar-#{trix_id}")
+ editor = trix_rich_textarea_tag(name, value, {
+ data: { turbo_permanent: "" },
+ toolbar: "trix-toolbar-#{trix_id}",
+ trix_id:,
+ }.merge_html(options), &)
+
+ toolbar + editor
+ end
+ end
+ end
+
+ module FormHelper
+ def koi_rich_textarea(...)
+ case Koi.action_text_editor
+ when :lexxy
+ lexxy_rich_textarea(...)
+ when :trix
+ trix_rich_textarea(...)
+ end
+ end
+ end
+
+ module FormBuilder
+ def koi_rich_textarea(...)
+ case Koi.action_text_editor
+ when :lexxy
+ lexxy_rich_textarea(...)
+ when :trix
+ trix_rich_textarea(...)
+ end
+ end
+ end
+
+ module ActionTextTag
+ def koi_render(...)
+ case Koi.action_text_editor
+ when :lexxy
+ lexxy_render(...)
+ when :trix
+ trix_render(...)
+ end
+ end
+ end
+
+ # Add aliases for trix default methods so that Koi can swap between trix and lexxy based on feature flags.
+ def capture_action_text_defaults
+ ::ActionText::TagHelper.module_eval do
+ alias_method :trix_rich_textarea_tag, :rich_textarea_tag
+ alias_method :trix_rich_text_area_tag, :rich_text_area_tag
+ end
+
+ ::ActionView::Helpers::FormHelper.module_eval do
+ alias_method :trix_rich_textarea, :rich_textarea
+ alias_method :trix_rich_text_area, :rich_text_area
+ end
+
+ ::ActionView::Helpers::FormBuilder.module_eval do
+ alias_method :trix_rich_textarea, :rich_textarea
+ alias_method :trix_rich_text_area, :rich_textarea
+ end
+
+ ::ActionView::Helpers::Tags::ActionText.module_eval do
+ alias_method :trix_render, :render
+ end
+ end
+ module_function :capture_action_text_defaults
+
+ # Add shims for rich_textarea and friends.
+ # This is intended as a migration path and will be removed when Rails 8.2 is released.
+ def override_action_text_defaults
+ ::ActionText::TagHelper.module_eval do
+ alias_method :rich_textarea_tag, :koi_rich_textarea_tag
+ alias_method :rich_text_area_tag, :koi_rich_textarea_tag
+ end
+
+ ::ActionView::Helpers::FormHelper.module_eval do
+ alias_method :rich_textarea, :koi_rich_textarea
+ alias_method :rich_text_area, :koi_rich_textarea
+ end
+
+ ::ActionView::Helpers::FormBuilder.module_eval do
+ alias_method :rich_textarea, :koi_rich_textarea
+ alias_method :rich_text_area, :koi_rich_textarea
+ end
+
+ ::ActionView::Helpers::Tags::ActionText.module_eval do
+ alias_method :render, :koi_render
+ end
+ end
+ module_function :override_action_text_defaults
+ end
+end
diff --git a/lib/koi/config.rb b/lib/koi/config.rb
index a970d650a..14b579c4f 100644
--- a/lib/koi/config.rb
+++ b/lib/koi/config.rb
@@ -4,7 +4,8 @@
module Koi
class Config
- attr_accessor :admin_name,
+ attr_accessor :action_text_editor,
+ :admin_name,
:authenticate_local_admins,
:resource_name_candidates,
:admin_stylesheet,
@@ -16,6 +17,7 @@ class Config
:site_name
def initialize
+ @action_text_editor = nil
@admin_name = "Koi"
@authenticate_local_admins = Rails.env.development?
@resource_name_candidates = %i[title name]
diff --git a/lib/koi/engine.rb b/lib/koi/engine.rb
index bb9a1faca..ac64bebe3 100644
--- a/lib/koi/engine.rb
+++ b/lib/koi/engine.rb
@@ -5,6 +5,7 @@
require "katalyst-govuk-formbuilder"
require "katalyst/navigation"
require "katalyst/tables"
+require "lexxy"
require "pagy"
require "rotp"
require "rqrcode"
@@ -39,6 +40,28 @@ class Engine < ::Rails::Engine
end
end
+ # Rails 8.0/8.1 shim for allowing flipper swapping between lexxy/trix
+ # Rails 8.2 will introduce action_text.editors, not yet implemented.
+ initializer "koi.actiontext.initialize", after: "lexxy.initialize" do |app|
+ # disable lexxy injection in favour of Koi injection
+ app.config.lexxy.override_action_text_defaults = false
+
+ # Capture ActionText defaults (trix) once
+ ActionText.capture_action_text_defaults
+
+ # Based on Lexxy::Engine's 'lexxy.initialize'
+ app.config.to_prepare do
+ ::ActionText::TagHelper.prepend(ActionText::TagHelper)
+ ::ActionView::Helpers::FormHelper.prepend(ActionText::FormHelper)
+ ::ActionView::Helpers::FormBuilder.prepend(ActionText::FormBuilder)
+ ::ActionView::Helpers::Tags::ActionText.prepend(ActionText::ActionTextTag)
+
+ # Install Koi's stubs.
+ # In Rails 8.2 this should be replaced by action_text.editors
+ ActionText.override_action_text_defaults
+ end
+ end
+
initializer "koi.content" do
Katalyst::Content.config.base_controller = "Admin::ApplicationController"
Katalyst::Content.config.errors_component = "Koi::Content::Editor::ErrorsComponent"
diff --git a/lib/koi/form/builder.rb b/lib/koi/form/builder.rb
index f0d509142..bde8b77be 100644
--- a/lib/koi/form/builder.rb
+++ b/lib/koi/form/builder.rb
@@ -10,12 +10,14 @@ module Builder
end
# Generates a submit button for saving admin resources.
+ # @deprecated will be removed in Koi 6.0
def admin_save(text = "Save", name: :commit, value: :save, class: "button", **)
button(text, name:, value:, class:, **)
end
# Generates a delete link formatted as a button that will perform a turbo
# delete with a confirmation.
+ # @deprecated will be removed in Koi 6.0
def admin_delete(text = "Delete", url: nil, confirm: "Are you sure?", data: {}, **)
return unless object.persisted?
@@ -27,12 +29,14 @@ def admin_delete(text = "Delete", url: nil, confirm: "Are you sure?", data: {},
# Generates an archive link formatted as a button that will perform a turbo
# delete with a confirmation.
+ # @deprecated will be removed in Koi 6.0
def admin_archive(text = "Archive", **)
admin_delete(text, **)
end
# Generates a discard changes link formatted as a text button that navigates
# the user back to the previous page.
+ # @deprecated will be removed in Koi 6.0
def admin_discard(text = "Discard", url: :back, **)
link_to(text, url, class: "button", data: { text_button: "" }, **)
end
@@ -59,16 +63,31 @@ def govuk_image_field(attribute_name, hint: {}, **, &)
super
end
- # Use content editor trix setup by default.
+ using Katalyst::HtmlAttributes::HasHtmlAttributes
+
+ # Use Koi's admin direct uploads URL.
+ #
+ # @api internal
+ # @see Lexxy::FormBuilder#lexxy_rich_textarea
+ def lexxy_rich_textarea(attribute_name, **attributes, &)
+ attributes = {
+ class: "lexxy-content",
+ data: { direct_upload_url: @template.admin_direct_uploads_url },
+ }.merge_html(**attributes)
+
+ super
+ end
+
+ # Use Koi's admin direct uploads URL.
#
# @api internal
- # @see GOVUKDesignSystemFormBuilder::Builder#govuk_rich_text_area
- def govuk_rich_text_area(attribute_name, data: {}, **, &)
- data = data.reverse_merge(
- direct_upload_url: @template.katalyst_content.direct_uploads_url,
- controller: "content--editor--trix",
- action: "trix-initialize->content--editor--trix#trixInitialize",
- )
+ # @see Koi::ActionText::FormBuilder#trix_rich_textarea
+ def trix_rich_textarea(attribute_name, **attributes, &)
+ attributes = {
+ class: "trix-content",
+ data: { direct_upload_url: @template.admin_direct_uploads_url },
+ }.merge_html(**attributes)
+
super
end
end
diff --git a/lib/koi/form/content.rb b/lib/koi/form/content.rb
deleted file mode 100644
index e08f05034..000000000
--- a/lib/koi/form/content.rb
+++ /dev/null
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-module Koi
- module Form
- module Content
- extend ActiveSupport::Concern
-
- def content_heading_fieldset
- govuk_fieldset(legend: { text: "Heading", size: "m" }) do
- concat(content_heading_field(label: nil))
- concat(content_heading_style_field)
- end
- end
-
- def content_heading_field(args = {})
- govuk_text_field(:heading,
- **{ label: { text: "Heading", size: "s" } }.deep_merge(args))
- end
-
- def content_heading_style_field(args = {})
- govuk_collection_radio_buttons(:heading_style,
- Katalyst::Content.config.heading_styles,
- :itself,
- :itself,
- **{ small: true, legend: { text: "Style", size: "s" } }.deep_merge(args))
- end
-
- def content_url_field(args = {})
- govuk_text_field(:url,
- **{ label: { text: "URL", size: "s" } }.deep_merge(args))
- end
-
- def content_http_method_field(methods, args = {})
- govuk_select(:http_method, methods,
- **{ label: { text: "HTTP method", size: "s" } }.deep_merge(args))
- end
-
- def content_target_field(targets, args = {})
- govuk_select(:target, targets,
- **{ label: { text: "HTTP target", size: "s" } }.deep_merge(args))
- end
-
- def content_theme_field(args = {})
- govuk_select(:theme, Katalyst::Content.config.themes,
- **{ label: { size: "s" }, options: { include_blank: true } }.deep_merge(args))
- end
- alias_method :content_background_field, :content_theme_field
-
- def content_visible_field(args = {})
- govuk_check_box_field(:visible,
- **{ label: { text: "Visible? ", size: "s" }, small: true }.deep_merge(args))
- end
- end
- end
-end
diff --git a/package.json b/package.json
index 7ca6c38c3..a46ad2f2b 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-terser": "^1.0.0",
- "prettier": "^3.8.4",
+ "prettier": "^3.9.4",
"rollup": "^4.62.2"
}
}
diff --git a/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb b/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb
new file mode 100644
index 000000000..6a581b694
--- /dev/null
+++ b/spec/requests/admin/active_storage/direct_uploads_controller_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Admin::ActiveStorage::DirectUploadsController do
+ subject { action && response }
+
+ let(:action) { post admin_direct_uploads_path, params:, headers: }
+ let(:headers) { {} }
+ let(:file) { StringIO.new("test") }
+ let(:params) do
+ {
+ blob: {
+ filename: "fringe.jpg",
+ content_type: "image/jpeg",
+ byte_size: file.size,
+ checksum: OpenSSL::Digest::MD5.new(file.read).base64digest,
+ },
+ }
+ end
+
+ it { is_expected.to have_http_status(:see_other).and(redirect_to(new_admin_session_path)) }
+ it { expect { action }.not_to change(ActiveStorage::Blob, :count) }
+
+ context "with an admin session" do
+ include_context "with admin session"
+
+ it { is_expected.to be_successful }
+ it { expect { action }.to change(ActiveStorage::Blob, :count).by(1) }
+ end
+
+ context "with an admin API token" do
+ let(:headers) { { "Authorization" => "Bearer #{access_token}" } }
+ let(:device_authorization) { create(:admin_device_authorization, :approved) }
+ let(:access_token) { device_authorization.generate_token_for(:api_access) }
+
+ before do
+ device_authorization.consume!(token_expires_in: 12.hours)
+ end
+
+ it { is_expected.to be_successful }
+ it { expect { action }.to change(ActiveStorage::Blob, :count).by(1) }
+ end
+end
diff --git a/spec/system/admin/rich_textarea_spec.rb b/spec/system/admin/rich_textarea_spec.rb
new file mode 100644
index 000000000..daedc3e03
--- /dev/null
+++ b/spec/system/admin/rich_textarea_spec.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "admin rich textarea integration" do
+ before do
+ admin = create(:admin)
+ visit "/admin"
+
+ fill_in "Email", with: admin.email
+ click_on "Next"
+
+ fill_in "Password", with: admin.password
+ click_on "Next"
+
+ fill_in "Token", with: admin.otp.now
+ click_on "Next"
+ end
+
+ context "with the trix editor" do
+ it "uploads attachments via the admin direct uploads endpoint" do
+ announcement = create(:announcement, content: "testing uploads")
+
+ visit edit_admin_announcement_path(announcement)
+
+ expect(page).to have_css("trix-editor[data-direct-upload-url*='/admin/']")
+ end
+
+ it "can add an anchor link" do
+ announcement = create(:announcement, content: "testing link editing")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # wait for editor to load
+ expect(page).to have_button(text: "Link")
+
+ find("trix-editor").click
+
+ page.execute_script("document.querySelector('trix-editor').editor.setSelectedRange([8, 12])")
+
+ click_on "Link"
+ fill_in "Enter a URL…", with: "#anchor"
+ within(".trix-dialog.trix-active") { click_on "Link" }
+
+ click_button(type: "submit")
+
+ expect(page).to have_link(text: "link", href: "#anchor")
+ end
+
+ it "can add a heading" do
+ announcement = create(:announcement, content: "testing headings")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # wait for editor to load
+ expect(page).to have_button(text: "Heading")
+
+ find("trix-editor").click
+
+ page.execute_script("document.querySelector('trix-editor').editor.setSelectedRange([0, 0])")
+
+ click_on "Heading"
+
+ click_button(type: "submit")
+
+ # Koi uses h4 for headings rather than the trix default of h1
+ expect(page).to have_css("h4", text: "testing headings")
+ end
+
+ # regression test for headings not being detected on first interaction
+ it "can modify an existing heading" do
+ announcement = create(:announcement, content: "
testing headings
")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # wait for editor to load
+ expect(page).to have_css("trix-editor h4")
+
+ # ensure heading has correctly identified that the first element is a heading
+ expect(page).to have_css("button[data-trix-attribute=heading4]", class: "trix-active")
+ end
+ end
+
+ context "with the lexxy editor enabled" do
+ before { Flipper.enable(:lexxy) }
+
+ it "uploads attachments via the admin direct uploads endpoint" do
+ announcement = create(:announcement, content: "testing uploads")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # Koi overrides lexxy's default rails_direct_uploads_url with the admin
+ # endpoint, so the URL must end with the admin path rather than /rails/.
+ expect(page).to have_css("lexxy-editor[data-direct-upload-url$='/admin/active_storage/direct_uploads']")
+ expect(page).to have_no_css("trix-editor")
+ end
+
+ it "can add an anchor link" do
+ announcement = create(:announcement, content: "testing link editing")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # wait for editor to load
+ expect(page).to have_button("Link")
+
+ select_lexxy_range(8, 12)
+
+ click_on "Link"
+ # Koi swaps lexxy's type=url link input for a type=text input with the
+ # same pattern as trix, so schemeless anchor/relative links pass
+ # validation (see koi/utils/lexxy.js).
+ fill_in "Enter a URL…", with: "#anchor"
+ within("lexxy-link-dropdown [role=dialog]") { click_on "Link" }
+
+ click_button(type: "submit")
+
+ expect(page).to have_link("link", href: "#anchor")
+ end
+
+ it "can add a heading" do
+ announcement = create(:announcement, content: "testing headings")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # wait for editor to load
+ expect(page).to have_button("Text formatting")
+
+ select_lexxy_range(0, 0)
+
+ click_on "Text formatting"
+ click_on "Large Heading"
+
+ click_button(type: "submit")
+
+ # lexxy's large heading maps to h2 (no Koi override, unlike trix)
+ expect(page).to have_css("h2", text: "testing headings")
+ end
+
+ # regression test for headings not being detected on first interaction
+ it "can modify an existing heading" do
+ announcement = create(:announcement, content: "
testing headings
")
+
+ visit edit_admin_announcement_path(announcement)
+
+ # wait for editor to load
+ expect(page).to have_css("lexxy-editor h2")
+
+ select_lexxy_range(0, 0)
+
+ # ensure the toolbar has identified the current block as a large heading
+ expect(page).to have_css("button[name=heading-large][aria-pressed=true]", visible: :all)
+ end
+ end
+
+ # Lexxy has no offset-based selection API like trix's editor.setSelectedRange,
+ # so we set a DOM range over the editor's contenteditable and let Lexical sync
+ # it into its editor state via the selectionchange event.
+ def select_lexxy_range(from, to)
+ page.execute_script(<<~JS, from, to)
+ const [from, to] = arguments;
+ const content = document.querySelector("lexxy-editor .lexxy-editor__content");
+ content.focus();
+ const textNode = document.createTreeWalker(content, NodeFilter.SHOW_TEXT).nextNode();
+ const range = document.createRange();
+ range.setStart(textNode, from);
+ range.setEnd(textNode, to);
+ const selection = window.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+ document.dispatchEvent(new Event("selectionchange"));
+ JS
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index a820135ea..6a877894f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -57,9 +57,9 @@
terser "^5.17.4"
"@rollup/pluginutils@^5.0.1":
- version "5.3.0"
- resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.3.0.tgz#57ba1b0cbda8e7a3c597a4853c807b156e21a7b4"
- integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==
+ version "5.4.0"
+ resolved "https://registry.yarnpkg.com/@rollup/pluginutils/-/pluginutils-5.4.0.tgz#ac23a29ced0247060a210815fca39c17de4d2f26"
+ integrity sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==
dependencies:
"@types/estree" "^1.0.0"
estree-walker "^2.0.2"
@@ -201,9 +201,9 @@
integrity sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==
acorn@^8.15.0:
- version "8.16.0"
- resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a"
- integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==
+ version "8.17.0"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe"
+ integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==
buffer-from@^1.0.0:
version "1.1.2"
@@ -269,10 +269,10 @@ picomatch@^4.0.2:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
-prettier@^3.8.4:
- version "3.8.4"
- resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.4.tgz#f334f013ac04a96676f24dabc23c1c4ae1bae411"
- integrity sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==
+prettier@^3.9.4:
+ version "3.9.4"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.9.4.tgz#a9c477cf1614376bd1f6bbc593d8c0d414bcec87"
+ integrity sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==
resolve@^1.22.1:
version "1.22.12"
@@ -319,9 +319,9 @@ rollup@^4.62.2:
fsevents "~2.3.2"
serialize-javascript@^7.0.3:
- version "7.0.5"
- resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.5.tgz#c798cc0552ffbb08981914a42a8756e339d0d5b1"
- integrity sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==
+ version "7.0.7"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.7.tgz#06ec40576d4cea96d68010a534520bff1f948a72"
+ integrity sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==
smob@^1.0.0:
version "1.6.2"