ckeditor5 1.15.9 → 1.16.0

Sign up to get free protection for your applications and to get access to all the features.
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