@pm-cm/yjs 0.0.2 → 0.0.4

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.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // src/bridge.ts
2
2
  import { prosemirrorToYXmlFragment, yXmlFragmentToProseMirrorRootNode, ySyncPluginKey } from "y-prosemirror";
3
- import { XmlElement as YXmlElement, XmlText as YXmlText } from "yjs";
4
3
 
5
4
  // src/types.ts
6
5
  var ORIGIN_TEXT_TO_PM = "bridge:text-to-prosemirror";
@@ -58,120 +57,10 @@ function replaceSharedProseMirror(doc, fragment, text, origin, config) {
58
57
  onError({ code: "parse-error", message: "failed to parse text into ProseMirror document", cause: error });
59
58
  return { ok: false, reason: "parse-error" };
60
59
  }
61
- try {
62
- const currentDoc = yXmlFragmentToProseMirrorRootNode(fragment, config.schema);
63
- if (currentDoc.eq(nextDoc)) {
64
- return { ok: false, reason: "unchanged" };
65
- }
66
- } catch {
67
- }
68
- if (!tryIncrementalFragmentUpdate(doc, fragment, nextDoc, origin)) {
69
- doc.transact(() => {
70
- prosemirrorToYXmlFragment(nextDoc, fragment);
71
- }, origin);
72
- }
73
- return { ok: true };
74
- }
75
- function pmNodeToYXmlElement(node) {
76
- const xmlEl = new YXmlElement(node.type.name);
77
- for (const [key, value] of Object.entries(node.attrs)) {
78
- if (value != null) xmlEl.setAttribute(key, value);
79
- }
80
- const children = [];
81
- node.forEach((child) => {
82
- if (child.isText && child.text) {
83
- const xmlText = new YXmlText();
84
- const attrs = {};
85
- for (const mark of child.marks) {
86
- attrs[mark.type.name] = mark.attrs && Object.keys(mark.attrs).length > 0 ? mark.attrs : true;
87
- }
88
- xmlText.insert(0, child.text, Object.keys(attrs).length > 0 ? attrs : void 0);
89
- children.push(xmlText);
90
- } else if (!child.isLeaf) {
91
- children.push(pmNodeToYXmlElement(child));
92
- }
93
- });
94
- if (children.length > 0) {
95
- xmlEl.insert(0, children);
96
- }
97
- return xmlEl;
98
- }
99
- function getXmlElementText(xmlEl) {
100
- let text = "";
101
- for (let j = 0; j < xmlEl.length; j++) {
102
- const child = xmlEl.get(j);
103
- if (child instanceof YXmlText) text += child.toString();
104
- }
105
- return text;
106
- }
107
- function updateXmlElementTextContent(xmlEl, pmNode) {
108
- if (xmlEl.length === 0 || !(xmlEl.get(0) instanceof YXmlText)) return;
109
- const xmlText = xmlEl.get(0);
110
- const oldText = xmlText.toString();
111
- const newText = pmNode.textContent;
112
- if (oldText === newText) return;
113
- let start = 0;
114
- const minLen = Math.min(oldText.length, newText.length);
115
- while (start < minLen && oldText.charCodeAt(start) === newText.charCodeAt(start)) {
116
- start++;
117
- }
118
- let oldEnd = oldText.length;
119
- let newEnd = newText.length;
120
- while (oldEnd > start && newEnd > start && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
121
- oldEnd--;
122
- newEnd--;
123
- }
124
- if (oldEnd > start) xmlText.delete(start, oldEnd - start);
125
- if (newEnd > start) xmlText.insert(start, newText.slice(start, newEnd));
126
- }
127
- function tryIncrementalFragmentUpdate(ydoc, fragment, nextDoc, origin) {
128
- const oldCount = fragment.length;
129
- const newCount = nextDoc.childCount;
130
- for (let i = 0; i < oldCount; i++) {
131
- if (!(fragment.get(i) instanceof YXmlElement)) return false;
132
- }
133
- let prefix = 0;
134
- while (prefix < Math.min(oldCount, newCount)) {
135
- const oldChild = fragment.get(prefix);
136
- const newChild = nextDoc.child(prefix);
137
- if (oldChild.nodeName !== newChild.type.name) break;
138
- if (getXmlElementText(oldChild) !== newChild.textContent) break;
139
- prefix++;
140
- }
141
- let suffix = 0;
142
- while (suffix < Math.min(oldCount - prefix, newCount - prefix)) {
143
- const oi = oldCount - 1 - suffix;
144
- const ni = newCount - 1 - suffix;
145
- const oldChild = fragment.get(oi);
146
- const newChild = nextDoc.child(ni);
147
- if (oldChild.nodeName !== newChild.type.name) break;
148
- if (getXmlElementText(oldChild) !== newChild.textContent) break;
149
- suffix++;
150
- }
151
- const oldMiddleLen = oldCount - prefix - suffix;
152
- const newMiddleLen = newCount - prefix - suffix;
153
- if (oldMiddleLen === 0 && newMiddleLen === 0) return true;
154
- ydoc.transact(() => {
155
- const updateCount = Math.min(oldMiddleLen, newMiddleLen);
156
- for (let i = 0; i < updateCount; i++) {
157
- const idx = prefix + i;
158
- const xmlEl = fragment.get(idx);
159
- const pmNode = nextDoc.child(idx);
160
- if (xmlEl.nodeName === pmNode.type.name) {
161
- updateXmlElementTextContent(xmlEl, pmNode);
162
- } else {
163
- fragment.delete(idx, 1);
164
- fragment.insert(idx, [pmNodeToYXmlElement(pmNode)]);
165
- }
166
- }
167
- if (oldMiddleLen > updateCount) {
168
- fragment.delete(prefix + updateCount, oldMiddleLen - updateCount);
169
- }
170
- for (let i = updateCount; i < newMiddleLen; i++) {
171
- fragment.insert(prefix + i, [pmNodeToYXmlElement(nextDoc.child(prefix + i))]);
172
- }
60
+ doc.transact(() => {
61
+ prosemirrorToYXmlFragment(nextDoc, fragment);
173
62
  }, origin);
174
- return true;
63
+ return { ok: true };
175
64
  }
176
65
  function createYjsBridge(config, options) {
177
66
  const {
@@ -208,10 +97,10 @@ function createYjsBridge(config, options) {
208
97
  normalize,
209
98
  onError
210
99
  });
211
- if (result.ok || result.reason === "unchanged") {
100
+ if (result.ok) {
212
101
  lastBridgedText = text;
213
102
  }
214
- return result.ok || result.reason === "unchanged";
103
+ return result.ok;
215
104
  };
216
105
  const sharedProseMirrorToText = (fragment) => {
217
106
  try {
@@ -239,7 +128,13 @@ function createYjsBridge(config, options) {
239
128
  return { source: "initial", parseError: true };
240
129
  }
241
130
  const pmDoc = yXmlFragmentToProseMirrorRootNode(sharedProseMirror, schema);
242
- const canonicalText = serialize(pmDoc);
131
+ let canonicalText;
132
+ try {
133
+ canonicalText = serialize(pmDoc);
134
+ } catch (error) {
135
+ onError({ code: "serialize-error", message: "failed to serialize ProseMirror document during bootstrap", cause: error });
136
+ return { source: "initial", parseError: true };
137
+ }
243
138
  replaceSharedText(sharedText, canonicalText, ORIGIN_INIT, normalize);
244
139
  lastBridgedText = normalize(canonicalText);
245
140
  return { source: "initial" };
@@ -301,7 +196,13 @@ function createYjsBridge(config, options) {
301
196
  return {
302
197
  bootstrapResult,
303
198
  syncToSharedText(doc2) {
304
- const text = serialize(doc2);
199
+ let text;
200
+ try {
201
+ text = serialize(doc2);
202
+ } catch (error) {
203
+ onError({ code: "serialize-error", message: "failed to serialize ProseMirror document", cause: error });
204
+ return { ok: false, reason: "serialize-error" };
205
+ }
305
206
  const result = replaceSharedText(sharedText, text, ORIGIN_PM_TO_TEXT, normalize);
306
207
  if (result.ok || result.reason === "unchanged") {
307
208
  lastBridgedText = normalize(text);
@@ -339,10 +240,12 @@ function createAwarenessProxy(awareness, cursorField = "pmCursor") {
339
240
  const states = target.getStates();
340
241
  const remapped = /* @__PURE__ */ new Map();
341
242
  states.forEach((state, clientId) => {
342
- remapped.set(clientId, {
343
- ...state,
344
- cursor: state[cursorField] ?? null
345
- });
243
+ const s = state;
244
+ if (cursorField in s) {
245
+ remapped.set(clientId, { ...s, cursor: s[cursorField] ?? null });
246
+ } else {
247
+ remapped.set(clientId, s);
248
+ }
346
249
  });
347
250
  return remapped;
348
251
  };
@@ -359,14 +262,12 @@ import { initProseMirrorDoc, yCursorPlugin, ySyncPlugin, yUndoPlugin } from "y-p
359
262
  // src/bridge-sync-plugin.ts
360
263
  import { Plugin, PluginKey } from "prosemirror-state";
361
264
  var bridgeSyncPluginKey = new PluginKey("pm-cm-bridge-sync");
362
- var wiredBridges = /* @__PURE__ */ new WeakSet();
265
+ var wiredBridges = /* @__PURE__ */ new WeakMap();
363
266
  var defaultOnWarning = (event) => console.warn(`[pm-cm] ${event.code}: ${event.message}`);
364
267
  function createBridgeSyncPlugin(bridge, options = {}) {
365
268
  const warn = options.onWarning ?? defaultOnWarning;
366
- if (wiredBridges.has(bridge)) {
367
- warn({ code: "bridge-already-wired", message: "this bridge is already wired to another plugin instance" });
368
- }
369
- wiredBridges.add(bridge);
269
+ let yjsBatchSeen = false;
270
+ let needsSync = false;
370
271
  return new Plugin({
371
272
  key: bridgeSyncPluginKey,
372
273
  state: {
@@ -374,27 +275,44 @@ function createBridgeSyncPlugin(bridge, options = {}) {
374
275
  return { needsSync: false };
375
276
  },
376
277
  apply(tr, _prev) {
377
- if (!tr.docChanged) return { needsSync: false };
378
- if (bridge.isYjsSyncChange(tr)) return { needsSync: false };
278
+ if (!tr.docChanged) return { needsSync };
279
+ if (bridge.isYjsSyncChange(tr)) {
280
+ yjsBatchSeen = true;
281
+ needsSync = false;
282
+ return { needsSync: false };
283
+ }
284
+ if (yjsBatchSeen) {
285
+ needsSync = false;
286
+ return { needsSync: false };
287
+ }
288
+ needsSync = true;
379
289
  return { needsSync: true };
380
290
  }
381
291
  },
382
292
  view() {
293
+ const count = wiredBridges.get(bridge) ?? 0;
294
+ if (count > 0) {
295
+ warn({ code: "bridge-already-wired", message: "this bridge is already wired to another plugin instance" });
296
+ }
297
+ wiredBridges.set(bridge, count + 1);
383
298
  return {
384
299
  update(view) {
385
- const state = bridgeSyncPluginKey.getState(view.state);
386
- if (state?.needsSync) {
300
+ if (needsSync) {
387
301
  const result = bridge.syncToSharedText(view.state.doc);
388
302
  if (!result.ok) {
389
- if (result.reason === "detached") {
303
+ if (result.reason !== "unchanged") {
390
304
  options.onSyncFailure?.(result, view);
391
305
  warn({ code: "sync-failed", message: `bridge sync failed: ${result.reason}` });
392
306
  }
393
307
  }
394
308
  }
309
+ needsSync = false;
310
+ yjsBatchSeen = false;
395
311
  },
396
312
  destroy() {
397
- wiredBridges.delete(bridge);
313
+ const remaining = (wiredBridges.get(bridge) ?? 1) - 1;
314
+ if (remaining <= 0) wiredBridges.delete(bridge);
315
+ else wiredBridges.set(bridge, remaining);
398
316
  }
399
317
  };
400
318
  }
@@ -404,7 +322,7 @@ function createBridgeSyncPlugin(bridge, options = {}) {
404
322
  // src/cursor-sync-plugin.ts
405
323
  import { Plugin as Plugin2, PluginKey as PluginKey2 } from "prosemirror-state";
406
324
  import { absolutePositionToRelativePosition, ySyncPluginKey as ySyncPluginKey2 } from "y-prosemirror";
407
- import { createRelativePositionFromTypeIndex } from "yjs";
325
+ import { createRelativePositionFromTypeIndex, createAbsolutePositionFromRelativePosition } from "yjs";
408
326
  import { buildCursorMap, cursorMapLookup, reverseCursorMapLookup } from "@pm-cm/core";
409
327
  var cursorSyncPluginKey = new PluginKey2("pm-cm-cursor-sync");
410
328
  function getYSyncState(view) {
@@ -445,12 +363,23 @@ function createCursorSyncPlugin(options) {
445
363
  const warn = options.onWarning ?? defaultOnWarning2;
446
364
  const cursorFieldName = options.cursorFieldName ?? "pmCursor";
447
365
  const cmCursorFieldName = options.cmCursorFieldName ?? "cursor";
366
+ if (sharedText && cursorFieldName === cmCursorFieldName) {
367
+ throw new Error(
368
+ `createCursorSyncPlugin: cursorFieldName and cmCursorFieldName must differ when sharedText is provided (both are "${cursorFieldName}")`
369
+ );
370
+ }
448
371
  let warnedSyncPluginMissing = false;
372
+ let pendingCmCursor = null;
449
373
  let cachedMap = null;
450
374
  let cachedMapDoc = null;
451
375
  function getOrBuildMap(doc) {
452
376
  if (cachedMapDoc !== doc || !cachedMap) {
453
- cachedMap = buildCursorMap(doc, serialize);
377
+ try {
378
+ cachedMap = buildCursorMap(doc, serialize);
379
+ } catch (error) {
380
+ warn({ code: "cursor-map-error", message: "failed to build cursor map \u2014 cursor sync skipped" });
381
+ cachedMap = null;
382
+ }
454
383
  cachedMapDoc = doc;
455
384
  }
456
385
  return cachedMap;
@@ -464,62 +393,141 @@ function createCursorSyncPlugin(options) {
464
393
  apply(tr, prev, _oldState, newState) {
465
394
  const cmMeta = tr.getMeta(cursorSyncPluginKey);
466
395
  if (cmMeta) {
467
- return { pendingCm: cmMeta, mappedTextOffset: prev.mappedTextOffset };
396
+ pendingCmCursor = cmMeta;
468
397
  }
469
398
  let mappedTextOffset = prev.mappedTextOffset;
470
399
  if (tr.selectionSet || tr.docChanged) {
471
400
  const map = getOrBuildMap(newState.doc);
472
- mappedTextOffset = cursorMapLookup(map, newState.selection.anchor);
401
+ mappedTextOffset = map ? cursorMapLookup(map, newState.selection.anchor) : null;
473
402
  }
474
403
  return {
475
- pendingCm: prev.pendingCm !== null ? null : prev.pendingCm,
404
+ pendingCm: pendingCmCursor,
476
405
  mappedTextOffset
477
406
  };
478
407
  }
479
408
  },
480
- view() {
409
+ view(editorView) {
410
+ let suppressCmReaction = false;
411
+ let lastCmAbsAnchor = -1;
412
+ let lastCmAbsHead = -1;
413
+ let cmCursorHandledByListener = false;
414
+ const handleAwarenessUpdate = ({ added, updated }) => {
415
+ if (suppressCmReaction) return;
416
+ if (!sharedText?.doc) return;
417
+ const localId = awareness.clientID;
418
+ if (!updated.includes(localId) && !added.includes(localId)) return;
419
+ const localState = awareness.getLocalState();
420
+ if (!localState) return;
421
+ const cmCursor = localState[cmCursorFieldName];
422
+ if (!cmCursor?.anchor || !cmCursor?.head) {
423
+ if (lastCmAbsAnchor !== -1) {
424
+ awareness.setLocalStateField(cursorFieldName, null);
425
+ lastCmAbsAnchor = -1;
426
+ lastCmAbsHead = -1;
427
+ }
428
+ return;
429
+ }
430
+ const absAnchor = createAbsolutePositionFromRelativePosition(cmCursor.anchor, sharedText.doc);
431
+ const absHead = createAbsolutePositionFromRelativePosition(cmCursor.head, sharedText.doc);
432
+ if (!absAnchor || !absHead) {
433
+ awareness.setLocalStateField(cursorFieldName, null);
434
+ lastCmAbsAnchor = -1;
435
+ lastCmAbsHead = -1;
436
+ return;
437
+ }
438
+ if (absAnchor.index === lastCmAbsAnchor && absHead.index === lastCmAbsHead) return;
439
+ const map = getOrBuildMap(editorView.state.doc);
440
+ if (!map) {
441
+ awareness.setLocalStateField(cursorFieldName, null);
442
+ return;
443
+ }
444
+ const pmAnchor = reverseCursorMapLookup(map, absAnchor.index);
445
+ const pmHead = reverseCursorMapLookup(map, absHead.index);
446
+ if (pmAnchor === null || pmHead === null) {
447
+ awareness.setLocalStateField(cursorFieldName, null);
448
+ return;
449
+ }
450
+ lastCmAbsAnchor = absAnchor.index;
451
+ lastCmAbsHead = absHead.index;
452
+ cmCursorHandledByListener = true;
453
+ const ok = broadcastPmCursor(awareness, cursorFieldName, editorView, pmAnchor, pmHead);
454
+ if (!ok && !warnedSyncPluginMissing) {
455
+ warnedSyncPluginMissing = true;
456
+ warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
457
+ }
458
+ };
459
+ if (sharedText) {
460
+ awareness.on("update", handleAwarenessUpdate);
461
+ }
481
462
  return {
482
463
  update(view, prevState) {
483
- const pluginState = cursorSyncPluginKey.getState(view.state);
484
- const prevPluginState = cursorSyncPluginKey.getState(prevState);
485
- if (pluginState?.pendingCm != null && pluginState.pendingCm !== prevPluginState?.pendingCm) {
486
- const map = getOrBuildMap(view.state.doc);
487
- const pmAnchor = reverseCursorMapLookup(map, pluginState.pendingCm.anchor);
488
- const pmHead = reverseCursorMapLookup(map, pluginState.pendingCm.head);
489
- if (pmAnchor !== null && pmHead !== null) {
490
- const ok = broadcastPmCursor(awareness, cursorFieldName, view, pmAnchor, pmHead);
491
- if (!ok && !warnedSyncPluginMissing) {
492
- warnedSyncPluginMissing = true;
493
- warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
494
- }
464
+ if (view.state.doc !== prevState.doc) {
465
+ lastCmAbsAnchor = -1;
466
+ lastCmAbsHead = -1;
467
+ if (sharedText?.doc && !suppressCmReaction) {
468
+ handleAwarenessUpdate({ added: [awareness.clientID], updated: [], removed: [] });
495
469
  }
496
- if (sharedText) {
497
- broadcastTextCursor(
498
- awareness,
499
- cmCursorFieldName,
500
- sharedText,
501
- pluginState.pendingCm.anchor,
502
- pluginState.pendingCm.head
503
- );
470
+ }
471
+ if (pendingCmCursor != null) {
472
+ const cursor = pendingCmCursor;
473
+ pendingCmCursor = null;
474
+ if (!cmCursorHandledByListener) {
475
+ const map = getOrBuildMap(view.state.doc);
476
+ const pmAnchor = map ? reverseCursorMapLookup(map, cursor.anchor) : null;
477
+ const pmHead = map ? reverseCursorMapLookup(map, cursor.head) : null;
478
+ if (pmAnchor !== null && pmHead !== null) {
479
+ const ok = broadcastPmCursor(awareness, cursorFieldName, view, pmAnchor, pmHead);
480
+ if (!ok && !warnedSyncPluginMissing) {
481
+ warnedSyncPluginMissing = true;
482
+ warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
483
+ }
484
+ if (sharedText?.doc) {
485
+ broadcastTextCursor(
486
+ awareness,
487
+ cmCursorFieldName,
488
+ sharedText,
489
+ cursor.anchor,
490
+ cursor.head
491
+ );
492
+ }
493
+ } else {
494
+ awareness.setLocalStateField(cursorFieldName, null);
495
+ }
504
496
  }
497
+ cmCursorHandledByListener = false;
505
498
  return;
506
499
  }
507
500
  if (view.hasFocus() && (view.state.selection !== prevState.selection || view.state.doc !== prevState.doc)) {
508
- const { anchor, head } = view.state.selection;
509
- const ok = broadcastPmCursor(awareness, cursorFieldName, view, anchor, head);
510
- if (!ok && !warnedSyncPluginMissing) {
511
- warnedSyncPluginMissing = true;
512
- warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
513
- }
514
- if (sharedText) {
515
- const map = getOrBuildMap(view.state.doc);
516
- const textAnchor = cursorMapLookup(map, anchor);
517
- const textHead = cursorMapLookup(map, head);
518
- if (textAnchor !== null && textHead !== null) {
519
- broadcastTextCursor(awareness, cmCursorFieldName, sharedText, textAnchor, textHead);
501
+ suppressCmReaction = true;
502
+ try {
503
+ const { anchor, head } = view.state.selection;
504
+ const ok = broadcastPmCursor(awareness, cursorFieldName, view, anchor, head);
505
+ if (!ok && !warnedSyncPluginMissing) {
506
+ warnedSyncPluginMissing = true;
507
+ warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
508
+ }
509
+ if (sharedText?.doc) {
510
+ const map = getOrBuildMap(view.state.doc);
511
+ const textAnchor = map ? cursorMapLookup(map, anchor) : null;
512
+ const textHead = map ? cursorMapLookup(map, head) : null;
513
+ if (textAnchor !== null && textHead !== null) {
514
+ broadcastTextCursor(awareness, cmCursorFieldName, sharedText, textAnchor, textHead);
515
+ } else {
516
+ awareness.setLocalStateField(cmCursorFieldName, null);
517
+ }
520
518
  }
519
+ } finally {
520
+ suppressCmReaction = false;
521
521
  }
522
522
  }
523
+ cmCursorHandledByListener = false;
524
+ },
525
+ destroy() {
526
+ awareness.setLocalStateField(cursorFieldName, null);
527
+ if (sharedText) {
528
+ awareness.setLocalStateField(cmCursorFieldName, null);
529
+ awareness.off("update", handleAwarenessUpdate);
530
+ }
523
531
  }
524
532
  };
525
533
  }
@@ -530,7 +538,10 @@ function syncCmCursor(view, anchor, head, onWarning) {
530
538
  (onWarning ?? defaultOnWarning2)({ code: "cursor-sync-not-installed", message: "cursor sync plugin is not installed on this EditorView" });
531
539
  return;
532
540
  }
533
- const sanitize = (v) => Math.max(0, Math.floor(v));
541
+ const sanitize = (v) => {
542
+ const n = Math.floor(v);
543
+ return Number.isFinite(n) ? Math.max(0, n) : 0;
544
+ };
534
545
  view.dispatch(
535
546
  view.state.tr.setMeta(cursorSyncPluginKey, {
536
547
  anchor: sanitize(anchor),