ckeditor5 1.8.0 → 1.10.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.
@@ -1,26 +1,3 @@
1
- /**
2
- * Custom web component for integrating CKEditor 5 into web applications.
3
- *
4
- * @class
5
- * @extends HTMLElement
6
- *
7
- * @property {import('ckeditor5').Editor|null} instance - The current CKEditor instance
8
- * @property {Record<string, HTMLElement>} editables - Object containing editable elements
9
- *
10
- * @fires editor-ready - Fired when editor is initialized with the editor instance as detail
11
- * @fires editor-error - Fired when initialization fails with the error as detail
12
- *
13
- * @example
14
- * // Basic usage with Classic Editor
15
- * <ckeditor-component type="ClassicEditor" config='{"toolbar": ["bold", "italic"]}'>
16
- * </ckeditor-component>
17
- *
18
- * // Multiroot editor with multiple editables
19
- * <ckeditor-component type="MultirootEditor">
20
- * <ckeditor-editable-component name="title">Title content</ckeditor-editable-component>
21
- * <ckeditor-editable-component name="content">Main content</ckeditor-editable-component>
22
- * </ckeditor-component>
23
- */
24
1
  class CKEditorComponent extends HTMLElement {
25
2
  /**
26
3
  * List of attributes that trigger updates when changed.
@@ -57,19 +34,100 @@ class CKEditorComponent extends HTMLElement {
57
34
  /** @type {String} Initial HTML passed to component */
58
35
  #initialHTML = '';
59
36
 
37
+ /** @type {CKEditorContextComponent|null} */
38
+ #context = null;
39
+
40
+ /** @type {String} ID of editor within context */
41
+ #contextEditorId = null;
42
+
43
+ /** @type {(event: CustomEvent) => void} Event handler for editor change */
44
+ get oneditorchange() {
45
+ return this.#getEventHandler('editorchange');
46
+ }
47
+
48
+ set oneditorchange(handler) {
49
+ this.#setEventHandler('editorchange', handler);
50
+ }
51
+
52
+ /** @type {(event: CustomEvent) => void} Event handler for editor ready */
53
+ get oneditorready() {
54
+ return this.#getEventHandler('editorready');
55
+ }
56
+
57
+ set oneditorready(handler) {
58
+ this.#setEventHandler('editorready', handler);
59
+ }
60
+
61
+ /** @type {(event: CustomEvent) => void} Event handler for editor error */
62
+ get oneditorerror() {
63
+ return this.#getEventHandler('editorerror');
64
+ }
65
+
66
+ set oneditorerror(handler) {
67
+ this.#setEventHandler('editorerror', handler);
68
+ }
69
+
70
+ /**
71
+ * Gets event handler function from attribute or property
72
+ *
73
+ * @private
74
+ * @param {string} name - Event name without 'on' prefix
75
+ * @returns {Function|null} Event handler or null
76
+ */
77
+ #getEventHandler(name) {
78
+ if (this.hasAttribute(`on${name}`)) {
79
+ const handler = this.getAttribute(`on${name}`);
80
+
81
+ if (!isSafeKey(handler)) {
82
+ throw new Error(`Unsafe event handler attribute value: ${handler}`);
83
+ }
84
+
85
+ return window[handler] || new Function('event', handler);
86
+ }
87
+ return this[`#${name}Handler`];
88
+ }
89
+
90
+ /**
91
+ * Sets event handler function
92
+ *
93
+ * @private
94
+ * @param {string} name - Event name without 'on' prefix
95
+ * @param {Function|string|null} handler - Event handler
96
+ */
97
+ #setEventHandler(name, handler) {
98
+ if (typeof handler === 'string') {
99
+ this.setAttribute(`on${name}`, handler);
100
+ } else {
101
+ this.removeAttribute(`on${name}`);
102
+ this[`#${name}Handler`] = handler;
103
+ }
104
+ }
105
+
60
106
  /**
61
107
  * Lifecycle callback when element is connected to DOM
62
108
  * Initializes the editor when DOM is ready
63
109
  * @protected
64
110
  */
