ckeditor5-livewire 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.
Files changed (46) hide show
  1. package/dist/hooks/editable.d.ts +3 -5
  2. package/dist/hooks/editable.d.ts.map +1 -1
  3. package/dist/hooks/editor/editor.d.ts +0 -4
  4. package/dist/hooks/editor/editor.d.ts.map +1 -1
  5. package/dist/hooks/editor/plugins/livewire-sync.d.ts.map +1 -1
  6. package/dist/hooks/editor/utils/cleanup-orphan-editor-elements.d.ts +8 -0
  7. package/dist/hooks/editor/utils/cleanup-orphan-editor-elements.d.ts.map +1 -0
  8. package/dist/hooks/editor/utils/create-editor-in-context.d.ts +6 -1
  9. package/dist/hooks/editor/utils/create-editor-in-context.d.ts.map +1 -1
  10. package/dist/hooks/editor/utils/index.d.ts +2 -0
  11. package/dist/hooks/editor/utils/index.d.ts.map +1 -1
  12. package/dist/hooks/editor/utils/is-multiroot-editor-instance.d.ts +6 -0
  13. package/dist/hooks/editor/utils/is-multiroot-editor-instance.d.ts.map +1 -0
  14. package/dist/hooks/editor/utils/wrap-with-watchdog.d.ts +7 -16
  15. package/dist/hooks/editor/utils/wrap-with-watchdog.d.ts.map +1 -1
  16. package/dist/hooks/ui-part.d.ts +2 -6
  17. package/dist/hooks/ui-part.d.ts.map +1 -1
  18. package/dist/index.cjs +2 -2
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.mjs +297 -220
  21. package/dist/index.mjs.map +1 -1
  22. package/dist/shared/are-maps-equal.d.ts +11 -0
  23. package/dist/shared/are-maps-equal.d.ts.map +1 -0
  24. package/dist/shared/async-registry.d.ts +43 -10
  25. package/dist/shared/async-registry.d.ts.map +1 -1
  26. package/dist/shared/index.d.ts +1 -0
  27. package/dist/shared/index.d.ts.map +1 -1
  28. package/package.json +3 -3
  29. package/src/hooks/context/context.test.ts +3 -1
  30. package/src/hooks/editable.ts +73 -46
  31. package/src/hooks/editor/editor.test.ts +44 -9
  32. package/src/hooks/editor/editor.ts +159 -149
  33. package/src/hooks/editor/plugins/livewire-sync.ts +17 -8
  34. package/src/hooks/editor/utils/cleanup-orphan-editor-elements.test.ts +285 -0
  35. package/src/hooks/editor/utils/cleanup-orphan-editor-elements.ts +60 -0
  36. package/src/hooks/editor/utils/create-editor-in-context.ts +6 -2
  37. package/src/hooks/editor/utils/index.ts +2 -0
  38. package/src/hooks/editor/utils/is-multiroot-editor-instance.ts +8 -0
  39. package/src/hooks/editor/utils/wrap-with-watchdog.test.ts +34 -14
  40. package/src/hooks/editor/utils/wrap-with-watchdog.ts +16 -26
  41. package/src/hooks/ui-part.ts +10 -16
  42. package/src/shared/are-maps-equal.test.ts +56 -0
  43. package/src/shared/are-maps-equal.ts +22 -0
  44. package/src/shared/async-registry.test.ts +212 -31
  45. package/src/shared/async-registry.ts +178 -61
  46. package/src/shared/index.ts +1 -0
@@ -2,7 +2,6 @@ import type { Editor } from 'ckeditor5';
2
2
 
3
3
  import type { RootAttributesUpdater } from '../utils';
4
4
  import type { EditorId, EditorLanguage, EditorPreset } from './typings';
5
- import type { EditorCreator } from './utils';
6
5
 
7
6
  import { ContextsRegistry } from '../../hooks/context';
8
7
  import { isEmptyObject, waitFor } from '../../shared';
@@ -14,6 +13,7 @@ import {
14
13
  createSyncEditorWithInputPlugin,
15
14
  } from './plugins';
