ckeditor5 1.9.0 → 1.11.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.
@@ -63,6 +40,69 @@ class CKEditorComponent extends HTMLElement {
63
40
  /** @type {String} ID of editor within context */
64
41
  #contextEditorId = null;
65
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
+
66
106
  /**
67
107
  * Lifecycle callback when element is connected to DOM
68
108
  * Initializes the editor when DOM is ready
@@ -83,7 +123,11 @@ class CKEditorComponent extends HTMLElement {
83
123
  });
84
124
  } catch (error) {
85
125
  console.error('Failed to initialize editor:', error);
86
- 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);
87
131
  }
88
132
  }
89
133
 
@@ -250,8 +294,6 @@ class CKEditorComponent extends HTMLElement {
250
294
  instance = await Editor.create(content, config);
251
295
  }
252
296
 
253
- this.dispatchEvent(new CustomEvent('editor-ready', { detail: instance }));
254
-
255
297
  return {
256
298
  contextId,
257
299
  instance,
@@ -299,14 +341,50 @@ class CKEditorComponent extends HTMLElement {
299
341
 
300
342
  this.#setupContentSync();
301
343
  this.#setupEditableHeight();
344
+ this.#setupDataChangeListener();
302
345
 
303
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);
304
353
  } catch (err) {
305
354
  this.instancePromise.reject(err);
306
355
  throw err;
307
356
  }
308
357
  }
309
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
+
310
388
  /**
311
389
  * Checks if current editor is classic type
312
390
  *
@@ -515,138 +593,6 @@ class CKEditorComponent extends HTMLElement {
515
593
  }
516
594
  }
517
595
 
518
- /**
519
- * Custom element that provides shared CKEditor context for multiple editors.
520
- *
521
- * @extends HTMLElement
522
- * @example
523
- *
524
- * <ckeditor-context-component plugins='[ ... ]'>
525
- * <ckeditor-component type="ClassicEditor" config='{"toolbar": ["bold", "italic"]}'>
526
- * <ckeditor-component type="ClassicEditor" config='{"toolbar": ["bold", "italic"]}'>
527
- * </ckeditor-component>
528
- */
529
- class CKEditorContextComponent extends HTMLElement {
530
- static get observedAttributes() {
531
- return ['plugins', 'config'];
532
- }
533
-
534
- /** @type {import('ckeditor5').Context|null} */
535
- instance = null;
536
-
537
- /** @type {Promise<import('ckeditor5').Context>} */
538
- instancePromise = Promise.withResolvers();
539
-
540
- /** @type {Set<CKEditorComponent>} */
541
- #connectedEditors = new Set();
542
-
543
- async connectedCallback() {
544
- try {
545
- execIfDOMReady(() => this.#initializeContext());
546
- } catch (error) {
547
- console.error('Failed to initialize context:', error);
548
- this.dispatchEvent(new CustomEvent('context-error', { detail: error }));
549
- }
550
- }
551
-
552
- async attributeChangedCallback(name, oldValue, newValue) {
553
- if (oldValue !== null && oldValue !== newValue) {
554
- await this.#initializeContext();
555
- }
556
- }
557
-
558
- async disconnectedCallback() {
559
- if (this.instance) {
560
- await this.instance.destroy();
561
- this.instance = null;
562
- }
563
- }
564
-
565
- /**
566
- * Register editor component with this context
567
- *
568
- * @param {CKEditorComponent} editor
569
- */
570
- registerEditor(editor) {
571
- this.#connectedEditors.add(editor);
572
- }
573
-
574
- /**
575
- * Unregister editor component from this context
576
- *
577
- * @param {CKEditorComponent} editor
578
- */
579
- unregisterEditor(editor) {
580
- this.#connectedEditors.delete(editor);
581
- }
582
-
583
- /**
584
- * Initialize CKEditor context with shared configuration
585
- *
586
- * @private
587
- */
588
- async #initializeContext() {
589
- if (this.instance) {
590
- this.instancePromise = Promise.withResolvers();
591
-
592
- await this.instance.destroy();
593
-
594
- this.instance = null;
595
- }
596
-
597
- const { Context, ContextWatchdog } = await import('ckeditor5');
598
- const plugins = await this.#getPlugins();
599
- const config = this.#getConfig();
600
-
601
- this.instance = new ContextWatchdog(Context, {
602
- crashNumberLimit: 10
603
- });
604
-
605
- await this.instance.create({
606
- ...config,
607
- plugins
608
- });
609
-
610
- this.instance.on('itemError', (...args) => {
611
- console.error('Context item error:', ...args);
612
- });
613
-
614
- this.instancePromise.resolve(this.instance);
615
- this.dispatchEvent(new CustomEvent('context-ready', { detail: this.instance }));
616
-
617
- // Reinitialize connected editors.
618
- await Promise.all(
619
- [...this.#connectedEditors].map(editor => editor.reinitializeEditor())
620
- );
621
- }
622
-
623
- async #getPlugins() {
624
- const raw = this.getAttribute('plugins');
625
-
626
- return loadAsyncImports(raw ? JSON.parse(raw) : []);
627
- }
628
-
629
- /**
630
- * Gets context configuration with resolved element references.
631
- *
632
- * @private
633
- */
634
- #getConfig() {
635
- const config = JSON.parse(this.getAttribute('config') || '{}');
636
-
637
- return resolveElementReferences(config);
638
- }
639
- }
640
-
641
- /**
642
- * Tracks and manages editable roots for CKEditor MultiRoot editor.
643
- * Provides a proxy-based API for dynamically managing editable elements with automatic
644
- * attachment/detachment of editor roots.
645
- *
646
- * @class
647
- * @property {CKEditorComponent} #editorElement - Reference to parent editor component
648
- * @property {Record<string, HTMLElement>} #editables - Map of tracked editable elements
649
- */
650
596
  class CKEditorMultiRootEditablesTracker {
651
597
  #editorElement;
652
598
  #editables;
@@ -765,325 +711,4 @@ class CKEditorMultiRootEditablesTracker {
765
711
  }
766
712
  }
