@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.
package/dist/y-tiptap.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as Y from 'yjs';
2
2
  import { Item, ContentType, Text, XmlElement, UndoManager } from 'yjs';
3
3
  import { DecorationSet, Decoration } from 'prosemirror-view';
4
- import { PluginKey, Plugin, TextSelection, AllSelection, NodeSelection } from 'prosemirror-state';
4
+ import { PluginKey, Plugin, TextSelection, AllSelection, NodeSelection, Selection } from 'prosemirror-state';
5
5
  import 'y-protocols/awareness';
6
6
  import { createMutex } from 'lib0/mutex';
7
7
  import * as PModel from 'prosemirror-model';
@@ -296,7 +296,28 @@ const restoreRelativeSelection = (tr, relSel, binding) => {
296
296
  relSel.anchor,
297
297
  binding.mapping
298
298
  );
299
- tr.setSelection(createSafeNodeSelection(tr, anchor));
299
+ // anchor is null when the referenced node was deleted or moved out of
300
+ // binding.type by a remote update; resolving null would throw.
301
+ if (anchor !== null) {
302
+ tr.setSelection(createSafeNodeSelection(tr, anchor));
303
+ }
304
+ } else if (relSel.type === 'nodeRange') {
305
+ const anchor = relativePositionToAbsolutePosition(
306
+ binding.doc,
307
+ binding.type,
308
+ relSel.anchor,
309
+ binding.mapping
310
+ );
311
+ const head = relativePositionToAbsolutePosition(
312
+ binding.doc,
313
+ binding.type,
314
+ relSel.head,
315
+ binding.mapping
316
+ );
317
+ const selection = createSafeNodeRangeSelection(tr, anchor, head, relSel.depth);
318
+ if (selection !== null) {
319
+ tr.setSelection(selection);
320
+ }
300
321
  } else {
301
322
  const anchor = relativePositionToAbsolutePosition(
302
323
  binding.doc,
@@ -334,23 +355,65 @@ const createSafeNodeSelection = (tr, pos) => {
334
355
  }
335
356
  };
336
357
 
358
+ /**
359
+ * Safely reconstructs a NodeRangeSelection from resolved absolute positions.
360
+ *
361
+ * @param {import('prosemirror-state').Transaction} tr - The transaction whose document provides resolved positions.
362
+ * @param {number|null} anchor - Absolute document position marking the start (boundary) of the node range.
363
+ * Use `relativePositionToAbsolutePosition` before calling this function.
364
+ * @param {number|null} head - Absolute document position marking the end (boundary) of the node range.
365
+ * Use `relativePositionToAbsolutePosition` before calling this function.
366
+ * @param {number|undefined} depth - The nesting depth at which the range operates (e.g. 0 for
367
+ * top-level blocks, 1 for blocks nested inside a wrapper). Passed through to
368
+ * `Selection.fromJSON`; ignored by `@tiptap/extension-node-range` < v2.29 but
369
+ * properly stored starting from that version.
370
+ * @returns {import('prosemirror-state').Selection|null} Reconstructed selection, or null if
371
+ * anchor/head could not be resolved.
372
+ */
373
+ const createSafeNodeRangeSelection = (tr, anchor, head, depth) => {
374
+ if (anchor === null || head === null) {
375
+ return null
376
+ }
377
+ const clampedAnchor = Math.min(Math.max(anchor, 0), tr.doc.content.size);
378
+ const clampedHead = Math.min(Math.max(head, 0), tr.doc.content.size);
379
+ try {
380
+ const selection = Selection.fromJSON(tr.doc, {
381
+ type: 'nodeRange',
382
+ anchor: clampedAnchor,
383
+ head: clampedHead,
384
+ depth
385
+ });
386
+ if (!selection.ranges.length) {
387
+ return TextSelection.near(tr.doc.resolve(clampedAnchor))
388
+ }
389
+ return selection
390
+ } catch (e) {
391
+ return TextSelection.near(tr.doc.resolve(clampedAnchor))
392
+ }
393
+ };
394
+
337
395
  /**
338
396
  * @param {ProsemirrorBinding} pmbinding
339
397
  * @param {import('prosemirror-state').EditorState} state
340
398
  */
341
- const getRelativeSelection = (pmbinding, state) => ({
342
- type: /** @type {any} */ (state.selection).jsonID,
343
- anchor: absolutePositionToRelativePosition(
344
- state.selection.anchor,
345
- pmbinding.type,
346
- pmbinding.mapping
347
- ),
348
- head: absolutePositionToRelativePosition(
349
- state.selection.head,
350
- pmbinding.type,
351
- pmbinding.mapping
352
- )
353
- });
399
+ const getRelativeSelection = (pmbinding, state) => {
400
+ const type = /** @type {any} */ (state.selection).jsonID;
401
+ return {
402
+ type,
403
+ // `depth` is only meaningful for NodeRangeSelection; undefined for every other type.
404
+ depth: type === 'nodeRange' ? /** @type {any} */ (state.selection).depth : undefined,
405
+ anchor: absolutePositionToRelativePosition(
406
+ state.selection.anchor,
407
+ pmbinding.type,
408
+ pmbinding.mapping
409
+ ),
410
+ head: absolutePositionToRelativePosition(
411
+ state.selection.head,
412
+ pmbinding.type,
413
+ pmbinding.mapping
414
+ )
415
+ }
416
+ };
354
417
 
355
418
  /**
356
419
  * Binding for prosemirror.
@@ -1609,7 +1672,11 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1609
1672
  if (t instanceof Y.XmlText) {
1610
1673
  pos += t._length;
1611
1674
  } else {
1612
- pos += /** @type {any} */ (mapping.get(t)).nodeSize;
1675
+ const mapped = mapping.get(t);
1676
+ if (mapped == null) {
1677
+ return null
1678
+ }
1679
+ pos += /** @type {any} */ (mapped).nodeSize;
1613
1680
  }
