ckeditor5 1.0.5 → 1.1.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.
@@ -41,7 +41,7 @@ module CKEditor5::Rails::Assets
41
41
  class JSExportsMeta
42
42
  attr_reader :url, :import_meta
43
43
 
44
- delegate :esm?, :window?, :import_name, :window_name, :import_as, to: :import_meta
44
+ delegate :esm?, :window?, :import_name, :window_name, :import_as, :to_h, to: :import_meta
45
45
 
46
46
  def initialize(url, translation: false, **import_options)
47
47
  @url = url
@@ -63,13 +63,14 @@ module CKEditor5::Rails::Assets
63
63
 
64
64
  def styles_tags
65
65
  @styles_tags ||= safe_join(bundle.stylesheets.map do |url|
66
- tag.link(href: url, rel: 'stylesheet')
66
+ tag.link(href: url, rel: 'stylesheet', crossorigin: 'anonymous')
67
67
  end)
68
68
  end
69
69
 
70
70
  def preload_tags
71
71
  @preload_tags ||= safe_join(bundle.preloads.map do |url|
72
- tag.link(href: url, rel: 'preload', as: self.class.url_resource_preload_type(url))
72
+ tag.link(href: url, rel: 'preload', as: self.class.url_resource_preload_type(url),
73
+ crossorigin: 'anonymous')
73
74
  end)
74
75
  end
75
76
  end
@@ -23,7 +23,8 @@
23
23
  */
