ckeditor5-livewire 1.2.10 → 1.3.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.
@@ -15,6 +15,7 @@ import {
15
15
 
16
16
  import type { Snapshot as EditorSnapshot } from './editor';
17
17
 
18
+ import { timeout } from '../shared';
18
19
  import { EditableComponentHook } from './editable';
19
20
  import { EditorComponentHook } from './editor';
20
21
  import { registerLivewireComponentHook } from './hook';
@@ -267,7 +268,9 @@ describe('editable component', () => {
267
268
  it('should update editable content after commit if content changed in Livewire', async () => {
268
269
  const { id } = livewireStub.$internal.appendComponentToDOM({
269
270
  name: 'ckeditor5-editable',
270
- el: createEditableHtmlElement(),
271
+ el: createEditableHtmlElement({
272
+ wireModel: 'content',
273
+ }),
271
274
  canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
272
275
  });
273
276
 
@@ -286,7 +289,7 @@ describe('editable component', () => {
286
289
  });
287
290
  });
288
291
 
289
- it('should not update editable content after commit if content has not changed', async () => {
292
+ it('should not update editable content after commit if `wire.model` is not set', async () => {
290
293
  const { id } = livewireStub.$internal.appendComponentToDOM({
291
294
  name: 'ckeditor5-editable',
292
295
  el: createEditableHtmlElement(),
@@ -299,6 +302,29 @@ describe('editable component', () => {
299
302
 
300
303
  const editor = await waitForTestEditor();
301
304
 
305
+ await livewireStub.$internal.dispatchComponentCommit(id, {
306
+ content: '<p>Updated foo content from Livewire</p>',
307
+ });
308
+
309
+ await timeout(50);
310
+ expect(editor.getData({ rootName: 'foo' })).toBe('<p>Initial foo content</p>');
311
+ });
312
+
313
+ it('should not update editable content after commit if content has not changed', async () => {
314
+ const { id } = livewireStub.$internal.appendComponentToDOM({
315
+ name: 'ckeditor5-editable',
316
+ el: createEditableHtmlElement({
317
+ wireModel: 'content',
318
+ }),
319
+ canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
320
+ });
321
+
322
+ appendMultirootEditor({
323
+ foo: '<p>Initial foo content</p>',
324
+ });
325
+
326
+ const editor = await waitForTestEditor();
327
+
302
328
  await livewireStub.$internal.dispatchComponentCommit(id, {
303
329
  content: '<p>Initial foo content</p>',
304
330
  });
@@ -309,7 +335,9 @@ describe('editable component', () => {
309
335
  it('should handle null content in commit', async () => {
310
336
  const { id } = livewireStub.$internal.appendComponentToDOM({
311
337
  name: 'ckeditor5-editable',
312
- el: createEditableHtmlElement(),
338
+ el: createEditableHtmlElement({
339
+ wireModel: 'content',
340
+ }),
313
341
  canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
314
342
  });
315
343
 
@@ -328,13 +356,106 @@ describe('editable component', () => {
328
356
  });
329
357
  });
330
358
 
359
+ it('should defer editable content update if editable is focused during commit and apply it on blur', async () => {
360
+ const { id } = livewireStub.$internal.appendComponentToDOM({
361
+ name: 'ckeditor5-editable',
362
+ el: createEditableHtmlElement({
363
+ wireModel: 'content',
364
+ }),
365
+ canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
366
+ });
367
+
368
+ appendMultirootEditor({
369
+ foo: '<p>Initial foo content</p>',
370
+ });
371
+
372
+ const editor = await waitForTestEditor();
373
+ const { ui: { focusTracker } } = editor;
374
+
375
+ focusTracker.isFocused = true;
376
+
377
+ await livewireStub.$internal.dispatchComponentCommit(id, {
378
+ content: '<p>Updated foo content from Livewire</p>',
379
+ });
380
+
381
+ expect(editor.getData({ rootName: 'foo' })).toBe('<p>Initial foo content</p>');
382
+
383
+ focusTracker.isFocused = false;
384
+
385
+ expect(editor.getData({ rootName: 'foo' })).toBe('<p>Updated foo content from Livewire</p>');
386
+ });
387
+
388
+ it('should abort editable content update if editable is focused during commit and content changes before blur', async () => {
389
+ const { id } = livewireStub.$internal.appendComponentToDOM({
390
+ name: 'ckeditor5-editable',
391
+ el: createEditableHtmlElement({
392
+ wireModel: 'content',
393
+ }),
394
+ canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
395
+ });
396
+
397
+ appendMultirootEditor({
398
+ foo: '<p>Initial foo content</p>',
399
+ });
400
+
401
+ const editor = await waitForTestEditor();
402
+ const { ui: { focusTracker } } = editor;
403
+
404
+ focusTracker.isFocused = true;
405
+
406
+ await livewireStub.$internal.dispatchComponentCommit(id, {
407
+ content: '<p>Updated foo content from Livewire</p>',
408
+ });
409
+
410
+ expect(editor.getData({ rootName: 'foo' })).toBe('<p>Initial foo content</p>');
411
+
412
+ editor.setData({
413
+ foo: '<p>Content changed by user</p>',
414
+ });
415
+
416
+ focusTracker.isFocused = false;
417
+
418
+ expect(editor.getData({ rootName: 'foo' })).toBe('<p>Content changed by user</p>');
419
+ });
420
+
421
+ it('should defer editable null content update if editable is focused during commit and apply it on blur', async () => {
422
+ const { id } = livewireStub.$internal.appendComponentToDOM({
423
+ name: 'ckeditor5-editable',
424
+ el: createEditableHtmlElement({
425
+ wireModel: 'content',
426
+ }),
427
+ canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
428
+ });
429
+
430
+ appendMultirootEditor({
431
+ foo: '<p>Initial foo content</p>',
432
+ });
433
+
434
+ const editor = await waitForTestEditor();
435
+ const { ui: { focusTracker } } = editor;
436
+
437
+ focusTracker.isFocused = true;
438
+
439
+ await livewireStub.$internal.dispatchComponentCommit(id, {
440
+ content: null,
441
+ });
442
+
443
+ expect(editor.getData({ rootName: 'foo' })).toBe('<p>Initial foo content</p>');
444
+
445
+ focusTracker.isFocused = false;
446
+
447
+ expect(editor.getData({ rootName: 'foo' })).toBe('');
448
+ });
449
+
331
450
  it('should not crash if editor is not found during commit', async () => {
332
451
  appendMultirootEditor();
333
452
  await waitForTestEditor();
334
453
 
335
454
  const { id } = livewireStub.$internal.appendComponentToDOM({
336
455
  name: 'ckeditor5-editable',
337
- el: createEditableHtmlElement(),
456
+ el: createEditableHtmlElement({
457
+ wireModel: 'content',
458
+ }),
338
459
  canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
339
460
  });
340
461
 
@@ -352,13 +473,17 @@ describe('editable component', () => {
352
473
  it('should sync multiple editables independently after commit', async () => {
353
474
  const { id: fooId } = livewireStub.$internal.appendComponentToDOM({
354
475
  name: 'ckeditor5-editable',
355
- el: createEditableHtmlElement(),
476
+ el: createEditableHtmlElement({
477
+ wireModel: 'content',
478
+ }),
356
479
  canonical: createEditableSnapshot('foo', '<p>Initial foo content</p>'),
357
480
  });
358
481
 
359
482
  const { id: barId } = livewireStub.$internal.appendComponentToDOM({
360
483
  name: 'ckeditor5-editable',
361
- el: createEditableHtmlElement(),
484
+ el: createEditableHtmlElement({
485
+ wireModel: 'content',
486
+ }),
362
487
  canonical: createEditableSnapshot('bar', '<p>Initial bar content</p>'),
363
488
  });
364
489
 
@@ -2,6 +2,7 @@ import type { MultiRootEditor } from 'ckeditor5';
2
2
 
3
3
  import { debounce } from '../shared';
4
4
  import { EditorsRegistry } from './editor/editors-registry';
5
+ import { isWireModelConnected } from './editor/utils';
5
6
  import { ClassHook } from './hook';
6
7
 
7
8
  /**
@@ -13,12 +14,16 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
13
14
  */
14
15
  private editorPromise: Promise<MultiRootEditor | null> | null = null;
15
16
 
17
+ /**
18
+ * Pending content to apply when the editor loses focus.
19
+ */
20
+ private pendingContent: string | null = null;
21
+
16
22
  /**
17
23
  * Mounts the editable component.
18
24
  */
19
25
  override mounted() {
20
- const { editorId, rootName, content, saveDebounceMs } = this.canonical;
21
- const input = this.element.querySelector<HTMLInputElement>('input');
26
+ const { editorId, rootName, content } = this.canonical;
22
27
 
23
28
  // If the editor is not registered yet, we will wait for it to be registered.
24
29
  this.editorPromise = EditorsRegistry.the.execute(editorId, (editor: MultiRootEditor) => {
@@ -41,54 +46,106 @@ export class EditableComponentHook extends ClassHook<Snapshot> {
41
46
  });
42
47
  }
43
48
  }
44
-
45
- return editor;
46
49
  }
50
+ else {
51
+ editor.addRoot(rootName, {
52
+ isUndoable: false,
53
+ ...content !== null && {
54
+ data: content,
55
+ },
56
+ });
57
+
58
+ const contentElement = this.element.querySelector('[data-cke-editable-content]') as HTMLElement | null;
59
+ const editable = ui.view.createEditable(rootName, contentElement!);
60
+
61
+ ui.addEditable(editable);
62
+ editing.view.forceRender();
63
+ }
64
+
65
+ // Add livewire sync.
66
+ this.syncTypingContentPush(editor);
67
+ this.setupPendingReceivedContentHandlers(editor);
47
68
 
48
- editor.addRoot(rootName, {
49
- isUndoable: false,
50
- ...content !== null && {
51
- data: content,
52
- },
53
- });
69
+ return editor;
70
+ });
71
+ }
54
72
 
55
- const contentElement = this.element.querySelector('[data-cke-editable-content]') as HTMLElement | null;
56
- const editable = ui.view.createEditable(rootName, contentElement!);
73
+ /**
74
+ * Setups the content sync from the editor to Livewire on user input with debounce.
75
+ */
76
+ private syncTypingContentPush(editor: MultiRootEditor) {
77
+ const { rootName, saveDebounceMs } = this.canonical;
78
+ const input = this.element.querySelector<HTMLInputElement>('input');
57
79
 
58
- ui.addEditable(editable);
59
- editing.view.forceRender();
80
+ const sync = () => {
81
+ const html = editor.getData({ rootName });
60
82
 
61
- // Sync data with socket and input element.
62
- const sync = () => {
63
- const html = editor.getData({ rootName });
83
+ if (input) {
84
+ input.value = html;
85
+ }
64
86
 
65
- if (input) {
66
- input.value = html;
67
- }
87
+ this.$wire.set('content', html);
88
+ };
68
89
 
69
- this.$wire.set('content', html);
70
- };
90
+ editor.model.document.on('change:data', debounce(saveDebounceMs, sync));
91
+ sync();
92
+ }
71
93
 
72
- editor.model.document.on('change:data', debounce(saveDebounceMs, sync));
73
- sync();
94
+ /**
95
+ * Sets up handlers that manage pending incoming content (clears pending
96
+ * content on user edits and applies pending content on blur).
97
+ */
98
+ private setupPendingReceivedContentHandlers(editor: MultiRootEditor): void {
99
+ const { ui, model } = editor;
100
+ const { rootName } = this.canonical;
74
101
 
75
- return editor;
102
+ model.document.on('change:data', () => {
103
+ this.pendingContent = null;
104
+ });
105
+
106
+ ui.focusTracker.on('change:isFocused', () => {
107
+ if (!ui.focusTracker.isFocused && this.pendingContent !== null) {
108
+ editor.setData({
109
+ [rootName]: this.pendingContent,
110
+ });
111
+
112
+ this.pendingContent = null;
113
+ }
76
114
  });
77
115
  }
78
116
 
117
+ /**
118
+ * Applies canonical content to the editor while respecting focus/pending state.
119
+ */
120
+ private applyCanonicalContentToEditor(editor: MultiRootEditor): void {
121
+ if (!isWireModelConnected(this.element)) {
122
+ return;
123
+ }
124
+
125
+ const { content, rootName } = this.canonical;
126
+ const { ui } = editor;
127
+
128
+ const currentValue = editor.getData({ rootName });
129
+
130
+ if (currentValue === (content ?? '')) {
131
+ return;
132
+ }
133
+
134
+ if (ui.focusTracker.isFocused) {
135
+ this.pendingContent = content ?? '';
136
+ return;
137
+ }
138
+
139
+ editor.setData({ [rootName]: content ?? '' });
140
+ }
141
+
79
142
  /**
80
143
  * Called when the component is updated by Livewire.
81
144
  */
82
145
  override async afterCommitSynced(): Promise<void> {
83
146
  const editor = (await this.editorPromise)!;
84
- const { content, rootName } = this.canonical;
85
- const value = editor.getData({ rootName });
86
147
 
87
- if (value !== content) {
88
- editor.setData({
89
- [rootName]: content ?? '',
90
- });
91
- }
148
+ this.applyCanonicalContentToEditor(editor);
92
149
  }
93
150
 
94
151
  /**
@@ -30,26 +30,29 @@ export async function createLivewireSyncPlugin(
30
30
  public init(): void {
31
31
  this.setupTypingContentPush();
32
32
  this.setupFocusableEventPush();
33
- this.setupContentServerSync();
33
+ this.setupAfterCommitHandler();
34
+ this.setupSetEditorContentHandler();
34
35
  }
35
36
 
36
37
  /**
37
- * Setups the content sync from Livewire to the editor.
38
+ * Setups handler that updates the editor content after Livewire changes attributes
39
+ * on the component and commits the changes, but only if the editor is not focused to prevent
40
+ * disrupting the user while editing.
38
41
  */
39
- private setupContentServerSync() {
42
+ private setupAfterCommitHandler() {
40
43
  const { editor } = this;
41
44
  const { model, ui: { focusTracker } } = editor;
42
45
 
43
46
  let pendingContent: Record<string, string> | null = null;
44
47
 
45
48
  editor.on('afterCommitSynced', () => {
46
- const { content } = component.canonical;
47
- const values = this.getEditorRootsValues();
48
-
49
49
  if (!isWireModelConnected(component.element)) {
50
50
  return;
51
51
  }
52
52
 
53
+ const { content } = component.canonical;
54
+ const values = this.getEditorRootsValues();
55
+
53
56
  // If editor is focused, save the content to apply later when it blurs.
54
57
  if (focusTracker.isFocused) {
55
58
  if (!shallowEqual(content, values)) {
@@ -64,18 +67,6 @@ export async function createLivewireSyncPlugin(
64
67
  }
65
68
  });
66
69
 
67
- Livewire.on('set-editor-content', ({ editorId, content }: SetContentPayload) => {
68
- if (editorId !== component.canonical.editorId) {
69
- return;
70
- }
71
-
72
- const currentValues = this.getEditorRootsValues();
73
-
74
- if (!shallowEqual(currentValues, content)) {
75
- editor.setData(content);
76
- }
77
- });
78
-
79
70
  // Track user changes while focused.
80
71
  model.document.on('change:data', () => {
81
72
  pendingContent = null;
@@ -90,6 +81,23 @@ export async function createLivewireSyncPlugin(
90
81
  });
91
82
  }
92
83
 
84
+ /**
85
+ * Setups the content sync from Livewire to the editor when Livewire emits an event.
86
+ */
87
+ private setupSetEditorContentHandler() {
88
+ Livewire.on('set-editor-content', ({ editorId, content }: SetContentPayload) => {
89
+ if (editorId !== component.canonical.editorId) {
90
+ return;
91
+ }
92
+
93
+ const currentValues = this.getEditorRootsValues();
94
+
95
+ if (!shallowEqual(currentValues, content)) {
96
+ this.editor.setData(content);
97
+ }
98
+ });
99
+ }
100
+
93
101
  /**
94
102
  * Setups the content push event for the editor.
95
103
  */