@yorkie-js/sdk 0.6.37 → 0.6.39

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.
package/dist/quill.html CHANGED
@@ -3,359 +3,566 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Yorkie + Quill Example</title>
6
+ <title>Quill Example</title>
7
7
  <link
8
- href="https://cdn.quilljs.com/1.3.6/quill.snow.css"
8
+ href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css"
9
9
  rel="stylesheet"
10
10
  />
11
11
  <link rel="stylesheet" href="style.css" />
12
- <script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
13
- <script src="https://cdn.jsdelivr.net/npm/quill-cursors@3.1.0/dist/quill-cursors.min.js"></script>
12
+ <link rel="stylesheet" href="quill.css" />
13
+ <script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
14
+ <script src="https://cdn.jsdelivr.net/npm/quill-cursors@4.0.3/dist/quill-cursors.js"></script>
14
15
  <script src="https://cdn.jsdelivr.net/npm/color-hash@1.0.3/dist/color-hash.js"></script>
15
16
  </head>
16
17
  <body>
17
- <div id="network-status"></div>
18
- <div id="online-clients"></div>
19
- <div id="editor"></div>
20
- <div id="document"></div>
21
- <div id="document-text"></div>
18
+ <div class="client-container">
19
+ <div id="client-a">
20
+ Client A ( id:<span class="client-id"></span>)
21
+ <span class="network-status"></span>
22
+ <div class="syncmode-option">
23
+ <span>SyncMode: </span>
24
+ <div class="realtime-sync">
25
+ <span class="realtime-sync-title">Realtime Sync</span>
26
+ <div class="option">
27
+ <input
28
+ type="radio"
29
+ id="realtime-pushpull-a"
30
+ name="syncMode-a"
31
+ value="pushpull"
32
+ checked
33
+ />
34
+ <label for="realtime-pushpull-a">PushPull</label>
35
+ </div>
36
+ <div class="option">
37
+ <input
38
+ type="radio"
39
+ id="realtime-pushonly-a"
40
+ name="syncMode-a"
41
+ value="pushonly"
42
+ />
43
+ <label for="realtime-pushonly-a">PushOnly</label>
44
+ </div>
45
+ <div class="option">
46
+ <input
47
+ type="radio"
48
+ id="realtime-syncoff-a"
49
+ name="syncMode-a"
50
+ value="syncoff"
51
+ />
52
+ <label for="realtime-syncoff-a">SyncOff</label>
53
+ </div>
54
+ </div>
55
+ <div class="option">
56
+ <input
57
+ type="radio"
58
+ id="manual-a"
59
+ name="syncMode-a"
60
+ value="manual"
61
+ />
62
+ <label for="manual-a">Manual Sync</label>
63
+ <button class="manual-sync">sync</button>
64
+ </div>
65
+ </div>
66
+ <div class="editor"></div>
67
+ <div class="online-clients"></div>
68
+ </div>
69
+ <div id="client-b">
70
+ Client B ( id:<span class="client-id"></span>)
71
+ <span class="network-status"></span>
72
+ <div class="syncmode-option">
73
+ <span>SyncMode: </span>
74
+ <div class="realtime-sync">
75
+ <span class="realtime-sync-title">Realtime Sync</span>
76
+ <div class="option">
77
+ <input
78
+ type="radio"
79
+ id="realtime-pushpull-b"
80
+ name="syncMode-b"
81
+ value="pushpull"
82
+ checked
83
+ />
84
+ <label for="realtime-pushpull-b">PushPull</label>
85
+ </div>
86
+ <div class="option">
87
+ <input
88
+ type="radio"
89
+ id="realtime-pushonly-b"
90
+ name="syncMode-b"
91
+ value="pushonly"
92
+ />
93
+ <label for="realtime-pushonly-b">PushOnly</label>
94
+ </div>
95
+ <div class="option">
96
+ <input
97
+ type="radio"
98
+ id="realtime-syncoff-b"
99
+ name="syncMode-b"
100
+ value="syncoff"
101
+ />
102
+ <label for="realtime-syncoff-b">SyncOff</label>
103
+ </div>
104
+ </div>
105
+ <div class="option">
106
+ <input
107
+ type="radio"
108
+ id="manual-b"
109
+ name="syncMode-b"
110
+ value="manual"
111
+ />
112
+ <label for="manual-b">Manual Sync</label>
113
+ <button class="manual-sync">sync</button>
114
+ </div>
115
+ </div>
116
+ <div class="editor"></div>
117
+ <div class="online-clients"></div>
118
+ </div>
119
+ </div>
22
120
  <script type="module">
