diff --git a/app/serializers/api/product_serializer.rb b/app/serializers/api/product_serializer.rb index cf37d7464a..11f228c1e6 100644 --- a/app/serializers/api/product_serializer.rb +++ b/app/serializers/api/product_serializer.rb @@ -21,7 +21,7 @@ class Api::ProductSerializer < ActiveModel::Serializer # return a sanitized html description def description_html - sanitizer.sanitize_content(object.description)&.html_safe + trix_sanitizer.sanitize_content(object.description) end def properties_with_values @@ -37,4 +37,8 @@ class Api::ProductSerializer < ActiveModel::Serializer def sanitizer @sanitizer ||= ContentSanitizer.new end + + def trix_sanitizer + @trix_sanitizer ||= TrixSanitizer.new + end end diff --git a/app/services/trix_sanitizer.rb b/app/services/trix_sanitizer.rb new file mode 100644 index 0000000000..5b4d83b4e6 --- /dev/null +++ b/app/services/trix_sanitizer.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class TrixSanitizer + include ActionView::Helpers::SanitizeHelper + + def sanitize_content(content) + return if content.blank? + + sanitize(content.to_s, scrubber: TrixScrubber.new) + end +end diff --git a/app/services/trix_scrubber.rb b/app/services/trix_scrubber.rb index b8328ffc1c..e8ab55afe2 100644 --- a/app/services/trix_scrubber.rb +++ b/app/services/trix_scrubber.rb @@ -2,7 +2,7 @@ class TrixScrubber < Rails::Html::PermitScrubber ALLOWED_TAGS = ["p", "b", "strong", "em", "i", "a", "u", "br", "del", "h1", "blockquote", "pre", - "ul", "ol", "li"].freeze + "ul", "ol", "li", "div", "hr"].freeze ALLOWED_ATTRIBUTES = ["href", "target", "src", "alt"].freeze def initialize diff --git a/app/views/spree/admin/products/_form.html.haml b/app/views/spree/admin/products/_form.html.haml index 2935fa0dc5..1bd362b7e7 100644 --- a/app/views/spree/admin/products/_form.html.haml +++ b/app/views/spree/admin/products/_form.html.haml @@ -7,9 +7,8 @@ = f.field_container :description do = f.label :description, t(:description) - %text-angular{'id' => 'product_description', 'name' => 'product[description]', 'class' => 'text-angular', 'textangular-unsafe-sanitizer' => true, "textangular-links-target-blank" => true, 'ta-toolbar' => "[['bold','italics','underline','clear'],['insertLink']]"} - = sanitize(@product.description, scrubber: ContentScrubber.new) - = f.error_message_on :description + = f.hidden_field :description, id: "product_description", value: @product.description + %trix-editor{ input: "product_description", "data-controller": "trixeditor" } .right.four.columns.omega .variant_units_form{ 'ng-app' => 'admin.products', 'ng-controller' => 'editUnitsCtrl' } diff --git a/app/views/spree/admin/products/new.html.haml b/app/views/spree/admin/products/new.html.haml index a885d77300..71710c1258 100644 --- a/app/views/spree/admin/products/new.html.haml +++ b/app/views/spree/admin/products/new.html.haml @@ -92,8 +92,8 @@ = f.field_container :description do = f.label :product_description, t(".product_description") %br/ - %text-angular{'id' => 'product_description', 'name' => 'product[description]', 'class' => 'text-angular', "textangular-links-target-blank" => true, 'ta-toolbar' => "[['bold','italics','underline','clear'],['insertLink']]", "ng-model": "product.description"} - = sanitize(@product.description) + = f.hidden_field :description, id: "product_description", value: @product.description + %trix-editor{ input: "product_description", "data-controller": "trixeditor" } = f.error_message_on :description .four.columns.omega{ style: "text-align: center" } %fieldset.no-border-bottom{ id: "image" } diff --git a/app/webpacker/controllers/trixeditor_controller.js b/app/webpacker/controllers/trixeditor_controller.js index 7975d213ed..506086fb2b 100644 --- a/app/webpacker/controllers/trixeditor_controller.js +++ b/app/webpacker/controllers/trixeditor_controller.js @@ -3,10 +3,43 @@ import { Controller } from "stimulus"; export default class extends Controller { connect() { window.addEventListener("trix-change", this.#trixChange); + this.#trixInitialize(); + window.addEventListener("trix-initialize", this.#trixInitialize); } #trixChange = (event) => { // trigger a change event on the form that contains the Trix editor event.target.form.dispatchEvent(new Event("change", { bubbles: true })); }; + + #trixActionInvoke = (event) => { + if (event.actionName === "hr") { + this.element.editor.insertAttachment( + new Trix.Attachment({ content: "
", contentType: "text/html" }) + ); + } + }; + + #trixInitialize = () => { + // Add HR button to the Trix toolbar if it's not already there and the editor is present + if ( + this.element.editor && + !this.element.toolbarElement.querySelector(".trix-button--icon-hr") + ) { + this.#addHRButton(); + } + }; + + #addHRButton = () => { + const button_html = ` + `; + const buttonGroup = this.element.toolbarElement.querySelector( + ".trix-button-group--block-tools" + ); + buttonGroup.insertAdjacentHTML("beforeend", button_html); + buttonGroup.querySelector(".trix-button--icon-hr").addEventListener("click", (event) => { + event.actionName = "hr"; + this.#trixActionInvoke(event); + }); + }; } diff --git a/app/webpacker/css/admin/all.scss b/app/webpacker/css/admin/all.scss index eba04c98ad..35e4270d8f 100644 --- a/app/webpacker/css/admin/all.scss +++ b/app/webpacker/css/admin/all.scss @@ -30,6 +30,8 @@ @import "shared/layout"; @import "shared/scroll_bar"; +@import "../shared/trix"; + @import "plugins/flatpickr-customization"; @import "plugins/powertip"; @import "plugins/jstree"; diff --git a/app/webpacker/css/admin/trix.scss b/app/webpacker/css/admin/trix.scss index 7ee927a2f3..78c149dc32 100644 --- a/app/webpacker/css/admin/trix.scss +++ b/app/webpacker/css/admin/trix.scss @@ -2,25 +2,29 @@ trix-toolbar [data-trix-button-group="file-tools"] { display: none; } +trix-toolbar .trix-button--icon-hr::before { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M3 12h18v2H3z'/%3E%3C/svg%3E"); +} + // Match the rendering into the shopfront (see ../darkswarm/) trix-editor { color: #222; - ol, - ul { - margin-left: 1.5em; - } - a { - color: #f27052; + color: #f27052; // Equivalent to text-angular a } - // Copy/pasted from _type.scss - blockquote { - line-height: 1.6; - color: #6f6f6f; - margin: 0 0 1.25rem; - padding: 0.5625rem 1.25rem 0 1.1875rem; - border-left: 1px solid #dddddd; + @include trix-styles; +} + +// Override app/webpacker/css/admin/shared/forms.scss#L81 +.field trix-editor ul { + border-top: none; + list-style: disc; + padding-top: 0; + + li { + padding-right: 0; + display: list-item; } } diff --git a/app/webpacker/css/admin_v3/all.scss b/app/webpacker/css/admin_v3/all.scss index e83df91661..b6a12acf66 100644 --- a/app/webpacker/css/admin_v3/all.scss +++ b/app/webpacker/css/admin_v3/all.scss @@ -35,6 +35,8 @@ @import "shared/layout"; // admin_v3 @import "../admin/shared/scroll_bar"; +@import "../shared/trix"; + @import "../admin/plugins/flatpickr-customization"; @import "../admin/plugins/powertip"; @import "../admin/plugins/jstree"; diff --git a/app/webpacker/css/darkswarm/_shop-modals.scss b/app/webpacker/css/darkswarm/_shop-modals.scss index bb6b6480dc..1beed67851 100644 --- a/app/webpacker/css/darkswarm/_shop-modals.scss +++ b/app/webpacker/css/darkswarm/_shop-modals.scss @@ -11,6 +11,12 @@ .product-description { margin: 1rem 0.25rem 0.25rem 0; + + .text-small div { + margin-bottom: 1.5rem; // Equivalent to p (trix doesn't use p as separator by default, so emulate div as p to be backward compatible) + } + + @include trix-styles; } .property-selectors li { diff --git a/app/webpacker/css/darkswarm/_shop-product-rows.scss b/app/webpacker/css/darkswarm/_shop-product-rows.scss index 401db4f8a0..f731ec4ce6 100644 --- a/app/webpacker/css/darkswarm/_shop-product-rows.scss +++ b/app/webpacker/css/darkswarm/_shop-product-rows.scss @@ -158,6 +158,12 @@ // line-clamp is not supported in Safari line-height: 1rem; height: 1.75rem; + + > div { + margin-bottom: 1.5rem; // Equivalent to p (trix doesn't use p as separator by default, so emulate div as p to be backward compatible) + } + + @include trix-styles; } .product-properties { diff --git a/app/webpacker/css/darkswarm/all.scss b/app/webpacker/css/darkswarm/all.scss index a642ac38fd..a78b2c30e2 100644 --- a/app/webpacker/css/darkswarm/all.scss +++ b/app/webpacker/css/darkswarm/all.scss @@ -10,6 +10,7 @@ @import 'branding'; @import 'typography'; @import 'mixins'; +@import '../shared/trix'; @import 'base/colors'; @import 'animations'; diff --git a/app/webpacker/css/darkswarm/shop_tabs.scss b/app/webpacker/css/darkswarm/shop_tabs.scss index 490231366e..e87b3ae0e1 100644 --- a/app/webpacker/css/darkswarm/shop_tabs.scss +++ b/app/webpacker/css/darkswarm/shop_tabs.scss @@ -121,17 +121,7 @@ } .custom-tab { - ol, ul { - margin-left: 1.5em; - } - - ol { - list-style-type: decimal; - } - - ul { - list-style-type: disc; - } + @include trix-styles; } } diff --git a/app/webpacker/css/shared/trix.scss b/app/webpacker/css/shared/trix.scss new file mode 100644 index 0000000000..7953954df7 --- /dev/null +++ b/app/webpacker/css/shared/trix.scss @@ -0,0 +1,35 @@ +// A mixin used to include some trix styles in the shopfront that could have been reseted +@mixin trix-styles { + ol, + ul { + margin-left: 1.5em; + } + + ol { + list-style-type: decimal; + } + + ul { + list-style-type: disc; + } + + div, + pre, + h1 { + margin-bottom: 1.5rem; // Equivalent to text-angular p (trix doesn't use p as default one, since we could not include figures inside p) + } + + h1 { + color: #222222; + font-size: 1.1rem; + } + + // Copy/pasted from _type.scss + blockquote { + line-height: 1.6; + color: #6f6f6f; + margin: 0 0 1.25rem; + padding: 0.5625rem 1.25rem 0 1.1875rem; + border-left: 1px solid #dddddd; + } +} diff --git a/spec/system/admin/products_spec.rb b/spec/system/admin/products_spec.rb index a6c2183b6e..d01ff3b5c3 100644 --- a/spec/system/admin/products_spec.rb +++ b/spec/system/admin/products_spec.rb @@ -45,7 +45,7 @@ describe ' fill_in 'product_on_hand', with: 5 check 'product_on_demand' select 'Test Tax Category', from: 'product_tax_category_id' - page.find("div[id^='taTextElement']").native.send_keys('A description...') + fill_in_trix_editor 'product_description', with: 'A description...' click_button 'Create' @@ -58,7 +58,8 @@ describe ' expect(page).to have_field 'product_on_hand', with: 5 expect(page).to have_field 'product_on_demand', checked: true expect(page).to have_field 'product_tax_category_id', with: tax_category.id - expect(page.find("div[id^='taTextElement']")).to have_content 'A description...' + expect(page.find("#product_description", + visible: false).value).to eq('
A description...
') expect(page.find("#product_variant_unit_field")).to have_content 'Weight (kg)' expect(page).to have_content "Name can't be blank" @@ -80,7 +81,7 @@ describe ' fill_in 'product_on_hand', with: 5 check 'product_on_demand' select 'Test Tax Category', from: 'product_tax_category_id' - page.find("div[id^='taTextElement']").native.send_keys('A description...') + fill_in_trix_editor 'product_description', with: 'A description...' click_button 'Create' @@ -93,7 +94,8 @@ describe ' expect(page).to have_field 'product_on_hand', with: 5 expect(page).to have_field 'product_on_demand', checked: true expect(page).to have_field 'product_tax_category_id', with: tax_category.id - expect(page.find("div[id^='taTextElement']")).to have_content 'A description...' + expect(page.find("#product_description", + visible: false).value).to eq('
A description...
') expect(page.find("#product_variant_unit_field")).to have_content 'Weight (kg)' expect(page).to have_content "Unit value must be greater than 0" @@ -128,7 +130,7 @@ describe ' fill_in 'product_price', with: '19.99' fill_in 'product_on_hand', with: 5 select 'Test Tax Category', from: 'product_tax_category_id' - page.find("div[id^='taTextElement']").native.send_keys('A description...') + fill_in_trix_editor 'product_description', with: 'A description...' click_button 'Create' @@ -146,7 +148,7 @@ describe ' expect(product.on_hand).to eq(5) expect(product.variants.first.tax_category_id).to eq(tax_category.id) expect(product.variants.first.shipping_category).to eq(shipping_category) - expect(product.description).to eq("

A description...

") + expect(product.description).to eq("
A description...
") expect(product.group_buy).to be_falsey expect(product.variants.first.unit_presentation).to eq("5kg") end @@ -166,8 +168,8 @@ describe ' fill_in 'product_on_hand', with: 0 check 'product_on_demand' select 'Test Tax Category', from: 'product_tax_category_id' - page.find("div[id^='taTextElement']").native - .send_keys('In demand, and on_demand! The hottest cakes in town.') + fill_in_trix_editor 'product_description', + with: 'In demand, and on_demand! The hottest cakes in town.' click_button 'Create' @@ -193,9 +195,8 @@ describe ' fill_in 'product_on_hand', with: 0 check 'product_on_demand' select 'Test Tax Category', from: 'product_tax_category_id' - find("div[id^='taTextElement']").native - .send_keys('In demand, and on_demand! The hottest cakes in town.') - + fill_in_trix_editor 'product_description', + with: 'In demand, and on_demand! The hottest cakes in town.' click_button 'Create' expect(current_path).to eq spree.admin_products_path