ckeditor5-blazor 0.1.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 (219) hide show
  1. package/dist/ckeditor5-blazor-error.d.ts +7 -0
  2. package/dist/ckeditor5-blazor-error.d.ts.map +1 -0
  3. package/dist/elements/context/context.d.ts +26 -0
  4. package/dist/elements/context/context.d.ts.map +1 -0
  5. package/dist/elements/context/contexts-registry.d.ts +9 -0
  6. package/dist/elements/context/contexts-registry.d.ts.map +1 -0
  7. package/dist/elements/context/index.d.ts +4 -0
  8. package/dist/elements/context/index.d.ts.map +1 -0
  9. package/dist/elements/context/typings.d.ts +34 -0
  10. package/dist/elements/context/typings.d.ts.map +1 -0
  11. package/dist/elements/editable.d.ts +34 -0
  12. package/dist/elements/editable.d.ts.map +1 -0
  13. package/dist/elements/editor/custom-editor-plugins.d.ts +54 -0
  14. package/dist/elements/editor/custom-editor-plugins.d.ts.map +1 -0
  15. package/dist/elements/editor/editor.d.ts +31 -0
  16. package/dist/elements/editor/editor.d.ts.map +1 -0
  17. package/dist/elements/editor/editors-registry.d.ts +9 -0
  18. package/dist/elements/editor/editors-registry.d.ts.map +1 -0
  19. package/dist/elements/editor/index.d.ts +3 -0
  20. package/dist/elements/editor/index.d.ts.map +1 -0
  21. package/dist/elements/editor/plugins/dispatch-editor-roots-change-event.d.ts +23 -0
  22. package/dist/elements/editor/plugins/dispatch-editor-roots-change-event.d.ts.map +1 -0
  23. package/dist/elements/editor/plugins/index.d.ts +3 -0
  24. package/dist/elements/editor/plugins/index.d.ts.map +1 -0
  25. package/dist/elements/editor/plugins/sync-editor-with-input.d.ts +6 -0
  26. package/dist/elements/editor/plugins/sync-editor-with-input.d.ts.map +1 -0
  27. package/dist/elements/editor/typings.d.ts +99 -0
  28. package/dist/elements/editor/typings.d.ts.map +1 -0
  29. package/dist/elements/editor/utils/create-editor-in-context.d.ts +44 -0
  30. package/dist/elements/editor/utils/create-editor-in-context.d.ts.map +1 -0
  31. package/dist/elements/editor/utils/get-editor-roots-values.d.ts +9 -0
  32. package/dist/elements/editor/utils/get-editor-roots-values.d.ts.map +1 -0
  33. package/dist/elements/editor/utils/index.d.ts +14 -0
  34. package/dist/elements/editor/utils/index.d.ts.map +1 -0
  35. package/dist/elements/editor/utils/is-single-root-editor.d.ts +9 -0
  36. package/dist/elements/editor/utils/is-single-root-editor.d.ts.map +1 -0
  37. package/dist/elements/editor/utils/load-editor-constructor.d.ts +9 -0
  38. package/dist/elements/editor/utils/load-editor-constructor.d.ts.map +1 -0
  39. package/dist/elements/editor/utils/load-editor-plugins.d.ts +20 -0
  40. package/dist/elements/editor/utils/load-editor-plugins.d.ts.map +1 -0
  41. package/dist/elements/editor/utils/load-editor-translations.d.ts +14 -0
  42. package/dist/elements/editor/utils/load-editor-translations.d.ts.map +1 -0
  43. package/dist/elements/editor/utils/normalize-custom-translations.d.ts +11 -0
  44. package/dist/elements/editor/utils/normalize-custom-translations.d.ts.map +1 -0
  45. package/dist/elements/editor/utils/query-all-editor-ids.d.ts +5 -0
  46. package/dist/elements/editor/utils/query-all-editor-ids.d.ts.map +1 -0
  47. package/dist/elements/editor/utils/query-editor-editables.d.ts +25 -0
  48. package/dist/elements/editor/utils/query-editor-editables.d.ts.map +1 -0
  49. package/dist/elements/editor/utils/resolve-editor-config-elements-references.d.ts +9 -0
  50. package/dist/elements/editor/utils/resolve-editor-config-elements-references.d.ts.map +1 -0
  51. package/dist/elements/editor/utils/resolve-editor-config-translations.d.ts +25 -0
  52. package/dist/elements/editor/utils/resolve-editor-config-translations.d.ts.map +1 -0
  53. package/dist/elements/editor/utils/set-editor-editable-height.d.ts +9 -0
  54. package/dist/elements/editor/utils/set-editor-editable-height.d.ts.map +1 -0
  55. package/dist/elements/editor/utils/wrap-with-watchdog.d.ts +24 -0
  56. package/dist/elements/editor/utils/wrap-with-watchdog.d.ts.map +1 -0
  57. package/dist/elements/ensure-editor-elements-registered.d.ts +5 -0
  58. package/dist/elements/ensure-editor-elements-registered.d.ts.map +1 -0
  59. package/dist/elements/index.d.ts +6 -0
  60. package/dist/elements/index.d.ts.map +1 -0
  61. package/dist/elements/ui-part.d.ts +18 -0
  62. package/dist/elements/ui-part.d.ts.map +1 -0
  63. package/dist/index.cjs +5 -0
  64. package/dist/index.cjs.map +1 -0
  65. package/dist/index.d.ts +11 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.mjs +1400 -0
  68. package/dist/index.mjs.map +1 -0
  69. package/dist/interop/create-context-blazor-interop.d.ts +10 -0
  70. package/dist/interop/create-context-blazor-interop.d.ts.map +1 -0
  71. package/dist/interop/create-editable-blazor-interop.d.ts +21 -0
  72. package/dist/interop/create-editable-blazor-interop.d.ts.map +1 -0
  73. package/dist/interop/create-editor-blazor-interop.d.ts +19 -0
  74. package/dist/interop/create-editor-blazor-interop.d.ts.map +1 -0
  75. package/dist/interop/create-ui-part-blazor-interop.d.ts +10 -0
  76. package/dist/interop/create-ui-part-blazor-interop.d.ts.map +1 -0
  77. package/dist/interop/index.d.ts +5 -0
  78. package/dist/interop/index.d.ts.map +1 -0
  79. package/dist/interop/utils/create-editor-value-sync.d.ts +63 -0
  80. package/dist/interop/utils/create-editor-value-sync.d.ts.map +1 -0
  81. package/dist/interop/utils/index.d.ts +2 -0
  82. package/dist/interop/utils/index.d.ts.map +1 -0
  83. package/dist/shared/async-registry.d.ts +136 -0
  84. package/dist/shared/async-registry.d.ts.map +1 -0
  85. package/dist/shared/camel-case.d.ts +8 -0
  86. package/dist/shared/camel-case.d.ts.map +1 -0
  87. package/dist/shared/debounce.d.ts +2 -0
  88. package/dist/shared/debounce.d.ts.map +1 -0
  89. package/dist/shared/deep-camel-case-keys.d.ts +8 -0
  90. package/dist/shared/deep-camel-case-keys.d.ts.map +1 -0
  91. package/dist/shared/filter-object-values.d.ts +9 -0
  92. package/dist/shared/filter-object-values.d.ts.map +1 -0
  93. package/dist/shared/index.d.ts +16 -0
  94. package/dist/shared/index.d.ts.map +1 -0
  95. package/dist/shared/is-empty-object.d.ts +2 -0
  96. package/dist/shared/is-empty-object.d.ts.map +1 -0
  97. package/dist/shared/is-plain-object.d.ts +8 -0
  98. package/dist/shared/is-plain-object.d.ts.map +1 -0
  99. package/dist/shared/map-object-values.d.ts +11 -0
  100. package/dist/shared/map-object-values.d.ts.map +1 -0
  101. package/dist/shared/once.d.ts +2 -0
  102. package/dist/shared/once.d.ts.map +1 -0
  103. package/dist/shared/shallow-equal.d.ts +9 -0
  104. package/dist/shared/shallow-equal.d.ts.map +1 -0
  105. package/dist/shared/timeout.d.ts +8 -0
  106. package/dist/shared/timeout.d.ts.map +1 -0
  107. package/dist/shared/uid.d.ts +7 -0
  108. package/dist/shared/uid.d.ts.map +1 -0
  109. package/dist/shared/wait-for-dom-ready.d.ts +5 -0
  110. package/dist/shared/wait-for-dom-ready.d.ts.map +1 -0
  111. package/dist/shared/wait-for-interactive-attribute.d.ts +18 -0
  112. package/dist/shared/wait-for-interactive-attribute.d.ts.map +1 -0
  113. package/dist/shared/wait-for.d.ts +20 -0
  114. package/dist/shared/wait-for.d.ts.map +1 -0
  115. package/dist/types/can-be-promise.type.d.ts +2 -0
  116. package/dist/types/can-be-promise.type.d.ts.map +1 -0
  117. package/dist/types/dot-net-interop.type.d.ts +7 -0
  118. package/dist/types/dot-net-interop.type.d.ts.map +1 -0
  119. package/dist/types/index.d.ts +4 -0
  120. package/dist/types/index.d.ts.map +1 -0
  121. package/dist/types/required-by.type.d.ts +2 -0
  122. package/dist/types/required-by.type.d.ts.map +1 -0
  123. package/package.json +49 -0
  124. package/src/ckeditor5-blazor-error.ts +9 -0
  125. package/src/elements/context/context.test.ts +323 -0
  126. package/src/elements/context/context.ts +128 -0
  127. package/src/elements/context/contexts-registry.test.ts +10 -0
  128. package/src/elements/context/contexts-registry.ts +10 -0
  129. package/src/elements/context/index.ts +3 -0
  130. package/src/elements/context/typings.ts +38 -0
  131. package/src/elements/editable.test.ts +383 -0
  132. package/src/elements/editable.ts +183 -0
  133. package/src/elements/editor/custom-editor-plugins.test.ts +103 -0
  134. package/src/elements/editor/custom-editor-plugins.ts +85 -0
  135. package/src/elements/editor/editor.test.ts +562 -0
  136. package/src/elements/editor/editor.ts +330 -0
  137. package/src/elements/editor/editors-registry.test.ts +10 -0
  138. package/src/elements/editor/editors-registry.ts +10 -0
  139. package/src/elements/editor/index.ts +2 -0
  140. package/src/elements/editor/plugins/dispatch-editor-roots-change-event.ts +76 -0
  141. package/src/elements/editor/plugins/index.ts +2 -0
  142. package/src/elements/editor/plugins/sync-editor-with-input.ts +79 -0
  143. package/src/elements/editor/typings.ts +114 -0
  144. package/src/elements/editor/utils/create-editor-in-context.ts +89 -0
  145. package/src/elements/editor/utils/get-editor-roots-values.test.ts +48 -0
  146. package/src/elements/editor/utils/get-editor-roots-values.ts +21 -0
  147. package/src/elements/editor/utils/index.ts +13 -0
  148. package/src/elements/editor/utils/is-single-root-editor.test.ts +40 -0
  149. package/src/elements/editor/utils/is-single-root-editor.ts +11 -0
  150. package/src/elements/editor/utils/load-editor-constructor.test.ts +62 -0
  151. package/src/elements/editor/utils/load-editor-constructor.ts +29 -0
  152. package/src/elements/editor/utils/load-editor-plugins.test.ts +100 -0
  153. package/src/elements/editor/utils/load-editor-plugins.ts +72 -0
  154. package/src/elements/editor/utils/load-editor-translations.ts +232 -0
  155. package/src/elements/editor/utils/normalize-custom-translations.test.ts +152 -0
  156. package/src/elements/editor/utils/normalize-custom-translations.ts +17 -0
  157. package/src/elements/editor/utils/query-all-editor-ids.ts +9 -0
  158. package/src/elements/editor/utils/query-editor-editables.ts +101 -0
  159. package/src/elements/editor/utils/resolve-editor-config-elements-references.test.ts +93 -0
  160. package/src/elements/editor/utils/resolve-editor-config-elements-references.ts +36 -0
  161. package/src/elements/editor/utils/resolve-editor-config-translations.test.ts +131 -0
  162. package/src/elements/editor/utils/resolve-editor-config-translations.ts +77 -0
  163. package/src/elements/editor/utils/set-editor-editable-height.test.ts +131 -0
  164. package/src/elements/editor/utils/set-editor-editable-height.ts +15 -0
  165. package/src/elements/editor/utils/wrap-with-watchdog.test.ts +45 -0
  166. package/src/elements/editor/utils/wrap-with-watchdog.ts +51 -0
  167. package/src/elements/ensure-editor-elements-registered.ts +24 -0
  168. package/src/elements/index.ts +14 -0
  169. package/src/elements/ui-part.test.ts +156 -0
  170. package/src/elements/ui-part.ts +84 -0
  171. package/src/index.ts +15 -0
  172. package/src/interop/create-context-blazor-interop.test.ts +30 -0
  173. package/src/interop/create-context-blazor-interop.ts +15 -0
  174. package/src/interop/create-editable-blazor-interop.test.ts +213 -0
  175. package/src/interop/create-editable-blazor-interop.ts +98 -0
  176. package/src/interop/create-editor-blazor-interop.test.ts +183 -0
  177. package/src/interop/create-editor-blazor-interop.ts +112 -0
  178. package/src/interop/create-ui-part-blazor-interop.test.ts +30 -0
  179. package/src/interop/create-ui-part-blazor-interop.ts +15 -0
  180. package/src/interop/index.ts +4 -0
  181. package/src/interop/utils/create-editor-value-sync.test.ts +302 -0
  182. package/src/interop/utils/create-editor-value-sync.ts +160 -0
  183. package/src/interop/utils/index.ts +1 -0
  184. package/src/shared/async-registry.test.ts +737 -0
  185. package/src/shared/async-registry.ts +353 -0
  186. package/src/shared/camel-case.test.ts +35 -0
  187. package/src/shared/camel-case.ts +11 -0
  188. package/src/shared/debounce.test.ts +72 -0
  189. package/src/shared/debounce.ts +16 -0
  190. package/src/shared/deep-camel-case-keys.test.ts +34 -0
  191. package/src/shared/deep-camel-case-keys.ts +26 -0
  192. package/src/shared/filter-object-values.test.ts +25 -0
  193. package/src/shared/filter-object-values.ts +17 -0
  194. package/src/shared/index.ts +15 -0
  195. package/src/shared/is-empty-object.test.ts +78 -0
  196. package/src/shared/is-empty-object.ts +3 -0
  197. package/src/shared/is-plain-object.test.ts +38 -0
  198. package/src/shared/is-plain-object.ts +15 -0
  199. package/src/shared/map-object-values.test.ts +29 -0
  200. package/src/shared/map-object-values.ts +19 -0
  201. package/src/shared/once.test.ts +116 -0
  202. package/src/shared/once.ts +12 -0
  203. package/src/shared/shallow-equal.test.ts +51 -0
  204. package/src/shared/shallow-equal.ts +30 -0
  205. package/src/shared/timeout.test.ts +65 -0
  206. package/src/shared/timeout.ts +13 -0
  207. package/src/shared/uid.test.ts +25 -0
  208. package/src/shared/uid.ts +8 -0
  209. package/src/shared/wait-for-dom-ready.test.ts +87 -0
  210. package/src/shared/wait-for-dom-ready.ts +21 -0
  211. package/src/shared/wait-for-interactive-attribute.test.ts +93 -0
  212. package/src/shared/wait-for-interactive-attribute.ts +50 -0
  213. package/src/shared/wait-for.test.ts +24 -0
  214. package/src/shared/wait-for.ts +56 -0
  215. package/src/types/can-be-promise.type.ts +1 -0
  216. package/src/types/dot-net-interop.type.ts +6 -0
  217. package/src/types/dotnet-global.d.ts +14 -0
  218. package/src/types/index.ts +3 -0
  219. package/src/types/required-by.type.ts +1 -0