767
713
 
768
- /**
769
- * Custom HTML element representing an editable region for CKEditor.
770
- * Must be used as a child of ckeditor-component element.
771
- *
772
- * @customElement ckeditor-editable-component
773
- * @extends HTMLElement
774
- *
775
- * @property {string} name - The name of the editable region, accessed via getAttribute
776
- * @property {HTMLDivElement} editableElement - The div element containing editable content
777
- *
778
- * @fires connectedCallback - When the element is added to the DOM
779
- * @fires attributeChangedCallback - When element attributes change
780
- * @fires disconnectedCallback - When the element is removed from the DOM
781
- *
782
- * @throws {Error} Throws error if not used as child of ckeditor-component
783
- *
784
- * @example
785
- * <ckeditor-component>
786
- * <ckeditor-editable-component name="main">
787
- * Content goes here
788
- * </ckeditor-editable-component>
789
- * </ckeditor-component>
790
- */
791
- class CKEditorEditableComponent extends HTMLElement {
792
- /**
793
- * List of attributes that trigger updates when changed
794
- *
795
- * @static
796
- * @returns {string[]} Array of attribute names to observe
797
- */
798
- static get observedAttributes() {
799
- return ['name'];
800
- }
801
-
802
- /**
803
- * Gets the name of this editable region
804
- *
805
- * @returns {string} The name attribute value
806
- */
807
- get name() {
808
- // The default value is set mainly for decoupled editors where the name is not required.
809
- return this.getAttribute('name') || 'editable';
810
- }
811
-
812
- /**
813
- * Gets the actual editable DOM element
814
- * @returns {HTMLDivElement|null} The div element containing editable content
815
- */
816
- get editableElement() {
817
- return this.querySelector('div');
818
- }
819
-
820
- /**
821
- * Lifecycle callback when element is added to DOM
822
- * Sets up the editable element and registers it with the parent editor
823
- *
824
- * @throws {Error} If not used as child of ckeditor-component
825
- */
826
- connectedCallback() {
827
- execIfDOMReady(() => {
828
- const editorComponent = this.#queryEditorElement();
829
-
830
- if (!editorComponent ) {
831
- throw new Error('ckeditor-editable-component must be a child of ckeditor-component');
832
- }
833
-
834
- this.innerHTML = `<div>${this.innerHTML}</div>`;
835
- this.style.display = 'block';
836
-
837
- if (editorComponent.isDecoupled()) {
838
- editorComponent.runAfterEditorReady(editor => {
839
- this.appendChild(editor.ui.view[this.name].element);
840
- });
841
- } else {
842
- if (!this.name) {
843
- throw new Error('Editable component missing required "name" attribute');
844
- }
845
-
846
- editorComponent.editables[this.name] = this;
847
- }
848
- });
849
- }
850
-
851
- /**
852
- * Lifecycle callback for attribute changes
853
- * Handles name changes and propagates other attributes to editable element
854
- *
855
- * @param {string} name - Name of changed attribute
856
- * @param {string|null} oldValue - Previous value
857
- * @param {string|null} newValue - New value
858
- */
859
- attributeChangedCallback(name, oldValue, newValue) {
860
- if (oldValue === newValue) {
861
- return;
862
- }
863
-
864
- if (name === 'name') {
865
- if (!oldValue) {
866
- return;
867
- }
868
-
869
- const editorComponent = this.#queryEditorElement();
870
-
871
- if (editorComponent) {
872
- editorComponent.editables[newValue] = editorComponent.editables[oldValue];
873
- delete editorComponent.editables[oldValue];
874
- }
875
- } else {
876
- this.editableElement.setAttribute(name, newValue);
877
- }
878
- }
879
-
880
- /**
881
- * Lifecycle callback when element is removed
882
- * Un-registers this editable from the parent editor
883
- */
884
- disconnectedCallback() {
885
- const editorComponent = this.#queryEditorElement();
886
-
887
- if (editorComponent) {
888
- delete editorComponent.editables[this.name];
889
- }
890
- }
891
-
892
- /**
893
- * Finds the parent editor component
894
- *
895
- * @private
896
- * @returns {CKEditorComponent|null} Parent editor component or null if not found
897
- */
898
- #queryEditorElement() {
899
- return this.closest('ckeditor-component') || document.body.querySelector('ckeditor-component');
900
- }
901
- }
902
-
903
- /**
904
- * Custom HTML element that represents a CKEditor UI part component.
905
- * It helpers with management of toolbar and other elements.
906
- *
907
- * @extends HTMLElement
908
- * @customElement ckeditor-ui-part-component
909
- * @example
910
- * <ckeditor-ui-part-component></ckeditor-ui-part-component>
911
- */
912
- class CKEditorUIPartComponent extends HTMLElement {
913
- /**
914
- * Lifecycle callback when element is added to DOM
915
- * Adds the toolbar to the editor UI
916
- */
917
- connectedCallback() {
918
- execIfDOMReady(async () => {
919
- const uiPart = this.getAttribute('name');
920
- const editor = await this.#queryEditorElement().instancePromise.promise;
921
-
922
- this.appendChild(editor.ui.view[uiPart].element);
923
- });
924
- }
925
-
926
- /**
927
- * Finds the parent editor component
928
- *
929
- * @private
930
- * @returns {CKEditorComponent|null} Parent editor component or null if not found
931
- */
932
- #queryEditorElement() {
933
- return this.closest('ckeditor-component') || document.body.querySelector('ckeditor-component');
934
- }
935
- }
936
-
937
- /**
938
- * Executes callback when DOM is ready
939
- *
940
- * @param {() => void} callback - Function to execute
941
- */
942
- function execIfDOMReady(callback) {
943
- switch (document.readyState) {
944
- case 'loading':
945
- document.addEventListener('DOMContentLoaded', callback, { once: true });
946
- break;
947
-
948
- case 'interactive':
949
- case 'complete':
950
- setTimeout(callback, 0);
951
- break;
952
-
953
- default:
954
- console.warn('Unexpected document.readyState:', document.readyState);
955
- setTimeout(callback, 0);
956
- }
957
- }
958
-
959
- /**
960
- * Dynamically imports modules based on configuration
961
- *
962
- * @param {Array<ImportConfig>} imports - Array of import configurations
963
- * @returns {Promise<Array<any>>} Loaded modules
964
- */
965
- function loadAsyncImports(imports = []) {
966
- const loadInlinePlugin = async ({ name, code }) => {
967
- const module = await import(`data:text/javascript,${encodeURIComponent(code)}`);
968
-
969
- if (!module.default) {
970
- throw new Error(`Inline plugin "${name}" must export a default class/function!`);
971
- }
972
-
973
- return module.default;
974
- };
975
-
976
- const loadExternalPlugin = async ({ import_name, import_as, window_name }) => {
977
- if (window_name) {
978
- if (!Object.prototype.hasOwnProperty.call(window, window_name)) {
979
- throw new Error(
980
- `Plugin window['${window_name}'] not found in global scope. ` +
981
- 'Please ensure the plugin is loaded before CKEditor initialization.'
982
- );
983
- }
984
-
985
- return window[window_name];
986
- }
987
-
988
- const module = await import(import_name);
989
- const imported = module[import_as || 'default'];
990
-
991
- if (!imported) {
992
- throw new Error(`Plugin "${import_as}" not found in the ESM module "${import_name}"!`);
993
- }
994
-
995
- return imported;
996
- };
997
-
998
- return Promise.all(imports.map(item => {
999
- switch(item.type) {
1000
- case 'inline':
1001
- return loadInlinePlugin(item);
1002
-
1003
- case 'external':
1004
- default:
1005
- return loadExternalPlugin(item);
1006
- }
1007
- }));
1008
- }
1009
-
1010
-
1011
- /**
1012
- * Checks if a key is safe to use in configuration objects to prevent prototype pollution.
1013
- *
1014
- * @param {string} key - Key name to check
1015
- * @returns {boolean} True if key is safe to use.
1016
- */
1017
- function isSafeKey(key) {
1018
- return typeof key === 'string' &&
1019
- key !== '__proto__' &&
1020
- key !== 'constructor' &&
1021
- key !== 'prototype';
1022
- }
1023
-
1024
- /**
1025
- * Resolves element references in configuration object.
1026
- * Looks for objects with { $element: "selector" } format and replaces them with actual elements.
1027
- *
1028
- * @param {Object} obj - Configuration object to process
1029
- * @returns {Object} Processed configuration object with resolved element references
1030
- */
1031
- function resolveElementReferences(obj) {
1032
- if (!obj || typeof obj !== 'object') {
1033
- return obj;
1034
- }
1035
-
1036
- if (Array.isArray(obj)) {
1037
- return obj.map(item => resolveElementReferences(item));
1038
- }
1039
-
1040
- const result = Object.create(null);
1041
-
1042
- for (const key of Object.getOwnPropertyNames(obj)) {
1043
- if (!isSafeKey(key)) {
1044
- console.warn(`Suspicious key "${key}" detected in config, skipping`);
1045
- continue;
1046
- }
1047
-
1048
- const value = obj[key];
1049
-
1050
- if (value && typeof value === 'object') {
1051
- if (value.$element) {
1052
- const selector = value.$element;
1053
-
1054
- if (typeof selector !== 'string') {
1055
- console.warn(`Invalid selector type for "${key}", expected string`);
1056
- continue;
1057
- }
1058
-
1059
- const element = document.querySelector(selector);
1060
-
1061
- if (!element) {
1062
- console.warn(`Element not found for selector: ${selector}`);
1063
- }
1064
-
1065
- result[key] = element || null;
1066
- } else {
1067
- result[key] = resolveElementReferences(value);
1068
- }
1069
- } else {
1070
- result[key] = value;
1071
- }
1072
- }
1073
-
1074
- return result;
1075
- }
1076
-
1077
- /**
1078
- * Custom element that provides shared CKEditor context for multiple editors.
1079
- *
1080
- * @returns {String} unique id
1081
- */
1082
- function uid() {
1083
- return Math.random().toString(36).substring(2);
1084
- }
1085
-
1086
714
  customElements.define('ckeditor-component', CKEditorComponent);
1087
- customElements.define('ckeditor-editable-component', CKEditorEditableComponent);
1088
- customElements.define('ckeditor-ui-part-component', CKEditorUIPartComponent);
1089
- customElements.define('ckeditor-context-component', CKEditorContextComponent);
@@ -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);