65
111
  connectedCallback() {
112
+ this.#context = this.closest('ckeditor-context-component');
66
113
  this.#initialHTML = this.innerHTML;
67
114
 
68
115
  try {
69
- execIfDOMReady(() => this.#reinitializeEditor());
116
+ execIfDOMReady(async () => {
117
+ if (this.#context) {
118
+ await this.#context.instancePromise.promise;
119
+ this.#context.registerEditor(this);
120
+ }
121
+
122
+ await this.reinitializeEditor();
123
+ });
70
124
  } catch (error) {
71
125
  console.error('Failed to initialize editor:', error);
72
- this.dispatchEvent(new CustomEvent('editor-error', { detail: error }));
126
+
127
+ const event = new CustomEvent('editor-error', { detail: error });
128
+
129
+ this.dispatchEvent(event);
130
+ this.oneditorerror?.(event);
73
131
  }
74
132
  }
75
133
 
@@ -84,7 +142,7 @@ class CKEditorComponent extends HTMLElement {
84
142
  if (oldValue !== null &&
85
143
  oldValue !== newValue &&
86
144
  CKEditorComponent.observedAttributes.includes(name) && this.isConnected) {
87
- await this.#reinitializeEditor();
145
+ await this.reinitializeEditor();
88
146
  }
89
147
  }
90
148
 
@@ -94,9 +152,12 @@ class CKEditorComponent extends HTMLElement {
94
152
  * @protected
95
153
  */
96
154
  async disconnectedCallback() {
155
+ if (this.#context) {
156
+ this.#context.unregisterEditor(this);
157
+ }
158
+
97
159
  try {
98
- await this.instance?.destroy();
99
- await this.watchdog?.destroy();
160
+ await this.#destroy();
100
161
  } catch (error) {
101
162
  console.error('Failed to destroy editor:', error);
102
163
  }
@@ -119,6 +180,7 @@ class CKEditorComponent extends HTMLElement {
119
180
 
120
181
  /**
121
182
  * Determines appropriate editor element tag based on editor type
183
+ *
122
184
  * @private
123
185
  * @returns {string} HTML tag name to use
124
186
  */
@@ -133,57 +195,25 @@ class CKEditorComponent extends HTMLElement {
133
195
  }
134
196
 
135
197
  /**
136
- * Resolves element references in configuration object.
137
- * Looks for objects with { $element: "selector" } format and replaces them with actual elements.
198
+ * Gets the CKEditor context instance if available.
138
199
  *
139
200
  * @private
140
- * @param {Object} obj - Configuration object to process
141
- * @returns {Object} Processed configuration object with resolved element references
201
+ * @returns {import('ckeditor5').ContextWatchdog|null}
142
202
  */
143
- #resolveElementReferences(obj) {
144
- if (!obj || typeof obj !== 'object') {
145
- return obj;
146
- }
147
-
148
- if (Array.isArray(obj)) {
149
- return obj.map(item => this.#resolveElementReferences(item));
150
- }
151
-
152
- const result = Object.create(null);
153
-
154
- for (const key of Object.getOwnPropertyNames(obj)) {
155
- if (!isSafeKey(key)) {
156
- console.warn(`Suspicious key "${key}" detected in config, skipping`);
157
- continue;
158
- }
159
-
160
- const value = obj[key];
161
-
162
- if (value && typeof value === 'object') {
163
- if (value.$element) {
164
- const selector = value.$element;
165
-
166
- if (typeof selector !== 'string') {
167
- console.warn(`Invalid selector type for "${key}", expected string`);
168
- continue;
169
- }
170
-
171
- const element = document.querySelector(selector);
172
-
173
- if (!element) {
174
- console.warn(`Element not found for selector: ${selector}`);
175
- }
203
+ get #contextWatchdog() {
204
+ return this.#context?.instance;
205
+ }
176
206
 
177
- result[key] = element || null;
178
- } else {
179
- result[key] = this.#resolveElementReferences(value);
180
- }
181
- } else {
182
- result[key] = value;
183
- }
207
+ /**
208
+ * Destroys the editor instance and watchdog if available
209
+ */
210
+ async #destroy() {
211
+ if (this.#contextEditorId) {
212
+ await this.#contextWatchdog.remove(this.#contextEditorId);
184
213
  }
185
214
 
186
- return result;
215
+ await this.instance?.destroy();
216
+ await this.watchdog?.destroy();
187
217
  }
188
218
 
189
219
  /**
@@ -195,7 +225,7 @@ class CKEditorComponent extends HTMLElement {
195
225
  #getConfig() {
196
226
  const config = JSON.parse(this.getAttribute('config') || '{}');
197
227
 
198
- return this.#resolveElementReferences(config);
228
+ return resolveElementReferences(config);
199
229
  }
200
230
 
201
231
  /**
@@ -232,13 +262,27 @@ class CKEditorComponent extends HTMLElement {
232
262
  plugins,
233
263
  };
234
264
 
235
- console.warn('Initializing CKEditor with:', { config, watchdog: this.hasWatchdog() });
265
+ console.warn('Initializing CKEditor with:', { config, watchdog: this.hasWatchdog(), context: this.#context });
236
266
 
237
267
  // Initialize watchdog if needed
238
268
  let watchdog = null;
239
269
  let instance = null;
240
-
241
- if (this.hasWatchdog()) {
270
+ let contextId = null;
271
+
272
+ if (this.#context) {
273
+ contextId = uid();
274
+
275
+ await this.#contextWatchdog.add( {
276
+ creator: (_element, _config) => Editor.create(_element, _config),
277
+ id: contextId,
278
+ sourceElementOrData: content,
279
+ type: 'editor',
280
+ config,
281
+ } );
282
+
283
+ instance = this.#contextWatchdog.getItem(contextId);
284
+ } else if (this.hasWatchdog()) {
285
+ // Let's create use with plain watchdog.
242
286
  const { EditorWatchdog } = await import('ckeditor5');
243
287
  const watchdog = new EditorWatchdog(Editor);
244
288
 
@@ -246,12 +290,12 @@ class CKEditorComponent extends HTMLElement {
246
290
 
247
291
  instance = watchdog.editor;
248
292
  } else {
293
+ // Let's create the editor without watchdog.
249
294
  instance = await Editor.create(content, config);
250
295
  }
251
296
 
252
- this.dispatchEvent(new CustomEvent('editor-ready', { detail: instance }));
253
-
254
297
  return {
298
+ contextId,
255
299
  instance,
256
300
  watchdog,
257
301
  };
@@ -263,11 +307,12 @@ class CKEditorComponent extends HTMLElement {
263
307
  * @private
264
308
  * @returns {Promise<void>}
265
309
  */
266
- async #reinitializeEditor() {
310
+ async reinitializeEditor() {
267
311
  if (this.instance) {
268
312
  this.instancePromise = Promise.withResolvers();
269
313
 
270
- await this.instance.destroy();
314
+ await this.#destroy();
315
+
271
316
  this.instance = null;
272
317
  }
273
318
 
@@ -288,21 +333,58 @@ class CKEditorComponent extends HTMLElement {
288
333
  }
289
334
 
290
335
  try {
291
- const { watchdog, instance } = await this.#initializeEditor(this.editables || this.#getConfig().initialData || '');
336
+ const { watchdog, instance, contextId } = await this.#initializeEditor(this.editables || this.#getConfig().initialData || '');
292
337
 
293
338
  this.watchdog = watchdog;
294
339
  this.instance = instance;
340
+ this.#contextEditorId = contextId;
295
341
 
296
342
  this.#setupContentSync();
297
343
  this.#setupEditableHeight();
344
+ this.#setupDataChangeListener();
298
345
 
299
346
  this.instancePromise.resolve(this.instance);
347
+
348
+ // Broadcast editor ready event
349
+ const event = new CustomEvent('editor-ready', { detail: this.instance });
350
+
351
+ this.dispatchEvent(event);
352
+ this.oneditorready?.(event);
300
353
  } catch (err) {
301
354
  this.instancePromise.reject(err);
302
355
  throw err;
303
356
  }
304
357
  }
305
358
 
359
+ /**
360
+ * Sets up data change listener that broadcasts content changes
361
+ *
362
+ * @private
363
+ */
364
+ #setupDataChangeListener() {
365
+ const getRootContent = rootName => this.instance.getData({ rootName });
366
+ const getAllRoots = () =>
367
+ this.instance.model.document
368
+ .getRootNames()
369
+ .reduce((acc, rootName) => ({
370
+ ...acc,
371
+ [rootName]: getRootContent(rootName)
372
+ }), {});
373
+
374
+ this.instance?.model.document.on('change:data', () => {
375
+ const event = new CustomEvent('editor-change', {
376
+ detail: {
377
+ editor: this.instance,
378
+ data: getAllRoots(),
379
+ },
380
+ bubbles: true
381
+ });
382
+
383
+ this.dispatchEvent(event);
384
+ this.oneditorchange?.(event);
385
+ });
386
+ }
387
+
306
388
  /**
307
389
  * Checks if current editor is classic type
308
390
  *
@@ -511,15 +593,6 @@ class CKEditorComponent extends HTMLElement {
511
593
  }
512
594
  }
513
595
 
514
- /**
515
- * Tracks and manages editable roots for CKEditor MultiRoot editor.
516
- * Provides a proxy-based API for dynamically managing editable elements with automatic
517
- * attachment/detachment of editor roots.
518
- *
519
- * @class
520
- * @property {CKEditorComponent} #editorElement - Reference to parent editor component
521
- * @property {Record<string, HTMLElement>} #editables - Map of tracked editable elements
522
- */
523
596
  class CKEditorMultiRootEditablesTracker {
524
597
  #editorElement;
525
598
  #editables;
@@ -638,262 +711,4 @@ class CKEditorMultiRootEditablesTracker {
638
711
  }
639
712
  }
640
713
 
641
- /**
642
- * Custom HTML element representing an editable region for CKEditor.
643
- * Must be used as a child of ckeditor-component element.
644
- *
645
- * @customElement ckeditor-editable-component
646
- * @extends HTMLElement
647
- *
648
- * @property {string} name - The name of the editable region, accessed via getAttribute
649
- * @property {HTMLDivElement} editableElement - The div element containing editable content
650
- *
651
- * @fires connectedCallback - When the element is added to the DOM
652
- * @fires attributeChangedCallback - When element attributes change
653
- * @fires disconnectedCallback - When the element is removed from the DOM
654
- *
655
- * @throws {Error} Throws error if not used as child of ckeditor-component
656
- *
657
- * @example
658
- * <ckeditor-component>
659
- * <ckeditor-editable-component name="main">
660
- * Content goes here
661
- * </ckeditor-editable-component>
662
- * </ckeditor-component>
663
- */
664
- class CKEditorEditableComponent extends HTMLElement {
665
- /**
666
- * List of attributes that trigger updates when changed
667
- *
668
- * @static
669
- * @returns {string[]} Array of attribute names to observe
670
- */
671
- static get observedAttributes() {
672
- return ['name'];
673
- }
674
-
675
- /**
676
- * Gets the name of this editable region
677
- *
678
- * @returns {string} The name attribute value
679
- */
680
- get name() {
681
- // The default value is set mainly for decoupled editors where the name is not required.
682
- return this.getAttribute('name') || 'editable';
683
- }
684
-
685
- /**
686
- * Gets the actual editable DOM element
687
- * @returns {HTMLDivElement|null} The div element containing editable content
688
- */
689
- get editableElement() {
690
- return this.querySelector('div');
691
- }
692
-
693
- /**
694
- * Lifecycle callback when element is added to DOM
695
- * Sets up the editable element and registers it with the parent editor
696
- *
697
- * @throws {Error} If not used as child of ckeditor-component
698
- */
699
- connectedCallback() {
700
- execIfDOMReady(() => {
701
- const editorComponent = this.#queryEditorElement();
702
-
703
- if (!editorComponent ) {
704
- throw new Error('ckeditor-editable-component must be a child of ckeditor-component');
705
- }
706
-
707
- this.innerHTML = `<div>${this.innerHTML}</div>`;
708
- this.style.display = 'block';
709
-
710
- if (editorComponent.isDecoupled()) {
711
- editorComponent.runAfterEditorReady(editor => {
712
- this.appendChild(editor.ui.view[this.name].element);
713
- });
714
- } else {
715
- if (!this.name) {
716
- throw new Error('Editable component missing required "name" attribute');
717
- }
718
-
719
- editorComponent.editables[this.name] = this;
720
- }
721
- });
722
- }
723
-
724
- /**
725
- * Lifecycle callback for attribute changes
726
- * Handles name changes and propagates other attributes to editable element
727
- *
728
- * @param {string} name - Name of changed attribute
729
- * @param {string|null} oldValue - Previous value
730
- * @param {string|null} newValue - New value
731
- */
732
- attributeChangedCallback(name, oldValue, newValue) {
733
- if (oldValue === newValue) {
734
- return;
735
- }
736
-
737
- if (name === 'name') {
738
- if (!oldValue) {
739
- return;
740
- }
741
-
742
- const editorComponent = this.#queryEditorElement();
743
-
744
- if (editorComponent) {
745
- editorComponent.editables[newValue] = editorComponent.editables[oldValue];
746
- delete editorComponent.editables[oldValue];
747
- }
748
- } else {
749
- this.editableElement.setAttribute(name, newValue);
750
- }
751
- }
752
-
753
- /**
754
- * Lifecycle callback when element is removed
755
- * Un-registers this editable from the parent editor
756
- */
757
- disconnectedCallback() {
758
- const editorComponent = this.#queryEditorElement();
759
-
760
- if (editorComponent) {
761
- delete editorComponent.editables[this.name];
762
- }
763
- }
764
-
765
- /**
766
- * Finds the parent editor component
767
- *
768
- * @private
769
- * @returns {CKEditorComponent|null} Parent editor component or null if not found
770
- */
771
- #queryEditorElement() {
772
- return this.closest('ckeditor-component') || document.body.querySelector('ckeditor-component');
773
- }
774
- }
775
-
776
- /**
777
- * Custom HTML element that represents a CKEditor UI part component.
778
- * It helpers with management of toolbar and other elements.
779
- *
780
- * @extends HTMLElement
781
- * @customElement ckeditor-ui-part-component
782
- * @example
783
- * <ckeditor-ui-part-component></ckeditor-ui-part-component>
784
- */
785
- class CKEditorUIPartComponent extends HTMLElement {
786
- /**
787
- * Lifecycle callback when element is added to DOM
788
- * Adds the toolbar to the editor UI
789
- */
790
- connectedCallback() {
791
- execIfDOMReady(async () => {
792
- const uiPart = this.getAttribute('name');
793
- const editor = await this.#queryEditorElement().instancePromise.promise;
794
-
795
- this.appendChild(editor.ui.view[uiPart].element);
796
- });
797
- }
798
-
799
- /**
800
- * Finds the parent editor component
801
- *
802
- * @private
803
- * @returns {CKEditorComponent|null} Parent editor component or null if not found
804
- */
805
- #queryEditorElement() {
806
- return this.closest('ckeditor-component') || document.body.querySelector('ckeditor-component');
807
- }
808
- }
809
-
810
- /**
811
- * Executes callback when DOM is ready
812
- *
813
- * @param {() => void} callback - Function to execute
814
- */
815
- function execIfDOMReady(callback) {
816
- switch (document.readyState) {
817
- case 'loading':
818
- document.addEventListener('DOMContentLoaded', callback, { once: true });
819
- break;
820
-
821
- case 'interactive':
822
- case 'complete':
823
- setTimeout(callback, 0);
824
- break;
825
-
826
- default:
827
- console.warn('Unexpected document.readyState:', document.readyState);
828
- setTimeout(callback, 0);
829
- }
830
- }
831
-
832
- /**
833
- * Dynamically imports modules based on configuration
834
- *
835
- * @param {Array<ImportConfig>} imports - Array of import configurations
836
- * @returns {Promise<Array<any>>} Loaded modules
837
- */
838
- function loadAsyncImports(imports = []) {
839
- const loadInlinePlugin = async ({ name, code }) => {
840
- const module = await import(`data:text/javascript,${encodeURIComponent(code)}`);
841
-
842
- if (!module.default) {
843
- throw new Error(`Inline plugin "${name}" must export a default class/function!`);
844
- }
845
-
846
- return module.default;
847
- };
848
-
849
- const loadExternalPlugin = async ({ import_name, import_as, window_name }) => {
850
- if (window_name) {
851
- if (!Object.prototype.hasOwnProperty.call(window, window_name)) {
852
- throw new Error(
853
- `Plugin window['${window_name}'] not found in global scope. ` +
854
- 'Please ensure the plugin is loaded before CKEditor initialization.'
855
- );
856
- }
857
-
858
- return window[window_name];
859
- }
860
-
861
- const module = await import(import_name);
862
- const imported = module[import_as || 'default'];
863
-
864
- if (!imported) {
865
- throw new Error(`Plugin "${import_as}" not found in the ESM module "${import_name}"!`);
866
- }
867
-
868
- return imported;
869
- };
870
-
871
- return Promise.all(imports.map(item => {
872
- switch(item.type) {
873
- case 'inline':
874
- return loadInlinePlugin(item);
875
-
876
- case 'external':
877
- default:
878
- return loadExternalPlugin(item);
879
- }
880
- }));
881
- }
882
-
883
-
884
- /**
885
- * Checks if a key is safe to use in configuration objects to prevent prototype pollution.
886
- *
887
- * @param {string} key - Key name to check
888
- * @returns {boolean} True if key is safe to use.
889
- */
890
- function isSafeKey(key) {
891
- return typeof key === 'string' &&
892
- key !== '__proto__' &&
893
- key !== 'constructor' &&
894
- key !== 'prototype';
895
- }
896
-
897
714
  customElements.define('ckeditor-component', CKEditorComponent);
898
- customElements.define('ckeditor-editable-component', CKEditorEditableComponent);
899
- customElements.define('ckeditor-ui-part-component', CKEditorUIPartComponent);
@@ -0,0 +1,26 @@
1
+ class CKEditorUIPartComponent extends HTMLElement {
2
+ /**
3
+ * Lifecycle callback when element is added to DOM
4
+ * Adds the toolbar to the editor UI
5
+ */
6
+ connectedCallback() {
7
+ execIfDOMReady(async () => {
8
+ const uiPart = this.getAttribute('name');
9
+ const editor = await this.#queryEditorElement().instancePromise.promise;
10
+
11
+ this.appendChild(editor.ui.view[uiPart].element);
12
+ });
13
+ }
14
+
15
+ /**
16
+ * Finds the parent editor component
17
+ *
18
+ * @private
19
+ * @returns {CKEditorComponent|null} Parent editor component or null if not found
20
+ */
21
+ #queryEditorElement() {
22
+ return this.closest('ckeditor-component') || document.body.querySelector('ckeditor-component');
23
+ }
24
+ }
25
+
26
+ customElements.define('ckeditor-ui-part-component', CKEditorUIPartComponent);