ckeditor5-blazor 1.9.1 → 1.10.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/elements/editable.d.ts +3 -11
  2. package/dist/elements/editable.d.ts.map +1 -1
  3. package/dist/elements/editor/editor.d.ts.map +1 -1
  4. package/dist/elements/editor/typings.d.ts +2 -1
  5. package/dist/elements/editor/typings.d.ts.map +1 -1
  6. package/dist/elements/editor/utils/cleanup-orphan-editor-elements.d.ts +8 -0
  7. package/dist/elements/editor/utils/cleanup-orphan-editor-elements.d.ts.map +1 -0
  8. package/dist/elements/editor/utils/create-editor-in-context.d.ts +6 -1
  9. package/dist/elements/editor/utils/create-editor-in-context.d.ts.map +1 -1
  10. package/dist/elements/editor/utils/index.d.ts +1 -0
  11. package/dist/elements/editor/utils/index.d.ts.map +1 -1
  12. package/dist/elements/editor/utils/wrap-with-watchdog.d.ts +7 -16
  13. package/dist/elements/editor/utils/wrap-with-watchdog.d.ts.map +1 -1
  14. package/dist/elements/ui-part.d.ts +3 -3
  15. package/dist/elements/ui-part.d.ts.map +1 -1
  16. package/dist/index.cjs +2 -2
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.mjs +459 -394
  19. package/dist/index.mjs.map +1 -1
  20. package/dist/interop/create-editable-blazor-interop.d.ts.map +1 -1
  21. package/dist/interop/create-editor-blazor-interop.d.ts.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 +44 -16
  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/elements/editable.ts +38 -58
  30. package/src/elements/editor/editor.ts +122 -101
  31. package/src/elements/editor/typings.ts +3 -1
  32. package/src/elements/editor/utils/cleanup-orphan-editor-elements.test.ts +285 -0
  33. package/src/elements/editor/utils/cleanup-orphan-editor-elements.ts +60 -0
  34. package/src/elements/editor/utils/create-editor-in-context.ts +8 -2
  35. package/src/elements/editor/utils/index.ts +1 -0
  36. package/src/elements/editor/utils/wrap-with-watchdog.test.ts +34 -14
  37. package/src/elements/editor/utils/wrap-with-watchdog.ts +15 -25
  38. package/src/elements/ui-part.test.ts +1 -1
  39. package/src/elements/ui-part.ts +12 -11
  40. package/src/interop/create-editable-blazor-interop.ts +19 -16
  41. package/src/interop/create-editor-blazor-interop.ts +15 -18
  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 +190 -88
  45. package/src/shared/async-registry.ts +179 -107
  46. package/src/shared/index.ts +1 -0
@@ -20,7 +20,7 @@ export function createEditorBlazorInterop(element: HTMLElement, interop: DotNetI
20
20
  const editorId = element.getAttribute('data-cke-editor-id');
21
21
 
22
22
  let unmounted = false;
23
- let unmountCKEditorListeners: VoidFunction | null = null;
23
+ let stopEffect: VoidFunction | null = null;
24
24
 
25
25
  let sync = createNoopSync<Record<string, string>>();
26
26
  let syncRootAttributes: RootAttributesUpdater | null = null;
@@ -39,12 +39,7 @@ export function createEditorBlazorInterop(element: HTMLElement, interop: DotNetI
39
39
  }
40
40
  };
41
41
 
