ckeditor5 1.15.9 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -0
  3. data/lib/ckeditor5/rails/assets/webcomponents/components/context.mjs +23 -0
  4. data/lib/ckeditor5/rails/assets/webcomponents/components/editor.mjs +24 -0
  5. data/lib/ckeditor5/rails/assets/webcomponents/utils.mjs +33 -10
  6. data/lib/ckeditor5/rails/concerns/checksum.rb +15 -0
  7. data/lib/ckeditor5/rails/context/props.rb +17 -2
  8. data/lib/ckeditor5/rails/editor/props.rb +16 -3
  9. data/lib/ckeditor5/rails/editor/props_base_plugin.rb +19 -0
  10. data/lib/ckeditor5/rails/editor/props_inline_plugin.rb +6 -3
  11. data/lib/ckeditor5/rails/editor/props_plugin.rb +6 -4
  12. data/lib/ckeditor5/rails/engine.rb +3 -2
  13. data/lib/ckeditor5/rails/presets/plugins_builder.rb +9 -9
  14. data/lib/ckeditor5/rails/presets/preset_builder.rb +4 -6
  15. data/lib/ckeditor5/rails/version.rb +1 -1
  16. data/lib/ckeditor5/rails.rb +1 -0
  17. data/spec/e2e/features/editor_types_spec.rb +178 -0
  18. data/spec/e2e/features/form_integration_spec.rb +60 -0
  19. data/spec/e2e/spec_helper.rb +43 -0
  20. data/spec/e2e/support/eventually.rb +14 -0
  21. data/spec/e2e/support/form_helpers.rb +37 -0
  22. data/spec/lib/ckeditor5/rails/assets/asset_bundle_hml_serializer_spec.rb +7 -0
  23. data/spec/lib/ckeditor5/rails/cdn/helpers_spec.rb +19 -1
  24. data/spec/lib/ckeditor5/rails/concerns/checksum_spec.rb +50 -0
  25. data/spec/lib/ckeditor5/rails/context/props_spec.rb +7 -1
  26. data/spec/lib/ckeditor5/rails/editor/props_base_plugin_spec.rb +27 -0
  27. data/spec/lib/ckeditor5/rails/editor/props_spec.rb +2 -0
  28. data/spec/lib/ckeditor5/rails/engine_spec.rb +88 -0
  29. data/spec/lib/ckeditor5/rails/hooks/form_spec.rb +156 -0
  30. data/spec/lib/ckeditor5/rails/hooks/simple_form_spec.rb +100 -0
  31. data/spec/lib/ckeditor5/rails/presets/plugins_builder_spec.rb +10 -10
  32. data/spec/lib/ckeditor5/rails/presets/preset_builder_spec.rb +10 -0
  33. data/spec/spec_helper.rb +2 -2
  34. metadata +22 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 604312ff3ad324ca5ebbccca70efe7157e9ee67608d6a3b8d3b2d4b7c070213b
4
- data.tar.gz: 8c44e0810acd1bb837cdcc8a37c4fb3051b5323a243e7b5a16341e9c8e3ef9a7
3
+ metadata.gz: e91fe2d11179c703901756efa978fcedcd3030d0381c4e550ab8c9c15ef73a19
4
+ data.tar.gz: 4119948cdae51969b3d1e77801f20faecd81cfbf2755388fc444421e7da8bdb9
5
5
  SHA512:
6
- metadata.gz: 54fc1a049c23a9c45bee8fc307de81f7976407850d068f018e715396b93a47e3c37f77c45682ba7ee4f36fe1d5cef156eeb5791dbf0793c35503a93e09e41f8a
7
- data.tar.gz: 23a2299e6b8685cf120fa40be70d23737ec68f732e6e96af4428d785234600ab17a24596f4d4794093b14fdd0625b20e3a2953950659e0ab0c9deda7fc2c1cc9
6
+ metadata.gz: d4f236d5bc883344daa8fa9bc0b7d55e3898360dd8fabfd48265bbd1eedf3c654e8ba6baf5b0a1ba5ffa215c6b2c14a60c02f7182b81885a875b0a4902f67746
7
+ data.tar.gz: 593de9199583c1f22a38b9858967eca63b9c98eef8b7a6a1e5a06ccfa1df759424dab96ed88c1719614744b26c8a32920722156875ba748e66c80272eeb0fd78
data/Gemfile CHANGED
@@ -24,6 +24,7 @@ end
24
24
 