16
15
  import {
16
+ cleanupOrphanEditorElements,
17
17
  createEditorInContext,
18
18
  isSingleRootEditor,
19
19
  loadAllEditorTranslations,
@@ -34,11 +34,6 @@ import {
34
34
  * The Livewire hook that manages the lifecycle of CKEditor5 instances.
35
35
  */
36
36
  export class EditorComponentHook extends ClassHook<Snapshot> {
37
- /**
38
- * The promise that resolves to the editor instance.
39
- */
40
- private editorPromise: Promise<Editor> | null = null;
41
-
42
37
  /**
43
38
  * Root attributes updater for the main editor root.
44
39
  */
@@ -53,27 +48,51 @@ export class EditorComponentHook extends ClassHook<Snapshot> {
53
48
  EditorsRegistry.the.resetErrors(editorId);
54
49
 
55
50
  try {
56
- this.editorPromise = this.createEditor();
57
-
58
- const editor = await this.editorPromise;
51
+ const editor = await this.createEditor();
52
+ const editorContext = unwrapEditorContext(editor);
53
+ const watchdog = unwrapEditorWatchdog(editor);
59
54
 
60
- // Do not even try to broadcast about the registration of the editor
61
- // if hook was immediately destroyed.
62
- /* v8 ignore next if -- @preserve */
63
- if (!this.isBeingDestroyed()) {
64
- EditorsRegistry.the.register(editorId, editor);
55
+ // Do not even try to broadcast about the registration of the editor if hook was immediately destroyed.
56
+ /* v8 ignore next 3 */
57
+ if (this.isBeingDestroyed()) {
58
+ return;
59
+ }
65
60
 
61
+ // Run some stuff that have to be reinitialized every-time editor is being restarted.
62
+ const unmountDestroyWatcher = EditorsRegistry.the.mountEffect(editorId, (editor) => {
63
+ // Enforce deregistration of the editor when it's being destroyed by watchdog.
66
64
  editor.once('destroy', () => {
67
- /* v8 ignore next if -- @preserve */
68
- if (EditorsRegistry.the.hasItem(editorId)) {
69
- EditorsRegistry.the.unregister(editorId);
65
+ // Let's handle case when watchdog (or context watchdog) destroyed editor "externally"
66
+ // user might also manually kill the editor using `.destroy()` method.
67
+ // Keep pending callbacks though. Someone might register new callbacks just before calling `.destroy()`.
68
+ EditorsRegistry.the.unregister(editorId, false);
69
+ }, { priority: 'highest' });
70
+ });
71
+
72
+ this.onBeforeDestroy(async () => {
73
+ // If for some reason editor not fired `destroy`, enforce deregistration.
74
+ EditorsRegistry.the.unregister(editorId);
75
+ unmountDestroyWatcher();
76
+
77
+ if (editorContext) {
78
+ // If context is present, make sure it's not in unmounting phase, as it'll kill the editors.
79
+ // If it's being destroyed, don't do anything, as the context will take care of it.
80
+ if (editorContext.state !== 'unavailable') {
81
+ await editorContext.context.remove(editorContext.editorContextId);
70
82
  }
71
- });
72
- }
83
+ }
84
+ else if (watchdog) {
85
+ await watchdog.destroy();
86
+ }
87
+ else {
88
+ await editor.destroy();
89
+ }
90
+ });
91
+
92
+ EditorsRegistry.the.register(editorId, editor);
73
93
  }
74
94
  catch (error: any) {
75
95
  console.error(`Error initializing CKEditor5 instance with ID "${editorId}":`, error);
76
- this.editorPromise = null;
77
96
  EditorsRegistry.the.error(editorId, error);
78
97
  }
79
98
  }
@@ -85,42 +104,13 @@ export class EditorComponentHook extends ClassHook<Snapshot> {
85
104
  override async destroyed() {
86
105
  // Let's hide the element during destruction to prevent flickering.
87
106
  this.element.style.display = 'none';
88
-
89
- // Let's wait for the mounted promise to resolve before proceeding with destruction.
90
- try {
91
- const editor = await this.editorPromise;
92
-
93
- if (!editor) {
94
- return;
95
- }
96
-
97
- const editorContext = unwrapEditorContext(editor);
98
- const watchdog = unwrapEditorWatchdog(editor);
99
-
100
- if (editorContext) {
101
- // If context is present, make sure it's not in unmounting phase, as it'll kill the editors.
102
- // If it's being destroyed, don't do anything, as the context will take care of it.
103
- if (editorContext.state !== 'unavailable') {
104
- await editorContext.context.remove(editorContext.editorContextId);
105
- }
106
- }
107
- else if (watchdog) {
108
- await watchdog.destroy();
109
- }
110
- else {
111
- await editor.destroy();
112
- }
113
- }
114
- finally {
115
- this.editorPromise = null;
116
- }
117
107
  }
118
108
 
119
109
  /**
120
110
  * Updates the editor content when the component is updated after commit changes.
121
111
  */
122
112
  override async afterCommitSynced(): Promise<void> {
123
- const editor = await this.editorPromise;
113
+ const editor = await EditorsRegistry.the.waitFor(this.canonical.editorId);
124
114
 
125
115
  /* v8 ignore if -- @preserve */
126
116
  if (editor) {
@@ -152,7 +142,7 @@ export class EditorComponentHook extends ClassHook<Snapshot> {
152
142
  editableHeight,
153
143
  saveDebounceMs,
154
144
  language,
155
- watchdog,
145
+ watchdog: useWatchdog,
156
146
  content,
157
147
  } = this.canonical;
158
148
 
@@ -160,133 +150,153 @@ export class EditorComponentHook extends ClassHook<Snapshot> {
160
150
  customTranslations,
161
151
  editorType,
162
152
  licenseKey,
153
+ watchdogConfig,
163
154
  config: { plugins, ...config },
164
155
  } = preset;
165
156
 
166
- // Wrap editor creator with watchdog if needed.
167
- let Constructor: EditorCreator = await loadEditorConstructor(editorType);
157
+ const Constructor = await loadEditorConstructor(editorType);
168
158
  const context = await (
169
159
  contextId
170
160
  ? ContextsRegistry.the.waitFor(contextId)
171
161
  : null
172
162
  );
173
163
 
174
- // Do not use editor specific watchdog if context is attached, as the context is by default protected.
175
- if (watchdog && !context) {
176
- const wrapped = await wrapWithWatchdog(Constructor);
164
+ /**
165
+ * Builds the full editor configuration and creates the editor instance.
166
+ */
167
+ const buildAndCreateEditor = async () => {
168
+ const { loadedPlugins, hasPremium } = await loadEditorPlugins(plugins);
177
169
 
178
- ({ Constructor } = wrapped);
179
- wrapped.watchdog.on('restart', () => {
180
- const newInstance = wrapped.watchdog.editor!;
170
+ // Add integration specific plugins.
171
+ loadedPlugins.push(
172
+ await createLivewireSyncPlugin(
173
+ {
174
+ saveDebounceMs,
175
+ component: this,
176
+ },
177
+ ),
178
+ );
181
179
 
182
- this.editorPromise = Promise.resolve(newInstance);
180
+ if (isSingleRootEditor(editorType)) {
181
+ loadedPlugins.push(
182
+ await createSyncEditorWithInputPlugin(saveDebounceMs),
183
+ );
184
+ }
183
185
 
184
- EditorsRegistry.the.register(editorId, newInstance);
185
- });
186
- }
186
+ // Mix custom translations with loaded translations.
187
+ const loadedTranslations = await loadAllEditorTranslations(language, hasPremium);
188
+ const mixedTranslations = [
189
+ ...loadedTranslations,
190
+ normalizeCustomTranslations(customTranslations || {}),
191
+ ]
192
+ .filter(translations => !isEmptyObject(translations));
193
+
194
+ // Let's query all elements, and create basic configuration.
195
+ let initialData: string | Record<string, string> = {
196
+ ...content,
197
+ ...queryEditablesSnapshotContent(editorId),
198
+ };
187
199
 
188
- const { loadedPlugins, hasPremium } = await loadEditorPlugins(plugins);
200
+ if (isSingleRootEditor(editorType)) {
201
+ initialData = initialData['main'] || '';
202
+ }
189
203
 
190
- // Add integration specific plugins.
191
- loadedPlugins.push(
192
- await createLivewireSyncPlugin(
193
- {
194
- saveDebounceMs,
195
- component: this,
196
- },
197
- ),
198
- );
204
+ // Depending of the editor type, and parent lookup for nearest context or initialize it without it.
205
+ const editor = await (async () => {
206
+ let sourceElements: HTMLElement | Record<string, HTMLElement> = queryEditablesElements(editorId);
207
+
208
+ // Handle special case when user specified `initialData` of several root elements, but editable components
209
+ // are not yet present in the DOM. In other words - editor is initialized before attaching root elements.
210
+ if (!(sourceElements instanceof HTMLElement) && !('main' in sourceElements)) {
211
+ const requiredRoots = (
212
+ editorType === 'decoupled'
213
+ ? ['main']
214
+ : Object.keys(initialData as Record<string, string>)
215
+ );
216
+
217
+ if (!checkIfAllRootsArePresent(sourceElements, requiredRoots)) {
218
+ sourceElements = await waitForAllRootsToBePresent(editorId, requiredRoots);
219
+ initialData = {
220
+ ...content,
221
+ ...queryEditablesSnapshotContent(editorId),
222
+ };
223
+ }
224
+ }
199
225
 
200
- if (isSingleRootEditor(editorType)) {
201
- loadedPlugins.push(
202
- await createSyncEditorWithInputPlugin(saveDebounceMs),
203
- );
204
- }
226
+ // If single root editor, unwrap the element from the object.
227
+ if (isSingleRootEditor(editorType) && 'main' in sourceElements) {
228
+ sourceElements = sourceElements['main'];
229
+ }
205
230
 
206
- // Mix custom translations with loaded translations.
207
- const loadedTranslations = await loadAllEditorTranslations(language, hasPremium);
208
- const mixedTranslations = [
209
- ...loadedTranslations,
210
- normalizeCustomTranslations(customTranslations || {}),
211
- ]
212
- .filter(translations => !isEmptyObject(translations));
213
-
214
- // Let's query all elements, and create basic configuration.
215
- let initialData: string | Record<string, string> = {
216
- ...content,
217
- ...queryEditablesSnapshotContent(editorId),
218
- };
231
+ // Construct parsed config. First resolve DOM element references in the provided configuration.
232
+ let resolvedConfig = resolveEditorConfigElementReferences(config);
233
+
234
+ // Then resolve translation references in the provided configuration, using the mixed translations.
235
+ resolvedConfig = resolveEditorConfigTranslations([...mixedTranslations].reverse(), language.ui, resolvedConfig);
236
+
237
+ // Construct parsed config.
238
+ const parsedConfig = {
239
+ ...resolvedConfig,
240
+ initialData,
241
+ licenseKey,
242
+ plugins: loadedPlugins,
243
+ language,
244
+ ...mixedTranslations.length && {
245
+ translations: mixedTranslations,
246
+ },
247
+ };
248
+
249
+ if (!context || !(sourceElements instanceof HTMLElement)) {
250
+ return Constructor.create(sourceElements as any, parsedConfig);
251
+ }
219
252
 
220
- if (isSingleRootEditor(editorType)) {
221
- initialData = initialData['main'] || '';
222
- }
253
+ const result = await createEditorInContext({
254
+ context,
255
+ element: sourceElements,
256
+ creator: Constructor,
257
+ config: parsedConfig,
258
+ });
223
259
 
224
- // Depending of the editor type, and parent lookup for nearest context or initialize it without it.
225
- const editor = await (async () => {
226
- let sourceElements: HTMLElement | Record<string, HTMLElement> = queryEditablesElements(editorId);
227
-
228
- // Handle special case when user specified `initialData` of several root elements, but editable components
229
- // are not yet present in the DOM. In other words - editor is initialized before attaching root elements.
230
- if (!(sourceElements instanceof HTMLElement) && !('main' in sourceElements)) {
231
- const requiredRoots = (
232
- editorType === 'decoupled'
233
- ? ['main']
234
- : Object.keys(initialData as Record<string, string>)
235
- );
260
+ return result.editor;
261
+ })();
236
262
 
237
- if (!checkIfAllRootsArePresent(sourceElements, requiredRoots)) {
238
- sourceElements = await waitForAllRootsToBePresent(editorId, requiredRoots);
239
- initialData = {
240
- ...content,
241
- ...queryEditablesSnapshotContent(editorId),
242
- };
243
- }
263
+ if (isSingleRootEditor(editorType) && editableHeight) {
264
+ setEditorEditableHeight(editor, editableHeight);
244
265
  }
245
266
 
246
- // If single root editor, unwrap the element from the object.
247
- if (isSingleRootEditor(editorType) && 'main' in sourceElements) {
248
- sourceElements = sourceElements['main'];
249
- }
267
+ this.applyRootAttributes(editor);
250
268
 
251
- // Construct parsed config. First resolve DOM element references in the provided configuration.
252
- let resolvedConfig = resolveEditorConfigElementReferences(config);
253
-
254
- // Then resolve translation references in the provided configuration, using the mixed translations.
255
- resolvedConfig = resolveEditorConfigTranslations([...mixedTranslations].reverse(), language.ui, resolvedConfig);
256
-
257
- // Construct parsed config.
258
- const parsedConfig = {
259
- ...resolvedConfig,
260
- initialData,
261
- licenseKey,
262
- plugins: loadedPlugins,
263
- language,
264
- ...mixedTranslations.length && {
265
- translations: mixedTranslations,
266
- },
267
- };
269
+ return editor;
270
+ };
268
271
 
269
- if (!context || !(sourceElements instanceof HTMLElement)) {
270
- return Constructor.create(sourceElements as any, parsedConfig);
271
- }
272
+ // Do not use editor specific watchdog if context is attached, as the context is by default protected.
273
+ if (useWatchdog && !context) {
274
+ const watchdog = await wrapWithWatchdog(buildAndCreateEditor, watchdogConfig);
275
+
276
+ // Cleanup editor registry before restart of the editor (restart might fail too).
277
+ watchdog.on('error', (_, { causesRestart }) => {
278
+ if (causesRestart) {
279
+ const prevEditor = EditorsRegistry.the.getItem(editorId);
280
+
281
+ /* v8 ignore next 3 */
282
+ if (prevEditor) {
283
+ cleanupOrphanEditorElements(prevEditor);
284
+ EditorsRegistry.the.unregister(editorId);
285
+ }
286
+ }
287
+ });
272
288
 
273
- const result = await createEditorInContext({
274
- context,
275
- element: sourceElements,
276
- creator: Constructor,
277
- config: parsedConfig,
289
+ // Register new instance after editor restarted.
290
+ watchdog.on('restart', () => {
291
+ EditorsRegistry.the.register(editorId, watchdog.editor!);
278
292
  });
279
293
 
280
- return result.editor;
281
- })();
294
+ await watchdog.create({});
282
295
 
283
- if (isSingleRootEditor(editorType) && editableHeight) {
284
- setEditorEditableHeight(editor, editableHeight);
296
+ return watchdog.editor!;
285
297
  }
286
298
 
287
- this.applyRootAttributes(editor);
288
-
289
- return editor;
299
+ return buildAndCreateEditor();
290
300
  };
291
301
  }
292
302
 
@@ -108,7 +108,9 @@ export async function createLivewireSyncPlugin(
108
108
  * Setups the content sync from Livewire to the editor when Livewire emits an event.
109
109
  */
110
110
  private setupSetEditorContentHandler() {
111
- Livewire.on('set-editor-content', ({ editorId, content }: SetContentPayload) => {
111
+ const { editor } = this;
112
+
113
+ const handler = ({ editorId, content }: SetContentPayload) => {
112
114
  if (editorId !== component.canonical.editorId) {
113
115
  return;
114
116
  }
@@ -116,9 +118,16 @@ export async function createLivewireSyncPlugin(
116
118
  const currentValues = this.getEditorRootsValues();
117
119
 
118
120
  if (!shallowEqual(currentValues, content)) {
119
- this.editor.setData(content);
121
+ editor.setData(content);
120
122
  }
121
- });
123
+ };
124
+
125
+ const clean: any = Livewire.on('set-editor-content', handler);
126
+
127
+ /* v8 ignore next if -- @preserve */
128
+ if (typeof clean === 'function') {
129
+ editor.once('destroy', clean);
130
+ }
122
131
  }
123
132
 
124
133
  /**
@@ -150,21 +159,21 @@ export async function createLivewireSyncPlugin(
150
159
  };
151
160
 
152
161
  const debouncedSync = debounce(saveDebounceMs, syncContentChange);
153
- const onChangeData = () => {
162
+
163
+ // Apply small debounce to avoid race conditions during re-mount of editables during watchdog restart.
164
+ // CKEditor tends to reset roots map and re-assign value in the same tick which may confuse two way binding.
165
+ model.document.on('change:data', debounce(10, () => {
154
166
  if (ui.focusTracker.isFocused) {
155
167
  debouncedSync();
156
168
  }
157
169
  else {
158
170
  syncContentChange();
159
171
  }
160
- };
161
-
162
- model.document.on('change:data', onChangeData);
172
+ }));
163
173
 
164
174
  editor.once('ready', syncContentChange);
165
175
  editor.once('destroy', () => {
166
176
  isDestroyed = true;
167
- model.document.off('change:data', onChangeData);
168
177
  });
169
178
  }
170
179