@tiptap/y-tiptap 3.0.3 → 3.0.5

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.
@@ -6,6 +6,7 @@ export function isVisible(item: Y.Item, snapshot?: Y.Snapshot): boolean;
6
6
  export function ySyncPlugin(yXmlFragment: Y.XmlFragment, { colors, colorMapping, permanentUserData, onFirstRender, mapping }?: YSyncOpts): any;
7
7
  export function getRelativeSelection(pmbinding: ProsemirrorBinding, state: import('prosemirror-state').EditorState): {
8
8
  type: any;
9
+ depth: any;
9
10
  anchor: any;
10
11
  head: any;
11
12
  };
@@ -44,6 +45,7 @@ export class ProsemirrorBinding {
44
45
  */
45
46
  beforeTransactionSelection: {
46
47
  type: any;
48
+ depth: any;
47
49
  anchor: any;
48
50
  head: any;
49
51
  };
package/dist/y-tiptap.cjs CHANGED
@@ -330,7 +330,28 @@ const restoreRelativeSelection = (tr, relSel, binding) => {
330
330
  relSel.anchor,
331
331
  binding.mapping
332
332
  );
333
- tr.setSelection(createSafeNodeSelection(tr, anchor));
333
+ // anchor is null when the referenced node was deleted or moved out of
334
+ // binding.type by a remote update; resolving null would throw.
335
+ if (anchor !== null) {
336
+ tr.setSelection(createSafeNodeSelection(tr, anchor));
337
+ }
338
+ } else if (relSel.type === 'nodeRange') {
339
+ const anchor = relativePositionToAbsolutePosition(
340
+ binding.doc,
341
+ binding.type,
342
+ relSel.anchor,
343
+ binding.mapping
344
+ );
345
+ const head = relativePositionToAbsolutePosition(
346
+ binding.doc,
347
+ binding.type,
348
+ relSel.head,
349
+ binding.mapping
350
+ );
351
+ const selection = createSafeNodeRangeSelection(tr, anchor, head, relSel.depth);
352
+ if (selection !== null) {
353
+ tr.setSelection(selection);
354
+ }
334
355
  } else {
335
356
  const anchor = relativePositionToAbsolutePosition(
336
357
  binding.doc,
@@ -368,23 +389,65 @@ const createSafeNodeSelection = (tr, pos) => {
368
389
  }
369
390
  };
370
391
 
392
+ /**
393
+ * Safely reconstructs a NodeRangeSelection from resolved absolute positions.
394
+ *
395
+ * @param {import('prosemirror-state').Transaction} tr - The transaction whose document provides resolved positions.
396
+ * @param {number|null} anchor - Absolute document position marking the start (boundary) of the node range.
397
+ * Use `relativePositionToAbsolutePosition` before calling this function.
398
+ * @param {number|null} head - Absolute document position marking the end (boundary) of the node range.
399
+ * Use `relativePositionToAbsolutePosition` before calling this function.
400
+ * @param {number|undefined} depth - The nesting depth at which the range operates (e.g. 0 for
401
+ * top-level blocks, 1 for blocks nested inside a wrapper). Passed through to
402
+ * `Selection.fromJSON`; ignored by `@tiptap/extension-node-range` < v2.29 but
403
+ * properly stored starting from that version.
404
+ * @returns {import('prosemirror-state').Selection|null} Reconstructed selection, or null if
405
+ * anchor/head could not be resolved.
406
+ */
407
+ const createSafeNodeRangeSelection = (tr, anchor, head, depth) => {
408
+ if (anchor === null || head === null) {
409
+ return null
410
+ }
411
+ const clampedAnchor = Math.min(Math.max(anchor, 0), tr.doc.content.size);
412
+ const clampedHead = Math.min(Math.max(head, 0), tr.doc.content.size);
413
+ try {
414
+ const selection = prosemirrorState.Selection.fromJSON(tr.doc, {
415
+ type: 'nodeRange',
416
+ anchor: clampedAnchor,
417
+ head: clampedHead,
418
+ depth
419
+ });
420
+ if (!selection.ranges.length) {
421
+ return prosemirrorState.TextSelection.near(tr.doc.resolve(clampedAnchor))
422
+ }
423
+ return selection
424
+ } catch (e) {
425
+ return prosemirrorState.TextSelection.near(tr.doc.resolve(clampedAnchor))
426
+ }
427
+ };
428
+
371
429
  /**
372
430
  * @param {ProsemirrorBinding} pmbinding
373
431
  * @param {import('prosemirror-state').EditorState} state
374
432
  */
375
- const getRelativeSelection = (pmbinding, state) => ({
376
- type: /** @type {any} */ (state.selection).jsonID,
377
- anchor: absolutePositionToRelativePosition(
378
- state.selection.anchor,
379
- pmbinding.type,
380
- pmbinding.mapping
381
- ),
382
- head: absolutePositionToRelativePosition(
383
- state.selection.head,
384
- pmbinding.type,
385
- pmbinding.mapping
386
- )
387
- });
433
+ const getRelativeSelection = (pmbinding, state) => {
434
+ const type = /** @type {any} */ (state.selection).jsonID;
435
+ return {
436
+ type,
437
+ // `depth` is only meaningful for NodeRangeSelection; undefined for every other type.
438
+ depth: type === 'nodeRange' ? /** @type {any} */ (state.selection).depth : undefined,
439
+ anchor: absolutePositionToRelativePosition(
440
+ state.selection.anchor,
441
+ pmbinding.type,
442
+ pmbinding.mapping
443
+ ),
444
+ head: absolutePositionToRelativePosition(
445
+ state.selection.head,
446
+ pmbinding.type,
447
+ pmbinding.mapping
448
+ )
449
+ }
450
+ };
388
451
 
389
452
  /**
390
453
  * Binding for prosemirror.
@@ -1643,7 +1706,11 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1643
1706
  if (t instanceof Y__namespace.XmlText) {
1644
1707
  pos += t._length;
1645
1708
  } else {
1646
- pos += /** @type {any} */ (mapping.get(t)).nodeSize;
1709
+ const mapped = mapping.get(t);
1710
+ if (mapped == null) {
1711
+ return null
1712
+ }
1713
+ pos += /** @type {any} */ (mapped).nodeSize;
1647
1714
  }