@@ -0,0 +1,112 @@
1
+ import type { DotNetInterop } from '../types';
2
+
3
+ import { ensureEditorElementsRegistered } from '../elements';
4
+ import { EditorsRegistry } from '../elements/editor/editors-registry';
5
+ import { CKEditor5ChangeDataEvent } from '../elements/editor/plugins/dispatch-editor-roots-change-event';
6
+ import { getEditorRootsValues } from '../elements/editor/utils';
7
+ import { markElementAsInteractive, shallowEqual } from '../shared';
8
+ import { createEditorValueSync, createNoopSync } from './utils/create-editor-value-sync';
9
+
10
+ /**
11
+ * Creates an interop layer to synchronize a CKEditor 5 instance with a Blazor component.
12
+ *
13
+ * @param element - The root HTML element of the editor component, used to identify the editor instance and attach necessary attributes.
14
+ * @param interop - The .NET object reference to trigger Blazor methods.
15
+ * @returns An object containing lifecycle and synchronization methods.
16
+ */
17
+ export function createEditorBlazorInterop(element: HTMLElement, interop: DotNetInterop) {
18
+ const editorId = element.getAttribute('data-cke-editor-id');
19
+
20
+ let unmounted = false;
21
+ let unmountCKEditorListeners: VoidFunction | null = null;
22
+
23
+ let sync = createNoopSync<Record<string, string>>();
24
+ let editorRef: unknown | null = null;
25
+
26
+ // Handles data change events dispatched by the CKEditor plugin.
27
+ // Dispatches updates back to Blazor if the data has changed.
28
+ const onDataChange = (event: Event) => {
29
+ if (!(event instanceof CKEditor5ChangeDataEvent) || event.detail.editorId !== editorId) {
30
+ return;
31
+ }
32
+
33
+ if (sync.shouldNotify(event.detail.roots)) {
34
+ void interop.invokeMethodAsync('OnChangeEditorData', editorRef!, event.detail.roots);
35
+ }
36
+ };
37
+
38
+ /**
39
+ * Initializes the focus tracker and model listeners for the editor.
40
+ */
41
+ const initializeSynchronization = async () => {
42
+ const editor = await EditorsRegistry.the.waitFor(editorId);
43
+
44
+ editorRef = globalThis.DotNet.createJSObjectReference(editor);
45
+ sync = createEditorValueSync(editor, {
46
+ getCurrentValue: () => getEditorRootsValues(editor),
47
+ applyValue: value => editor.setData(value),
48
+ isEqual: shallowEqual,
49
+ });
50
+
51
+ // Notify Blazor of focus changes so it can trigger the appropriate callbacks.
52
+ const onFocusChange = (_evt: unknown, _name: unknown, isFocused: boolean) => {
53
+ const method = isFocused ? 'OnEditorFocus' : 'OnEditorBlur';
54
+
55
+ void interop.invokeMethodAsync(method, editorRef);
56
+ };
57
+
58
+ editor.ui.focusTracker.on('change:isFocused', onFocusChange);
59
+
60
+ // Notify Blazor that the editor instance is ready so the consumer can
61
+ // retain an IJSObjectReference or perform additional JS calls directly.
62
+ // This mirrors the `OnChangeEditorData` (which now also drives the public
63
+ // `OnChange` event) as well as `OnEditorFocus` and `OnEditorBlur` callbacks
64
+ // that already exist on the .NET side.
65
+ void interop.invokeMethodAsync('OnEditorReady', editorRef);
66
+
67
+ // When the Blazor component is disposed, clean up event listeners.
68
+ unmountCKEditorListeners = () => {
69
+ editor.ui.focusTracker.off('change:isFocused', onFocusChange);
70
+ };
71
+ };
72
+
73
+ void initializeSynchronization();
74
+ document.body.addEventListener(CKEditor5ChangeDataEvent.EVENT_NAME, onDataChange);
75
+
76
+ ensureEditorElementsRegistered();
77
+ markElementAsInteractive(element);
78
+
79
+ return {
80
+ /**
81
+ * Updates the editor data from Blazor. If the editor is focused, the update is deferred until blur to avoid interrupting the user.
82
+ */
83
+ setValue: async (value: Record<string, string>) => {
84
+ if (unmounted) {
85
+ return;
86
+ }
87
+
88
+ await EditorsRegistry.the.waitFor(editorId);
89
+ sync.setValue(value);
90
+ },
91
+
92
+ /**
93
+ * Cleans up all event listeners when the Blazor component is disposed.
94
+ */
95
+ unmount() {
96
+ if (unmounted) {
97
+ return;
98
+ }
99
+
100
+ document.body.removeEventListener(CKEditor5ChangeDataEvent.EVENT_NAME, onDataChange);
101
+ sync.unmount();
102
+ unmountCKEditorListeners?.();
103
+
104
+ if (editorRef) {
105
+ globalThis.DotNet.disposeJSObjectReference(editorRef);
106
+ editorRef = null;
107
+ }
108
+
109
+ unmounted = true;
110
+ },
111
+ };
112
+ }
@@ -0,0 +1,30 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
+
3
+ import { createUIPartBlazorInterop } from './create-ui-part-blazor-interop';
4
+
5
+ describe('createUIPartBlazorInterop', () => {
6
+ let element: HTMLElement;
7
+
8
+ beforeEach(() => {
9
+ element = document.createElement('ckeditor5-editable');
10
+ document.body.appendChild(element);
11
+ });
12
+
13
+ afterEach(() => {
14
+ element.remove();
15
+ });
16
+
17
+ it('should mark the element as interactive after initialization', () => {
18
+ expect(element.hasAttribute('data-cke-interactive')).toBe(false);
19
+
20
+ createUIPartBlazorInterop(element);
21
+
22
+ expect(element.getAttribute('data-cke-interactive')).toBe('true');
23
+ });
24
+
25
+ it('unmount should not throw', () => {
26
+ const interop = createUIPartBlazorInterop(element);
27
+
28
+ expect(() => interop.unmount()).not.toThrow();
29
+ });
30
+ });
@@ -0,0 +1,15 @@
1
+ import { markElementAsInteractive } from '../shared';
2
+
3
+ /**
4
+ * Creates a simple interop layer for CKEditor 5 UI part to set the interactive attribute.
5
+ *
6
+ * @param element - The root HTML element of the UI part component, used to identify
7
+ * the UI part instance and attach necessary attributes.
8
+ */
9
+ export function createUIPartBlazorInterop(element: HTMLElement) {
10
+ markElementAsInteractive(element);
11
+
12
+ return {
13
+ unmount() {},
14
+ };
15
+ }
@@ -0,0 +1,4 @@
1
+ export * from './create-context-blazor-interop';
2
+ export * from './create-editable-blazor-interop';
3
+ export * from './create-editor-blazor-interop';
4
+ export * from './create-ui-part-blazor-interop';
@@ -0,0 +1,302 @@
1
+ import { ClassicEditor, Essentials, Paragraph } from 'ckeditor5';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { createEditorValueSync, createNoopSync } from './create-editor-value-sync';
5
+
6
+ describe('editorValueSync', () => {
7
+ let editor: ClassicEditor;
8
+ let element: HTMLDivElement;
9
+
10
+ beforeEach(async () => {
11
+ element = document.createElement('div');
12
+ document.body.appendChild(element);
13
+
14
+ editor = await ClassicEditor.create(element, {
15
+ licenseKey: 'GPL',
16
+ plugins: [Essentials, Paragraph],
17
+ toolbar: [],
18
+ });
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await editor.destroy();
23
+ element.remove();
24
+ });
25
+
26
+ describe('createNoopSync', () => {
27
+ it('noop: shouldNotify always returns false', () => {
28
+ const sync = createNoopSync<string>();
29
+
30
+ expect(sync.shouldNotify('hello')).toBe(false);
31
+ expect(sync.shouldNotify('')).toBe(false);
32
+ });
33
+
34
+ it('noop: setValue and unmount do not throw', () => {
35
+ const sync = createNoopSync<string>();
36
+
37
+ expect(() => sync.setValue('x')).not.toThrow();
38
+ expect(() => sync.unmount()).not.toThrow();
39
+ });
40
+ });
41
+
42
+ describe('shouldNotify', () => {
43
+ it('returns true on first call', () => {
44
+ const sync = createEditorValueSync(editor, {
45
+ getCurrentValue: () => editor.getData(),
46
+ applyValue: v => editor.setData(v),
47
+ isEqual: (a, b) => a === b,
48
+ });
49
+
50
+ expect(sync.shouldNotify('first')).toBe(true);
51
+ sync.unmount();
52
+ });
53
+
54
+ it('returns false when the same value is passed twice', () => {
55
+ const sync = createEditorValueSync(editor, {
56
+ getCurrentValue: () => editor.getData(),
57
+ applyValue: v => editor.setData(v),
58
+ isEqual: (a, b) => a === b,
59
+ });
60
+
61
+ sync.shouldNotify('same');
62
+ expect(sync.shouldNotify('same')).toBe(false);
63
+ sync.unmount();
64
+ });
65
+
66
+ it('returns true when the value changes', () => {
67
+ const sync = createEditorValueSync(editor, {
68
+ getCurrentValue: () => editor.getData(),
69
+ applyValue: v => editor.setData(v),
70
+ isEqual: (a, b) => a === b,
71
+ });
72
+
73
+ sync.shouldNotify('first');
74
+ expect(sync.shouldNotify('second')).toBe(true);
75
+ sync.unmount();
76
+ });
77
+ });
78
+
79
+ describe('setValue — editor not focused', () => {
80
+ it('applies value immediately when editor is not focused', () => {
81
+ const applyValue = vi.fn((v: string) => editor.setData(v));
82
+ const sync = createEditorValueSync(editor, {
83
+ getCurrentValue: () => editor.getData(),
84
+ applyValue,
85
+ isEqual: (a, b) => a === b,
86
+ });
87
+
88
+ sync.setValue('<p>hello</p>');
89
+
90
+ expect(applyValue).toHaveBeenCalledOnce();
91
+ expect(applyValue).toHaveBeenCalledWith('<p>hello</p>');
92
+ sync.unmount();
93
+ });
94
+
95
+ it('skips applying when value matches lastSyncedValue', () => {
96
+ const applyValue = vi.fn((v: string) => editor.setData(v));
97
+ const sync = createEditorValueSync(editor, {
98
+ getCurrentValue: () => editor.getData(),
99
+ applyValue,
100
+ isEqual: (a, b) => a === b,
101
+ });
102
+
103
+ sync.setValue('<p>hello</p>');
104
+ applyValue.mockClear();
105
+
106
+ sync.setValue('<p>hello</p>');
107
+ expect(applyValue).not.toHaveBeenCalled();
108
+ sync.unmount();
109
+ });
110
+
111
+ it('skips when value equals lastSyncedValue set by shouldNotify', () => {
112
+ const applyValue = vi.fn((v: string) => editor.setData(v));
113
+ const sync = createEditorValueSync(editor, {
114
+ getCurrentValue: () => editor.getData(),
115
+ applyValue,
116
+ isEqual: (a, b) => a === b,
117
+ });
118
+
119
+ sync.shouldNotify('<p>blazor-sent</p>');
120
+ sync.setValue('<p>blazor-sent</p>');
121
+
122
+ expect(applyValue).not.toHaveBeenCalled();
123
+ sync.unmount();
124
+ });
125
+ });
126
+
127
+ describe('setValue — editor focused', () => {
128
+ it('defers value while editor is focused, applies on blur', async () => {
129
+ const applyValue = vi.fn((v: string) => editor.setData(v));
130
+ const sync = createEditorValueSync(editor, {
131
+ getCurrentValue: () => editor.getData(),
132
+ applyValue,
133
+ isEqual: (a, b) => a === b,
134
+ });
135
+
136
+ editor.editing.view.focus();
137
+ expect(editor.ui.focusTracker.isFocused).toBe(true);
138
+
139
+ sync.setValue('<p>deferred</p>');
140
+ expect(applyValue).not.toHaveBeenCalled();
141
+
142
+ (document.activeElement as HTMLElement)?.blur();
143
+ await vi.waitFor(() => expect(editor.ui.focusTracker.isFocused).toBe(false));
144
+
145
+ expect(applyValue).toHaveBeenCalledOnce();
146
+ expect(applyValue).toHaveBeenCalledWith('<p>deferred</p>');
147
+ sync.unmount();
148
+ });
149
+
150
+ it('last value wins when setValue is called multiple times while focused', async () => {
151
+ const applyValue = vi.fn((v: string) => editor.setData(v));
152
+ const sync = createEditorValueSync(editor, {
153
+ getCurrentValue: () => editor.getData(),
154
+ applyValue,
155
+ isEqual: (a, b) => a === b,
156
+ });
157
+
158
+ editor.editing.view.focus();
159
+
160
+ sync.setValue('<p>v1</p>');
161
+ sync.setValue('<p>v2</p>');
162
+ sync.setValue('<p>v3</p>');
163
+
164
+ (document.activeElement as HTMLElement)?.blur();
165
+ await vi.waitFor(() => expect(editor.ui.focusTracker.isFocused).toBe(false));
166
+
167
+ expect(applyValue).toHaveBeenCalledOnce();
168
+ expect(applyValue).toHaveBeenCalledWith('<p>v3</p>');
169
+ sync.unmount();
170
+ });
171
+
172
+ it('discards pending value when user types before blur', async () => {
173
+ const applyValue = vi.fn((v: string) => editor.setData(v));
174
+ const sync = createEditorValueSync(editor, {
175
+ getCurrentValue: () => editor.getData(),
176
+ applyValue,
177
+ isEqual: (a, b) => a === b,
178
+ });
179
+
180
+ editor.editing.view.focus();
181
+ sync.setValue('<p>stale</p>');
182
+
183
+ // Simulate user typing (internal model change → clears pendingValue)
184
+ editor.model.change((writer) => {
185
+ const root = editor.model.document.getRoot()!;
186
+ const paragraph = writer.createElement('paragraph');
187
+ writer.append(paragraph, root);
188
+ writer.insertText('typed', paragraph);
189
+ });
190
+
191
+ (document.activeElement as HTMLElement)?.blur();
192
+ await vi.waitFor(() => expect(editor.ui.focusTracker.isFocused).toBe(false));
193
+
194
+ expect(applyValue).not.toHaveBeenCalled();
195
+ sync.unmount();
196
+ });
197
+
198
+ it('does not apply pending value on blur when it matches current editor content', async () => {
199
+ const applyValue = vi.fn((v: string) => editor.setData(v));
200
+
201
+ editor.setData('<p>already here</p>');
202
+
203
+ const sync = createEditorValueSync(editor, {
204
+ getCurrentValue: () => editor.getData(),
205
+ applyValue,
206
+ isEqual: (a, b) => a === b,
207
+ });
208
+
209
+ editor.editing.view.focus();
210
+ sync.setValue('<p>already here</p>');
211
+
212
+ (document.activeElement as HTMLElement)?.blur();
213
+ await vi.waitFor(() => expect(editor.ui.focusTracker.isFocused).toBe(false));
214
+
215
+ expect(applyValue).not.toHaveBeenCalled();
216
+ sync.unmount();
217
+ });
218
+ });
219
+
220
+ describe('unmount', () => {
221
+ it('stops applying deferred values after unmount', async () => {
222
+ const applyValue = vi.fn((v: string) => editor.setData(v));
223
+ const sync = createEditorValueSync(editor, {
224
+ getCurrentValue: () => editor.getData(),
225
+ applyValue,
226
+ isEqual: (a, b) => a === b,
227
+ });
228
+
229
+ editor.editing.view.focus();
230
+ sync.setValue('<p>pending</p>');
231
+
232
+ sync.unmount();
233
+
234
+ (document.activeElement as HTMLElement)?.blur();
235
+ await vi.waitFor(() => expect(editor.ui.focusTracker.isFocused).toBe(false));
236
+
237
+ expect(applyValue).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it('is safe to call multiple times', () => {
241
+ const sync = createEditorValueSync(editor, {
242
+ getCurrentValue: () => editor.getData(),
243
+ applyValue: v => editor.setData(v),
244
+ isEqual: (a, b) => a === b,
245
+ });
246
+
247
+ expect(() => {
248
+ sync.unmount();
249
+ sync.unmount();
250
+ }).not.toThrow();
251
+ });
252
+ });
253
+
254
+ describe('integration', () => {
255
+ it('stale Blazor update does not overwrite user edits', async () => {
256
+ const applyValue = vi.fn((v: string) => editor.setData(v));
257
+ const sync = createEditorValueSync(editor, {
258
+ getCurrentValue: () => editor.getData(),
259
+ applyValue,
260
+ isEqual: (a, b) => a === b,
261
+ });
262
+
263
+ sync.shouldNotify('<p>Hello</p>');
264
+
265
+ editor.editing.view.focus();
266
+
267
+ // Blazor sends back the old (stale) value while user is typing
268
+ sync.setValue('<p>Hello</p>');
269
+
270
+ // User types something — change:data fires and discards the pending value
271
+ editor.model.change((writer) => {
272
+ const root = editor.model.document.getRoot()!;
273
+ const paragraph = writer.createElement('paragraph');
274
+ writer.append(paragraph, root);
275
+ writer.insertText(' World', paragraph);
276
+ });
277
+
278
+ (document.activeElement as HTMLElement)?.blur();
279
+ await vi.waitFor(() => expect(editor.ui.focusTracker.isFocused).toBe(false));
280
+
281
+ expect(applyValue).not.toHaveBeenCalled();
282
+ sync.unmount();
283
+ });
284
+
285
+ it('fresh Blazor value is applied when editor is idle', () => {
286
+ const applyValue = vi.fn((v: string) => editor.setData(v));
287
+ const sync = createEditorValueSync(editor, {
288
+ getCurrentValue: () => editor.getData(),
289
+ applyValue,
290
+ isEqual: (a, b) => a === b,
291
+ });
292
+
293
+ sync.shouldNotify('<p>old</p>');
294
+ applyValue.mockClear();
295
+
296
+ sync.setValue('<p>brand new</p>');
297
+
298
+ expect(applyValue).toHaveBeenCalledWith('<p>brand new</p>');
299
+ sync.unmount();
300
+ });
301
+ });
302
+ });
@@ -0,0 +1,160 @@
1
+ import type { Editor } from 'ckeditor5';
2
+
3
+ /**
4
+ * Creates a focus-aware value synchronization layer for a CKEditor 5 instance.
5
+ *
6
+ * Handles the shared mechanics present in both the Editor and Editable Blazor interops:
7
+ * - Tracking `lastSyncedValue` to prevent circular Blazor ↔ JS updates.
8
+ * - Storing a `pendingValue` when a Blazor update arrives while the editor is focused,
9
+ * and applying it safely once the user blurs.
10
+ * - Registering and cleaning up the `change:data` and `change:isFocused` editor listeners.
11
+ *
12
+ * @param editor - The CKEditor 5 editor instance to synchronize with.
13
+ * @param options - Callbacks and equality check specific to the consumer (editor vs editable).
14
+ * @param options.getCurrentValue - Returns the current value of the tracked root(s) from the editor.
15
+ * @param options.applyValue - Applies the given value to the editor (e.g. via `editor.setData`).
16
+ * @param options.isEqual - Returns true if two values are considered equal, used to avoid redundant updates.
17
+ * @returns An object with `setValue`, `shouldNotify`, and `unmount`.
18
+ */
19
+ export function createEditorValueSync<T>(
20
+ editor: Editor,
21
+ options: {
22
+
23
+ /**
24
+ * Returns the current value of the tracked root(s) from the editor.
25
+ * Called to compare against the pending value before applying it.
26
+ */
27
+ getCurrentValue: () => T;
28
+
29
+ /**
30
+ * Applies the given value to the editor (e.g. via `editor.setData`).
31
+ */
32
+ applyValue: (value: T) => void;
33
+
34
+ /**
35
+ * Returns true if two values are considered equal, used to avoid redundant
36
+ * updates in both directions.
37
+ */
38
+ isEqual: (a: T, b: T) => boolean;
39
+ },
40
+ ): EditorValueSync<T> {
41
+ const state = {
42
+ /** Value received from Blazor while the editor was focused, pending application on blur. */
43
+ pendingValue: null as T | null,
44
+
45
+ /** The last value sent to or received from Blazor to prevent circular updates. */
46
+ lastSyncedValue: null as T | null,
47
+ };
48
+
49
+ /**
50
+ * Any internal model change means the pending external value is stale — discard it.
51
+ */
52
+ const onChangeData = () => {
53
+ state.pendingValue = null;
54
+ };
55
+
56
+ /**
57
+ * When the editor loses focus, apply any value that Blazor sent while the user was typing.
58
+ */
59
+ const onFocusChange = (_evt: unknown, _name: unknown, isFocused: boolean) => {
60
+ if (isFocused || state.pendingValue === null) {
61
+ return;
62
+ }
63
+
64
+ const current = options.getCurrentValue();
65
+
66
+ if (!options.isEqual(current, state.pendingValue)) {
67
+ options.applyValue(state.pendingValue);
68
+ }
69
+
70
+ state.pendingValue = null;
71
+ };
72
+
73
+ editor.model.document.on('change:data', onChangeData);
74
+ editor.ui.focusTracker.on('change:isFocused', onFocusChange);
75
+
76
+ return {
77
+ /**
78
+ * Removes all editor listeners registered by this sync instance.
79
+ * Call this when the Blazor component is disposed.
80
+ */
81
+ unmount() {
82
+ editor.model.document.off('change:data', onChangeData);
83
+ editor.ui.focusTracker.off('change:isFocused', onFocusChange);
84
+ },
85
+
86
+ /**
87
+ * Checks whether the given value differs from the last synced value and, if so,
88
+ * updates `lastSyncedValue` and returns `true` to signal that Blazor should be notified.
89
+ *
90
+ * Call this from the `CKEditor5ChangeDataEvent` handler to conditionally invoke
91
+ * the .NET interop method.
92
+ */
93
+ shouldNotify(value: T): boolean {
94
+ if (state.lastSyncedValue !== null && options.isEqual(state.lastSyncedValue, value)) {
95
+ return false;
96
+ }
97
+
98
+ state.lastSyncedValue = value;
99
+ return true;
100
+ },
101
+
102
+ /**
103
+ * Pushes a new value from Blazor into the editor.
104
+ * If the editor is currently focused, the update is deferred until blur.
105
+ * If the value matches the last synced state, the update is skipped entirely.
106
+ */
107
+ setValue(value: T) {
108
+ if (editor.ui.focusTracker.isFocused) {
109
+ state.pendingValue = value;
110
+ return;
111
+ }
112
+
113
+ if (state.lastSyncedValue !== null && options.isEqual(state.lastSyncedValue, value)) {
114
+ return;
115
+ }
116
+
117
+ state.lastSyncedValue = value;
118
+ options.applyValue(value);
119
+ },
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Returns a no-op {@link EditorValueSync} used as a placeholder before the real editor
125
+ * instance is available, so callers never have to null-check `sync`.
126
+ */
127
+ export function createNoopSync<T>(): EditorValueSync<T> {
128
+ return {
129
+ unmount() {},
130
+ shouldNotify(_value: T): boolean { return false; },
131
+ setValue(_value: T) {},
132
+ };
133
+ }
134
+
135
+ /**
136
+ * The public interface returned by {@link createEditorValueSync} and {@link createNoopSync}.
137
+ * Typed over the value shape `T` so consumers can declare `sync` variables without
138
+ * repeating the full return type.
139
+ */
140
+ export type EditorValueSync<T> = {
141
+ /** Removes all editor listeners registered by this sync instance. */
142
+ unmount: () => void;
143
+
144
+ /**
145
+ * Checks whether the given value differs from the last synced value and, if so,
146
+ * updates `lastSyncedValue` and returns `true` to signal that Blazor should be notified.
147
+ */
148
+ /**
149
+ * Called by the consumer to determine if a value change should trigger a
150
+ * notification. Returns `true` when the value differs from the last one that
151
+ * was synced and updates internal tracking.
152
+ */
153
+ shouldNotify: (value: T) => boolean;
154
+
155
+ /**
156
+ * Pushes a new value from Blazor into the editor.
157
+ * Deferred until blur when the editor is focused; skipped when value is unchanged.
158
+ */
159
+ setValue: (value: T) => void;
160
+ };
@@ -0,0 +1 @@
1
+ export * from './create-editor-value-sync';