ckeditor5 0.0.1 → 1.0.1

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