23
121
  import './src/yorkie.ts';
24
122
  import Network from './devtool/network.js';
25
123
 
26
- const onlineClientsElem = document.getElementById('online-clients');
27
- const documentElem = document.getElementById('document');
28
- const documentTextElem = document.getElementById('document-text');
29
- const networkStatusElem = document.getElementById('network-status');
30
124
  const colorHash = new ColorHash();
31
- const documentKey = 'quill';
125
+ const clientAElem = document.getElementById('client-a');
126
+ const clientBElem = document.getElementById('client-b');
127
+ const documentKey = 'quill-two-clients';
32
128
 
33
- function displayLog(elem, textElem, doc) {
34
- elem.innerText = doc.toJSON();
35
- textElem.innerText = doc.getRoot().content.toTestString();
129
+ function filterNullAttrs(attributes) {
130
+ if (!attributes) return undefined;
131
+
132
+ const filtered = {};
133
+ let hasNonNullValue = false;
134
+
135
+ for (const [key, value] of Object.entries(attributes)) {
136
+ if (value !== null) {
137
+ filtered[key] = value;
138
+ hasNonNullValue = true;
139
+ }
140
+ }
141
+
142
+ return hasNonNullValue ? filtered : undefined;
36
143
  }
37
144
 
38
- function toDeltaOperation(textValue) {
145
+ function toDeltaOperation(textValue, filterNull = false) {
39
146
  const { embed, ...restAttributes } = textValue.attributes ?? {};
40
-
41
147
  if (embed) {
42
- return { insert: embed, attributes: restAttributes };
148
+ return {
149
+ insert: JSON.parse(embed.toString()),
150
+ attributes: filterNull
151
+ ? filterNullAttrs(restAttributes)
152
+ : restAttributes,
153
+ };
43
154
  }
44
155
 
45
156
  return {
46
157
  insert: textValue.content || '',
47
- attributes: textValue.attributes,
158
+ attributes: filterNull
159
+ ? filterNullAttrs(textValue.attributes)
160
+ : textValue.attributes,
48
161
  };
49
162
  }
50
163
 
51
- function displayOnlineClients(presences, myClientID) {
52
- const clients = [];
53
- for (const { clientID, presence } of presences) {
54
- const clientElem = `<span class="client" style='background: ${presence.color}; color: white; margin-right:2px; padding:2px;'>${presence.name}</span>`;
55
- if (myClientID === clientID) {
56
- clients.unshift(clientElem);
57
- continue;
58
- }
59
- clients.push(clientElem);
60
- }
61
- onlineClientsElem.innerHTML = clients.join('');
62
- }
63
-
64
164
  async function main() {
65
165
  try {
66
- // 01. create client with RPCAddr then activate it.
67
- const client = new yorkie.Client({
68
- rpcAddr: 'http://localhost:8080',
69
- });
70
- await client.activate();
71
-
72
- // 02. create a document then attach it into the client.
73
- const doc = new yorkie.Document(documentKey, {
74
- enableDevtools: true,
75
- });
76
- doc.subscribe(
77
- 'connection',
78
- new Network(networkStatusElem).statusListener,
79
- );
80
- doc.subscribe('presence', (event) => {
81
- if (event.type === 'presence-changed') return;
82
- displayOnlineClients(doc.getPresences(), client.getID());
83
- });
84
-
85
- await client.attach(doc, {
86
- initialPresence: {
87
- name: client.getID().slice(-2),
88
- color: colorHash.hex(client.getID().slice(-2)),
89
- },
90
- });
91
-
92
- doc.update((root) => {
93
- if (!root.content) {
94
- root.content = new yorkie.Text();
95
- root.content.edit(0, 0, '\n');
96
- }
97
- }, 'create content if not exists');
166
+ async function initializeRealtimeEditor(clientElem) {
167
+ // 01. create client with RPCAddr(envoy) then activate it.
168
+ const client = new yorkie.Client({
169
+ rpcAddr: 'http://localhost:8080',
170
+ });
171
+ await client.activate();
172
+ const clientID = client.getID().slice(-2);
173
+ clientElem.querySelector('.client-id').textContent = clientID;
98
174
 
99
- // 02-2. subscribe document event.
100
- doc.subscribe((event) => {
101
- if (event.type === 'snapshot') {
102
- // The text is replaced to snapshot and must be re-synced.
103
- syncText();
104
- }
105
- displayLog(documentElem, documentTextElem, doc);
106
- });
175
+ // 02. create a document then attach it into the client.
176
+ const doc = new yorkie.Document(documentKey, {
177
+ enableDevtools: true,
178
+ });
179
+ doc.subscribe(
180
+ 'connection',
181
+ new Network(clientElem.querySelector('.network-status'))
182
+ .statusListener,
183
+ );
184
+ const onlineClients = clientElem.querySelector('.online-clients');
185
+ doc.subscribe('presence', (event) => {
186
+ // Update online clients list
187
+ if (event.type !== 'presence-changed') {
188
+ const clientIDs = doc
189
+ .getPresences()
190
+ .map(({ clientID }) => clientID)
191
+ .join(', ');
192
+ onlineClients.textContent = clientIDs;
193
+ }
107
194
 
108
- doc.subscribe('$.content', (event) => {
109
- if (event.type === 'remote-change') {
110
- const { actor, message, operations } = event.value;
111
- handleOperations(operations, actor);
112
- }
113
- updateAllCursors();
114
- });
195
+ console.warn(
196
+ `%c${clientID}`,
197
+ `color:white; padding: 2px 4px; border-radius: 3px;
198
+ background: ${colorHash.hex(clientID)}; `,
199
+ event.type,
200
+ );
201
+ });
115
202
 
116
- doc.subscribe('others', (event) => {
117
- if (event.type === 'unwatched') {
118
- cursors.removeCursor(event.value.clientID);
119
- } else if (event.type === 'presence-changed') {
120
- updateCursor(event.value);
121
- }
122
- });
123
-
124
- await client.sync();
125
-
126
- // 03. create an instance of Quill
127
- Quill.register('modules/cursors', QuillCursors);
128
- const quill = new Quill('#editor', {
129
- modules: {
130
- toolbar: [
131
- ['bold', 'italic', 'underline', 'strike'],
132
- ['blockquote', 'code-block'],
133
- [{ header: 1 }, { header: 2 }],
134
- [{ list: 'ordered' }, { list: 'bullet' }],
135
- [{ script: 'sub' }, { script: 'super' }],
136
- [{ indent: '-1' }, { indent: '+1' }],
137
- [{ direction: 'rtl' }],
138
- [{ size: ['small', false, 'large', 'huge'] }],
139
- [{ header: [1, 2, 3, 4, 5, 6, false] }],
140
- [{ color: [] }, { background: [] }],
141
- [{ font: [] }],
142
- [{ align: [] }],
143
- ['image', 'video'],
144
- ['clean'],
145
- ],
146
- cursors: true,
147
- },
148
- theme: 'snow',
149
- });
150
- const cursors = quill.getModule('cursors');
151
-
152
- function updateCursor(user) {
153
- const { clientID, presence } = user;
154
- if (clientID === client.getID()) return;
155
- // TODO(chacha912): After resolving the presence initialization issue(#608),
156
- // remove the following check.
157
- if (!presence) return;
158
-
159
- const { name, color, selection } = presence;
160
- if (!selection) return;
161
- const range = doc.getRoot().content.posRangeToIndexRange(selection);
162
- cursors.createCursor(clientID, name, color);
163
- cursors.moveCursor(clientID, {
164
- index: range[0],
165
- length: range[1] - range[0],
203
+ await client.attach(doc, {
204
+ initialPresence: { name: clientID },
166
205
  });
167
- }
168
206
 
169
- function updateAllCursors() {
170
- for (const user of doc.getPresences()) {
171
- updateCursor(user);
172
- }
173
- }
207
+ doc.update((root) => {
208
+ if (!root.content) {
209
+ root.content = new yorkie.Text();
210
+ root.content.edit(0, 0, '\n');
211
+ }
212
+ }, 'create content if not exists');
174
213
 
175
- // 04. bind the document with the Quill.
176
- // 04-1. Quill to Document.
177
- quill
178
- .on('text-change', (delta, _, source) => {
179
- if (source === 'api' || !delta.ops) {
180
- return;
214
+ // 02-2. subscribe document event.
215
+ doc.subscribe((event) => {
216
+ if (event.type === 'snapshot') {
217
+ // The text is replaced to snapshot and must be re-synced.
218
+ syncText(doc, quill);
181
219
  }
182
220
 
183
- let from = 0,
184
- to = 0;
185
- console.log(
186
- `%c quill: ${JSON.stringify(delta.ops)}`,
187
- 'color: green',
221
+ console.warn(
222
+ `%c${clientID}`,
223
+ `color:white; padding: 2px 4px; border-radius: 3px;
224
+ background: ${colorHash.hex(clientID)}; `,
225
+ event.type,
188
226
  );
189
- for (const op of delta.ops) {
190
- if (op.attributes !== undefined || op.insert !== undefined) {
191
- if (op.retain !== undefined) {
192
- to = from + op.retain;
193
- }
194
- console.log(
195
- `%c local: ${from}-${to}: ${op.insert} ${
196
- op.attributes ? JSON.stringify(op.attributes) : '{}'
197
- }`,
198
- 'color: green',
199
- );
227
+ });
228
+
229
+ doc.subscribe('$.content', (event) => {
230
+ if (event.type === 'remote-change') {
231
+ const { message, operations } = event.value;
232
+ handleOperations(quill, operations);
233
+ }
234
+ updateAllCursors();
235
+ });
236
+
237
+ doc.subscribe('presence', (event) => {
238
+ // Update cursors
239
+ if (event.type === 'initialized') {
240
+ updateAllCursors();
241
+ } else if (event.type === 'unwatched') {
242
+ cursors.removeCursor(event.value.clientID);
243
+ } else {
244
+ updateCursor(event.value);
245
+ }
246
+ });
247
+
248
+ // 03. create an instance of Quill
249
+ // Track composition state to prevent selection updates during IME input
250
+ let isComposing = false;
251
+
252
+ const editorElem = clientElem?.getElementsByClassName('editor')[0];
253
+ Quill.register('modules/cursors', QuillCursors);
254
+ const quill = new Quill(editorElem, {
255
+ modules: {
256
+ toolbar: [
257
+ ['bold', 'italic', 'underline'],
258
+ [{ header: 1 }, { header: 2 }],
259
+ [{ list: 'ordered' }, { list: 'bullet' }],
260
+ ['blockquote', 'code-block'],
261
+ ['image', 'video'],
262
+ ['clean'],
263
+ ],
264
+ cursors: {
265
+ hideDelayMs: Number.MAX_VALUE,
266
+ },
267
+ },
268
+ theme: 'snow',
269
+ });
270
+ const cursors = quill.getModule('cursors');
271
+
272
+ function updateCursor(user) {
273
+ const { clientID, presence } = user;
274
+ // TODO(chacha912): After resolving the presence initialization issue(#608),
275
+ // remove the following check.
276
+ if (!presence) return;
277
+
278
+ const { name, selection } = presence;
279
+ if (!selection) return;
280
+ const range = doc
281
+ .getRoot()
282
+ .content.posRangeToIndexRange(selection);
283
+ cursors.createCursor(clientID, name, colorHash.hex(name));
284
+ cursors.moveCursor(clientID, {
285
+ index: range[0],
286
+ length: range[1] - range[0],
287
+ });
288
+ }
289
+
290
+ function updateAllCursors() {
291
+ cursors.clearCursors();
292
+ for (const user of doc.getPresences()) {
293
+ updateCursor(user);
294
+ }
295
+ }
296
+
297
+ // 04. bind the document with the Quill.
298
+ // Track composition events to prevent selection updates during IME input
299
+ quill.root.addEventListener('compositionstart', () => {
300
+ isComposing = true;
301
+ });
302
+ quill.root.addEventListener('compositionend', () => {
303
+ isComposing = false;
304
+ });
305
+
306
+ // 04-1. Quill to Document.
307
+ quill
308
+ .on('text-change', (delta, _, source) => {
309
+ if (source === 'api' || !delta.ops) {
310
+ return;
311
+ }
200
312
 
201
- doc.update((root, presence) => {
202
- let range;
313
+ let from = 0,
314
+ to = 0;
315
+ console.log(
316
+ `%c quill: ${JSON.stringify(delta.ops)}`,
317
+ 'color: green',
318
+ );
319
+ doc.update((root, presence) => {
320
+ for (const op of delta.ops) {
203
321
  if (
204
- op.attributes !== undefined &&
205
- op.insert === undefined
322
+ op.attributes !== undefined ||
323
+ op.insert !== undefined
206
324
  ) {
207
- root.content.setStyle(from, to, op.attributes);
208
- } else if (op.insert !== undefined) {
209
- if (to < from) {
325
+ if (
326
+ op.retain !== undefined &&
327
+ typeof op.retain === 'number'
328
+ ) {
329
+ to = from + op.retain;
330
+ }
331
+ console.log(
332
+ `%c local: ${from}-${to}: ${op.insert} ${
333
+ op.attributes ? JSON.stringify(op.attributes) : '{}'
334
+ }`,
335
+ 'color: green',
336
+ );
337
+
338
+ let range;
339
+ if (
340
+ op.attributes !== undefined &&
341
+ op.insert === undefined
342
+ ) {
343
+ root.content.setStyle(from, to, op.attributes);
344
+ from = to;
345
+ } else if (op.insert !== undefined) {
346
+ if (to < from) {
347
+ to = from;
348
+ }
349
+
350
+ if (typeof op.insert === 'object') {
351
+ range = root.content.edit(from, to, ' ', {
352
+ embed: JSON.stringify(op.insert),
353
+ ...op.attributes,
354
+ });
355
+ } else {
356
+ range = root.content.edit(
357
+ from,
358
+ to,
359
+ op.insert,
360
+ op.attributes,
361
+ );
362
+ }
363
+ from =
364
+ to +
365
+ (typeof op.insert === 'string'
366
+ ? op.insert.length
367
+ : 1);
210
368
  to = from;
211
369
  }
212
370
 
213
- if (typeof op.insert === 'object') {
214
- range = root.content.edit(from, to, ' ', {
215
- embed: op.insert,
216
- ...op.attributes,
371
+ if (range) {
372
+ presence.set({
373
+ selection: root.content.indexRangeToPosRange(range),
217
374
  });
218
- } else {
219
- range = root.content.edit(
220
- from,
221
- to,
222
- op.insert,
223
- op.attributes,
224
- );
225
375
  }
226
- from = to + op.insert.length;
227
- }
228
-
229
- if (range) {
230
- presence.set({
231
- selection: root.content.indexRangeToPosRange(range),
232
- });
233
- }
234
- }, `update style by ${client.getID()}`);
235
- } else if (op.delete !== undefined) {
236
- to = from + op.delete;
237
- console.log(`%c local: ${from}-${to}: ''`, 'color: green');
238
-
239
- doc.update((root, presence) => {
240
- const range = root.content.edit(from, to, '');
241
- if (range) {
242
- presence.set({
243
- selection: root.content.indexRangeToPosRange(range),
244
- });
376
+ } else if (op.delete !== undefined) {
377
+ to = from + op.delete;
378
+ console.log(
379
+ `%c local: ${from}-${to}: ''`,
380
+ 'color: green',
381
+ );
382
+
383
+ const range = root.content.edit(from, to, '');
384
+ if (range) {
385
+ presence.set({
386
+ selection: root.content.indexRangeToPosRange(range),
387
+ });
388
+ }
389
+ // After delete, 'to' should stay at 'from' since content was removed
390
+ to = from;
391
+ } else if (
392
+ op.retain !== undefined &&
393
+ typeof op.retain === 'number'
394
+ ) {
395
+ from += op.retain;
396
+ to = from;
245
397
  }
246
- }, `update content by ${client.getID()}`);
247
- } else if (op.retain !== undefined) {
248
- from = to + op.retain;
249
- to = from;
398
+ }
399
+ });
400
+ })
401
+ .on('selection-change', (range, _, source) => {
402
+ if (!range) {
403
+ return;
250
404
  }
251
- }
252
- })
253
- .on('selection-change', (range, _, source) => {
254
- if (!range) {
255
- return;
256
- }
257
- // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,
258
- // additional updates are necessary. This condition addresses situations where Quill's selection behaves
259
- // differently, such as when inserting text before a range selection made by another user, causing
260
- // the second character onwards to be included in the selection.
261
- if (source === 'api') {
262
- const [from, to] = doc
263
- .getRoot()
264
- .content.posRangeToIndexRange(doc.getMyPresence().selection);
265
- const { index, length } = range;
266
- if (from === index && to === index + length) {
405
+
406
+ // Ignore selection changes during composition (e.g., Korean IME input)
407
+ // to prevent cursor position from being broadcast incorrectly to other users
408
+ if (isComposing) {
267
409
  return;
268
410
  }
269
- }
270
411
 
271
- doc.update((root, presence) => {
272
- presence.set({
273
- selection: root.content.indexRangeToPosRange([
274
- range.index,
275
- range.index + range.length,
276
- ]),
277
- });
278
- }, `update selection by ${client.getID()}`);
279
- });
412
+ // NOTE(chacha912): If the selection in the Quill editor does not match the range computed by yorkie,
413
+ // additional updates are necessary. This condition addresses situations where Quill's selection behaves
414
+ // differently, such as when inserting text before a range selection made by another user, causing
415
+ // the second character onwards to be included in the selection.
416
+ if (source === 'api') {
417
+ const { selection } = doc.getMyPresence();
418
+ if (selection) {
419
+ const [from, to] = doc
420
+ .getRoot()
421
+ .content.posRangeToIndexRange(selection);
422
+ const { index, length } = range;
423
+ if (from === index && to === index + length) {
424
+ return;
425
+ }
426
+ }
427
+ }
280
428
 
281
- // 04-2. document to Quill(remote).
282
- function handleOperations(ops, actor) {
283
- const deltaOperations = [];
284
- let prevTo = 0;
285
- for (const op of ops) {
286
- const from = op.from;
287
- const to = op.to;
288
- const retainFrom = from - prevTo;
289
- const retainTo = to - from;
290
-
291
- if (op.type === 'edit') {
292
- const { insert, attributes } = toDeltaOperation(op.value);
293
- console.log(
294
- `%c remote: ${from}-${to}: ${insert}`,
295
- 'color: skyblue',
296
- );
429
+ doc.update((root, presence) => {
430
+ presence.set({
431
+ selection: root.content.indexRangeToPosRange([
432
+ range.index,
433
+ range.index + range.length,
434
+ ]),
435
+ });
436
+ }, `update selection by ${client.getID()}`);
437
+ });
438
+
439
+ // 04-2. document to Quill(remote).
440
+ function handleOperations(quill, ops) {
441
+ for (const op of ops) {
442
+ if (op.type === 'edit') {
443
+ const from = op.from;
444
+ const to = op.to;
445
+ const { insert, attributes } = toDeltaOperation(
446
+ op.value,
447
+ true,
448
+ );
449
+ console.log(
450
+ `%c remote: ${from}-${to}: ${insert}`,
451
+ 'color: skyblue',
452
+ );
297
453
 
298
- if (retainFrom) {
299
- deltaOperations.push({ retain: retainFrom });
300
- }
301
- if (retainTo) {
302
- deltaOperations.push({ delete: retainTo });
303
- }
304
- if (insert) {
305
- const deltaOp = { insert };
306
- if (attributes) {
307
- deltaOp.attributes = attributes;
454
+ const deltaOperations = [];
455
+
456
+ if (from > 0) {
457
+ deltaOperations.push({ retain: from });
308
458
  }
309
- deltaOperations.push(deltaOp);
310
- }
311
- } else if (op.type === 'style') {
312
- const { attributes } = toDeltaOperation(op.value);
313
- console.log(
314
- `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,
315
- 'color: skyblue',
316
- );
317
459
 
318
- if (retainFrom) {
319
- deltaOperations.push({ retain: retainFrom });
320
- }
321
- if (attributes) {
322
- const deltaOp = { attributes };
323
- if (retainTo) {
324
- deltaOp.retain = retainTo;
460
+ const deleteLength = to - from;
461
+ if (deleteLength > 0) {
462
+ deltaOperations.push({ delete: deleteLength });
463
+ }
464
+
465
+ if (insert) {
466
+ const op = { insert };
467
+ if (attributes) {
468
+ op.attributes = attributes;
469
+ }
470
+ deltaOperations.push(op);
471
+ }
472
+
473
+ if (deltaOperations.length > 0) {
474
+ console.log(
475
+ `%c to quill: ${JSON.stringify(deltaOperations)}`,
476
+ 'color: green',
477
+ );
478
+ const delta = new Quill.imports.delta(deltaOperations);
479
+ quill.updateContents(delta, 'api');
325
480
  }
481
+ } else if (op.type === 'style') {
482
+ const from = op.from;
483
+ const to = op.to;
484
+ const { attributes } = toDeltaOperation(op.value, false);
485
+ console.log(
486
+ `%c remote: ${from}-${to}: ${JSON.stringify(attributes)}`,
487
+ 'color: skyblue',
488
+ );
489
+
490
+ if (attributes) {
491
+ const deltaOperations = [];
492
+
493
+ if (from > 0) {
494
+ deltaOperations.push({ retain: from });
495
+ }
326
496
 
327
- deltaOperations.push(deltaOp);
497
+ const op = { attributes };
498
+ const retainLength = to - from;
499
+ if (retainLength > 0) {
500
+ op.retain = retainLength;
501
+ }
502
+ deltaOperations.push(op);
503
+
504
+ console.log(
505
+ `%c to quill: ${JSON.stringify(deltaOperations)}`,
506
+ 'color: green',
507
+ );
508
+ const delta = new Quill.imports.delta(deltaOperations);
509
+ quill.updateContents(delta, 'api');
510
+ }
328
511
  }
329
512
  }
330
-
331
- prevTo = to;
332
513
  }
333
514
 
334
- if (deltaOperations.length) {
335
- console.log(
336
- `%c to quill: ${JSON.stringify(deltaOperations)}`,
337
- 'color: green',
515
+ // 05. synchronize text of document and Quill.
516
+ function syncText(doc, quill) {
517
+ const text = doc.getRoot().content;
518
+ const delta = new Quill.imports.delta(
519
+ text.values().map((val) => toDeltaOperation(val, true)),
338
520
  );
339
- quill.updateContents({ ops: deltaOperations }, 'api');
521
+ quill.setContents(delta, 'api');
340
522
  }
341
- }
342
523
 
343
- // 05. synchronize text of document and Quill.
344
- function syncText() {
345
- const text = doc.getRoot().content;
346
- const delta = {
347
- ops: text.values().map((val) => toDeltaOperation(val)),
348
- };
349
- quill.setContents(delta, 'api');
524
+ // 06. sync option
525
+ const option = clientElem.querySelector('.syncmode-option');
526
+ option.addEventListener('change', async (e) => {
527
+ if (!event.target.matches('input[type="radio"]')) {
528
+ return;
529
+ }
530
+ const syncMode = event.target.value;
531
+ switch (syncMode) {
532
+ case 'pushpull':
533
+ await client.changeSyncMode(doc, 'realtime');
534
+ break;
535
+ case 'pushonly':
536
+ await client.changeSyncMode(doc, 'realtime-pushonly');
537
+ break;
538
+ case 'syncoff':
539
+ await client.changeSyncMode(doc, 'realtime-syncoff');
540
+ break;
541
+ case 'manual':
542
+ await client.changeSyncMode(doc, 'manual');
543
+ break;
544
+ default:
545
+ break;
546
+ }
547
+ });
548
+ const syncButton = clientElem.querySelector('.manual-sync');
549
+ syncButton.addEventListener('click', async () => {
550
+ await client.sync(doc);
551
+ });
552
+
553
+ syncText(doc, quill);
554
+ updateAllCursors();
555
+
556
+ return { client, doc, quill };
350
557
  }
351
558
 
352
- syncText();
353
- updateAllCursors();
354
- displayLog(documentElem, documentTextElem, doc);
559
+ await initializeRealtimeEditor(clientAElem);
560
+ await initializeRealtimeEditor(clientBElem);
355
561
  } catch (e) {
356
562
  console.error(e);
357
563
  }
358
564
  }
565
+
359
566
  main();
360
567
  </script>
361
568
  </body>