1614
1681
  }
1615
1682
  n = /** @type {Y.Item} */ (n.right);
@@ -1633,7 +1700,11 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1633
1700
  if (contentType instanceof Y.XmlText) {
1634
1701
  pos += contentType._length;
1635
1702
  } else {
1636
- pos += /** @type {any} */ (mapping.get(contentType)).nodeSize;
1703
+ const mapped = mapping.get(contentType);
1704
+ if (mapped == null) {
1705
+ return null
1706
+ }
1707
+ pos += /** @type {any} */ (mapped).nodeSize;
1637
1708
  }
1638
1709
  }
1639
1710
  n = n.right;
@@ -1975,7 +2046,9 @@ const createDecorations = (
1975
2046
  return
1976
2047
  }
1977
2048
 
1978
- if (aw.cursor != null) {
2049
+ // `aw` can be null when a client disconnects, so we guard against it
2050
+ // before reading `cursor` to avoid a TypeError.
2051
+ if (aw && aw.cursor != null) {
1979
2052
  const user = aw.user || {};
1980
2053
  if (user.color == null) {
1981
2054
  user.color = '#ffa500';
@@ -2090,6 +2163,9 @@ const yCursorPlugin = (
2090
2163
  };
2091
2164
  const updateCursorInfo = () => {
2092
2165
  const ystate = ySyncPluginKey.getState(view.state);
2166
+ // `ystate` is undefined during sync-plugin init and Y.Doc transitions;
2167
+ // reading from it here would throw and break cursor tracking for the view.
2168
+ if (!ystate || !ystate.binding) return
2093
2169
  // @note We make implicit checks when checking for the cursor property
2094
2170
  const current = awareness.getLocalState() || {};
2095
2171
  if (view.hasFocus()) {
@@ -2184,11 +2260,26 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2184
2260
  init: (initargs, state) => {
2185
2261
  // TODO: check if plugin order matches and fix
2186
2262
  const ystate = ySyncPluginKey.getState(state);
2187
- const _undoManager = undoManager || new UndoManager(ystate.type, {
2188
- trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2189
- deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2190
- captureTransaction: tr => tr.meta.get('addToHistory') !== false
2191
- });
2263
+ let _undoManager = undoManager;
2264
+ if (!_undoManager) {
2265
+ // Y.UndoManager registers a `doc.on('destroy', )` listener in its
2266
+ // constructor that UndoManager.destroy() never removes. When the doc
2267
+ // outlives the editor (e.g. several editors sharing one provider), that
2268
+ // listener keeps the UndoManager — and everything it references —
2269
+ // reachable from the doc, leaking memory on every editor destroy.
2270
+ // We only own the lifecycle of a manager we create here, so capture the
2271
+ // listener(s) it adds and remove them when the plugin view is destroyed.
2272
+ const doc = ystate.doc;
2273
+ const destroyListenersBefore = new Set(doc ? doc._observers.get('destroy') : []);
2274
+ _undoManager = new UndoManager(ystate.type, {
2275
+ trackedOrigins: new Set([ySyncPluginKey].concat(trackedOrigins)),
2276
+ deleteFilter: (item) => defaultDeleteFilter(item, protectedNodes),
2277
+ captureTransaction: tr => tr.meta.get('addToHistory') !== false
2278
+ });
2279
+ const destroyListenersAfter = doc ? doc._observers.get('destroy') : new Set();
2280
+ _undoManager._yTiptapDocDestroyListeners = Array.from(destroyListenersAfter || [])
2281
+ .filter(listener => !destroyListenersBefore.has(listener));
2282
+ }
2192
2283
  return {
2193
2284
  undoManager: _undoManager,
2194
2285
  prevSel: null,
@@ -2241,6 +2332,13 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2241
2332
  return {
2242
2333
  destroy: () => {
2243
2334
  undoManager.destroy();
2335
+ // Remove the doc 'destroy' listener Y.UndoManager fails to clean up
2336
+ // (only for managers we created — see state.init above).
2337
+ const leakedDestroyListeners = undoManager._yTiptapDocDestroyListeners;
2338
+ if (leakedDestroyListeners && undoManager.doc) {
2339
+ leakedDestroyListeners.forEach(listener => undoManager.doc.off('destroy', listener));
2340
+ undoManager._yTiptapDocDestroyListeners = null;
2341
+ }
2244
2342
  }
2245
2343
  }
2246
2344
  }