ckeditor5 1.8.0 → 1.10.0

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