From 2cb6a8e5bd2a9d2bb08b278e3fe1fe904b96fe8f Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <122262394+sascha-karnatz@users.noreply.github.com> Date: Fri, 4 Aug 2023 17:27:35 +0200 Subject: [PATCH 1/5] Migrate Tinymce module into a web component Instead of multiple initializations and removals let the browser handle the problem. Web components have life cycle hooks to create and destroy the Tinymce editor. --- .../alchemy/alchemy.dialog.js.coffee | 4 - .../alchemy/alchemy.dragndrop.js.coffee | 12 -- app/javascript/alchemy_admin.js | 8 +- .../alchemy_admin/components/tinymce.js | 109 +++++++++++++ app/javascript/alchemy_admin/tinymce.js | 146 ------------------ .../alchemy/admin/elements/create.js.erb | 1 - .../alchemy/admin/elements/destroy.js.erb | 1 - app/views/alchemy/admin/elements/fold.js.erb | 23 +-- app/views/alchemy/admin/pages/edit.html.erb | 1 - .../ingredients/_richtext_editor.html.erb | 13 +- .../alchemy/admin/elements_controller_spec.rb | 38 ----- .../ingredients/richtext_editor_spec.rb | 2 +- 12 files changed, 125 insertions(+), 233 deletions(-) create mode 100644 app/javascript/alchemy_admin/components/tinymce.js diff --git a/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee b/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee index 365e7920bc..49f9ed79aa 100644 --- a/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.dialog.js.coffee @@ -53,7 +53,6 @@ class window.Alchemy.Dialog @overlay.removeClass('open') if @overlay? @$document.on 'webkitTransitionEnd transitionend oTransitionEnd', => @$document.off 'webkitTransitionEnd transitionend oTransitionEnd' - Alchemy.Tinymce.removeFrom $('.tinymce', @dialog_body) @dialog_container.remove() @overlay.remove() if @overlay? @$body.removeClass('prevent-scrolling') @@ -105,9 +104,6 @@ class window.Alchemy.Dialog # Initializes the Dialog body init: -> Alchemy.GUI.init(@dialog_body) - Alchemy.Tinymce.initWith - selector: ".alchemy-dialog-body textarea.tinymce", - width: '65%' $('#overlay_tabs', @dialog_body).tabs() @watch_remote_forms() diff --git a/app/assets/javascripts/alchemy/alchemy.dragndrop.js.coffee b/app/assets/javascripts/alchemy/alchemy.dragndrop.js.coffee index 290365cedb..b239ce125b 100644 --- a/app/assets/javascripts/alchemy/alchemy.dragndrop.js.coffee +++ b/app/assets/javascripts/alchemy/alchemy.dragndrop.js.coffee @@ -9,14 +9,6 @@ $.extend Alchemy, Alchemy.initializedSortableElements = false $sortable_area = $(selector) - getTinymceIDs = (ui) -> - ids = [] - $textareas = ui.item.find('textarea.has_tinymce') - $($textareas).each -> - id = this.id.replace(/tinymce_/, '') - ids.push parseInt(id, 10) - return ids - sortable_options = items: "> .element-editor" handle: "> .element-header .element-handle" @@ -55,7 +47,6 @@ $.extend Alchemy, $this = $(this) name = ui.item.data('element-name') $dropzone = $("[data-droppable-elements~='#{name}']") - ids = getTinymceIDs(ui) $this.sortable('option', 'connectWith', $dropzone) $this.sortable('refresh') $dropzone.css('minHeight', 36) @@ -63,15 +54,12 @@ $.extend Alchemy, if ui.item.hasClass('compact') ui.placeholder.addClass('compact').css height: ui.item.outerHeight() - Alchemy.Tinymce.remove(ids) return stop: (event, ui) -> - ids = getTinymceIDs(ui) name = ui.item.data('element-name') $dropzone = $("[data-droppable-elements~='#{name}']") $dropzone.css('minHeight', '') ui.item.removeClass('dragged') - Alchemy.Tinymce.init(ids) return $sortable_area.sortable(sortable_options) diff --git a/app/javascript/alchemy_admin.js b/app/javascript/alchemy_admin.js index aac764b228..b2be5eaedf 100644 --- a/app/javascript/alchemy_admin.js +++ b/app/javascript/alchemy_admin.js @@ -13,11 +13,11 @@ import ImageCropper from "alchemy_admin/image_cropper" import Initializer from "alchemy_admin/initializer" import Sitemap from "alchemy_admin/sitemap" import Spinner from "alchemy_admin/spinner" -import Tinymce from "alchemy_admin/tinymce" import PagePublicationFields from "alchemy_admin/page_publication_fields" // Web Components import "alchemy_admin/components/char_counter" +import "alchemy_admin/components/tinymce" import "alchemy_admin/components/tooltip" import "alchemy_admin/components/datepicker" import "alchemy_admin/components/spinner" @@ -42,13 +42,7 @@ Object.assign(Alchemy, { IngredientAnchorLink, Sitemap, Spinner, - Tinymce, PagePublicationFields }) $(document).on("turbo:load", Initializer) - -$(document).on("turbo:before-fetch-request", function () { - Alchemy.Tinymce.removeIntersectionObserver() - Alchemy.Tinymce.removeFrom($(".has_tinymce")) -}) diff --git a/app/javascript/alchemy_admin/components/tinymce.js b/app/javascript/alchemy_admin/components/tinymce.js new file mode 100644 index 0000000000..ba0cd74461 --- /dev/null +++ b/app/javascript/alchemy_admin/components/tinymce.js @@ -0,0 +1,109 @@ +class Tinymce extends HTMLElement { + constructor() { + super() + this.externalConfig = {} + + this.className = "tinymce_container" + this.textarea.className = "has_tinymce" + } + + /** + * the observer will initialize Tinymce if the textarea becomes visible + */ + connectedCallback() { + const observerCallback = (entries, observer) => { + entries.forEach((entry) => { + if (entry.intersectionRatio > 0) { + this.initTinymceEditor() + // disable observer after the Tinymce was initialized + observer.unobserve(entry.target) + } + }) + } + + const options = { + root: document.getElementById("element_area"), + rootMargin: "0px", + threshold: [0.05] + } + + this.tinymceIntersectionObserver = new IntersectionObserver( + observerCallback, + options + ) + this.tinymceIntersectionObserver.observe(this) + } + + /** + * disconnect intersection observer and remove Tinymce editor if the web components get destroyed + */ + disconnectedCallback() { + if (this.tinymceIntersectionObserver !== null) { + this.tinymceIntersectionObserver.disconnect() + } + + tinymce.get(this.textareaId)?.remove(this.textareaId) + } + + initTinymceEditor() { + this.appendSpinner("small") + + const element = document + .getElementById(this.textareaId) + .closest(".element-editor") + + // initialize TinyMCE + tinymce.init(this.configuration).then((editors) => { + editors.forEach((editor) => { + this.removeSpinner() + + // mark the editor container as visible + // without these correction the editor remains hidden + // after a drag and drop action + editor.editorContainer.style.display = null + + // event listener to mark the editor as dirty + editor.on("dirty", () => Alchemy.setElementDirty(element)) + editor.on("click", (event) => { + event.target = element + Alchemy.ElementEditors.onClickElement(event) + }) + }) + }) + } + + appendSpinner() { + const spinner = new Alchemy.Spinner("small") + this.prepend(spinner.spin().el.get(0)) + } + + removeSpinner() { + const spinners = this.getElementsByClassName("spinner") + while (spinners.length > 0) { + spinners[0].parentNode.removeChild(spinners[0]) + } + } + + get textarea() { + return this.getElementsByTagName("textarea")[0] + } + + get textareaId() { + return this.textarea.id + } + + get configuration() { + return { + ...Alchemy.TinymceDefaults, + ...this.externalConfig, + locale: Alchemy.locale, + selector: `#${this.textareaId}` + } + } + + set configuration(config) { + this.externalConfig = config + } +} + +customElements.define("alchemy-tinymce", Tinymce) diff --git a/app/javascript/alchemy_admin/tinymce.js b/app/javascript/alchemy_admin/tinymce.js index 3e45540650..e69de29bb2 100644 --- a/app/javascript/alchemy_admin/tinymce.js +++ b/app/javascript/alchemy_admin/tinymce.js @@ -1,146 +0,0 @@ -// Alchemy Tinymce wrapper -// - -let tinymceCustomConfigs = {} -let tinymceIntersectionObserver = null - -// Returns default config for a tinymce editor. -function getDefaultConfig(editorId) { - const config = Alchemy.TinymceDefaults - config.language = Alchemy.locale - config.selector = `#${editorId}` - config.init_instance_callback = initInstanceCallback - return config -} - -// Returns configuration for given custom tinymce editor selector. -// -// It uses the +.getDefaultConfig+ and merges the custom parts. -function getConfig(editorId) { - const editorConfig = tinymceCustomConfigs[editorId] || {} - return { ...getDefaultConfig(editorId), ...editorConfig } -} - -// create intersection observer and register textareas to be initialized when -// they are visible -function initEditors(ids) { - initializeIntersectionObserver() - - ids.forEach((id) => { - const editorId = `tinymce_${id}` - const textarea = document.getElementById(editorId) - - if (textarea) { - tinymceIntersectionObserver.observe(textarea) - } else { - console.warn(`Could not initialize TinyMCE for textarea#${editorId}!`) - } - }) -} - -// initialize IntersectionObserver -// the observer will initialize Tinymce if the textarea becomes visible -function initializeIntersectionObserver() { - const observerCallback = (entries, observer) => { - entries.forEach((entry) => { - if (entry.intersectionRatio > 0) { - initTinymceEditor(entry.target) - // disable observer after the Tinymce was initialized - observer.unobserve(entry.target) - } - }) - } - const options = { - root: Alchemy.ElementEditors.element_area.get(0), - rootMargin: "0px", - threshold: [0.05] - } - - tinymceIntersectionObserver = new IntersectionObserver( - observerCallback, - options - ) -} - -// Initializes one specific TinyMCE editor -function initTinymceEditor(textarea) { - const editorId = textarea.id - const config = getConfig(editorId) - - // remove editor instance, if already initialized - removeEditor(editorId) - - if (config) { - const spinner = new Alchemy.Spinner("small") - spinner.spin(textarea.closest(".tinymce_container")) - tinymce.init(config) - } else { - console.warn("No tinymce configuration found for", id) - } -} - -// Gets called after an editor instance gets initialized -function initInstanceCallback(editor) { - const element = document.getElementById(editor.id).closest(".element-editor") - element.getElementsByClassName("spinner").item(0).remove() - editor.on("dirty", function () { - Alchemy.setElementDirty(element) - }) - editor.on("click", function (event) { - event.target = element - Alchemy.ElementEditors.onClickElement(event) - }) -} - -function removeEditor(editorId) { - const editorElement = document.getElementById(editorId) - if (tinymceIntersectionObserver && editorElement) { - tinymceIntersectionObserver.unobserve(editorElement) - } - - const editor = tinymce.get(editorId) - if (editor) { - editor.remove() - } -} - -function removeIntersectionObserver() { - if (tinymceIntersectionObserver !== null) { - tinymceIntersectionObserver.disconnect() - } -} - -export default { - // Initializes all TinyMCE editors with given ids - // - // @param ids [Array] - // - Editor ids that should be initialized. - init(ids) { - initEditors(ids) - }, - - // Initializes TinyMCE editor with given options - initWith(options) { - tinymce.init({ ...Alchemy.TinymceDefaults, ...options }) - }, - - // Removes the TinyMCE editor from given dom ids. - remove(ids) { - ids.forEach((id) => removeEditor(`tinymce_${id}`)) - }, - - // Remove all tinymce instances for given selector - removeFrom(selector) { - // the selector is a jQuery selector - it has to be refactor if we taking care of the calling methods - $(selector).each(function (element) { - removeEditor(element.id) - }) - }, - - removeIntersectionObserver, - - // set tinymce configuration for a given selector key - setCustomConfig(key, configuration) { - tinymceCustomConfigs[key] = configuration - } -} diff --git a/app/views/alchemy/admin/elements/create.js.erb b/app/views/alchemy/admin/elements/create.js.erb index b95c7979b1..d5acc21766 100644 --- a/app/views/alchemy/admin/elements/create.js.erb +++ b/app/views/alchemy/admin/elements/create.js.erb @@ -34,7 +34,6 @@ Alchemy.growl('<%= Alchemy.t(:successfully_added_element) %>'); Alchemy.closeCurrentDialog(); - Alchemy.Tinymce.init(<%= @element.richtext_ingredients_ids.to_json %>); Alchemy.PreviewWindow.refresh(function() { Alchemy.ElementEditors.focusElementPreview(<%= @element.id %>); }); diff --git a/app/views/alchemy/admin/elements/destroy.js.erb b/app/views/alchemy/admin/elements/destroy.js.erb index e74b575efd..71b65737d1 100644 --- a/app/views/alchemy/admin/elements/destroy.js.erb +++ b/app/views/alchemy/admin/elements/destroy.js.erb @@ -3,7 +3,6 @@ $('#element_<%= @element.id %>').hide(200, function() { Alchemy.growl('<%= j @notice %>'); $('#element_area .sortable-elements').sortable('refresh'); Alchemy.PreviewWindow.refresh(); - Alchemy.Tinymce.remove(<%= @richtext_ids.to_json %>); <% if @element.fixed? %> Alchemy.FixedElements.removeTab(<%= @element.id %>); <% end %> diff --git a/app/views/alchemy/admin/elements/fold.js.erb b/app/views/alchemy/admin/elements/fold.js.erb index 44ca3082a5..2694cc723c 100644 --- a/app/views/alchemy/admin/elements/fold.js.erb +++ b/app/views/alchemy/admin/elements/fold.js.erb @@ -12,21 +12,14 @@ $el = $('#element_<%= @element.id %>'); $('#element_area .sortable-elements').sortable('refresh'); - <% if @element.folded? -%> - - Alchemy.Tinymce.remove(<%= @element.richtext_ingredients_ids.to_json %>); - - <% else -%> - - $el.trigger('FocusElementEditor.Alchemy'); - Alchemy.Tinymce.init(<%= @element.richtext_ingredients_ids.to_json %>); - Alchemy.GUI.initElement($el); - Alchemy.SortableElements( - <%= @page.id %>, - '<%= form_authenticity_token %>', - $('> .nestable-elements .nested-elements', $el) - ); - + <% unless @element.folded? -%> + $el.trigger('FocusElementEditor.Alchemy'); + Alchemy.GUI.initElement($el); + Alchemy.SortableElements( + <%= @page.id %>, + '<%= form_authenticity_token %>', + $('> .nestable-elements .nested-elements', $el) + ); <% end -%> <% end -%> diff --git a/app/views/alchemy/admin/pages/edit.html.erb b/app/views/alchemy/admin/pages/edit.html.erb index 5dd99cebd0..397a87b974 100644 --- a/app/views/alchemy/admin/pages/edit.html.erb +++ b/app/views/alchemy/admin/pages/edit.html.erb @@ -193,7 +193,6 @@ Alchemy.SortableElements(<%= @page.id %>, '<%= form_authenticity_token %>'); Alchemy.ElementEditors.init(); Alchemy.SelectBox('.element-editor'); - Alchemy.Tinymce.init(<%= @page.richtext_ingredients_ids.to_json %>); $('#fixed-elements').tabs().tabs('paging', { follow: true, followOnSelect: true, diff --git a/app/views/alchemy/ingredients/_richtext_editor.html.erb b/app/views/alchemy/ingredients/_richtext_editor.html.erb index 829147db7f..5340afd8e1 100644 --- a/app/views/alchemy/ingredients/_richtext_editor.html.erb +++ b/app/views/alchemy/ingredients/_richtext_editor.html.erb @@ -4,19 +4,18 @@ <%- richtext_dom_id = "tinymce_#{richtext_editor.id}" %> <%= element_form.fields_for(:ingredients, richtext_editor.ingredient) do |f| %> <%= ingredient_label(richtext_editor, :value, for: richtext_dom_id) %> -
- <%= f.text_area :value, - class: "has_tinymce", - id: richtext_dom_id %> -
+ + + <%= f.text_area :value, id: richtext_dom_id %> + <% end %> <% if richtext_editor.has_custom_tinymce_config? %> <% end %> <% end %> diff --git a/spec/requests/alchemy/admin/elements_controller_spec.rb b/spec/requests/alchemy/admin/elements_controller_spec.rb index 4bf5e91002..32473a6482 100644 --- a/spec/requests/alchemy/admin/elements_controller_spec.rb +++ b/spec/requests/alchemy/admin/elements_controller_spec.rb @@ -7,36 +7,10 @@ authorize_user(:as_admin) end - describe "#create" do - let(:page_version) { create(:alchemy_page_version) } - - context "element with ingredients" do - it "inits Tinymce for richtext ingredients" do - post admin_elements_path(element: {page_version_id: page_version.id, name: "article"}, format: :js) - element = Alchemy::Element.last - expect(response.body).to include("Alchemy.Tinymce.init([#{element.ingredient_by_role(:text).id}]);") - end - end - end - describe "#fold" do - context "expanded element with ingredients" do - let(:element) { create(:alchemy_element, :with_ingredients) } - - it "removes Tinymce for richtext ingredients" do - post fold_admin_element_path(id: element.id, format: :js) - expect(response.body).to include("Alchemy.Tinymce.remove([#{element.ingredient_by_role(:text).id}]);") - end - end - context "folded element with ingredients" do let(:element) { create(:alchemy_element, :with_ingredients, folded: true) } - it "inits Tinymce for richtext ingredients" do - post fold_admin_element_path(id: element.id, format: :js) - expect(response.body).to include("Alchemy.Tinymce.init([#{element.ingredient_by_role(:text).id}]);") - end - context "with validations" do let(:element) { create(:alchemy_element, :with_ingredients, name: :all_you_can_eat) } @@ -47,16 +21,4 @@ end end end - - describe "#destroy" do - context "element with ingredients" do - let(:element) { create(:alchemy_element, :with_ingredients) } - - it "removes Tinymce for richtext ingredients" do - text_id = element.ingredient_by_role(:text).id - delete admin_element_path(id: element.id, format: :js) - expect(response.body).to include("Alchemy.Tinymce.remove([#{text_id}]);") - end - end - end end diff --git a/spec/views/alchemy/ingredients/richtext_editor_spec.rb b/spec/views/alchemy/ingredients/richtext_editor_spec.rb index 2cf8df63f8..9ba281d63b 100644 --- a/spec/views/alchemy/ingredients/richtext_editor_spec.rb +++ b/spec/views/alchemy/ingredients/richtext_editor_spec.rb @@ -19,7 +19,7 @@ end it "renders a text area for tinymce" do - expect(rendered).to have_selector(".tinymce_container textarea#tinymce_#{ingredient.id}.has_tinymce") + expect(rendered).to have_selector("alchemy-tinymce textarea#tinymce_#{ingredient.id}") end context "without custom configuration" do From fca5ff64acb001ad75d92ae0f6c73a4015fc6561 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <122262394+sascha-karnatz@users.noreply.github.com> Date: Mon, 28 Aug 2023 22:37:33 +0200 Subject: [PATCH 2/5] Add support for attributes on alchemy-tinymce Allow the toolbar and plugin attribute on the tinymce web component. The configuration is read in from the elements.yml and will be available as (escaped) JSON. It is also possible to use a plain string. --- .../alchemy_admin/components/tinymce.js | 23 ++++++++++++++----- .../ingredients/_richtext_editor.html.erb | 21 +++++++++-------- .../ingredients/richtext_editor_spec.rb | 2 +- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/app/javascript/alchemy_admin/components/tinymce.js b/app/javascript/alchemy_admin/components/tinymce.js index ba0cd74461..fdf782417d 100644 --- a/app/javascript/alchemy_admin/components/tinymce.js +++ b/app/javascript/alchemy_admin/components/tinymce.js @@ -1,8 +1,8 @@ class Tinymce extends HTMLElement { constructor() { super() - this.externalConfig = {} + // add default css classes to support the current styles this.className = "tinymce_container" this.textarea.className = "has_tinymce" } @@ -93,17 +93,28 @@ class Tinymce extends HTMLElement { } get configuration() { + const externalConfig = {} + + // read the attributes on the component and add them as custom configuration + this.getAttributeNames().forEach((attributeName) => { + if (attributeName !== "class") { + const config = this.getAttribute(attributeName) + try { + externalConfig[attributeName] = JSON.parse(config) + } catch (e) { + // also string values as parameter + externalConfig[attributeName] = config + } + } + }) + return { ...Alchemy.TinymceDefaults, - ...this.externalConfig, + ...externalConfig, locale: Alchemy.locale, selector: `#${this.textareaId}` } } - - set configuration(config) { - this.externalConfig = config - } } customElements.define("alchemy-tinymce", Tinymce) diff --git a/app/views/alchemy/ingredients/_richtext_editor.html.erb b/app/views/alchemy/ingredients/_richtext_editor.html.erb index 5340afd8e1..070987de14 100644 --- a/app/views/alchemy/ingredients/_richtext_editor.html.erb +++ b/app/views/alchemy/ingredients/_richtext_editor.html.erb @@ -5,17 +5,18 @@ <%= element_form.fields_for(:ingredients, richtext_editor.ingredient) do |f| %> <%= ingredient_label(richtext_editor, :value, for: richtext_dom_id) %> - - <%= f.text_area :value, id: richtext_dom_id %> - - <% end %> - <% if richtext_editor.has_custom_tinymce_config? %> - + > + <%= f.text_area :value, id: richtext_dom_id %> + + <% else %> + + <%= f.text_area :value, id: richtext_dom_id %> + + <% end %> <% end %> <% end %> diff --git a/spec/views/alchemy/ingredients/richtext_editor_spec.rb b/spec/views/alchemy/ingredients/richtext_editor_spec.rb index 9ba281d63b..dd9b121c64 100644 --- a/spec/views/alchemy/ingredients/richtext_editor_spec.rb +++ b/spec/views/alchemy/ingredients/richtext_editor_spec.rb @@ -32,7 +32,7 @@ let(:settings) { {tinymce: {plugin: "link"}} } it "renders a custom configuration" do - expect(rendered).to have_selector(".ingredient-editor.richtext script") + expect(rendered).to have_selector("alchemy-tinymce[plugin]") end end end From e84fa41c327da91df4b6f2887cbf97e3aa8df0ba Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <122262394+sascha-karnatz@users.noreply.github.com> Date: Mon, 28 Aug 2023 22:44:26 +0200 Subject: [PATCH 3/5] Remove custom id on alchemy-tinymce The id is not necessary anymore, because the custom configuration is now build in as attributes on the component it self and the previous configuration block is gone. --- app/views/alchemy/ingredients/_richtext_editor.html.erb | 7 +++---- spec/views/alchemy/ingredients/richtext_editor_spec.rb | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/app/views/alchemy/ingredients/_richtext_editor.html.erb b/app/views/alchemy/ingredients/_richtext_editor.html.erb index 070987de14..d3e75801a8 100644 --- a/app/views/alchemy/ingredients/_richtext_editor.html.erb +++ b/app/views/alchemy/ingredients/_richtext_editor.html.erb @@ -1,9 +1,8 @@ <%= content_tag :div, class: richtext_editor.css_classes, data: richtext_editor.data_attributes do %> - <%- richtext_dom_id = "tinymce_#{richtext_editor.id}" %> <%= element_form.fields_for(:ingredients, richtext_editor.ingredient) do |f| %> - <%= ingredient_label(richtext_editor, :value, for: richtext_dom_id) %> + <%= ingredient_label(richtext_editor, :value) %> <% if richtext_editor.has_custom_tinymce_config? %> ="<%= v.to_json %>" <% end %> > - <%= f.text_area :value, id: richtext_dom_id %> + <%= f.text_area :value %> <% else %> - <%= f.text_area :value, id: richtext_dom_id %> + <%= f.text_area :value %> <% end %> <% end %> diff --git a/spec/views/alchemy/ingredients/richtext_editor_spec.rb b/spec/views/alchemy/ingredients/richtext_editor_spec.rb index dd9b121c64..c224664476 100644 --- a/spec/views/alchemy/ingredients/richtext_editor_spec.rb +++ b/spec/views/alchemy/ingredients/richtext_editor_spec.rb @@ -19,7 +19,7 @@ end it "renders a text area for tinymce" do - expect(rendered).to have_selector("alchemy-tinymce textarea#tinymce_#{ingredient.id}") + expect(rendered).to have_selector("alchemy-tinymce textarea") end context "without custom configuration" do From 675ab92b3ffa53060dd8738c4e8e3b75e13a4435 Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <122262394+sascha-karnatz@users.noreply.github.com> Date: Tue, 29 Aug 2023 09:55:49 +0200 Subject: [PATCH 4/5] Allow more complex attributes on alchemy-tinymce You need sometimes attributes with underscores, which is uncommon in HTML. For that reason the attributes are casted and will be transform back in the web component. Also a new element with a lot of custom configuration was added to test the Tinymce behavior. --- app/assets/stylesheets/alchemy/forms.scss | 2 +- .../alchemy_admin/components/tinymce.js | 6 +++-- app/models/alchemy/ingredients/richtext.rb | 2 +- .../ingredients/_richtext_editor.html.erb | 21 ++++++---------- spec/dummy/config/alchemy/elements.yml | 25 +++++++++++++++++++ spec/dummy/config/alchemy/page_layouts.yml | 3 ++- spec/libraries/alchemy/tasks/usage_spec.rb | 3 ++- .../alchemy/ingredients/richtext_spec.rb | 2 +- spec/models/alchemy/site_spec.rb | 3 ++- 9 files changed, 45 insertions(+), 22 deletions(-) diff --git a/app/assets/stylesheets/alchemy/forms.scss b/app/assets/stylesheets/alchemy/forms.scss index 66a7c458a6..35a62b2033 100644 --- a/app/assets/stylesheets/alchemy/forms.scss +++ b/app/assets/stylesheets/alchemy/forms.scss @@ -31,7 +31,7 @@ form { > textarea, > .select2-container, > .autocomplete_tag_list, - > .mce-tinymce, + > .tinymce_container, > .with-hint { width: $form-right-width; float: right; diff --git a/app/javascript/alchemy_admin/components/tinymce.js b/app/javascript/alchemy_admin/components/tinymce.js index fdf782417d..93138ac649 100644 --- a/app/javascript/alchemy_admin/components/tinymce.js +++ b/app/javascript/alchemy_admin/components/tinymce.js @@ -99,11 +99,13 @@ class Tinymce extends HTMLElement { this.getAttributeNames().forEach((attributeName) => { if (attributeName !== "class") { const config = this.getAttribute(attributeName) + const key = attributeName.replaceAll("-", "_") + try { - externalConfig[attributeName] = JSON.parse(config) + externalConfig[key] = JSON.parse(config) } catch (e) { // also string values as parameter - externalConfig[attributeName] = config + externalConfig[key] = config } } }) diff --git a/app/models/alchemy/ingredients/richtext.rb b/app/models/alchemy/ingredients/richtext.rb index 3e62d97068..437fae93b0 100644 --- a/app/models/alchemy/ingredients/richtext.rb +++ b/app/models/alchemy/ingredients/richtext.rb @@ -38,7 +38,7 @@ def has_custom_tinymce_config? end def custom_tinymce_config - settings[:tinymce] + settings[:tinymce] || [] end private diff --git a/app/views/alchemy/ingredients/_richtext_editor.html.erb b/app/views/alchemy/ingredients/_richtext_editor.html.erb index d3e75801a8..43d7d7ece4 100644 --- a/app/views/alchemy/ingredients/_richtext_editor.html.erb +++ b/app/views/alchemy/ingredients/_richtext_editor.html.erb @@ -3,19 +3,12 @@ data: richtext_editor.data_attributes do %> <%= element_form.fields_for(:ingredients, richtext_editor.ingredient) do |f| %> <%= ingredient_label(richtext_editor, :value) %> - - <% if richtext_editor.has_custom_tinymce_config? %> - - <%= k %>="<%= v.to_json %>" - <% end %> - > - <%= f.text_area :value %> - - <% else %> - - <%= f.text_area :value %> - - <% end %> + + <%= k.to_s.dasherize %>="<%= v.to_json %>" + <% end %> + > + <%= f.text_area :value %> + <% end %> <% end %> diff --git a/spec/dummy/config/alchemy/elements.yml b/spec/dummy/config/alchemy/elements.yml index e17fc01680..637cdcbe99 100644 --- a/spec/dummy/config/alchemy/elements.yml +++ b/spec/dummy/config/alchemy/elements.yml @@ -151,6 +151,31 @@ - role: text type: Richtext +- name: tinymce_custom + ingredients: + - role: text + type: Richtext + as_element_title: true + settings: + tinymce: + toolbar: bold italic | subscript superscript | numlist bullist | + styleselect removeformat | undo redo | pastetext | anchor alchemy_link unlink | fullscreen code + end_container_on_empty_block: true + style_formats: + - title: Text styles + items: + - title: Paragraph + block: p + - title: Lead Paragraph + block: p + classes: [lead] + - title: Wrappers + items: + - title: Centered section + block: section + classes: [centered] + wrapper: true + - name: slide compact: true ingredients: diff --git a/spec/dummy/config/alchemy/page_layouts.yml b/spec/dummy/config/alchemy/page_layouts.yml index 8ec281e991..7edd85c90f 100644 --- a/spec/dummy/config/alchemy/page_layouts.yml +++ b/spec/dummy/config/alchemy/page_layouts.yml @@ -33,7 +33,8 @@ left_column, old, article, - element_with_ingredient_groups + element_with_ingredient_groups, + tinymce_custom ] autogenerate: [all_you_can_eat, right_column, left_column] diff --git a/spec/libraries/alchemy/tasks/usage_spec.rb b/spec/libraries/alchemy/tasks/usage_spec.rb index 8c8cbe6d4b..b4af2e6072 100644 --- a/spec/libraries/alchemy/tasks/usage_spec.rb +++ b/spec/libraries/alchemy/tasks/usage_spec.rb @@ -35,7 +35,8 @@ {"name" => "right_column", "count" => 0}, {"name" => "search", "count" => 0}, {"name" => "slide", "count" => 0}, - {"name" => "slider", "count" => 0} + {"name" => "slider", "count" => 0}, + {"name" => "tinymce_custom", "count" => 0} ] end end diff --git a/spec/models/alchemy/ingredients/richtext_spec.rb b/spec/models/alchemy/ingredients/richtext_spec.rb index 22769a5078..129c6e15ae 100644 --- a/spec/models/alchemy/ingredients/richtext_spec.rb +++ b/spec/models/alchemy/ingredients/richtext_spec.rb @@ -47,7 +47,7 @@ describe "#custom_tinymce_config" do subject { richtext_ingredient.custom_tinymce_config } - it { is_expected.to be_nil } + it { is_expected.to eq([]) } context "with custom configuration" do let(:richtext_settings) { {tinymce: {plugin: "link"}} } diff --git a/spec/models/alchemy/site_spec.rb b/spec/models/alchemy/site_spec.rb index 7ce53fd08a..e95130324b 100644 --- a/spec/models/alchemy/site_spec.rb +++ b/spec/models/alchemy/site_spec.rb @@ -308,7 +308,8 @@ module Alchemy "left_column", "old", "article", - "element_with_ingredient_groups" + "element_with_ingredient_groups", + "tinymce_custom" ], "hint" => true }, From 93777f1238162767b0e04f3e5bb82abeb7fde5db Mon Sep 17 00:00:00 2001 From: Sascha Karnatz <122262394+sascha-karnatz@users.noreply.github.com> Date: Tue, 29 Aug 2023 14:55:36 +0200 Subject: [PATCH 5/5] Use alchemy-tinymce web component only on textareas it is increasing the readability and "feels" a bit better. Also removed a method on the richtext - model, which isn't used anymore. --- .../alchemy_admin/components/tinymce.js | 71 ++++++++----------- app/models/alchemy/ingredients/richtext.rb | 7 +- .../ingredients/_richtext_editor.html.erb | 10 +-- .../alchemy/ingredients/richtext_spec.rb | 13 +--- .../ingredients/richtext_editor_spec.rb | 10 ++- 5 files changed, 40 insertions(+), 71 deletions(-) diff --git a/app/javascript/alchemy_admin/components/tinymce.js b/app/javascript/alchemy_admin/components/tinymce.js index 93138ac649..ddf9b4374e 100644 --- a/app/javascript/alchemy_admin/components/tinymce.js +++ b/app/javascript/alchemy_admin/components/tinymce.js @@ -1,10 +1,14 @@ -class Tinymce extends HTMLElement { +import { createHtmlElement, wrap } from "alchemy_admin/utils/dom_helpers" +import Spinner from "alchemy_admin/spinner" + +class Tinymce extends HTMLTextAreaElement { constructor() { super() - // add default css classes to support the current styles - this.className = "tinymce_container" - this.textarea.className = "has_tinymce" + // create a wrapper around the the textarea and place everything inside that container + this.container = createHtmlElement('
') + wrap(this, this.container) + this.className = "has_tinymce" } /** @@ -31,7 +35,7 @@ class Tinymce extends HTMLElement { observerCallback, options ) - this.tinymceIntersectionObserver.observe(this) + this.tinymceIntersectionObserver.observe(this.container) } /** @@ -42,81 +46,62 @@ class Tinymce extends HTMLElement { this.tinymceIntersectionObserver.disconnect() } - tinymce.get(this.textareaId)?.remove(this.textareaId) + tinymce.get(this.id)?.remove(this.id) } initTinymceEditor() { - this.appendSpinner("small") - - const element = document - .getElementById(this.textareaId) - .closest(".element-editor") + const spinner = new Spinner("small") + spinner.spin(this) // initialize TinyMCE tinymce.init(this.configuration).then((editors) => { - editors.forEach((editor) => { - this.removeSpinner() + spinner.stop() + editors.forEach((editor) => { // mark the editor container as visible // without these correction the editor remains hidden // after a drag and drop action - editor.editorContainer.style.display = null + editor.show() + + const elementEditor = document + .getElementById(this.id) + .closest(".element-editor") // event listener to mark the editor as dirty - editor.on("dirty", () => Alchemy.setElementDirty(element)) + editor.on("dirty", () => Alchemy.setElementDirty(elementEditor)) editor.on("click", (event) => { - event.target = element + event.target = elementEditor Alchemy.ElementEditors.onClickElement(event) }) }) }) } - appendSpinner() { - const spinner = new Alchemy.Spinner("small") - this.prepend(spinner.spin().el.get(0)) - } - - removeSpinner() { - const spinners = this.getElementsByClassName("spinner") - while (spinners.length > 0) { - spinners[0].parentNode.removeChild(spinners[0]) - } - } - - get textarea() { - return this.getElementsByTagName("textarea")[0] - } - - get textareaId() { - return this.textarea.id - } - get configuration() { - const externalConfig = {} + const customConfig = {} // read the attributes on the component and add them as custom configuration this.getAttributeNames().forEach((attributeName) => { - if (attributeName !== "class") { + if (!["class", "id", "is", "name"].includes(attributeName)) { const config = this.getAttribute(attributeName) const key = attributeName.replaceAll("-", "_") try { - externalConfig[key] = JSON.parse(config) + customConfig[key] = JSON.parse(config) } catch (e) { // also string values as parameter - externalConfig[key] = config + customConfig[key] = config } } }) return { ...Alchemy.TinymceDefaults, - ...externalConfig, + ...customConfig, locale: Alchemy.locale, - selector: `#${this.textareaId}` + selector: `#${this.id}` } } } -customElements.define("alchemy-tinymce", Tinymce) +customElements.define("alchemy-tinymce", Tinymce, { extends: "textarea" }) diff --git a/app/models/alchemy/ingredients/richtext.rb b/app/models/alchemy/ingredients/richtext.rb index 437fae93b0..844634b70f 100644 --- a/app/models/alchemy/ingredients/richtext.rb +++ b/app/models/alchemy/ingredients/richtext.rb @@ -32,13 +32,8 @@ def has_tinymce? true end - # Returns true if there is a tinymce setting defined that contains settings. - def has_custom_tinymce_config? - custom_tinymce_config.is_a?(Hash) - end - def custom_tinymce_config - settings[:tinymce] || [] + settings[:tinymce] || {} end private diff --git a/app/views/alchemy/ingredients/_richtext_editor.html.erb b/app/views/alchemy/ingredients/_richtext_editor.html.erb index 43d7d7ece4..1180d4f265 100644 --- a/app/views/alchemy/ingredients/_richtext_editor.html.erb +++ b/app/views/alchemy/ingredients/_richtext_editor.html.erb @@ -3,12 +3,8 @@ data: richtext_editor.data_attributes do %> <%= element_form.fields_for(:ingredients, richtext_editor.ingredient) do |f| %> <%= ingredient_label(richtext_editor, :value) %> - - <%= k.to_s.dasherize %>="<%= v.to_json %>" - <% end %> - > - <%= f.text_area :value %> - + + <%- custom_tinymce_config = richtext_editor.custom_tinymce_config.inject({}) { |obj, (k, v)| obj[k.to_s.dasherize] = v.to_json; obj} %> + <%= f.text_area :value, custom_tinymce_config.merge(is: "alchemy-tinymce", id: richtext_editor.form_field_id(:value)) %> <% end %> <% end %> diff --git a/spec/models/alchemy/ingredients/richtext_spec.rb b/spec/models/alchemy/ingredients/richtext_spec.rb index 129c6e15ae..5218424733 100644 --- a/spec/models/alchemy/ingredients/richtext_spec.rb +++ b/spec/models/alchemy/ingredients/richtext_spec.rb @@ -33,21 +33,10 @@ expect(richtext_ingredient.sanitized_body).to eq("

Hello!

Welcome to Peters Petshop.

") end - describe "#has_custom_tinymce_config?" do - subject { richtext_ingredient.has_custom_tinymce_config? } - - it { is_expected.to be_falsy } - - context "with custom configuration" do - let(:richtext_settings) { {tinymce: {plugin: "link"}} } - it { is_expected.to be_truthy } - end - end - describe "#custom_tinymce_config" do subject { richtext_ingredient.custom_tinymce_config } - it { is_expected.to eq([]) } + it { is_expected.to eq({}) } context "with custom configuration" do let(:richtext_settings) { {tinymce: {plugin: "link"}} } diff --git a/spec/views/alchemy/ingredients/richtext_editor_spec.rb b/spec/views/alchemy/ingredients/richtext_editor_spec.rb index c224664476..d721fb2307 100644 --- a/spec/views/alchemy/ingredients/richtext_editor_spec.rb +++ b/spec/views/alchemy/ingredients/richtext_editor_spec.rb @@ -19,7 +19,7 @@ end it "renders a text area for tinymce" do - expect(rendered).to have_selector("alchemy-tinymce textarea") + expect(rendered).to have_selector("textarea[is=alchemy-tinymce]") end context "without custom configuration" do @@ -29,10 +29,14 @@ end context "with custom configuration" do - let(:settings) { {tinymce: {plugin: "link"}} } + let(:settings) { {tinymce: {plugin: "link", foo_bar: "foo-bar"}} } it "renders a custom configuration" do - expect(rendered).to have_selector("alchemy-tinymce[plugin]") + expect(rendered).to have_selector("textarea[is=alchemy-tinymce][plugin]") + end + + it "dasherize the attribute keys" do + expect(rendered).to have_selector("textarea[is=alchemy-tinymce][foo-bar]") end end end