24
24
  class CKEditorComponent extends HTMLElement {
25
25
  /**
26
- * List of attributes that trigger updates when changed
26
+ * List of attributes that trigger updates when changed.
27
+ *
27
28
  * @static
28
29
  * @returns {string[]} Array of attribute names to observe
29
30
  */
@@ -31,6 +32,16 @@ class CKEditorComponent extends HTMLElement {
31
32
  return ['config', 'plugins', 'translations', 'type'];
32
33
  }
33
34
 
35
+ /**
36
+ * List of input attributes that trigger updates when changed.
37
+ *
38
+ * @static
39
+ * @returns {string[]} Array of input attribute names to observe
40
+ */
41
+ static get inputAttributes() {
42
+ return ['name', 'required', 'value'];
43
+ }
44
+
34
45
  /** @type {Promise<import('ckeditor5').Editor>|null} Promise to initialize editor instance */
35
46
  instancePromise = Promise.withResolvers();
36
47
 
@@ -134,16 +145,17 @@ class CKEditorComponent extends HTMLElement {
134
145
  content = editablesOrContent.main;
135
146
  }
136
147
 
137
- const instance = await Editor.create(
138
- content,
139
- {
140
- ...this.#getConfig(),
141
- ...translations.length && {
142
- translations
143
- },
144
- plugins,
145
- }
146
- );
148
+ const config = {
149
+ ...this.#getConfig(),
150
+ ...translations.length && {
151
+ translations
152
+ },
153
+ plugins,
154
+ };
155
+
156
+ console.warn('Initializing CKEditor with config:', config);
157
+
158
+ const instance = await Editor.create(content, config);
147
159
 
148
160
  this.dispatchEvent(new CustomEvent('editor-ready', { detail: instance }));
149
161
 
@@ -168,6 +180,7 @@ class CKEditorComponent extends HTMLElement {
168
180
 
169
181
  if (!this.isMultiroot() && !this.isDecoupled()) {
170
182
  this.innerHTML = `<${this.#editorElementTag}></${this.#editorElementTag}>`;
183
+ this.#assignInputAttributes();
171
184
  }
172
185
 
173
186
  // Let's track changes in editables if it's a multiroot editor.
@@ -181,6 +194,8 @@ class CKEditorComponent extends HTMLElement {
181
194
 
182
195
  try {
183
196
  this.instance = await this.#initializeEditor(this.editables || this.#getConfig().initialData || '');
197
+ this.#setupContentSync();
198
+
184
199
  this.instancePromise.resolve(this.instance);
185
200
  } catch (err) {
186
201
  this.instancePromise.reject(err);
@@ -251,6 +266,74 @@ class CKEditorComponent extends HTMLElement {
251
266
  return { main: mainEditable };
252
267
  }
253
268
 
269
+ /**
270
+ * Copies input-related attributes from component to the main editable element
271
+ *
272
+ * @private
273
+ */
274
+ #assignInputAttributes() {
275
+ const textarea = this.querySelector('textarea');
276
+
277
+ if (!textarea) {
278
+ return;
279
+ }
280
+
281
+ for (const attr of CKEditorComponent.inputAttributes) {
282
+ if (this.hasAttribute(attr)) {
283
+ textarea.setAttribute(attr, this.getAttribute(attr));
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Sets up content sync between editor and textarea element.
290
+ *
291
+ * @private
292
+ */
293
+ #setupContentSync() {
294
+ if (!this.instance) {
295
+ return;
296
+ }
297
+
298
+ const textarea = this.querySelector('textarea');
299
+
300
+ if (!textarea) {
301
+ return;
302
+ }
303
+
304
+ // Initial sync
305
+ const syncInput = () => {
306
+ this.style.position = 'relative';
307
+
308
+ textarea.value = this.instance.getData();
309
+ textarea.tabIndex = -1;
310
+
311
+ Object.assign(textarea.style, {
312
+ display: 'flex',
313
+ position: 'absolute',
314
+ bottom: '0',
315
+ left: '50%',
316
+ width: '1px',
317
+ height: '1px',
318
+ opacity: '0',
319
+ pointerEvents: 'none',
320
+ margin: '0',
321
+ padding: '0',
322
+ border: 'none'
323
+ });
324
+ };
325
+
326
+ syncInput();
327
+
328
+ // Listen for changes
329
+ this.instance.model.document.on('change:data', () => {
330
+ textarea.dispatchEvent(new Event('input', { bubbles: true }));
331
+ textarea.dispatchEvent(new Event('change', { bubbles: true }));
332
+
333
+ syncInput();
334
+ });
335
+ }
336
+
254
337
  /**
255
338
  * Loads translation modules
256
339
  *
@@ -624,16 +707,48 @@ function execIfDOMReady(callback) {
624
707
  * @returns {Promise<Array<any>>} Loaded modules
625
708
  */
626
709
  function loadAsyncImports(imports = []) {
627
- return Promise.all(
628
- imports.map(async ({ import_name, import_as, window_name }) => {
629
- if (window_name && Object.prototype.hasOwnProperty.call(window, window_name)) {
630
- return window[window_name];
710
+ const loadInlinePlugin = async ({ name, code }) => {
711
+ const module = await import(`data:text/javascript,${encodeURIComponent(code)}`);
712
+
713
+ if (!module.default) {
714
+ throw new Error(`Inline plugin "${name}" must export a default class/function!`);
715
+ }
716
+
717
+ return module.default;
718
+ };
719
+
720
+ const loadExternalPlugin = async ({ import_name, import_as, window_name }) => {
721
+ if (window_name) {
722
+ if (!Object.prototype.hasOwnProperty.call(window, window_name)) {
723
+ throw new Error(
724
+ `Plugin window['${window_name}'] not found in global scope. ` +
725
+ 'Please ensure the plugin is loaded before CKEditor initialization.'
726
+ );
631
727
  }
632
728
 
633
- const module = await import(import_name);
634
- return import_as ? module[import_as] : module.default;
635
- })
636
- );
729
+ return window[window_name];
730
+ }
731
+
732
+ const module = await import(import_name);
733
+ const imported = module[import_as || 'default'];
734
+
735
+ if (!imported) {
736
+ throw new Error(`Plugin "${import_as}" not found in the ESM module "${import_name}"!`);
737
+ }
738
+
739
+ return imported;
740
+ };
741
+
742
+ return Promise.all(imports.map(item => {
743
+ switch(item.type) {
744
+ case 'inline':
745
+ return loadInlinePlugin(item);
746
+
747
+ case 'external':
748
+ default:
749
+ return loadExternalPlugin(item);
750
+ }
751
+ }));
637
752
  }
638
753
 
639
754
  customElements.define('ckeditor-component', CKEditorComponent);
@@ -7,7 +7,7 @@ module CKEditor5::Rails
7
7
 
8
8
  attr_reader :cdn, :version, :theme, :translations
9
9
 
10
- def initialize(version, theme: :lark, cdn: Engine.base.default_cdn, translations: [])
10
+ def initialize(version, theme: :lark, cdn: Engine.default_preset.cdn, translations: [])
11
11
  raise ArgumentError, 'version must be semver' unless version.is_a?(Semver)
12
12
  raise ArgumentError, 'theme must be a string' unless theme.is_a?(String)
13
13
  raise ArgumentError, 'translations must be an array' unless translations.is_a?(Array)
@@ -7,7 +7,7 @@ module CKEditor5::Rails
7
7
 
8
8
  attr_reader :version, :translations, :import_name
9
9
 
10
- def initialize(version, import_name, cdn: Engine.base.default_cdn, translations: [])
10
+ def initialize(version, import_name, cdn: Engine.default_preset.cdn, translations: [])
11
11
  raise ArgumentError, 'version must be semver' unless version.is_a?(Semver)
12
12
  raise ArgumentError, 'import_name must be a string' unless import_name.is_a?(String)
13
13
  raise ArgumentError, 'translations must be an array' unless translations.is_a?(Array)
@@ -6,7 +6,17 @@ require_relative 'ckbox_bundle'
6
6
 
7
7
  module CKEditor5::Rails
8
8
  module Cdn::Helpers
9
- def ckeditor5_cdn_assets(version:, cdn:, license_key: 'GPL', premium: false, translations: [], ckbox: nil)
9
+ def ckeditor5_cdn_assets(preset: :default, **kwargs)
10
+ merge_with_editor_preset(preset, **kwargs) => {
11
+ cdn:,
12
+ version:,
13
+ translations:,
14
+ ckbox:,
15
+ license_key:,
16
+ premium:,
17
+ **kwargs
18
+ }
19
+
10
20
  bundle = build_base_cdn_bundle(cdn, version, translations)
11
21
  bundle << build_premium_cdn_bundle(cdn, version, translations) if premium
12
22
  bundle << build_ckbox_cdn_bundle(ckbox) if ckbox
@@ -29,12 +39,34 @@ module CKEditor5::Rails
29
39
  if kwargs[:license_key] && kwargs[:license_key] != 'GPL'
30
40
  ckeditor5_cloud_assets(**kwargs)
31
41
  else
32
- ckeditor5_cdn_assets(**kwargs.merge(cdn: Engine.base.default_cdn))
42
+ ckeditor5_cdn_assets(**kwargs.merge(cdn: Engine.default_preset.cdn))
33
43
  end
34
44
  end
35
45
 
36
46
  private
37
47
 
48
+ def merge_with_editor_preset(preset, **kwargs)
49
+ found_preset = Engine.base.presets[preset]
50
+
51
+ if found_preset.blank?
52
+ raise ArgumentError,
53
+ "Poor thing. You forgot to define your #{preset} preset. " \
54
+ 'Please define it in initializer. Thank you!'
55
+ end
56
+
57
+ hash = found_preset.to_h_with_overrides(**kwargs)
58
+
59
+ %i[version type].each do |key|
60
+ next if hash[key].present?
61
+
62
+ raise ArgumentError,
63
+ "Poor thing. You forgot to define #{key}. Make sure you passed `#{key}:` parameter to " \
64
+ "`ckeditor5_cdn_assets` or defined default one in your `#{preset}` preset!"
65
+ end
66
+
67
+ hash
68
+ end
69
+
38
70
  def build_base_cdn_bundle(cdn, version, translations)
39
71
  Cdn::CKEditorBundle.new(
40
72
  Semver.new(version),
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'props_plugin'
4
+ require_relative 'props_inline_plugin'
4
5
  require_relative 'props'
5
6
 
6
7
  module CKEditor5::Rails
@@ -11,6 +12,7 @@ module CKEditor5::Rails
11
12
  def ckeditor5_editor(
12
13
  config: nil, extra_config: {},
13
14
  type: nil, preset: :default,
15
+ initial_data: nil,
14
16
  **html_attributes, &block
15
17
  )
16
18
  context = validate_and_get_editor_context!
@@ -19,8 +21,11 @@ module CKEditor5::Rails
19
21
  config ||= preset.config
20
22
  type ||= preset.type
21
23
 
24
+ config = config.deep_merge(extra_config)
25
+ config[:initialData] = initial_data if initial_data
26
+
22
27
  editor_props = build_editor_props(
23
- config: config.deep_merge(extra_config),
28
+ config: config,
24
29
  type: type,
25
30
  context: context
26
31
  )
@@ -44,7 +44,7 @@ module CKEditor5::Rails::Editor
44
44
  end
45
45
 
46
46
  def serialize_translations
47
- context[:bundle].translations_scripts.map { |script| script.import_meta.to_h }.to_json
47
+ context[:bundle].translations_scripts.map(&:to_h).to_json
48
48
  end
49
49
 
50
50
  def serialize_plugins
@@ -54,7 +54,7 @@ module CKEditor5::Rails::Editor
54
54
  def serialize_config
55
55
  config
56
56
  .except(:plugins)
57
- .merge(licenseKey: context[:license_key] || 'GPL')
57
+ .tap { |cfg| cfg[:licenseKey] = context[:license_key] if context[:license_key] }
58
58
  .to_json
59
59
  end
60
60
  end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CKEditor5::Rails::Editor
4
+ class PropsInlinePlugin
5
+ def initialize(name, code)
6
+ @name = name
7
+ @code = code
8
+ validate_code!
9
+ end
10
+
11
+ def to_h
12
+ {
13
+ type: :inline,
14
+ name: name,
15
+ code: code
16
+ }
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :name, :code
22
+
23
+ def validate_code!
24
+ raise ArgumentError, 'Code must be a String' unless code.is_a?(String)
25
+
26
+ return if code.include?('export default')
27
+
28
+ raise ArgumentError,
29
+ 'Code must include `export default` that exports plugin definition!'
30
+ end
31
+ end
32
+ end
@@ -4,30 +4,35 @@ module CKEditor5::Rails::Editor
4
4
  class PropsPlugin
5
5
  delegate :to_h, to: :import_meta
6
6
 
7
- def initialize(name, premium: false, import_name: nil)
7
+ def initialize(name, premium: false, **js_import_meta)
8
8
  @name = name
9
- @premium = premium
10
- @import_name = import_name
11
- @import_name ||= premium ? 'ckeditor5-premium-features' : 'ckeditor5'
9
+ @js_import_meta = if js_import_meta.empty?
10
+ { import_name: premium ? 'ckeditor5-premium-features' : 'ckeditor5' }
11
+ else
12
+ js_import_meta
13
+ end
12
14
  end
13
15
 
14
16
  def self.normalize(plugin)
15
17
  case plugin
16
18
  when String, Symbol then new(plugin)
17
- when PropsPlugin then plugin
19
+ when PropsPlugin, PropsInlinePlugin then plugin
18
20
  else raise ArgumentError, "Invalid plugin: #{plugin}"
19
21
  end
20
22
  end
21
23
 
22
- private
23
-
24
- attr_reader :name, :premium, :import_name
24
+ def to_h
25
+ meta = ::CKEditor5::Rails::Assets::JSImportMeta.new(
26
+ import_as: js_import_meta[:window_name] ? nil : name,
27
+ **js_import_meta
28
+ ).to_h
25
29
 
26
- def import_meta
27
- ::CKEditor5::Rails::Assets::JSImportMeta.new(
28
- import_as: name,
29
- import_name: import_name
30
- )
30
+ meta.merge!({ type: :external })
31
+ meta
31
32
  end
33
+
34
+ private
35
+
36
+ attr_reader :name, :js_import_meta
32
37
  end
33
38
  end
@@ -2,17 +2,12 @@
2
2
 
3
3
  require 'rails/engine'
4
4
  require_relative 'presets'
5
+ require_relative 'hooks/form'
5
6
 
6
7
  module CKEditor5::Rails
7
8
  class Engine < ::Rails::Engine
8
9
  config.ckeditor5 = ActiveSupport::OrderedOptions.new
9
10
 
10
- # Specifies which CDN should be used to load CKEditor 5 using the ckeditor5_assets helper.
11
- # It is possible to use the following CDNs:
12
- # - :unpkg
13
- # - :jsdelivr (default)
14
- config.ckeditor5.default_cdn = :jsdelivr
15
-
16
11
  # Specifies configuration of editors generated by gem.
17
12
  config.ckeditor5.presets = PresetsManager.new
18
13
 
@@ -22,10 +17,30 @@ module CKEditor5::Rails
22
17
  end
23
18
  end
24
19
 
20
+ initializer 'ckeditor5.simple_form' do
21
+ next unless defined?(::SimpleForm)
22
+
23
+ require_relative 'hooks/simple_form'
24
+
25
+ ::SimpleForm::FormBuilder.map_type :ckeditor5, to: Hooks::SimpleForm::CKEditor5Input
26
+ end
27
+
28
+ initializer 'ckeditor5.form_builder' do
29
+ require_relative 'hooks/form'
30
+
31
+ ActionView::Helpers::FormBuilder.include(
32
+ Hooks::Form::FormBuilderExtension
33
+ )
34
+ end
35
+
25
36
  def self.base
26
37
  config.ckeditor5
27
38
  end
28
39
 
40
+ def self.default_preset
41
+ config.ckeditor5.presets.default
42
+ end
43
+
29
44
  def self.configure
30
45
  yield config.ckeditor5
31
46
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CKEditor5::Rails::Hooks
4
+ module Form
5
+ module FormBuilderExtension
6
+ def ckeditor5(method, options = {})
7
+ value = if object.respond_to?(method)
8
+ object.send(method)
9
+ else
10
+ options[:initial_data]
11
+ end
12
+
13
+ html_options = options.merge(
14
+ name: object_name,
15
+ required: options.delete(:required),
16
+ initial_data: value
17
+ )
18
+
19
+ @template.ckeditor5_editor(**html_options)
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CKEditor5::Rails::Hooks
4
+ module SimpleForm
5
+ class CKEditor5Input < ::SimpleForm::Inputs::Base
6
+ def input(wrapper_options = nil)
7
+ merged_input_options = merge_wrapper_options(input_html_options, wrapper_options)
8
+ @builder.template.ckeditor5_editor(**editor_options(merged_input_options))
9
+ end
10
+
11
+ private
12
+
13
+ def editor_options(merged_input_options)
14
+ {
15
+ preset: input_options.fetch(:preset, :default),
16
+ type: input_options.fetch(:type, :classic),
17
+ config: input_options[:config],
18
+ initial_data: object.try(attribute_name) || input_options[:initial_data],
19
+ name: "#{object_name}[#{attribute_name}]",
20
+ **merged_input_options
21
+ }
22
+ end
23
+ end
24
+ end
25
+ end