ckeditor5 0.0.1 → 1.0.1

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.
@@ -0,0 +1,603 @@
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
+ class CKEditorComponent extends HTMLElement {
25
+ /**
26
+ * List of attributes that trigger updates when changed
27
+ * @static
28
+ * @returns {string[]} Array of attribute names to observe
29
+ */
30
+ static get observedAttributes() {
31
+ return ['config', 'plugins', 'translations', 'type'];
32
+ }
33
+
34
+ /** @type {Promise<import('ckeditor5').Editor>|null} Promise to initialize editor instance */
35
+ instancePromise = Promise.withResolvers();
36
+
37
+ /** @type {import('ckeditor5').Editor|null} Current editor instance */
38
+ instance = null;
39
+
40
+ /** @type {Record<string, HTMLElement>} Map of editable elements by name */
41
+ editables = {};
42
+
43
+ /**
44
+ * Lifecycle callback when element is connected to DOM
45
+ * Initializes the editor when DOM is ready
46
+ * @protected
47
+ */
48
+ connectedCallback() {
49
+ try {
50
+ execIfDOMReady(() => this.#reinitializeEditor());
51
+ } catch (error) {
52
+ console.error('Failed to initialize editor:', error);
53
+ this.dispatchEvent(new CustomEvent('editor-error', { detail: error }));
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Handles attribute changes and reinitializes editor if needed
59
+ * @protected
60
+ * @param {string} name - Name of changed attribute
61
+ * @param {string|null} oldValue - Previous attribute value
62
+ * @param {string|null} newValue - New attribute value
63
+ */
64
+ async attributeChangedCallback(name, oldValue, newValue) {
65
+ if (oldValue !== null &&
66
+ oldValue !== newValue &&
67
+ CKEditorComponent.observedAttributes.includes(name) && this.isConnected) {
68
+ await this.#reinitializeEditor();
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Lifecycle callback when element is removed from DOM
74
+ * Destroys the editor instance
75
+ * @protected
76
+ */
77
+ async disconnectedCallback() {
78
+ try {
79
+ await this.instance?.destroy();
80
+ } catch (error) {
81
+ console.error('Failed to destroy editor:', error);
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Runs a callback after the editor is ready. It waits for editor
87
+ * initialization if needed.
88
+ *
89
+ * @param {(editor: import('ckeditor5').Editor) => void} callback - Callback to run
90
+ * @returns {Promise<void>}
91
+ */
92
+ runAfterEditorReady(callback) {
93
+ if (this.instance) {
94
+ return Promise.resolve(callback(this.instance));
95
+ }
96
+
97
+ return this.instancePromise.then(callback);
98
+ }
99
+
100
+ /**
101
+ * Determines appropriate editor element tag based on editor type
102
+ * @private
103
+ * @returns {string} HTML tag name to use
104
+ */
105
+ get #editorElementTag() {
106
+ switch (this.getAttribute('type')) {
107
+ case 'ClassicEditor':
108
+ return 'textarea';
109
+
110
+ default:
111
+ return 'div';
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Initializes a new CKEditor instance
117
+ * @private
118
+ * @param {Record<string, HTMLElement>|CKEditorMultiRootEditablesTracker} editables - Editable elements
119
+ * @returns {Promise<import('ckeditor5').Editor>} Initialized editor instance
120
+ * @throws {Error} When initialization fails
121
+ */
122
+ async #initializeEditor(editables) {
123
+ const Editor = await this.#getEditorConstructor();
124
+ const [plugins, translations] = await Promise.all([
125
+ this.#getPlugins(),
126
+ this.#getTranslations()
127
+ ]);
128
+
129
+ const instance = await Editor.create(
130
+ editables instanceof CKEditorMultiRootEditablesTracker
131
+ ? editables.getAll()
132
+ : editables.main,
133
+ {
134
+ ...this.#getConfig(),
135
+ plugins,
136
+ translations
137
+ }
138
+ );
139
+
140
+ this.dispatchEvent(new CustomEvent('editor-ready', { detail: instance }));
141
+
142
+ return instance;
143
+ }
144
+
145
+ /**
146
+ * Re-initializes the editor by destroying existing instance and creating new one
147
+ *
148
+ * @private
149
+ * @returns {Promise<void>}
150
+ */
151
+ async #reinitializeEditor() {
152
+ if (this.instance) {
153
+ this.instancePromise = Promise.withResolvers();
154
+
155
+ await this.instance.destroy();
156
+ this.instance = null;
157
+ }
158
+
159
+ this.style.display = 'block';
160
+
161
+ if (!this.#isMultiroot()) {
162
+ this.innerHTML = `<${this.#editorElementTag}></${this.#editorElementTag}>`;
163
+ }
164
+
165
+ // Let's track changes in editables if it's a multiroot editor.
166
+ const editables = this.#queryEditables();
167
+
168
+ if(this.#isMultiroot()) {
169
+ this.editables = new CKEditorMultiRootEditablesTracker(this, editables);
170
+ } else {
171
+ this.editables = editables;
172
+ }
173
+
174
+ try {
175
+ this.instance = await this.#initializeEditor(this.editables);
176
+ this.instancePromise.resolve(this.instance);
177
+ } catch (err) {
178
+ this.instancePromise.reject(err);
179
+ throw err;
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Checks if current editor is multiroot type
185
+ *
186
+ * @private
187
+ * @returns {boolean}
188
+ */
189
+ #isMultiroot() {
190
+ return this.getAttribute('type') === 'MultiRootEditor';
191
+ }
192
+
193
+ /**
194
+ * Parses editor configuration from config attribute
195
+ *
196
+ * @private
197
+ * @returns {EditorConfig}
198
+ */
199
+ #getConfig() {
200
+ return JSON.parse(this.getAttribute('config') || '{}');
201
+ }
202
+
203
+ /**
204
+ * Queries and validates editable elements
205
+ *
206
+ * @private
207
+ * @returns {Record<string, HTMLElement>}
208
+ * @throws {Error} When required editables are missing
209
+ */
210
+ #queryEditables() {
211
+ if (this.#isMultiroot()) {
212
+ const editables = [...this.querySelectorAll('ckeditor-editable-component')];
213
+
214
+ return editables.reduce((acc, element) => {
215
+ if (!element.name) {
216
+ throw new Error('Editable component missing required "name" attribute');
217
+ }
218
+ acc[element.name] = element;
219
+ return acc;
220
+ }, Object.create(null));
221
+ }
222
+
223
+ const mainEditable = this.querySelector(this.#editorElementTag);
224
+
225
+ if (!mainEditable) {
226
+ throw new Error(`No ${this.#editorElementTag} element found`);
227
+ }
228
+
229
+ return { main: mainEditable };
230
+ }
231
+
232
+ /**
233
+ * Loads translation modules
234
+ *
235
+ * @private
236
+ * @returns {Promise<Array<any>>}
237
+ */
238
+ async #getTranslations() {
239
+ const raw = this.getAttribute('translations');
240
+ return loadAsyncImports(raw ? JSON.parse(raw) : []);
241
+ }
242
+
243
+ /**
244
+ * Loads plugin modules
245
+ *
246
+ * @private
247
+ * @returns {Promise<Array<any>>}
248
+ */
249
+ async #getPlugins() {
250
+ const raw = this.getAttribute('plugins');
251
+ const items = raw ? JSON.parse(raw) : [];
252
+ const mappedItems = items.map(item =>
253
+ typeof item === 'string'
254
+ ? { import_name: 'ckeditor5', import_as: item }
255
+ : item
256
+ );
257
+
258
+ return loadAsyncImports(mappedItems);
259
+ }
260
+
261
+ /**
262
+ * Gets editor constructor based on type attribute
263
+ *
264
+ * @private
265
+ * @returns {Promise<typeof import('ckeditor5').Editor>}
266
+ * @throws {Error} When editor type is invalid
267
+ */
268
+ async #getEditorConstructor() {
269
+ const CKEditor = await import('ckeditor5');
270
+ const editorType = this.getAttribute('type');
271
+
272
+ if (!editorType || !Object.prototype.hasOwnProperty.call(CKEditor, editorType)) {
273
+ throw new Error(`Invalid editor type: ${editorType}`);
274
+ }
275
+
276
+ return CKEditor[editorType];
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Tracks and manages editable roots for CKEditor MultiRoot editor.
282
+ * Provides a proxy-based API for dynamically managing editable elements with automatic
283
+ * attachment/detachment of editor roots.
284
+ *
285
+ * @class
286
+ * @property {CKEditorComponent} #editorElement - Reference to parent editor component
287
+ * @property {Record<string, HTMLElement>} #editables - Map of tracked editable elements
288
+ */
289
+ class CKEditorMultiRootEditablesTracker {
290
+ #editorElement;
291
+ #editables;
292
+
293
+ /**
294
+ * Creates new tracker instance wrapped in a Proxy for dynamic property access
295
+ *
296
+ * @param {CKEditorComponent} editorElement - Parent editor component reference
297
+ * @param {Record<string, HTMLElement>} initialEditables - Initial editable elements
298
+ * @returns {Proxy<CKEditorMultiRootEditablesTracker>} Proxy wrapping the tracker
299
+ */
300
+ constructor(editorElement, initialEditables = {}) {
301
+ this.#editorElement = editorElement;
302
+ this.#editables = initialEditables;
303
+
304
+ return new Proxy(this, {
305
+ /**
306
+ * Handles property access, returns class methods or editable elements
307
+ *
308
+ * @param {CKEditorMultiRootEditablesTracker} target - The tracker instance
309
+ * @param {string|symbol} name - Property name being accessed
310
+ */
311
+ get(target, name) {
312
+ if (typeof target[name] === 'function') {
313
+ return target[name].bind(target);
314
+ }
315
+
316
+ return target.#editables[name];
317
+ },
318
+
319
+ /**
320
+ * Handles setting new editable elements, triggers root attachment
321
+ *
322
+ * @param {CKEditorMultiRootEditablesTracker} target - The tracker instance
323
+ * @param {string} name - Name of the editable root
324
+ * @param {HTMLElement} element - Element to attach as editable
325
+ */
326
+ set(target, name, element) {
327
+ if (target.#editables[name] !== element) {
328
+ target.attachRoot(name, element);
329
+ target.#editables[name] = element;
330
+ }
331
+ return true;
332
+ },
333
+
334
+ /**
335
+ * Handles removing editable elements, triggers root detachment
336
+ *
337
+ * @param {CKEditorMultiRootEditablesTracker} target - The tracker instance
338
+ * @param {string} name - Name of the root to remove
339
+ */
340
+ deleteProperty(target, name) {
341
+ target.detachRoot(name);
342
+ delete target.#editables[name];
343
+ return true;
344
+ }
345
+ });
346
+ }
347
+
348
+ /**
349
+ * Attaches a new editable root to the editor.
350
+ * Creates new editor root and binds UI elements.
351
+ *
352
+ * @param {string} name - Name of the editable root
353
+ * @param {HTMLElement} element - DOM element to use as editable
354
+ * @returns {Promise<void>} Resolves when root is attached
355
+ */
356
+ async attachRoot(name, element) {
357
+ await this.detachRoot(name);
358
+
359
+ return this.#editorElement.runAfterEditorReady((editor) => {
360
+ const { ui, editing, model } = editor;
361
+
362
+ editor.addRoot(name, {
363
+ isUndoable: false,
364
+ data: element.innerHTML
365
+ });
366
+
367
+ const root = model.document.getRoot(name);
368
+
369
+ if (ui.getEditableElement(name)) {
370
+ editor.detachEditable(root);
371
+ }
372
+
373
+ const editable = ui.view.createEditable(name, element);
374
+ ui.addEditable(editable);
375
+ editing.view.forceRender();
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Detaches an editable root from the editor.
381
+ * Removes editor root and cleans up UI bindings.
382
+ *
383
+ * @param {string} name - Name of root to detach
384
+ * @returns {Promise<void>} Resolves when root is detached
385
+ */
386
+ async detachRoot(name) {
387
+ return this.#editorElement.runAfterEditorReady(editor => {
388
+ const root = editor.model.document.getRoot(name);
389
+
390
+ if (root) {
391
+ editor.detachEditable(root);
392
+ editor.detachRoot(name, true);
393
+ }
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Gets all currently tracked editable elements
399
+ *
400
+ * @returns {Record<string, HTMLElement>} Map of all editable elements
401
+ */
402
+ getAll() {
403
+ return this.#editables;
404
+ }
405
+ }
406
+
407
+ /**
408
+ * Custom HTML element representing an editable region for CKEditor.
409
+ * Must be used as a child of ckeditor-component element.
410
+ *
411
+ * @customElement ckeditor-editable-component
412
+ * @extends HTMLElement
413
+ *
414
+ * @property {string} name - The name of the editable region, accessed via getAttribute
415
+ * @property {HTMLDivElement} editableElement - The div element containing editable content
416
+ *
417
+ * @fires connectedCallback - When the element is added to the DOM
418
+ * @fires attributeChangedCallback - When element attributes change
419
+ * @fires disconnectedCallback - When the element is removed from the DOM
420
+ *
421
+ * @throws {Error} Throws error if not used as child of ckeditor-component
422
+ *
423
+ * @example
424
+ * <ckeditor-component>
425
+ * <ckeditor-editable-component name="main">
426
+ * Content goes here
427
+ * </ckeditor-editable-component>
428
+ * </ckeditor-component>
429
+ */
430
+ class CKEditorEditableComponent extends HTMLElement {
431
+ /**
432
+ * List of attributes that trigger updates when changed
433
+ *
434
+ * @static
435
+ * @returns {string[]} Array of attribute names to observe
436
+ */
437
+ static get observedAttributes() {
438
+ return ['name'];
439
+ }
440
+
441
+ /**
442
+ * Gets the name of this editable region
443
+ *
444
+ * @returns {string} The name attribute value
445
+ */
446
+ get name() {
447
+ return this.getAttribute('name');
448
+ }
449
+
450
+ /**
451
+ * Gets the actual editable DOM element
452
+ * @returns {HTMLDivElement|null} The div element containing editable content
453
+ */
454
+ get editableElement() {
455
+ return this.querySelector('div');
456
+ }
457
+
458
+ /**
459
+ * Lifecycle callback when element is added to DOM
460
+ * Sets up the editable element and registers it with the parent editor
461
+ *
462
+ * @throws {Error} If not used as child of ckeditor-component
463
+ */
464
+ connectedCallback() {
465
+ const editorComponent = this.#queryEditorElement();
466
+
467
+ if (!editorComponent ) {
468
+ throw new Error('ckeditor-editable-component must be a child of ckeditor-component');
469
+ }
470
+
471
+ this.innerHTML = `<div>${this.innerHTML}</div>`;
472
+ this.style.display = 'block';
473
+
474
+ editorComponent.editables[this.name] = this;
475
+ }
476
+
477
+ /**
478
+ * Lifecycle callback for attribute changes
479
+ * Handles name changes and propagates other attributes to editable element
480
+ *
481
+ * @param {string} name - Name of changed attribute
482
+ * @param {string|null} oldValue - Previous value
483
+ * @param {string|null} newValue - New value
484
+ */
485
+ attributeChangedCallback(name, oldValue, newValue) {
486
+ if (oldValue === newValue) {
487
+ return;
488
+ }
489
+
490
+ if (name === 'name') {
491
+ if (!oldValue) {
492
+ return;
493
+ }
494
+
495
+ const editorComponent = this.#queryEditorElement();
496
+
497
+ if (editorComponent) {
498
+ editorComponent.editables[newValue] = editorComponent.editables[oldValue];
499
+ delete editorComponent.editables[oldValue];
500
+ }
501
+ } else {
502
+ this.editableElement.setAttribute(name, newValue);
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Lifecycle callback when element is removed
508
+ * Un-registers this editable from the parent editor
509
+ */
510
+ disconnectedCallback() {
511
+ const editorComponent = this.#queryEditorElement();
512
+
513
+ if (editorComponent) {
514
+ delete editorComponent.editables[this.name];
515
+ }
516
+ }
517
+
518
+ /**
519
+ * Finds the parent editor component
520
+ *
521
+ * @private
522
+ * @returns {CKEditorComponent|null} Parent editor component or null if not found
523
+ */
524
+ #queryEditorElement() {
525
+ return this.closest('ckeditor-component');
526
+ }
527
+ }
528
+
529
+ /**
530
+ * Custom HTML element that represents a CKEditor toolbar component.
531
+ * Manages the toolbar placement and integration with the main editor component.
532
+ *
533
+ * @extends HTMLElement
534
+ * @customElement ckeditor-toolbar
535
+ * @example
536
+ * <ckeditor-toolbar></ckeditor-toolbar>
537
+ */
538
+ class CKEditorToolbarComponent extends HTMLElement {
539
+ /**
540
+ * Lifecycle callback when element is added to DOM
541
+ * Adds the toolbar to the editor UI
542
+ */
543
+ async connectedCallback() {
544
+ const editor = await this.#queryEditorElement().instancePromise.promise;
545
+
546
+ this.appendChild(editor.ui.view.toolbar.element);
547
+ }
548
+
549
+ /**
550
+ * Finds the parent editor component
551
+ *
552
+ * @private
553
+ * @returns {CKEditorComponent|null} Parent editor component or null if not found
554
+ */
555
+ #queryEditorElement() {
556
+ return this.closest('ckeditor-component');
557
+ }
558
+ }
559
+
560
+ /**
561
+ * Executes callback when DOM is ready
562
+ *
563
+ * @param {() => void} callback - Function to execute
564
+ */
565
+ function execIfDOMReady(callback) {
566
+ switch (document.readyState) {
567
+ case 'loading':
568
+ document.addEventListener('DOMContentLoaded', callback, { once: true });
569
+ break;
570
+
571
+ case 'interactive':
572
+ case 'complete':
573
+ setTimeout(callback, 0);
574
+ break;
575
+
576
+ default:
577
+ console.warn('Unexpected document.readyState:', document.readyState);
578
+ setTimeout(callback, 0);
579
+ }
580
+ }
581
+
582
+ /**
583
+ * Dynamically imports modules based on configuration
584
+ *
585
+ * @param {Array<ImportConfig>} imports - Array of import configurations
586
+ * @returns {Promise<Array<any>>} Loaded modules
587
+ */
588
+ function loadAsyncImports(imports = []) {
589
+ return Promise.all(
590
+ imports.map(async ({ import_name, import_as, window_name }) => {
591
+ if (window_name && Object.prototype.hasOwnProperty.call(window, window_name)) {
592
+ return window[window_name];
593
+ }
594
+
595
+ const module = await import(import_name);
596
+ return import_as ? module[import_as] : module.default;
597
+ })
598
+ );
599
+ }
600
+
601
+ customElements.define('ckeditor-component', CKEditorComponent);
602
+ customElements.define('ckeditor-editable-component', CKEditorEditableComponent);
603
+ customElements.define('ckeditor-toolbar-component', CKEditorToolbarComponent);
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CKEditor5::Rails
4
+ module Cdn
5
+ class CKBoxBundle < Assets::AssetsBundle
6
+ include Cdn::UrlGenerator
7
+
8
+ attr_reader :cdn, :version, :theme, :translations
9
+
10
+ def initialize(version, theme: :lark, cdn: Engine.base.default_cdn, translations: [])
11
+ raise ArgumentError, 'version must be semver' unless version.is_a?(Semver)
12
+ raise ArgumentError, 'theme must be a string' unless theme.is_a?(String)
13
+ raise ArgumentError, 'translations must be an array' unless translations.is_a?(Array)
14
+
15
+ super()
16
+
17
+ @cdn = cdn
18
+ @version = version
19
+ @theme = theme
20
+ @translations = translations
21
+ end
22
+
23
+ def scripts
24
+ @scripts ||= [
25
+ Assets::JSExportsMeta.new(
26
+ create_cdn_url('ckbox', 'ckbox.js', version),
27
+ *translations_js_exports_meta
28
+ )
29
+ ]
30
+ end
31
+
32
+ def stylesheets
33
+ @stylesheets ||= [
34
+ create_cdn_url('ckbox', "styles/themes/#{theme}.css", version)
35
+ ]
36
+ end
37
+
38
+ private
39
+
40
+ def translations_js_exports_meta
41
+ translations.map do |lang|
42
+ url = create_cdn_url('ckbox', "translations/#{lang}.js", version)
43
+
44
+ Assets::JSExportsMeta.new(url, window_name: 'CKBOX_TRANSLATIONS', translation: true)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CKEditor5::Rails
4
+ module Cdn
5
+ class CKEditorBundle < Assets::AssetsBundle
6
+ include Cdn::UrlGenerator
7
+
8
+ attr_reader :version, :translations, :import_name
9
+
10
+ def initialize(version, import_name, cdn: Engine.base.default_cdn, translations: [])
11
+ raise ArgumentError, 'version must be semver' unless version.is_a?(Semver)
12
+ raise ArgumentError, 'import_name must be a string' unless import_name.is_a?(String)
13
+ raise ArgumentError, 'translations must be an array' unless translations.is_a?(Array)
14
+
15
+ super()
16
+
17
+ @cdn = cdn
18
+ @version = version
19
+ @import_name = import_name
20
+ @translations = translations
21
+ end
22
+
23
+ def scripts
24
+ @scripts ||= [
25
+ js_exports_meta,
26
+ *translations_js_exports_meta
27
+ ]
28
+ end
29
+
30
+ def stylesheets
31
+ @stylesheets ||= [
32
+ create_cdn_url(import_name, version, "#{import_name}.css")
33
+ ]
34
+ end
35
+
36
+ private
37
+
38
+ def js_exports_meta
39
+ Assets::JSExportsMeta.new(
40
+ create_cdn_url(import_name, version, "#{import_name}.js"),
41
+ import_name: import_name
42
+ )
43
+ end
44
+
45
+ def translations_js_exports_meta
46
+ translations.map do |lang|
47
+ url = create_cdn_url(import_name, version, "translations/#{lang}.js")
48
+
49
+ Assets::JSExportsMeta.new(
50
+ url,
51
+ import_name: "#{import_name}/translations/#{lang}.js",
52
+ translation: true
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end