25
25
  group :test, :development do
26
26
  gem 'capybara', '~> 3.40'
27
+ gem 'cuprite', '~> 0.15.0'
27
28
  gem 'rspec', '~> 3.13'
28
29
  gem 'rspec-expectations', '~> 3.13'
29
30
  gem 'rspec-html-matchers', '~> 0.10.0'
@@ -12,7 +12,12 @@ class CKEditorContextComponent extends HTMLElement {
12
12
  /** @type {Set<CKEditorComponent>} */
13
13
  #connectedEditors = new Set();
14
14
 
15
+ /** @type {String} Attributes checksum hash */
16
+ #integrity = '';
17
+
15
18
  async connectedCallback() {
19
+ this.#integrity = this.getAttribute('integrity');
20
+
16
21
  try {
17
22
  execIfDOMReady(() => this.#initializeContext());
18
23
  } catch (error) {
@@ -52,6 +57,22 @@ class CKEditorContextComponent extends HTMLElement {
52
57
  this.#connectedEditors.delete(editor);
53
58
  }
54
59
 
60
+ /**
61
+ * Validates editor configuration integrity hash to prevent attacks.
62
+ */
63
+ async #validateIntegrity() {
64
+ const integrity = await calculateChecksum({
65
+ plugins: this.getAttribute('plugins'),
66
+ });
67
+
68
+ if (integrity !== this.#integrity) {
69
+ throw new Error(
70
+ 'Configuration integrity check failed. It means that #integrity attributes mismatch from attributes passed to webcomponent. ' +
71
+ 'This could be a security issue. Please check if you are passing correct attributes to the webcomponent.'
72
+ );
73
+ }
74
+ }
75
+
55
76
  /**
56
77
  * Initialize CKEditor context with shared configuration
57
78
  *
@@ -66,6 +87,8 @@ class CKEditorContextComponent extends HTMLElement {
66
87
  this.instance = null;
67
88
  }
68
89
 
90
+ await this.#validateIntegrity();
91
+
69
92
  const { Context, ContextWatchdog } = await import('ckeditor5');
70
93
  const plugins = await this.#getPlugins();
71
94
  const config = this.#getConfig();
@@ -40,6 +40,9 @@ class CKEditorComponent extends HTMLElement {
40
40
  /** @type {String} ID of editor within context */
41
41
  #contextEditorId = null;
42
42
 
43
+ /** @type {String} Attributes checksum hash */
44
+ #integrity = '';
45
+
43
46
  /** @type {(event: CustomEvent) => void} Event handler for editor change */
44
47
  get oneditorchange() {
45
48
  return this.#getEventHandler('editorchange');
@@ -106,9 +109,11 @@ class CKEditorComponent extends HTMLElement {
106
109
  /**
107
110
  * Lifecycle callback when element is connected to DOM
108
111
  * Initializes the editor when DOM is ready
112
+ *
109
113
  * @protected
110
114
  */
111
115
  connectedCallback() {
116
+ this.#integrity = this.getAttribute('integrity');
112
117
  this.#context = this.closest('ckeditor-context-component');
113
118
  this.#initialHTML = this.innerHTML;
114
119
 
@@ -228,6 +233,23 @@ class CKEditorComponent extends HTMLElement {
228
233
  return resolveElementReferences(config);
229
234
  }
230
235
 
236
+ /**
237
+ * Validates editor configuration integrity hash to prevent attacks.
238
+ */
239
+ async #validateIntegrity() {
240
+ const integrity = await calculateChecksum({
241
+ translations: this.getAttribute('translations'),
242
+ plugins: this.getAttribute('plugins'),
243
+ });
244
+
245
+ if (integrity !== this.#integrity) {
246
+ throw new Error(
247
+ 'Configuration integrity check failed. It means that #integrity attributes mismatch from attributes passed to webcomponent. ' +
248
+ 'This could be a security issue. Please check if you are passing correct attributes to the webcomponent.'
249
+ );
250
+ }
251
+ }
252
+
231
253
  /**
232
254
  * Creates a new CKEditor instance
233
255
  *
@@ -237,6 +259,8 @@ class CKEditorComponent extends HTMLElement {
237
259
  * @throws {Error} When initialization fails
238
260
  */
239
261
  async #initializeEditor(editablesOrContent) {
262
+ await this.#validateIntegrity();
263
+
240
264
  const Editor = await this.#getEditorConstructor();
241
265
  const [plugins, translations] = await Promise.all([
242
266
  this.#getPlugins(),
@@ -66,16 +66,18 @@ function loadAsyncImports(imports = []) {
66
66
  return imported;
67
67
  };
68
68
 
69
- return Promise.all(imports.map(item => {
70
- switch(item.type) {
71
- case 'inline':
72
- return loadInlinePlugin(item);
73
-
74
- case 'external':
75
- default:
76
- return loadExternalPlugin(item);
77
- }
78
- }));
69
+ return Promise.all(
70
+ imports.map(async (item) => {
71
+ switch(item.type) {
72
+ case 'inline':
73
+ return loadInlinePlugin(item);
74
+
75
+ case 'external':
76
+ default:
77
+ return loadExternalPlugin(item);
78
+ }
79
+ })
80
+ );
79
81
  }
80
82
 
81
83
  /**
@@ -153,3 +155,24 @@ function resolveElementReferences(obj) {
153
155
  function uid() {
154
156
  return Math.random().toString(36).substring(2);
155
157
  }
158
+
159
+ /**
160
+ * Calculates checksum for an object.
161
+ */
162
+ async function calculateChecksum(obj) {
163
+ const objCopy = { ...obj, checksum: undefined };
164
+
165
+ return sha256(JSON.stringify(objCopy));
166
+ }
167
+
168
+ /**
169
+ * Calculates SHA-256 hash for a string
170
+ */
171
+ async function sha256(str) {
172
+ const buffer = new TextEncoder().encode(str);
173
+ const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
174
+
175
+ return Array.from(new Uint8Array(hashBuffer))
176
+ .map(b => b.toString(16).padStart(2, '0'))
177
+ .join('');
178
+ }
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json'
5
+
6
+ module CKEditor5::Rails::Concerns
7
+ module Checksum
8
+ private
9
+
10
+ def calculate_object_checksum(obj)
11
+ json = JSON.generate(obj)
12
+ Digest::SHA256.hexdigest(json)
13
+ end
14
+ end
15
+ end
@@ -3,14 +3,16 @@
3
3
  module CKEditor5::Rails
4
4
  module Context
5
5
  class Props
6
+ include CKEditor5::Rails::Concerns::Checksum
7
+
6
8
  def initialize(config)
7
9
  @config = config
8
10
  end
9
11
 
10
12
  def to_attributes
11
13
  {
12
- plugins: serialize_plugins,
13
- config: serialize_config
14
+ **serialized_attributes,
15
+ integrity: integrity_checksum
14
16
  }
15
17
  end
16
18
 
@@ -18,6 +20,19 @@ module CKEditor5::Rails
18
20
 
19
21
  attr_reader :config
20
22
 
23
+ def integrity_checksum
24
+ unsafe_attributes = serialized_attributes.slice(:plugins)
25
+
26
+ calculate_object_checksum(unsafe_attributes)
27
+ end
28
+
29
+ def serialized_attributes
30
+ @serialized_attributes ||= {
31
+ plugins: serialize_plugins,
32
+ config: serialize_config
33
+ }
34
+ end
35
+
21
36
  def serialize_plugins
22
37
  (config[:plugins] || []).map { |plugin| Editor::PropsPlugin.normalize(plugin).to_h }.to_json
23
38
  end
@@ -5,6 +5,8 @@ require_relative 'editable_height_normalizer'
5
5
 
6
6
  module CKEditor5::Rails::Editor
7
7
  class Props
8
+ include CKEditor5::Rails::Concerns::Checksum
9
+
8
10
  EDITOR_TYPES = {
9
11
  classic: 'ClassicEditor',
10
12
  inline: 'InlineEditor',
@@ -25,8 +27,9 @@ module CKEditor5::Rails::Editor
25
27
 
26
28
  def to_attributes
27
29
  {
30
+ **serialized_attributes,
28
31
  type: EDITOR_TYPES[@type],
29
- **serialized_attributes
32
+ integrity: integrity_checksum
30
33
  }
31
34
  end
32
35
 
@@ -38,14 +41,24 @@ module CKEditor5::Rails::Editor
38
41
 
39
42
  attr_reader :controller_context, :watchdog, :type, :config, :editable_height
40
43
 
44
+ def integrity_checksum
45
+ unsafe_attributes = serialized_attributes.slice(:translations, :plugins)
46
+
47
+ calculate_object_checksum(unsafe_attributes)
48
+ end
49
+
41
50
  def serialized_attributes
42
- {
51
+ return @serialized_attributes if defined?(@serialized_attributes)
52
+
53
+ attributes = {
43
54
  translations: serialize_translations,
44
55
  plugins: serialize_plugins,
45
56
  config: serialize_config,
46
57
  watchdog: watchdog
47
58
  }
48
- .merge(editable_height ? { 'editable-height' => editable_height } : {})
59
+
60
+ attributes.merge!(editable_height ? { 'editable-height' => editable_height } : {})
61
+ @serialized_attributes = attributes
49
62
  end
50
63
 
51
64
  def serialize_translations
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CKEditor5::Rails
4
+ module Editor
5
+ class PropsBasePlugin
6
+ include Concerns::Checksum
7
+
8
+ attr_reader :name
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ end
13
+
14
+ def to_h
15
+ raise NotImplementedError, 'This method must be implemented in a subclass'
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'props_base_plugin'
4
+
3
5
  module CKEditor5::Rails::Editor
4
- class PropsInlinePlugin
5
- attr_reader :name, :code
6
+ class PropsInlinePlugin < PropsBasePlugin
7
+ attr_reader :code
6
8
 
7
9
  def initialize(name, code)
8
- @name = name
10
+ super(name)
11
+
9
12
  @code = code
10
13
  validate_code!
11
14
  end
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module CKEditor5::Rails::Editor
4
- class PropsPlugin
5
- attr_reader :name, :js_import_meta
3
+ require_relative 'props_base_plugin'
6
4
 
7
- delegate :to_h, to: :import_meta
5
+ module CKEditor5::Rails::Editor
6
+ class PropsPlugin < PropsBasePlugin
7
+ attr_reader :js_import_meta
8
8
 
9
9
  def initialize(name, premium: false, **js_import_meta)
10
+ super(name)
11
+
10
12
  @name = name
11
13
  @js_import_meta = if js_import_meta.empty?
12
14
  { import_name: premium ? 'ckeditor5-premium-features' : 'ckeditor5' }
@@ -45,7 +45,7 @@ module CKEditor5::Rails
45
45
  def find_preset(preset)
46
46
  return preset if preset.is_a?(CKEditor5::Rails::Presets::PresetBuilder)
47
47
 
48
- Engine.base.presets[preset]
48
+ base.presets[preset]
49
49
  end
50
50
  end
51
51
 
@@ -54,7 +54,8 @@ module CKEditor5::Rails
54
54
 
55
55
  delegate :version, :gpl, :premium, :cdn, :translations, :license_key,
56
56
  :type, :menubar, :toolbar, :plugins, :plugin, :inline_plugin,
57
- :language, :ckbox, :configure, to: :default_preset
57
+ :language, :ckbox, :configure, :automatic_upgrades, :simple_upload_adapter,
58
+ :editable_height, to: :default_preset
58
59
 
59
60
  def initialize(configuration)
60
61
  @configuration = configuration
@@ -2,10 +2,10 @@
2
2
 
3
3
  module CKEditor5::Rails
4
4
  class Presets::PluginsBuilder
5
- attr_reader :plugins
5
+ attr_reader :items
6
6
 
7
7
  def initialize(plugins)
8
- @plugins = plugins
8
+ @items = plugins
9
9
  end
10
10
 
11
11
  def self.create_plugin(name, **kwargs)
@@ -17,19 +17,19 @@ module CKEditor5::Rails
17
17
  end
18
18
 
19
19
  def remove(*names)
20
- names.each { |name| plugins.delete_if { |plugin| plugin.name == name } }
20
+ names.each { |name| items.delete_if { |plugin| plugin.name == name } }
21
21
  end
22
22
 
23
23
  def prepend(*names, before: nil, **kwargs)
24
24
  new_plugins = names.map { |name| self.class.create_plugin(name, **kwargs) }
25
25
 
26
26
  if before
27
- index = plugins.index { |p| p.name == before }
27
+ index = items.index { |p| p.name == before }
28
28
  raise ArgumentError, "Plugin '#{before}' not found" unless index
29
29
 
30
- plugins.insert(index, *new_plugins)
30
+ items.insert(index, *new_plugins)
31
31
  else
32
- plugins.insert(0, *new_plugins)
32
+ items.insert(0, *new_plugins)
33
33
  end
34
34
  end
35
35
 
@@ -37,12 +37,12 @@ module CKEditor5::Rails
37
37
  new_plugins = names.map { |name| self.class.create_plugin(name, **kwargs) }
38
38
 
39
39
  if after
40
- index = plugins.index { |p| p.name == after }
40
+ index = items.index { |p| p.name == after }
41
41
  raise ArgumentError, "Plugin '#{after}' not found" unless index
42
42
 
43
- plugins.insert(index + 1, *new_plugins)
43
+ items.insert(index + 1, *new_plugins)
44
44
  else
45
- plugins.push(*new_plugins)
45
+ items.push(*new_plugins)
46
46
  end
47
47
  end
48
48
  end
@@ -162,10 +162,9 @@ module CKEditor5::Rails
162
162
  }
163
163
  end
164
164
 
165
- return unless block
166
-
167
165
  builder = ToolbarBuilder.new(@config[:toolbar][:items])
168
- builder.instance_eval(&block)
166
+ builder.instance_eval(&block) if block_given?
167
+ builder
169
168
  end
170
169
 
171
170
  def inline_plugin(name, code)
@@ -183,10 +182,9 @@ module CKEditor5::Rails
183
182
 
184
183
  names.each { |name| plugin(name, **kwargs) } unless names.empty?
185
184
 
186
- return unless block
187
-
188
185
  builder = PluginsBuilder.new(@config[:plugins])
189
- builder.instance_eval(&block)
186
+ builder.instance_eval(&block) if block_given?
187
+ builder
190
188
  end
191
189
 
192
190
  def language(ui = nil, content: ui) # rubocop:disable Naming/MethodParameterName
@@ -2,7 +2,7 @@
2
2
 
3
3
  module CKEditor5
4
4
  module Rails
5
- VERSION = '1.15.9'
5
+ VERSION = '1.16.0'
6
6
 
7
7
  DEFAULT_CKEDITOR_VERSION = '43.3.1'
8
8
  end
@@ -5,6 +5,7 @@ module CKEditor5
5
5
  require_relative 'rails/version'
6
6
  require_relative 'rails/version_detector'
7
7
  require_relative 'rails/semver'
8
+ require_relative 'rails/concerns/checksum'
8
9
  require_relative 'rails/assets/assets_bundle'
9
10
  require_relative 'rails/assets/assets_bundle_html_serializer'
10
11
  require_relative 'rails/helpers'
@@ -0,0 +1,178 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'e2e/spec_helper'
4
+
5
+ RSpec.describe 'CKEditor5 Types Integration', type: :feature, js: true do
6
+ shared_examples 'an editor' do |path|
7
+ before { visit path }
8
+
9
+ it 'loads and initializes the editor' do
10
+ expect(page).to have_css('.ck-editor__editable', wait: 10)
11
+ end
12
+ end
13
+
14
+ shared_examples 'an editor that fires change event with main payload' do |path|
15
+ before { visit path }
16
+
17
+ it 'sends properly change events with proper payload' do
18
+ editor = first('.ck-editor__editable')
19
+
20
+ # Set up detailed change event listener
21
+ page.execute_script(<<~JS)
22
+ window._editorEvents = [];
23
+ document.querySelector('ckeditor-component').addEventListener('editor-change', (e) => {
24
+ window._editorEvents.push({
25
+ data: e.detail.data,
26
+ hasEditor: !!e.detail.editor
27
+ });
28
+ });
29
+ JS
30
+
31
+ # Clear editor and type text
32
+ editor.click
33
+ editor.send_keys([[:control, 'a'], :backspace])
34
+ editor.send_keys('Hello from keyboard!')
35
+
36
+ # Wait for change events and verify the last one
37
+ eventually do
38
+ events = page.evaluate_script('window._editorEvents')
39
+ last_event = events.last
40
+
41
+ expect(last_event['data']).to eq('main' => '<p>Hello from keyboard!</p>')
42
+ expect(last_event['hasEditor']).to be true
43
+ end
44
+ end
45
+ end
46
+
47
+ shared_examples 'a multiroot editor that fires change events' do |path, editables| # rubocop:disable Metrics/BlockLength
48
+ before { visit path }
49
+
50
+ it 'sends properly change events with proper payload for editables' do # rubocop:disable Metrics/BlockLength
51
+ editors = editables.map do |name|
52
+ find("[data-testid='#{name}-editable']")
53
+ end
54
+
55
+ # Set up detailed change event listener
56
+ page.execute_script(<<~JS)
57
+ window._editorEvents = [];
58
+ document.querySelector('ckeditor-component').addEventListener('editor-change', (e) => {
59
+ window._editorEvents.push({
60
+ data: e.detail.data,
61
+ hasEditor: !!e.detail.editor
62
+ });
63
+ });
64
+ JS
65
+
66
+ # Test each editable
67
+ expected_data = {}
68
+ editors.each_with_index do |editor, index|
69
+ editor.click
70
+ editor.send_keys([[:control, 'a'], :backspace])
71
+ content = "Content for #{editables[index]}"
72
+ editor.send_keys(content)
73
+ expected_data[editables[index]] = "<p>#{content}</p>"
74
+ end
75
+
76
+ # Wait for change events and verify the last one
77
+ eventually do
78
+ events = page.evaluate_script('window._editorEvents')
79
+ last_event = events.last
80
+
81
+ expect(last_event['data']).to eq(expected_data)
82
+ expect(last_event['hasEditor']).to be true
83
+ end
84
+ end
85
+ end
86
+
87
+ describe 'Classic Editor' do
88
+ it_behaves_like 'an editor', 'classic'
89
+ it_behaves_like 'an editor that fires change event with main payload', 'classic'
90
+ end
91
+
92
+ describe 'Decoupled Editor' do
93
+ before { visit 'decoupled' }
94
+
95
+ it_behaves_like 'an editor', 'decoupled'
96
+ it_behaves_like 'an editor that fires change event with main payload', 'decoupled'
97
+
98
+ it 'has separate toolbar' do
99
+ expect(page).to have_css('.toolbar-container .ck-toolbar')
100
+ end
101
+ end
102
+
103
+ describe 'Balloon Editor' do
104
+ before { visit 'balloon' }
105
+
106
+ it_behaves_like 'an editor', 'balloon'
107
+ it_behaves_like 'an editor that fires change event with main payload', 'balloon'
108
+
109
+ it 'shows balloon toolbar on selection' do
110
+ editor = first('.ck-editor__editable')
111
+ editor.click
112
+
113
+ expect(page).to have_css('.ck-balloon-panel', wait: 5)
114
+ end
115
+ end
116
+
117
+ describe 'Inline Editor' do
118
+ it_behaves_like 'an editor', 'inline'
119
+ it_behaves_like 'an editor that fires change event with main payload', 'inline'
120
+ end
121
+
122
+ describe 'Multiroot Editor' do
123
+ before { visit 'multiroot' }
124
+
125
+ it_behaves_like 'an editor', 'multiroot'
126
+ it_behaves_like 'a multiroot editor that fires change events', 'multiroot', %w[toolbar content]
127
+
128
+ it 'supports multiple editable areas' do
129
+ expect(page).to have_css('.ck-editor__editable', minimum: 2)
130
+ end
131
+
132
+ it 'shares toolbar between editables' do
133
+ expect(page).to have_css('.ck-toolbar', count: 1)
134
+ end
135
+
136
+ it 'handles dynamically added editables' do # rubocop:disable Metrics/BlockLength
137
+ # Set up event listener
138
+ page.execute_script(<<~JS)
139
+ window._newEditableEvents = [];
140
+ document.querySelector('ckeditor-component').addEventListener('editor-change', (e) => {
141
+ window._newEditableEvents.push({
142
+ data: e.detail.data,
143
+ hasEditor: !!e.detail.editor
144
+ });
145
+ });
146
+ JS
147
+
148
+ # Add new editable component
149
+ page.execute_script(<<~JS)
150
+ const container = document.querySelector('[data-testid="multiroot-editor"]');
151
+ const newEditable = document.createElement('ckeditor-editable-component');
152
+ newEditable.setAttribute('name', 'new-root');
153
+ container.appendChild(newEditable);
154
+ JS
155
+
156
+ sleep 0.1 # Wait for component initialization
157
+
158
+ # Find and interact with new editable
159
+ new_editable = find("[name='new-root']")
160
+ new_editable.click
161
+ new_editable.send_keys('Content for new root')
162
+
163
+ # Verify the change event
164
+ eventually do
165
+ events = page.evaluate_script('window._newEditableEvents')
166
+ last_event = events.last
167
+
168
+ expect(last_event['data']).to include(
169
+ 'content' => '',
170
+ 'new-root' => '<p>Content for new root</p>',
171
+ 'toolbar' => '<p>This is a toolbar editable</p>'
172
+ )
173
+
174
+ expect(last_event['hasEditor']).to be true
175
+ end
176
+ end
177
+ end
178
+ end