@pm-cm/yjs 0.0.12 → 0.0.13

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/index.d.cts CHANGED
@@ -101,6 +101,12 @@ type ReplaceTextResult = {
101
101
  } | {
102
102
  ok: false;
103
103
  reason: 'serialize-error';
104
+ } | {
105
+ ok: false;
106
+ reason: 'skip-pending';
107
+ } | {
108
+ ok: false;
109
+ reason: 'parse-failed';
104
110
  };
105
111
  /** Result of {@link replaceSharedProseMirror}. */
106
112
  type ReplaceProseMirrorResult = {
@@ -207,6 +213,15 @@ type CollabPluginsOptions = {
207
213
  yCursorPluginOpts?: YCursorPluginOpts;
208
214
  /** Extra options forwarded to `yUndoPlugin`. */
209
215
  yUndoPluginOpts?: YUndoPluginOpts;
216
+ /**
217
+ * When `true`, the bridge sync plugin calls `bridge.dispose()` when
218
+ * the last plugin instance for this bridge is destroyed. Default `false`.
219
+ *
220
+ * Enable this only when the factory owns the bridge lifecycle.
221
+ * When the caller creates and manages the bridge separately, leave
222
+ * this `false` and call `bridge.dispose()` manually.
223
+ */
224
+ autoDisposeBridge?: boolean;
210
225
  /** Called for non-fatal warnings. Propagated to child plugins. Default `console.warn`. */
211
226
  onWarning?: OnWarning;
212
227
  };
@@ -227,7 +242,7 @@ type BridgeSyncState = {
227
242
  };
228
243
  type BridgeSyncFailure = {
229
244
  ok: false;
230
- reason: 'detached' | 'serialize-error';
245
+ reason: 'detached' | 'serialize-error' | 'parse-failed';
231
246
  };
232
247
  /** Options for {@link createBridgeSyncPlugin}. */
233
248
  type BridgeSyncPluginOptions = {
@@ -235,6 +250,11 @@ type BridgeSyncPluginOptions = {
235
250
  onSyncFailure?: (result: BridgeSyncFailure, view: EditorView) => void;
236
251
  /** Called for non-fatal warnings. Default `console.warn`. */
237
252
  onWarning?: OnWarning;
253
+ /**
254
+ * When `true`, automatically call `bridge.dispose()` when the last
255
+ * plugin instance for this bridge is destroyed. Default `false`.
256
+ */
257
+ autoDispose?: boolean;
238
258
  };
239
259
  /** ProseMirror plugin key for {@link createBridgeSyncPlugin}. Use to read the plugin state. */
240
260
  declare const bridgeSyncPluginKey: PluginKey<BridgeSyncState>;
package/dist/index.d.ts CHANGED
@@ -101,6 +101,12 @@ type ReplaceTextResult = {
101
101
  } | {
102
102
  ok: false;
103
103
  reason: 'serialize-error';
104
+ } | {
105
+ ok: false;
106
+ reason: 'skip-pending';
107
+ } | {
108
+ ok: false;
109
+ reason: 'parse-failed';
104
110
  };
105
111
  /** Result of {@link replaceSharedProseMirror}. */
106
112
  type ReplaceProseMirrorResult = {
@@ -207,6 +213,15 @@ type CollabPluginsOptions = {
207
213
  yCursorPluginOpts?: YCursorPluginOpts;
208
214
  /** Extra options forwarded to `yUndoPlugin`. */
209
215
  yUndoPluginOpts?: YUndoPluginOpts;
216
+ /**
217
+ * When `true`, the bridge sync plugin calls `bridge.dispose()` when
218
+ * the last plugin instance for this bridge is destroyed. Default `false`.
219
+ *
220
+ * Enable this only when the factory owns the bridge lifecycle.
221
+ * When the caller creates and manages the bridge separately, leave
222
+ * this `false` and call `bridge.dispose()` manually.
223
+ */
224
+ autoDisposeBridge?: boolean;
210
225
  /** Called for non-fatal warnings. Propagated to child plugins. Default `console.warn`. */
211
226
  onWarning?: OnWarning;
212
227
  };
@@ -227,7 +242,7 @@ type BridgeSyncState = {
227
242
  };
228
243
  type BridgeSyncFailure = {
229
244
  ok: false;
230
- reason: 'detached' | 'serialize-error';
245
+ reason: 'detached' | 'serialize-error' | 'parse-failed';
231
246
  };
232
247
  /** Options for {@link createBridgeSyncPlugin}. */
233
248
  type BridgeSyncPluginOptions = {
@@ -235,6 +250,11 @@ type BridgeSyncPluginOptions = {
235
250
  onSyncFailure?: (result: BridgeSyncFailure, view: EditorView) => void;
236
251
  /** Called for non-fatal warnings. Default `console.warn`. */
237
252
  onWarning?: OnWarning;
253
+ /**
254
+ * When `true`, automatically call `bridge.dispose()` when the last
255
+ * plugin instance for this bridge is destroyed. Default `false`.
256
+ */
257
+ autoDispose?: boolean;
238
258
  };
239
259
  /** ProseMirror plugin key for {@link createBridgeSyncPlugin}. Use to read the plugin state. */
240
260
  declare const bridgeSyncPluginKey: PluginKey<BridgeSyncState>;
package/dist/index.js CHANGED
@@ -98,6 +98,9 @@ function createYjsBridge(config, options) {
98
98
  throw new Error("sharedProseMirror belongs to a different Y.Doc than the provided doc");
99
99
  }
100
100
  let lastBridgedText = null;
101
+ let pendingSkipCleanup = null;
102
+ let skipPending = false;
103
+ let parseFailed = false;
101
104
  const syncTextToProsemirror = (origin) => {
102
105
  const text = normalize(sharedText.toString());
103
106
  if (lastBridgedText === text) {
@@ -110,6 +113,7 @@ function createYjsBridge(config, options) {
110
113
  onError
111
114
  });
112
115
  if (result.ok) {
116
+ parseFailed = false;
113
117
  let canonical = text;
114
118
  if (origin === ORIGIN_INIT) {
115
119
  try {
@@ -122,6 +126,9 @@ function createYjsBridge(config, options) {
122
126
  }
123
127
  }
124
128
  lastBridgedText = canonical;
129
+ } else {
130
+ parseFailed = true;
131
+ lastBridgedText = text;
125
132
  }
126
133
  return result.ok;
127
134
  };
@@ -183,13 +190,17 @@ function createYjsBridge(config, options) {
183
190
  let parseError = false;
184
191
  if (fallbackText.length > 0) {
185
192
  replaceSharedText(sharedText, fallbackText, ORIGIN_INIT, normalize);
193
+ lastBridgedText = normalize(fallbackText);
186
194
  const fallbackResult = replaceSharedProseMirror(doc, sharedProseMirror, fallbackText, ORIGIN_INIT, {
187
195
  schema,
188
196
  parse,
189
197
  normalize,
190
198
  onError
191
199
  });
192
- if (!fallbackResult.ok) parseError = true;
200
+ if (!fallbackResult.ok) {
201
+ parseError = true;
202
+ parseFailed = true;
203
+ }
193
204
  }
194
205
  return { source: "text", ...parseError && { parseError: true } };
195
206
  }
@@ -214,10 +225,57 @@ function createYjsBridge(config, options) {
214
225
  }
215
226
  if (transactionTouchedXmlFragment(transaction.changed, sharedProseMirror)) {
216
227
  lastBridgedText = normalize(sharedText.toString());
228
+ parseFailed = false;
217
229
  return;
218
230
  }
219
231
  if (skipOrigins !== null && skipOrigins.has(transaction.origin)) {
220
- lastBridgedText = normalize(sharedText.toString());
232
+ const expectedText = normalize(sharedText.toString());
233
+ lastBridgedText = expectedText;
234
+ const pmTextNow = sharedProseMirrorToText(sharedProseMirror);
235
+ if (pmTextNow !== null && normalize(pmTextNow) === expectedText) {
236
+ parseFailed = false;
237
+ if (pendingSkipCleanup) pendingSkipCleanup();
238
+ return;
239
+ }
240
+ const xmlTextAtSkipStart = pmTextNow;
241
+ if (pendingSkipCleanup) pendingSkipCleanup();
242
+ skipPending = true;
243
+ let resolved = false;
244
+ const resolve = () => {
245
+ if (resolved) return;
246
+ resolved = true;
247
+ skipPending = false;
248
+ sharedProseMirror.unobserveDeep(xmlCatchUpObserver);
249
+ clearTimeout(timer);
250
+ pendingSkipCleanup = null;
251
+ };
252
+ const xmlCatchUpObserver = (_events, transaction2) => {
253
+ if (resolved) return;
254
+ if (skipOrigins.has(transaction2.origin)) {
255
+ const pmText = sharedProseMirrorToText(sharedProseMirror);
256
+ if (pmText !== null && normalize(pmText) === expectedText) {
257
+ parseFailed = false;
258
+ resolve();
259
+ }
260
+ }
261
+ };
262
+ const runFallback = () => {
263
+ resolve();
264
+ if (lastBridgedText !== expectedText) return;
265
+ const pmText = sharedProseMirrorToText(sharedProseMirror);
266
+ if (pmText === null || normalize(pmText) !== expectedText) {
267
+ const changedDuringSkip = xmlTextAtSkipStart !== null && pmText !== null && normalize(pmText) !== normalize(xmlTextAtSkipStart) || xmlTextAtSkipStart === null && pmText !== null || xmlTextAtSkipStart !== null && pmText === null;
268
+ if (changedDuringSkip) {
269
+ lastBridgedText = pmText !== null ? normalize(pmText) : lastBridgedText;
270
+ return;
271
+ }
272
+ lastBridgedText = null;
273
+ syncTextToProsemirror(ORIGIN_TEXT_TO_PM);
274
+ }
275
+ };
276
+ const timer = setTimeout(runFallback, 500);
277
+ sharedProseMirror.observeDeep(xmlCatchUpObserver);
278
+ pendingSkipCleanup = resolve;
221
279
  return;
222
280
  }
223
281
  syncTextToProsemirror(ORIGIN_TEXT_TO_PM);
@@ -227,6 +285,12 @@ function createYjsBridge(config, options) {
227
285
  return {
228
286
  bootstrapResult,
229
287
  syncToSharedText(doc2) {
288
+ if (skipPending) {
289
+ return { ok: false, reason: "skip-pending" };
290
+ }
291
+ if (parseFailed) {
292
+ return { ok: false, reason: "parse-failed" };
293
+ }
230
294
  let text;
231
295
  try {
232
296
  text = serialize(doc2);
@@ -246,6 +310,7 @@ function createYjsBridge(config, options) {
246
310
  },
247
311
  dispose() {
248
312
  sharedText.unobserve(textObserver);
313
+ if (pendingSkipCleanup) pendingSkipCleanup();
249
314
  }
250
315
  };
251
316
  }
@@ -297,7 +362,6 @@ var wiredBridges = /* @__PURE__ */ new WeakMap();
297
362
  var defaultOnWarning = (event) => console.warn(`[pm-cm] ${event.code}: ${event.message}`);
298
363
  function createBridgeSyncPlugin(bridge, options = {}) {
299
364
  const warn = options.onWarning ?? defaultOnWarning;
300
- let yjsBatchSeen = false;
301
365
  let needsSync = false;
302
366
  return new Plugin({
303
367
  key: bridgeSyncPluginKey,
@@ -308,10 +372,6 @@ function createBridgeSyncPlugin(bridge, options = {}) {
308
372
  apply(tr, _prev) {
309
373
  if (!tr.docChanged) return { needsSync };
310
374
  if (bridge.isYjsSyncChange(tr)) {
311
- yjsBatchSeen = true;
312
- return { needsSync };
313
- }
314
- if (yjsBatchSeen && tr.getMeta("appendedTransaction")) {
315
375
  return { needsSync };
316
376
  }
317
377
  needsSync = true;
@@ -329,6 +389,14 @@ function createBridgeSyncPlugin(bridge, options = {}) {
329
389
  if (needsSync) {
330
390
  const result = bridge.syncToSharedText(view.state.doc);
331
391
  if (!result.ok) {
392
+ if (result.reason === "skip-pending") {
393
+ return;
394
+ }
395
+ if (result.reason === "parse-failed") {
396
+ options.onSyncFailure?.(result, view);
397
+ warn({ code: "sync-failed", message: `bridge sync failed: ${result.reason}` });
398
+ return;
399
+ }
332
400
  if (result.reason !== "unchanged") {
333
401
  options.onSyncFailure?.(result, view);
334
402
  warn({ code: "sync-failed", message: `bridge sync failed: ${result.reason}` });
@@ -336,12 +404,15 @@ function createBridgeSyncPlugin(bridge, options = {}) {
336
404
  }
337
405
  }
338
406
  needsSync = false;
339
- yjsBatchSeen = false;
340
407
  },
341
408
  destroy() {
342
409
  const remaining = (wiredBridges.get(bridge) ?? 1) - 1;
343
- if (remaining <= 0) wiredBridges.delete(bridge);
344
- else wiredBridges.set(bridge, remaining);
410
+ if (remaining <= 0) {
411
+ wiredBridges.delete(bridge);
412
+ if (options.autoDispose) bridge.dispose();
413
+ } else {
414
+ wiredBridges.set(bridge, remaining);
415
+ }
345
416
  }
346
417
  };
347
418
  }
@@ -387,6 +458,8 @@ function broadcastTextCursor(awareness, cmCursorFieldName, sharedText, textAncho
387
458
  awareness.setLocalStateField(cmCursorFieldName, { anchor: relAnchor, head: relHead });
388
459
  }
389
460
  var defaultOnWarning2 = (event) => console.warn(`[pm-cm] ${event.code}: ${event.message}`);
461
+ var awarenessRefCounts = /* @__PURE__ */ new WeakMap();
462
+ var awarenessFieldNames = /* @__PURE__ */ new WeakMap();
390
463
  function createCursorSyncPlugin(options) {
391
464
  const { awareness, serialize, sharedText } = options;
392
465
  const warn = options.onWarning ?? defaultOnWarning2;
@@ -436,6 +509,17 @@ function createCursorSyncPlugin(options) {
436
509
  }
437
510
  },
438
511
  view(editorView) {
512
+ const viewCount = awarenessRefCounts.get(awareness) ?? 0;
513
+ awarenessRefCounts.set(awareness, viewCount + 1);
514
+ let fieldNames = awarenessFieldNames.get(awareness);
515
+ if (!fieldNames) {
516
+ fieldNames = /* @__PURE__ */ new Set();
517
+ awarenessFieldNames.set(awareness, fieldNames);
518
+ }
519
+ fieldNames.add(cursorFieldName);
520
+ if (sharedText) {
521
+ fieldNames.add(cmCursorFieldName);
522
+ }
439
523
  let suppressCmReaction = false;
440
524
  let lastCmAbsAnchor = -1;
441
525
  let lastCmAbsHead = -1;
@@ -468,6 +552,9 @@ function createCursorSyncPlugin(options) {
468
552
  lastCmAbsHead = -1;
469
553
  return;
470
554
  }
555
+ if (absAnchor.type !== sharedText || absHead.type !== sharedText) {
556
+ return;
557
+ }
471
558
  if (absAnchor.index === lastCmAbsAnchor && absHead.index === lastCmAbsHead) return;
472
559
  const map = getOrBuildMap(editorView.state.doc);
473
560
  if (!map) {
@@ -482,12 +569,18 @@ function createCursorSyncPlugin(options) {
482
569
  }
483
570
  lastCmAbsAnchor = absAnchor.index;
484
571
  lastCmAbsHead = absHead.index;
485
- cmCursorHandledByListener = true;
572
+ if (pendingCmCursor != null) {
573
+ cmCursorHandledByListener = true;
574
+ }
486
575
  const ok = broadcastPmCursor(awareness, cursorFieldName, editorView, pmAnchor, pmHead);
487
576
  if (!ok && !warnedSyncPluginMissing) {
488
577
  warnedSyncPluginMissing = true;
489
578
  warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
490
579
  }
580
+ } catch {
581
+ awareness.setLocalStateField(cursorFieldName, null);
582
+ lastCmAbsAnchor = -1;
583
+ lastCmAbsHead = -1;
491
584
  } finally {
492
585
  inAwarenessHandler = false;
493
586
  }
@@ -528,6 +621,9 @@ function createCursorSyncPlugin(options) {
528
621
  }
529
622
  } else {
530
623
  awareness.setLocalStateField(cursorFieldName, null);
624
+ if (sharedText?.doc) {
625
+ awareness.setLocalStateField(cmCursorFieldName, null);
626
+ }
531
627
  }
532
628
  }
533
629
  cmCursorHandledByListener = false;
@@ -562,9 +658,18 @@ function createCursorSyncPlugin(options) {
562
658
  if (sharedText) {
563
659
  awareness.off("update", handleAwarenessUpdate);
564
660
  }
565
- awareness.setLocalStateField(cursorFieldName, null);
566
- if (sharedText) {
567
- awareness.setLocalStateField(cmCursorFieldName, null);
661
+ const remaining = (awarenessRefCounts.get(awareness) ?? 1) - 1;
662
+ if (remaining <= 0) {
663
+ awarenessRefCounts.delete(awareness);
664
+ const fields = awarenessFieldNames.get(awareness);
665
+ if (fields) {
666
+ for (const field of fields) {
667
+ awareness.setLocalStateField(field, null);
668
+ }
669
+ awarenessFieldNames.delete(awareness);
670
+ }
671
+ } else {
672
+ awarenessRefCounts.set(awareness, remaining);
568
673
  }
569
674
  }
570
675
  };
@@ -605,7 +710,10 @@ function createCollabPlugins(schema, options) {
605
710
  yUndoPlugin(options.yUndoPluginOpts)
606
711
  ];
607
712
  if (options.bridge) {
608
- plugins.push(createBridgeSyncPlugin(options.bridge, { onWarning: options.onWarning }));
713
+ plugins.push(createBridgeSyncPlugin(options.bridge, {
714
+ onWarning: options.onWarning,
715
+ autoDispose: options.autoDisposeBridge
716
+ }));
609
717
  }
610
718
  if (enableCursorSync && options.serialize) {
611
719
  plugins.push(