ckeditor5 1.9.0 → 1.11.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.
@@ -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);