ckeditor5 1.0.5 → 1.1.0

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