1648
1715
  }
1649
1716
  n = /** @type {Y.Item} */ (n.right);
@@ -1667,7 +1734,11 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1667
1734
  if (contentType instanceof Y__namespace.XmlText) {
1668
1735
  pos += contentType._length;
1669
1736
  } else {
1670
- pos += /** @type {any} */ (mapping.get(contentType)).nodeSize;
1737
+ const mapped = mapping.get(contentType);
1738
+ if (mapped == null) {
1739
+ return null
1740
+ }
1741
+ pos += /** @type {any} */ (mapped).nodeSize;
1671
1742
  }
1672
1743
  }
1673
1744
  n = n.right;
@@ -2009,7 +2080,9 @@ const createDecorations = (
2009
2080
  return
2010
2081
  }
2011
2082
 
2012
- if (aw.cursor != null) {
2083
+ // `aw` can be null when a client disconnects, so we guard against it
2084
+ // before reading `cursor` to avoid a TypeError.
2085
+ if (aw && aw.cursor != null) {
2013
2086
  const user = aw.user || {};
2014
2087
  if (user.color == null) {
2015
2088
  user.color = '#ffa500';
@@ -2124,6 +2197,9 @@ const yCursorPlugin = (
2124
2197
  };
2125
2198
  const updateCursorInfo = () => {
2126
2199
  const ystate = ySyncPluginKey.getState(view.state);
2200
+ // `ystate` is undefined during sync-plugin init and Y.Doc transitions;
2201
+ // reading from it here would throw and break cursor tracking for the view.
2202
+ if (!ystate || !ystate.binding) return
2127
2203
  // @note We make implicit checks when checking for the cursor property
2128
2204
  const current = awareness.getLocalState() || {};
2129
2205
  if (view.hasFocus()) {
@@ -2218,11 +2294,26 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2218
2294
  init: (initargs, state) => {
2219
2295
  // TODO: check if plugin order matches and fix
2220
2296
  const ystate = ySyncPluginKey.getState(state);
2221
- const _undoManager = undoManager || new Y.UndoManager(ystate.type, {
2222
- trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2223
- deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2224
- captureTransaction: tr => tr.meta.get('addToHistory') !== false
2225
- });
2297
+ let _undoManager = undoManager;
2298
+ if (!_undoManager) {
2299
+ // Y.UndoManager registers a `doc.on('destroy', )` listener in its
2300
+ // constructor that UndoManager.destroy() never removes. When the doc
2301
+ // outlives the editor (e.g. several editors sharing one provider), that
2302
+ // listener keeps the UndoManager — and everything it references —
2303
+ // reachable from the doc, leaking memory on every editor destroy.
2304
+ // We only own the lifecycle of a manager we create here, so capture the
2305
+ // listener(s) it adds and remove them when the plugin view is destroyed.
2306
+ const doc = ystate.doc;
2307
+ const destroyListenersBefore = new Set(doc ? doc._observers.get('destroy') : []);
2308
+ _undoManager = new Y.UndoManager(ystate.type, {
2309
+ trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2310
+ deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2311
+ captureTransaction: tr => tr.meta.get('addToHistory') !== false
2312
+ });
2313
+ const destroyListenersAfter = doc ? doc._observers.get('destroy') : new Set();
2314
+ _undoManager._yTiptapDocDestroyListeners = Array.from(destroyListenersAfter || [])
2315
+ .filter(listener => !destroyListenersBefore.has(listener));
2316
+ }
2226
2317
  return {
2227
2318
  undoManager: _undoManager,
2228
2319
  prevSel: null,
@@ -2275,6 +2366,13 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2275
2366
  return {
2276
2367
  destroy: () => {
2277
2368
  undoManager.destroy();
2369
+ // Remove the doc 'destroy' listener Y.UndoManager fails to clean up
2370
+ // (only for managers we created — see state.init above).
2371
+ const leakedDestroyListeners = undoManager._yTiptapDocDestroyListeners;
2372
+ if (leakedDestroyListeners && undoManager.doc) {
2373
+ leakedDestroyListeners.forEach(listener => undoManager.doc.off('destroy', listener));
2374
+ undoManager._yTiptapDocDestroyListeners = null;
2375
+ }
2278
2376
  }
2279
2377
  }
2280
2378
  }