42
- /**
43
- * Initializes the focus tracker and model listeners for the editor.
44
- */
45
- const initializeSynchronization = async () => {
46
- const editor = await EditorsRegistry.the.waitFor(editorId);
47
-
42
+ stopEffect = EditorsRegistry.the.mountEffect(editorId, (editor) => {
48
43
  editorRef = globalThis.DotNet.createJSObjectReference(editor);
49
44
  sync = createEditorValueSync(editor, {
50
45
  getCurrentValue: () => getEditorRootsValues(editor),
@@ -70,13 +65,20 @@ export function createEditorBlazorInterop(element: HTMLElement, interop: DotNetI
70
65
  // that already exist on the .NET side.
71
66
  void interop.invokeMethodAsync('OnEditorReady', editorRef);
72
67
 
73
- // When the Blazor component is disposed, clean up event listeners.
74
- unmountCKEditorListeners = () => {
68
+ return () => {
75
69
  editor.ui.focusTracker.off('change:isFocused', onFocusChange);
70
+ sync.unmount();
71
+
72
+ /* v8 ignore else -- @preserve */
73
+ if (editorRef) {
74
+ globalThis.DotNet?.disposeJSObjectReference(editorRef);
75
+ editorRef = null;
76
+ }
77
+
78
+ syncRootAttributes = null;
76
79
  };
77
- };
80
+ });
78
81
 
79
- void initializeSynchronization();
80
82
  document.body.addEventListener(CKEditor5ChangeDataEvent.EVENT_NAME, onDataChange);
81
83
 
82
84
  ensureEditorElementsRegistered();
@@ -117,15 +119,10 @@ export function createEditorBlazorInterop(element: HTMLElement, interop: DotNetI
117
119
  }
118
120
 
119
121
  document.body.removeEventListener(CKEditor5ChangeDataEvent.EVENT_NAME, onDataChange);
120
- sync.unmount();
121
- unmountCKEditorListeners?.();
122
122
 
123
- if (editorRef) {
124
- globalThis.DotNet.disposeJSObjectReference(editorRef);
125
- editorRef = null;
126
- }
123
+ stopEffect?.();
124
+ stopEffect = null;
127
125
 
128
- syncRootAttributes = null;
129
126
  unmounted = true;
130
127
  },
131
128
 
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { areMapsEqual } from './are-maps-equal';
4
+
5
+ describe('areMapsEqual', () => {
6
+ it('should return true for two empty maps', () => {
7
+ expect(areMapsEqual(new Map(), new Map())).toBe(true);
8
+ });
9
+
10
+ it('should return true for maps with identical keys and primitive values', () => {
11
+ const map1 = new Map([['a', 1], ['b', 2]]);
12
+ const map2 = new Map([['a', 1], ['b', 2]]);
13
+
14
+ expect(areMapsEqual(map1, map2)).toBe(true);
15
+ });
16
+
17
+ it('should return false if map sizes are different', () => {
18
+ const map1 = new Map([['a', 1]]);
19
+ const map2 = new Map([['a', 1], ['b', 2]]);
20
+
21
+ expect(areMapsEqual(map1, map2)).toBe(false);
22
+ });
23
+
24
+ it('should return false if the keys are different', () => {
25
+ const map1 = new Map([['a', 1], ['c', 2]]);
26
+ const map2 = new Map([['a', 1], ['b', 2]]);
27
+
28
+ expect(areMapsEqual(map1, map2)).toBe(false);
29
+ });
30
+
31
+ it('should return false if the values for the same keys are different', () => {
32
+ const map1 = new Map([['a', 1], ['b', 3]]);
33
+ const map2 = new Map([['a', 1], ['b', 2]]);
34
+
35
+ expect(areMapsEqual(map1, map2)).toBe(false);
36
+ });
37
+
38
+ it('should return false if the first map (map1) is null', () => {
39
+ const map2 = new Map([['a', 1]]);
40
+
41
+ expect(areMapsEqual(null, map2)).toBe(false);
42
+ });
43
+
44
+ it('should correctly handle objects as values using shallow comparison (reference equality)', () => {
45
+ const obj = { id: 1 };
46
+
47
+ const map1 = new Map([['key', obj]]);
48
+ const map2 = new Map([['key', obj]]);
49
+
50
+ expect(areMapsEqual(map1, map2)).toBe(true);
51
+
52
+ const map3 = new Map([['key', { id: 1 }]]);
53
+
54
+ expect(areMapsEqual(map1, map3)).toBe(false);
55
+ });
56
+ });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Compares two Map structures for equality based on their contents.
3
+ * The function checks if the maps have the same size, contain the exact same keys,
4
+ * and have strictly equal values (using shallow comparison).
5
+ *
6
+ * @param map1 - The first map to compare (can be null).
7
+ * @param map2 - The second map to compare.
8
+ * @returns Returns `true` if the maps are identical in terms of keys and values, otherwise `false`.
9
+ */
10
+ export function areMapsEqual(map1: Map<any, any> | null, map2: Map<any, any>): boolean {
11
+ if (!map1 || map1.size !== map2.size) {
12
+ return false;
13
+ }
14
+
15
+ for (const [key, value] of map1) {
16
+ if (!map2.has(key) || map2.get(key) !== value) {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ return true;
22
+ }
@@ -96,30 +96,6 @@ describe('async registry', () => {
96
96
  registry.unregister('item1');
97
97
  expect(registry.getItems()).not.toContain(item);
98
98
  });
99
-
100
- it('should throw an error if trying to unregister an item that is not registered', () => {
101
- expect(() => registry.unregister('nonexistent')).toThrow(
102
- 'Item with ID "nonexistent" is not registered.',
103
- );
104
- });
105
-
106
- it('should also unregister the default item if the unregistered item was the default one', async () => {
107
- const item1 = createMockItem('item1');
108
-
109
- registry.register('item1', item1); // This also registers it as default
110
-
111
- // Check it is the default
112
- const promise = registry.execute(null, item => item);
113
-
114
- await expect(promise).resolves.toBe(item1);
115
-
116
- registry.unregister('item1');
117
-
118
- // Now check that the default is also gone
119
- expect(() => registry.unregister(null)).toThrow(
120
- 'Item with ID "null" is not registered.',
121
- );
122
- });
123
99
  });
124
100
 
125
101
  describe('execute', () => {
@@ -234,7 +210,7 @@ describe('async registry', () => {
234
210
  });
235
211
  });
236
212
 
237
- describe('getitems', () => {
213
+ describe('getItems', () => {
238
214
  it('should return all registered items', () => {
239
215
  const item1 = createMockItem('item1');
240
216
  const item2 = createMockItem('item2');
@@ -244,7 +220,7 @@ describe('async registry', () => {
244
220
 
245
221
  const items = registry.getItems();
246
222
 
247
- expect(items).toHaveLength(3); // item1, item2, and default (which is item1)
223
+ expect(items).toHaveLength(3);
248
224
  expect(items).toContain(item1);
249
225
  expect(items).toContain(item2);
250
226
  });
@@ -252,16 +228,30 @@ describe('async registry', () => {
252
228
  it('should return unique items if some point to the same instance', () => {
253
229
  const item1 = createMockItem('item1');
254
230
 
255
- registry.register('item1', item1); // This also registers it as default
231
+ registry.register('item1', item1);
256
232
 
257
233
  const items = registry.getItems();
258
234
 
259
- expect(items).toHaveLength(2); // item1 and default (which is item1)
235
+ expect(items).toHaveLength(2);
260
236
  expect(items.filter(e => e === item1)).toHaveLength(2);
261
237
  });
262
238
  });
263
239
 
264
- describe('hasitem', () => {
240
+ describe('getItem', () => {
241
+ it('should return registered item', () => {
242
+ const item = createMockItem('item1');
243
+
244
+ registry.register('item1', item);
245
+
246
+ expect(registry.getItem('item1')).toBe(item);
247
+ });
248
+
249
+ it('should return undefined if item doesn\'t exist', () => {
250
+ expect(registry.getItem('item1')).toBeUndefined();
251
+ });
252
+ });
253
+
254
+ describe('hasItem', () => {
265
255
  it('should return true if an item with the given ID is registered', () => {
266
256
  const item = createMockItem('item1');
267
257
 
@@ -402,110 +392,222 @@ describe('async registry', () => {
402
392
  });
403
393
  });
404
394
 
405
- describe('waitFor', () => {
406
- it('should return a promise that resolves with the item instance', async () => {
395
+ describe('reset', () => {
396
+ it('should destroy all registered items', async () => {
407
397
  const item1 = createMockItem('item1');
398
+ const item2 = createMockItem('item2');
399
+
408
400
  registry.register('item1', item1);
401
+ registry.register('item2', item2);
409
402
 
410
- const result = await registry.waitFor('item1');
403
+ await registry.reset();
411
404
 
412
- expect(result).toBe(item1);
405
+ expect(registry.getItems()).toHaveLength(0);
413
406
  });
414
407
 
415
- it('should wait for the item to be registered before resolving', async () => {
416
- const promise = registry.waitFor('item1');
417
- const item1 = createMockItem('item1');
408
+ it('should call destroy on each unique item', async () => {
409
+ const destroyMock1 = vi.fn().mockResolvedValue(undefined);
410
+ const destroyMock2 = vi.fn().mockResolvedValue(undefined);
411
+
412
+ const item1 = { name: 'item1', destroy: destroyMock1 } as unknown as Mockitem;
413
+ const item2 = { name: 'item2', destroy: destroyMock2 } as unknown as Mockitem;
418
414
 
419
415
  registry.register('item1', item1);
416
+ registry.register('item2', item2);
420
417
 
421
- expect(await promise).toBe(item1);
418
+ await registry.reset();
419
+
420
+ expect(destroyMock1).toHaveBeenCalledOnce();
421
+ expect(destroyMock2).toHaveBeenCalledOnce();
422
422
  });
423
423
 
424
- it('should reject if error is registered after waitFor call', async () => {
425
- const promise = registry.waitFor('item1');
424
+ it('should clear watchers so they are no longer called after reset', async () => {
425
+ const watcher = vi.fn();
426
+ registry.watch(watcher);
426
427
 
427
- registry.error('item1', 'Failed to initialize');
428
+ await registry.reset();
429
+ watcher.mockClear();
428
430
 
429
- await expect(promise).rejects.toThrow('Failed to initialize');
431
+ const item = createMockItem('item1');
432
+ registry.register('item1', item);
433
+
434
+ expect(watcher).not.toHaveBeenCalled();
430
435
  });
436
+ });
431
437
 
432
- it('should reject with timeout error if item is not registered within timeout', async () => {
433
- vi.useFakeTimers();
434
- const promise = registry.waitFor('item1', 100);
438
+ describe('mountEffect', () => {
439
+ it('should call onMount immediately when item is already registered', () => {
440
+ const item = createMockItem('item1');
441
+ registry.register('item1', item);
435
442
 
436
- vi.advanceTimersByTime(100);
443
+ const onMount = vi.fn();
444
+ registry.mountEffect('item1', onMount);
437
445
 
438
- await expect(promise).rejects.toThrow('Timeout waiting for item with ID "item1" to be registered.');
439
- vi.useRealTimers();
446
+ expect(onMount).toHaveBeenCalledOnce();
447
+ expect(onMount).toHaveBeenCalledWith(item);
440
448
  });
441
449
 
442
- it('should cleanup timer when item is registered before timeout', async () => {
443
- vi.useFakeTimers();
444
- const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
445
- const promise = registry.waitFor('item1', 100);
450
+ it('should call onMount when item registers later', () => {
451
+ const onMount = vi.fn();
452
+ registry.mountEffect('item1', onMount);
453
+
454
+ expect(onMount).not.toHaveBeenCalled();
446
455
 
447
456
  const item = createMockItem('item1');
448
457
  registry.register('item1', item);
449
458
 
450
- await promise;
459
+ expect(onMount).toHaveBeenCalledOnce();
460
+ expect(onMount).toHaveBeenCalledWith(item);
461
+ });
462
+
463
+ it('should call cleanup returned by onMount when item is unregistered', () => {
464
+ const item = createMockItem('item1');
465
+ registry.register('item1', item);
466
+
467
+ const cleanup = vi.fn();
468
+ registry.mountEffect('item1', () => cleanup);
469
+
470
+ registry.unregister('item1');
471
+
472
+ expect(cleanup).toHaveBeenCalledOnce();
473
+ });
474
+
475
+ it('should call cleanup and re-call onMount on re-registration', () => {
476
+ const item1a = createMockItem('item1');
477
+ registry.register('item1', item1a);
478
+
479
+ const cleanup = vi.fn();
480
+ const onMount = vi.fn(() => cleanup);
481
+ registry.mountEffect('item1', onMount);
482
+
483
+ expect(onMount).toHaveBeenCalledOnce();
484
+
485
+ registry.unregister('item1');
486
+ expect(cleanup).toHaveBeenCalledOnce();
487
+
488
+ const item1b = createMockItem('item1');
489
+ registry.register('item1', item1b);
490
+
491
+ expect(onMount).toHaveBeenCalledTimes(2);
492
+ expect(onMount).toHaveBeenLastCalledWith(item1b);
493
+ });
494
+
495
+ it('should not call onMount if item never registers', () => {
496
+ const onMount = vi.fn();
497
+ const stop = registry.mountEffect('item1', onMount);
498
+
499
+ stop();
451
500
 
452
- expect(clearTimeoutSpy).toHaveBeenCalled();
453
- vi.useRealTimers();
501
+ expect(onMount).not.toHaveBeenCalled();
454
502
  });
455
503
 
456
- it('should cleanup timer when error occurs before timeout', async () => {
457
- vi.useFakeTimers();
458
- const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
459
- const promise = registry.waitFor('item1', 100);
504
+ it('should run cleanup and stop watching when stop is called with item mounted', () => {
505
+ const item = createMockItem('item1');
506
+ registry.register('item1', item);
507
+
508
+ const cleanup = vi.fn();
509
+ const stop = registry.mountEffect('item1', () => cleanup);
510
+
511
+ stop();
512
+
513
+ expect(cleanup).toHaveBeenCalledOnce();
514
+
515
+ // Watcher should be gone — unregistering should not trigger cleanup again.
516
+ registry.unregister('item1');
517
+
518
+ expect(cleanup).toHaveBeenCalledOnce();
519
+ });
520
+
521
+ it('should not call cleanup when stop is called before item registers', () => {
522
+ const cleanup = vi.fn();
523
+ const onMount = vi.fn(() => cleanup);
524
+ const stop = registry.mountEffect('item1', onMount);
525
+
526
+ stop();
527
+
528
+ expect(onMount).not.toHaveBeenCalled();
529
+ expect(cleanup).not.toHaveBeenCalled();
530
+ });
531
+
532
+ it('should call onMount and immediately run cleanup when item registers after stop', () => {
533
+ const cleanup = vi.fn();
534
+ const onMount = vi.fn(() => cleanup);
535
+ const stop = registry.mountEffect('item1', onMount);
460
536
 
461
- registry.error('item1', new Error('fail'));
537
+ stop();
462
538
 
463
- await expect(promise).rejects.toThrow('fail');
539
+ const item = createMockItem('item1');
540
+ registry.register('item1', item);
464
541
 
465
- expect(clearTimeoutSpy).toHaveBeenCalled();
466
- vi.useRealTimers();
542
+ expect(onMount).toHaveBeenCalledOnce();
543
+ expect(onMount).toHaveBeenCalledWith(item);
544
+ expect(cleanup).toHaveBeenCalledOnce();
467
545
  });
468
546
 
469
- it('should not try to clear timer if timeout exceeded before item registration', async () => {
470
- vi.useFakeTimers();
471
- const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
472
- const promise = registry.waitFor('item1', 100);
547
+ it('should stop watching after late cleanup fires', () => {
548
+ const onMount = vi.fn();
549
+ const stop = registry.mountEffect('item1', onMount);
473
550
 
474
- // Trigger timeout
475
- vi.advanceTimersByTime(100);
551
+ stop();
476
552
 
477
- await expect(promise).rejects.toThrow('Timeout');
553
+ const item1 = createMockItem('item1');
554
+ registry.register('item1', item1);
555
+
556
+ expect(onMount).toHaveBeenCalledOnce();
557
+
558
+ // Watcher should have been removed after late cleanup — further changes are ignored.
559
+ registry.unregister('item1');
560
+ registry.register('item1', createMockItem('item1'));
478
561
 
479
- // Clear spy to reset calls made by internal mechanisms
480
- clearTimeoutSpy.mockClear();
562
+ expect(onMount).toHaveBeenCalledOnce();
563
+ });
481
564
 
482
- // Now register item - verify logic in success callback
565
+ it('should not throw when onMount returns void and stop is called', () => {
483
566
  const item = createMockItem('item1');
484
567
  registry.register('item1', item);
485
568
 
486
- expect(clearTimeoutSpy).not.toHaveBeenCalled();
569
+ const stop = registry.mountEffect('item1', () => {});
570
+
571
+ expect(() => stop()).not.toThrow();
572
+ });
573
+
574
+ it('should work with the default null ID', () => {
575
+ const item = createMockItem('item1');
576
+ registry.register('item1', item); // also registered as default
577
+
578
+ const onMount = vi.fn();
579
+ registry.mountEffect(null, onMount);
487
580
 
488
- vi.useRealTimers();
581
+ expect(onMount).toHaveBeenCalledOnce();
582
+ expect(onMount).toHaveBeenCalledWith(item);
489
583
  });
584
+ });
585
+
586
+ describe('waitFor', () => {
587
+ it('should return a promise that resolves with the item instance', async () => {
588
+ const item1 = createMockItem('item1');
589
+ registry.register('item1', item1);
590
+
591
+ const result = await registry.waitFor('item1');
490
592
 
491
- it('should not try to clear timer if timeout exceeded before error registration', async () => {
492
- vi.useFakeTimers();
493
- const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout');
494
- const promise = registry.waitFor('item1', 100);
593
+ expect(result).toBe(item1);
594
+ });
495
595
 
496
- // Trigger timeout
497
- vi.advanceTimersByTime(100);
596
+ it('should wait for the item to be registered before resolving', async () => {
597
+ const promise = registry.waitFor('item1');
598
+ const item1 = createMockItem('item1');
498
599
 
499
- await expect(promise).rejects.toThrow('Timeout');
600
+ registry.register('item1', item1);
500
601
 
501
- clearTimeoutSpy.mockClear();
602
+ expect(await promise).toBe(item1);
603
+ });
502
604
 
503
- // Now register error - verify logic in error callback
504
- registry.error('item1', new Error('late fail'));
605
+ it('should reject if error is registered after waitFor call', async () => {
606
+ const promise = registry.waitFor('item1');
505
607
 
506
- expect(clearTimeoutSpy).not.toHaveBeenCalled();
608
+ registry.error('item1', 'Failed to initialize');
507
609
 
508
- vi.useRealTimers();
610
+ await expect(promise).rejects.toThrow('Failed to initialize');
509
611
  });
510
612
  });
511
613
 
@@ -597,7 +699,7 @@ describe('async registry', () => {
597
699
  const item1 = createMockItem('item1');
598
700
  registry.register('item1', item1);
599
701
 
600
- expect(watcher).toHaveBeenCalledTimes(3);
702
+ expect(watcher).toHaveBeenCalledTimes(2);
601
703
  });
602
704
 
603
705
  it('should call watcher when item is unregistered', () => {
@@ -610,7 +712,7 @@ describe('async registry', () => {
610
712
  watcher.mockClear();
611
713
  registry.unregister('item1');
612
714
 
613
- expect(watcher).toHaveBeenCalledTimes(2); // Unregister + default unregister
715
+ expect(watcher).toHaveBeenCalledTimes(1);
614
716
  });
615
717
 
616
718
  it('should call watcher when all items are destroyed', async () => {