@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.cjs CHANGED
@@ -43,7 +43,6 @@ module.exports = __toCommonJS(index_exports);
43
43
 
44
44
  // src/bridge.ts
45
45
  var import_y_prosemirror = require("y-prosemirror");
46
- var import_yjs = require("yjs");
47
46
 
48
47
  // src/types.ts
49
48
  var ORIGIN_TEXT_TO_PM = "bridge:text-to-prosemirror";
@@ -101,120 +100,10 @@ function replaceSharedProseMirror(doc, fragment, text, origin, config) {
101
100
  onError({ code: "parse-error", message: "failed to parse text into ProseMirror document", cause: error });
102
101
  return { ok: false, reason: "parse-error" };
103
102
  }
104
- try {
105
- const currentDoc = (0, import_y_prosemirror.yXmlFragmentToProseMirrorRootNode)(fragment, config.schema);
106
- if (currentDoc.eq(nextDoc)) {
107
- return { ok: false, reason: "unchanged" };
108
- }
109
- } catch {
110
- }
111
- if (!tryIncrementalFragmentUpdate(doc, fragment, nextDoc, origin)) {
112
- doc.transact(() => {
113
- (0, import_y_prosemirror.prosemirrorToYXmlFragment)(nextDoc, fragment);
114
- }, origin);
115
- }
116
- return { ok: true };
117
- }
118
- function pmNodeToYXmlElement(node) {
119
- const xmlEl = new import_yjs.XmlElement(node.type.name);
120
- for (const [key, value] of Object.entries(node.attrs)) {
121
- if (value != null) xmlEl.setAttribute(key, value);
122
- }
123
- const children = [];
124
- node.forEach((child) => {
125
- if (child.isText && child.text) {
126
- const xmlText = new import_yjs.XmlText();
127
- const attrs = {};
128
- for (const mark of child.marks) {
129
- attrs[mark.type.name] = mark.attrs && Object.keys(mark.attrs).length > 0 ? mark.attrs : true;
130
- }
131
- xmlText.insert(0, child.text, Object.keys(attrs).length > 0 ? attrs : void 0);
132
- children.push(xmlText);
133
- } else if (!child.isLeaf) {
134
- children.push(pmNodeToYXmlElement(child));
135
- }
136
- });
137
- if (children.length > 0) {
138
- xmlEl.insert(0, children);
139
- }
140
- return xmlEl;
141
- }
142
- function getXmlElementText(xmlEl) {
143
- let text = "";
144
- for (let j = 0; j < xmlEl.length; j++) {
145
- const child = xmlEl.get(j);
146
- if (child instanceof import_yjs.XmlText) text += child.toString();
147
- }
148
- return text;
149
- }
150
- function updateXmlElementTextContent(xmlEl, pmNode) {
151
- if (xmlEl.length === 0 || !(xmlEl.get(0) instanceof import_yjs.XmlText)) return;
152
- const xmlText = xmlEl.get(0);
153
- const oldText = xmlText.toString();
154
- const newText = pmNode.textContent;
155
- if (oldText === newText) return;
156
- let start = 0;
157
- const minLen = Math.min(oldText.length, newText.length);
158
- while (start < minLen && oldText.charCodeAt(start) === newText.charCodeAt(start)) {
159
- start++;
160
- }
161
- let oldEnd = oldText.length;
162
- let newEnd = newText.length;
163
- while (oldEnd > start && newEnd > start && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
164
- oldEnd--;
165
- newEnd--;
166
- }
167
- if (oldEnd > start) xmlText.delete(start, oldEnd - start);
168
- if (newEnd > start) xmlText.insert(start, newText.slice(start, newEnd));
169
- }
170
- function tryIncrementalFragmentUpdate(ydoc, fragment, nextDoc, origin) {
171
- const oldCount = fragment.length;
172
- const newCount = nextDoc.childCount;
173
- for (let i = 0; i < oldCount; i++) {
174
- if (!(fragment.get(i) instanceof import_yjs.XmlElement)) return false;
175
- }
176
- let prefix = 0;
177
- while (prefix < Math.min(oldCount, newCount)) {
178
- const oldChild = fragment.get(prefix);
179
- const newChild = nextDoc.child(prefix);
180
- if (oldChild.nodeName !== newChild.type.name) break;
181
- if (getXmlElementText(oldChild) !== newChild.textContent) break;
182
- prefix++;
183
- }
184
- let suffix = 0;
185
- while (suffix < Math.min(oldCount - prefix, newCount - prefix)) {
186
- const oi = oldCount - 1 - suffix;
187
- const ni = newCount - 1 - suffix;
188
- const oldChild = fragment.get(oi);
189
- const newChild = nextDoc.child(ni);
190
- if (oldChild.nodeName !== newChild.type.name) break;
191
- if (getXmlElementText(oldChild) !== newChild.textContent) break;
192
- suffix++;
193
- }
194
- const oldMiddleLen = oldCount - prefix - suffix;
195
- const newMiddleLen = newCount - prefix - suffix;
196
- if (oldMiddleLen === 0 && newMiddleLen === 0) return true;
197
- ydoc.transact(() => {
198
- const updateCount = Math.min(oldMiddleLen, newMiddleLen);
199
- for (let i = 0; i < updateCount; i++) {
200
- const idx = prefix + i;
201
- const xmlEl = fragment.get(idx);
202
- const pmNode = nextDoc.child(idx);
203
- if (xmlEl.nodeName === pmNode.type.name) {
204
- updateXmlElementTextContent(xmlEl, pmNode);
205
- } else {
206
- fragment.delete(idx, 1);
207
- fragment.insert(idx, [pmNodeToYXmlElement(pmNode)]);
208
- }
209
- }
210
- if (oldMiddleLen > updateCount) {
211
- fragment.delete(prefix + updateCount, oldMiddleLen - updateCount);
212
- }
213
- for (let i = updateCount; i < newMiddleLen; i++) {
214
- fragment.insert(prefix + i, [pmNodeToYXmlElement(nextDoc.child(prefix + i))]);
215
- }
103
+ doc.transact(() => {
104
+ (0, import_y_prosemirror.prosemirrorToYXmlFragment)(nextDoc, fragment);
216
105
  }, origin);
217
- return true;
106
+ return { ok: true };
218
107
  }
219
108
  function createYjsBridge(config, options) {
220
109
  const {
@@ -251,10 +140,10 @@ function createYjsBridge(config, options) {
251
140
  normalize,
252
141
  onError
253
142
  });
254
- if (result.ok || result.reason === "unchanged") {
143
+ if (result.ok) {
255
144
  lastBridgedText = text;
256
145
  }
257
- return result.ok || result.reason === "unchanged";
146
+ return result.ok;
258
147
  };
259
148
  const sharedProseMirrorToText = (fragment) => {
260
149
  try {
@@ -282,7 +171,13 @@ function createYjsBridge(config, options) {
282
171
  return { source: "initial", parseError: true };
283
172
  }
284
173
  const pmDoc = (0, import_y_prosemirror.yXmlFragmentToProseMirrorRootNode)(sharedProseMirror, schema);
285
- const canonicalText = serialize(pmDoc);
174
+ let canonicalText;
175
+ try {
176
+ canonicalText = serialize(pmDoc);
177
+ } catch (error) {
178
+ onError({ code: "serialize-error", message: "failed to serialize ProseMirror document during bootstrap", cause: error });
179
+ return { source: "initial", parseError: true };
180
+ }
286
181
  replaceSharedText(sharedText, canonicalText, ORIGIN_INIT, normalize);
287
182
  lastBridgedText = normalize(canonicalText);
288
183
  return { source: "initial" };
@@ -344,7 +239,13 @@ function createYjsBridge(config, options) {
344
239
  return {
345
240
  bootstrapResult,
346
241
  syncToSharedText(doc2) {
347
- const text = serialize(doc2);
242
+ let text;
243
+ try {
244
+ text = serialize(doc2);
245
+ } catch (error) {
246
+ onError({ code: "serialize-error", message: "failed to serialize ProseMirror document", cause: error });
247
+ return { ok: false, reason: "serialize-error" };
248
+ }
348
249
  const result = replaceSharedText(sharedText, text, ORIGIN_PM_TO_TEXT, normalize);
349
250
  if (result.ok || result.reason === "unchanged") {
350
251
  lastBridgedText = normalize(text);
@@ -382,10 +283,12 @@ function createAwarenessProxy(awareness, cursorField = "pmCursor") {
382
283
  const states = target.getStates();
383
284
  const remapped = /* @__PURE__ */ new Map();
384
285
  states.forEach((state, clientId) => {
385
- remapped.set(clientId, {
386
- ...state,
387
- cursor: state[cursorField] ?? null
388
- });
286
+ const s = state;
287
+ if (cursorField in s) {
288
+ remapped.set(clientId, { ...s, cursor: s[cursorField] ?? null });
289
+ } else {
290
+ remapped.set(clientId, s);
291
+ }
389
292
  });
390
293
  return remapped;
391
294
  };
@@ -402,14 +305,12 @@ var import_y_prosemirror3 = require("y-prosemirror");
402
305
  // src/bridge-sync-plugin.ts
403
306
  var import_prosemirror_state = require("prosemirror-state");
404
307
  var bridgeSyncPluginKey = new import_prosemirror_state.PluginKey("pm-cm-bridge-sync");
405
- var wiredBridges = /* @__PURE__ */ new WeakSet();
308
+ var wiredBridges = /* @__PURE__ */ new WeakMap();
406
309
  var defaultOnWarning = (event) => console.warn(`[pm-cm] ${event.code}: ${event.message}`);
407
310
  function createBridgeSyncPlugin(bridge, options = {}) {
408
311
  const warn = options.onWarning ?? defaultOnWarning;
409
- if (wiredBridges.has(bridge)) {
410
- warn({ code: "bridge-already-wired", message: "this bridge is already wired to another plugin instance" });
411
- }
412
- wiredBridges.add(bridge);
312
+ let yjsBatchSeen = false;
313
+ let needsSync = false;
413
314
  return new import_prosemirror_state.Plugin({
414
315
  key: bridgeSyncPluginKey,
415
316
  state: {
@@ -417,27 +318,44 @@ function createBridgeSyncPlugin(bridge, options = {}) {
417
318
  return { needsSync: false };
418
319
  },
419
320
  apply(tr, _prev) {
420
- if (!tr.docChanged) return { needsSync: false };
421
- if (bridge.isYjsSyncChange(tr)) return { needsSync: false };
321
+ if (!tr.docChanged) return { needsSync };
322
+ if (bridge.isYjsSyncChange(tr)) {
323
+ yjsBatchSeen = true;
324
+ needsSync = false;
325
+ return { needsSync: false };
326
+ }
327
+ if (yjsBatchSeen) {
328
+ needsSync = false;
329
+ return { needsSync: false };
330
+ }
331
+ needsSync = true;
422
332
  return { needsSync: true };
423
333
  }
424
334
  },
425
335
  view() {
336
+ const count = wiredBridges.get(bridge) ?? 0;
337
+ if (count > 0) {
338
+ warn({ code: "bridge-already-wired", message: "this bridge is already wired to another plugin instance" });
339
+ }
340
+ wiredBridges.set(bridge, count + 1);
426
341
  return {
427
342
  update(view) {
428
- const state = bridgeSyncPluginKey.getState(view.state);
429
- if (state?.needsSync) {
343
+ if (needsSync) {
430
344
  const result = bridge.syncToSharedText(view.state.doc);
431
345
  if (!result.ok) {
432
- if (result.reason === "detached") {
346
+ if (result.reason !== "unchanged") {
433
347
  options.onSyncFailure?.(result, view);
434
348
  warn({ code: "sync-failed", message: `bridge sync failed: ${result.reason}` });
435
349
  }
436
350
  }
437
351
  }
352
+ needsSync = false;
353
+ yjsBatchSeen = false;
438
354
  },
439
355
  destroy() {
440
- wiredBridges.delete(bridge);
356
+ const remaining = (wiredBridges.get(bridge) ?? 1) - 1;
357
+ if (remaining <= 0) wiredBridges.delete(bridge);
358
+ else wiredBridges.set(bridge, remaining);
441
359
  }
442
360
  };
443
361
  }
@@ -447,7 +365,7 @@ function createBridgeSyncPlugin(bridge, options = {}) {
447
365
  // src/cursor-sync-plugin.ts
448
366
  var import_prosemirror_state2 = require("prosemirror-state");
449
367
  var import_y_prosemirror2 = require("y-prosemirror");
450
- var import_yjs2 = require("yjs");
368
+ var import_yjs = require("yjs");
451
369
  var import_core = require("@pm-cm/core");
452
370
  var cursorSyncPluginKey = new import_prosemirror_state2.PluginKey("pm-cm-cursor-sync");
453
371
  function getYSyncState(view) {
@@ -478,8 +396,8 @@ function broadcastPmCursor(awareness, cursorFieldName, view, pmAnchor, pmHead) {
478
396
  function broadcastTextCursor(awareness, cmCursorFieldName, sharedText, textAnchor, textHead) {
479
397
  const len = sharedText.length;
480
398
  const clamp = (v) => Math.max(0, Math.min(v, len));
481
- const relAnchor = (0, import_yjs2.createRelativePositionFromTypeIndex)(sharedText, clamp(textAnchor));
482
- const relHead = (0, import_yjs2.createRelativePositionFromTypeIndex)(sharedText, clamp(textHead));
399
+ const relAnchor = (0, import_yjs.createRelativePositionFromTypeIndex)(sharedText, clamp(textAnchor));
400
+ const relHead = (0, import_yjs.createRelativePositionFromTypeIndex)(sharedText, clamp(textHead));
483
401
  awareness.setLocalStateField(cmCursorFieldName, { anchor: relAnchor, head: relHead });
484
402
  }
485
403
  var defaultOnWarning2 = (event) => console.warn(`[pm-cm] ${event.code}: ${event.message}`);
@@ -488,12 +406,23 @@ function createCursorSyncPlugin(options) {
488
406
  const warn = options.onWarning ?? defaultOnWarning2;
489
407
  const cursorFieldName = options.cursorFieldName ?? "pmCursor";
490
408
  const cmCursorFieldName = options.cmCursorFieldName ?? "cursor";
409
+ if (sharedText && cursorFieldName === cmCursorFieldName) {
410
+ throw new Error(
411
+ `createCursorSyncPlugin: cursorFieldName and cmCursorFieldName must differ when sharedText is provided (both are "${cursorFieldName}")`
412
+ );
413
+ }
491
414
  let warnedSyncPluginMissing = false;
415
+ let pendingCmCursor = null;
492
416
  let cachedMap = null;
493
417
  let cachedMapDoc = null;
494
418
  function getOrBuildMap(doc) {
495
419
  if (cachedMapDoc !== doc || !cachedMap) {
496
- cachedMap = (0, import_core.buildCursorMap)(doc, serialize);
420
+ try {
421
+ cachedMap = (0, import_core.buildCursorMap)(doc, serialize);
422
+ } catch (error) {
423
+ warn({ code: "cursor-map-error", message: "failed to build cursor map \u2014 cursor sync skipped" });
424
+ cachedMap = null;
425
+ }
497
426
  cachedMapDoc = doc;
498
427
  }
499
428
  return cachedMap;
@@ -507,62 +436,141 @@ function createCursorSyncPlugin(options) {
507
436
  apply(tr, prev, _oldState, newState) {
508
437
  const cmMeta = tr.getMeta(cursorSyncPluginKey);
509
438
  if (cmMeta) {
510
- return { pendingCm: cmMeta, mappedTextOffset: prev.mappedTextOffset };
439
+ pendingCmCursor = cmMeta;
511
440
  }
512
441
  let mappedTextOffset = prev.mappedTextOffset;
513
442
  if (tr.selectionSet || tr.docChanged) {
514
443
  const map = getOrBuildMap(newState.doc);
515
- mappedTextOffset = (0, import_core.cursorMapLookup)(map, newState.selection.anchor);
444
+ mappedTextOffset = map ? (0, import_core.cursorMapLookup)(map, newState.selection.anchor) : null;
516
445
  }
517
446
  return {
518
- pendingCm: prev.pendingCm !== null ? null : prev.pendingCm,
447
+ pendingCm: pendingCmCursor,
519
448
  mappedTextOffset
520
449
  };
521
450
  }
522
451
  },
523
- view() {
452
+ view(editorView) {
453
+ let suppressCmReaction = false;
454
+ let lastCmAbsAnchor = -1;
455
+ let lastCmAbsHead = -1;
456
+ let cmCursorHandledByListener = false;
457
+ const handleAwarenessUpdate = ({ added, updated }) => {
458
+ if (suppressCmReaction) return;
459
+ if (!sharedText?.doc) return;
460
+ const localId = awareness.clientID;
461
+ if (!updated.includes(localId) && !added.includes(localId)) return;
462
+ const localState = awareness.getLocalState();
463
+ if (!localState) return;
464
+ const cmCursor = localState[cmCursorFieldName];
465
+ if (!cmCursor?.anchor || !cmCursor?.head) {
466
+ if (lastCmAbsAnchor !== -1) {
467
+ awareness.setLocalStateField(cursorFieldName, null);
468
+ lastCmAbsAnchor = -1;
469
+ lastCmAbsHead = -1;
470
+ }
471
+ return;
472
+ }
473
+ const absAnchor = (0, import_yjs.createAbsolutePositionFromRelativePosition)(cmCursor.anchor, sharedText.doc);
474
+ const absHead = (0, import_yjs.createAbsolutePositionFromRelativePosition)(cmCursor.head, sharedText.doc);
475
+ if (!absAnchor || !absHead) {
476
+ awareness.setLocalStateField(cursorFieldName, null);
477
+ lastCmAbsAnchor = -1;
478
+ lastCmAbsHead = -1;
479
+ return;
480
+ }
481
+ if (absAnchor.index === lastCmAbsAnchor && absHead.index === lastCmAbsHead) return;
482
+ const map = getOrBuildMap(editorView.state.doc);
483
+ if (!map) {
484
+ awareness.setLocalStateField(cursorFieldName, null);
485
+ return;
486
+ }
487
+ const pmAnchor = (0, import_core.reverseCursorMapLookup)(map, absAnchor.index);
488
+ const pmHead = (0, import_core.reverseCursorMapLookup)(map, absHead.index);
489
+ if (pmAnchor === null || pmHead === null) {
490
+ awareness.setLocalStateField(cursorFieldName, null);
491
+ return;
492
+ }
493
+ lastCmAbsAnchor = absAnchor.index;
494
+ lastCmAbsHead = absHead.index;
495
+ cmCursorHandledByListener = true;
496
+ const ok = broadcastPmCursor(awareness, cursorFieldName, editorView, pmAnchor, pmHead);
497
+ if (!ok && !warnedSyncPluginMissing) {
498
+ warnedSyncPluginMissing = true;
499
+ warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
500
+ }
501
+ };
502
+ if (sharedText) {
503
+ awareness.on("update", handleAwarenessUpdate);
504
+ }
524
505
  return {
525
506
  update(view, prevState) {
526
- const pluginState = cursorSyncPluginKey.getState(view.state);
527
- const prevPluginState = cursorSyncPluginKey.getState(prevState);
528
- if (pluginState?.pendingCm != null && pluginState.pendingCm !== prevPluginState?.pendingCm) {
529
- const map = getOrBuildMap(view.state.doc);
530
- const pmAnchor = (0, import_core.reverseCursorMapLookup)(map, pluginState.pendingCm.anchor);
531
- const pmHead = (0, import_core.reverseCursorMapLookup)(map, pluginState.pendingCm.head);
532
- if (pmAnchor !== null && pmHead !== null) {
533
- const ok = broadcastPmCursor(awareness, cursorFieldName, view, pmAnchor, pmHead);
534
- if (!ok && !warnedSyncPluginMissing) {
535
- warnedSyncPluginMissing = true;
536
- warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
537
- }
507
+ if (view.state.doc !== prevState.doc) {
508
+ lastCmAbsAnchor = -1;
509
+ lastCmAbsHead = -1;
510
+ if (sharedText?.doc && !suppressCmReaction) {
511
+ handleAwarenessUpdate({ added: [awareness.clientID], updated: [], removed: [] });
538
512
  }
539
- if (sharedText) {
540
- broadcastTextCursor(
541
- awareness,
542
- cmCursorFieldName,
543
- sharedText,
544
- pluginState.pendingCm.anchor,
545
- pluginState.pendingCm.head
546
- );
513
+ }
514
+ if (pendingCmCursor != null) {
515
+ const cursor = pendingCmCursor;
516
+ pendingCmCursor = null;
517
+ if (!cmCursorHandledByListener) {
518
+ const map = getOrBuildMap(view.state.doc);
519
+ const pmAnchor = map ? (0, import_core.reverseCursorMapLookup)(map, cursor.anchor) : null;
520
+ const pmHead = map ? (0, import_core.reverseCursorMapLookup)(map, cursor.head) : null;
521
+ if (pmAnchor !== null && pmHead !== null) {
522
+ const ok = broadcastPmCursor(awareness, cursorFieldName, view, pmAnchor, pmHead);
523
+ if (!ok && !warnedSyncPluginMissing) {
524
+ warnedSyncPluginMissing = true;
525
+ warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
526
+ }
527
+ if (sharedText?.doc) {
528
+ broadcastTextCursor(
529
+ awareness,
530
+ cmCursorFieldName,
531
+ sharedText,
532
+ cursor.anchor,
533
+ cursor.head
534
+ );
535
+ }
536
+ } else {
537
+ awareness.setLocalStateField(cursorFieldName, null);
538
+ }
547
539
  }
540
+ cmCursorHandledByListener = false;
548
541
  return;
549
542
  }
550
543
  if (view.hasFocus() && (view.state.selection !== prevState.selection || view.state.doc !== prevState.doc)) {
551
- const { anchor, head } = view.state.selection;
552
- const ok = broadcastPmCursor(awareness, cursorFieldName, view, anchor, head);
553
- if (!ok && !warnedSyncPluginMissing) {
554
- warnedSyncPluginMissing = true;
555
- warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
556
- }
557
- if (sharedText) {
558
- const map = getOrBuildMap(view.state.doc);
559
- const textAnchor = (0, import_core.cursorMapLookup)(map, anchor);
560
- const textHead = (0, import_core.cursorMapLookup)(map, head);
561
- if (textAnchor !== null && textHead !== null) {
562
- broadcastTextCursor(awareness, cmCursorFieldName, sharedText, textAnchor, textHead);
544
+ suppressCmReaction = true;
545
+ try {
546
+ const { anchor, head } = view.state.selection;
547
+ const ok = broadcastPmCursor(awareness, cursorFieldName, view, anchor, head);
548
+ if (!ok && !warnedSyncPluginMissing) {
549
+ warnedSyncPluginMissing = true;
550
+ warn({ code: "ysync-plugin-missing", message: "ySyncPlugin state not available \u2014 cursor broadcast skipped" });
551
+ }
552
+ if (sharedText?.doc) {
553
+ const map = getOrBuildMap(view.state.doc);
554
+ const textAnchor = map ? (0, import_core.cursorMapLookup)(map, anchor) : null;
555
+ const textHead = map ? (0, import_core.cursorMapLookup)(map, head) : null;
556
+ if (textAnchor !== null && textHead !== null) {
557
+ broadcastTextCursor(awareness, cmCursorFieldName, sharedText, textAnchor, textHead);
558
+ } else {
559
+ awareness.setLocalStateField(cmCursorFieldName, null);
560
+ }
563
561
  }
562
+ } finally {
563
+ suppressCmReaction = false;
564
564
  }
565
565
  }
566
+ cmCursorHandledByListener = false;
567
+ },
568
+ destroy() {
569
+ awareness.setLocalStateField(cursorFieldName, null);
570
+ if (sharedText) {
571
+ awareness.setLocalStateField(cmCursorFieldName, null);
572
+ awareness.off("update", handleAwarenessUpdate);
573
+ }
566
574
  }
567
575
  };
568
576
  }
@@ -573,7 +581,10 @@ function syncCmCursor(view, anchor, head, onWarning) {
573
581
  (onWarning ?? defaultOnWarning2)({ code: "cursor-sync-not-installed", message: "cursor sync plugin is not installed on this EditorView" });
574
582
  return;
575
583
  }
576
- const sanitize = (v) => Math.max(0, Math.floor(v));
584
+ const sanitize = (v) => {
585
+ const n = Math.floor(v);
586
+ return Number.isFinite(n) ? Math.max(0, n) : 0;
587
+ };
577
588
  view.dispatch(
578
589
  view.state.tr.setMeta(cursorSyncPluginKey, {
579
590
  anchor: sanitize(anchor),