@tiptap/y-tiptap 3.0.4 → 3.0.6

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';
@@ -18,6 +18,7 @@ import * as eventloop from 'lib0/eventloop';
18
18
  import * as map from 'lib0/map';
19
19
  import * as sha256 from 'lib0/hash/sha256';
20
20
  import * as buf from 'lib0/buffer';
21
+ import { ReplaceStep } from 'prosemirror-transform';
21
22
 
22
23
  /**
23
24
  * The unique prosemirror plugin key for syncPlugin
@@ -284,8 +285,9 @@ const ySyncPlugin = (yXmlFragment, {
284
285
  * @param {import('prosemirror-state').Transaction} tr
285
286
  * @param {ReturnType<typeof getRelativeSelection>} relSel
286
287
  * @param {ProsemirrorBinding} binding
288
+ * @param {import('prosemirror-model').Node} [oldDoc]
287
289
  */
288
- const restoreRelativeSelection = (tr, relSel, binding) => {
290
+ const restoreRelativeSelection = (tr, relSel, binding, oldDoc) => {
289
291
  if (relSel !== null && relSel.anchor !== null && relSel.head !== null) {
290
292
  if (relSel.type === 'all') {
291
293
  tr.setSelection(new AllSelection(tr.doc));
@@ -296,8 +298,12 @@ const restoreRelativeSelection = (tr, relSel, binding) => {
296
298
  relSel.anchor,
297
299
  binding.mapping
298
300
  );
299
- tr.setSelection(createSafeNodeSelection(tr, anchor));
300
- } else {
301
+ // anchor is null when the referenced node was deleted or moved out of
302
+ // binding.type by a remote update; resolving null would throw.
303
+ if (anchor !== null) {
304
+ tr.setSelection(createSafeNodeSelection(tr, anchor));
305
+ }
306
+ } else if (relSel.type === 'nodeRange') {
301
307
  const anchor = relativePositionToAbsolutePosition(
302
308
  binding.doc,
303
309
  binding.type,
@@ -310,6 +316,46 @@ const restoreRelativeSelection = (tr, relSel, binding) => {
310
316
  relSel.head,
311
317
  binding.mapping
312
318
  );
319
+ const selection = createSafeNodeRangeSelection(tr, anchor, head, relSel.depth);
320
+ if (selection !== null) {
321
+ tr.setSelection(selection);
322
+ }
323
+ } else {
324
+ let anchor = relativePositionToAbsolutePosition(
325
+ binding.doc,
326
+ binding.type,
327
+ relSel.anchor,
328
+ binding.mapping
329
+ );
330
+ let head = relativePositionToAbsolutePosition(
331
+ binding.doc,
332
+ binding.type,
333
+ relSel.head,
334
+ binding.mapping
335
+ );
336
+ const needsContentFallback = oldDoc != null &&
337
+ relSel.absAnchor != null &&
338
+ relSel.absHead != null &&
339
+ (
340
+ anchor === null ||
341
+ head === null ||
342
+ (relSel.absAnchor > 1 && anchor !== null && anchor <= 1) ||
343
+ (relSel.absHead > 1 && head !== null && head <= 1) ||
344
+ isMisresolvedAfterStructuralChange(oldDoc, tr.doc, relSel.absAnchor, anchor) ||
345
+ isMisresolvedAfterStructuralChange(oldDoc, tr.doc, relSel.absHead, head)
346
+ );
347
+ if (needsContentFallback) {
348
+ anchor = findAbsolutePositionAfterStructuralChange(
349
+ oldDoc,
350
+ tr.doc,
351
+ relSel.absAnchor
352
+ );
353
+ head = findAbsolutePositionAfterStructuralChange(
354
+ oldDoc,
355
+ tr.doc,
356
+ relSel.absHead
357
+ );
358
+ }
313
359
  if (anchor !== null && head !== null) {
314
360
  tr.setSelection(TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(head)));
315
361
  }
@@ -334,23 +380,67 @@ const createSafeNodeSelection = (tr, pos) => {
334
380
  }
335
381
  };
336
382
 
383
+ /**
384
+ * Safely reconstructs a NodeRangeSelection from resolved absolute positions.
385
+ *
386
+ * @param {import('prosemirror-state').Transaction} tr - The transaction whose document provides resolved positions.
387
+ * @param {number|null} anchor - Absolute document position marking the start (boundary) of the node range.
388
+ * Use `relativePositionToAbsolutePosition` before calling this function.
389
+ * @param {number|null} head - Absolute document position marking the end (boundary) of the node range.
390
+ * Use `relativePositionToAbsolutePosition` before calling this function.
391
+ * @param {number|undefined} depth - The nesting depth at which the range operates (e.g. 0 for
392
+ * top-level blocks, 1 for blocks nested inside a wrapper). Passed through to
393
+ * `Selection.fromJSON`; ignored by `@tiptap/extension-node-range` < v2.29 but
394
+ * properly stored starting from that version.
395
+ * @returns {import('prosemirror-state').Selection|null} Reconstructed selection, or null if
396
+ * anchor/head could not be resolved.
397
+ */
398
+ const createSafeNodeRangeSelection = (tr, anchor, head, depth) => {
399
+ if (anchor === null || head === null) {
400
+ return null
401
+ }
402
+ const clampedAnchor = Math.min(Math.max(anchor, 0), tr.doc.content.size);
403
+ const clampedHead = Math.min(Math.max(head, 0), tr.doc.content.size);
404
+ try {
405
+ const selection = Selection.fromJSON(tr.doc, {
406
+ type: 'nodeRange',
407
+ anchor: clampedAnchor,
408
+ head: clampedHead,
409
+ depth
410
+ });
411
+ if (!selection.ranges.length) {
412
+ return TextSelection.near(tr.doc.resolve(clampedAnchor))
413
+ }
414
+ return selection
415
+ } catch (e) {
416
+ return TextSelection.near(tr.doc.resolve(clampedAnchor))
417
+ }
418
+ };
419
+
337
420
  /**
338
421
  * @param {ProsemirrorBinding} pmbinding
339
422
  * @param {import('prosemirror-state').EditorState} state
340
423
  */
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
- });
424
+ const getRelativeSelection = (pmbinding, state) => {
425
+ const type = /** @type {any} */ (state.selection).jsonID;
426
+ return {
427
+ type,
428
+ // `depth` is only meaningful for NodeRangeSelection; undefined for every other type.
429
+ depth: type === 'nodeRange' ? /** @type {any} */ (state.selection).depth : undefined,
430
+ anchor: absolutePositionToRelativePosition(
431
+ state.selection.anchor,
432
+ pmbinding.type,
433
+ pmbinding.mapping
434
+ ),
435
+ head: absolutePositionToRelativePosition(
436
+ state.selection.head,
437
+ pmbinding.type,
438
+ pmbinding.mapping
439
+ ),
440
+ absAnchor: state.selection.anchor,
441
+ absHead: state.selection.head
442
+ }
443
+ };
354
444
 
355
445
  /**
356
446
  * Binding for prosemirror.
@@ -673,6 +763,9 @@ class ProsemirrorBinding {
673
763
  );
674
764
  transaction.changed.forEach(delType);
675
765
  transaction.changedParentTypes.forEach(delType);
766
+ // Rebuild the full Y↔PM mapping so relative cursor positions resolve against
767
+ // current node sizes after structural changes (e.g. drag-and-drop block moves).
768
+ this.mapping.clear();
676
769
  const fragmentContent = this.type.toArray().map((t) =>
677
770
  createNodeIfNotExists(
678
771
  /** @type {Y.XmlElement | Y.XmlHook} */ (t),
@@ -680,13 +773,14 @@ class ProsemirrorBinding {
680
773
  this
681
774
  )
682
775
  ).filter((n) => n !== null);
776
+ const oldDoc = this.prosemirrorView.state.doc;
683
777
  // @ts-ignore
684
778
  let tr = this._tr.replace(
685
779
  0,
686
780
  this.prosemirrorView.state.doc.content.size,
687
781
  new PModel.Slice(PModel.Fragment.from(fragmentContent), 0, 0)
688
782
  );
689
- restoreRelativeSelection(tr, this.beforeTransactionSelection, this);
783
+ restoreRelativeSelection(tr, this.beforeTransactionSelection, this, oldDoc);
690
784
  tr = tr.setMeta(ySyncPluginKey, { isChangeOrigin: true, isUndoRedoOperation: transaction.origin instanceof Y.UndoManager });
691
785
  if (
692
786
  this.beforeTransactionSelection !== null && this._isLocalCursorInView()
@@ -1572,6 +1666,26 @@ const absolutePositionToRelativePosition = (pos, type, mapping) => {
1572
1666
  return Y.createRelativePositionFromTypeIndex(type, type._length, -1)
1573
1667
  };
1574
1668
 
1669
+ /**
1670
+ * Item-id based relative positions can misresolve to the document start after
1671
+ * block reorder during collaborative drag-and-drop.
1672
+ *
1673
+ * @param {Y.Doc} y
1674
+ * @param {Y.RelativePosition} relPos
1675
+ * @param {number|null} absPos
1676
+ * @return {boolean}
1677
+ */
1678
+ const isMisresolvedTextPosition = (y, relPos, absPos) => {
1679
+ if (absPos === null) {
1680
+ return false
1681
+ }
1682
+ const decoded = Y.createAbsolutePositionFromRelativePosition(relPos, y);
1683
+ return decoded !== null &&
1684
+ decoded.type instanceof Y.XmlText &&
1685
+ relPos.item !== null &&
1686
+ absPos <= 1
1687
+ };
1688
+
1575
1689
  const createRelativePosition = (type, item) => {
1576
1690
  let typeid = null;
1577
1691
  let tname = null;
@@ -1609,7 +1723,11 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1609
1723
  if (t instanceof Y.XmlText) {
1610
1724
  pos += t._length;
1611
1725
  } else {
1612
- pos += /** @type {any} */ (mapping.get(t)).nodeSize;
1726
+ const mapped = mapping.get(t);
1727
+ if (mapped == null) {
1728
+ return null
1729
+ }
1730
+ pos += /** @type {any} */ (mapped).nodeSize;
1613
1731
  }
1614
1732
  }
1615
1733
  n = /** @type {Y.Item} */ (n.right);
@@ -1633,7 +1751,11 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1633
1751
  if (contentType instanceof Y.XmlText) {
1634
1752
  pos += contentType._length;
1635
1753
  } else {
1636
- pos += /** @type {any} */ (mapping.get(contentType)).nodeSize;
1754
+ const mapped = mapping.get(contentType);
1755
+ if (mapped == null) {
1756
+ return null
1757
+ }
1758
+ pos += /** @type {any} */ (mapped).nodeSize;
1637
1759
  }
1638
1760
  }
1639
1761
  n = n.right;
@@ -1641,7 +1763,133 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1641
1763
  }
1642
1764
  type = /** @type {Y.AbstractType} */ (parent);
1643
1765
  }
1644
- return pos - 1 // we don't count the most outer tag, because it is a fragment
1766
+ const absPos = pos - 1; // we don't count the most outer tag, because it is a fragment
1767
+ if (isMisresolvedTextPosition(y, relPos, absPos)) {
1768
+ return null
1769
+ }
1770
+ return absPos
1771
+ };
1772
+
1773
+ /**
1774
+ * @param {import('prosemirror-model').Node} oldDoc
1775
+ * @param {import('prosemirror-model').Node} newDoc
1776
+ * @param {number} absPos
1777
+ * @return {number|null}
1778
+ */
1779
+ const findAbsolutePositionAfterStructuralChange = (oldDoc, newDoc, absPos) => {
1780
+ let pos = 0;
1781
+ let targetIdx = 0;
1782
+ for (; targetIdx < oldDoc.childCount; targetIdx++) {
1783
+ const child = oldDoc.child(targetIdx);
1784
+ if (pos + child.nodeSize > absPos) {
1785
+ break
1786
+ }
1787
+ pos += child.nodeSize;
1788
+ }
1789
+ if (targetIdx >= oldDoc.childCount) {
1790
+ return null
1791
+ }
1792
+ const targetChild = oldDoc.child(targetIdx);
1793
+ const offsetInChild = absPos - pos;
1794
+
1795
+ let occurrence = 0;
1796
+ for (let i = 0; i <= targetIdx; i++) {
1797
+ const child = oldDoc.child(i);
1798
+ if (child.type === targetChild.type && child.textContent === targetChild.textContent) {
1799
+ occurrence++;
1800
+ }
1801
+ }
1802
+
1803
+ let matchCount = 0;
1804
+ let newPos = 0;
1805
+ for (let i = 0; i < newDoc.childCount; i++) {
1806
+ const child = newDoc.child(i);
1807
+ if (child.type === targetChild.type && child.textContent === targetChild.textContent) {
1808
+ matchCount++;
1809
+ if (matchCount === occurrence) {
1810
+ const remapped = newPos + offsetInChild;
1811
+ const contentStart = newPos + 1;
1812
+ const contentEnd = newPos + child.nodeSize - 1;
1813
+ return Math.max(contentStart, Math.min(remapped, contentEnd))
1814
+ }
1815
+ }
1816
+ newPos += child.nodeSize;
1817
+ }
1818
+ return null
1819
+ };
1820
+
1821
+ /**
1822
+ * Returns true when a transaction changes block structure rather than only
1823
+ * editing inline content inside existing blocks.
1824
+ *
1825
+ * @param {import('prosemirror-state').Transaction} tr
1826
+ * @param {import('prosemirror-model').Node} oldDoc
1827
+ * @return {boolean}
1828
+ */
1829
+ const isStructuralTransaction = (tr, oldDoc) => {
1830
+ if (!tr.docChanged) {
1831
+ return false
1832
+ }
1833
+ if (tr.doc.childCount !== oldDoc.childCount) {
1834
+ return true
1835
+ }
1836
+ for (const step of tr.steps) {
1837
+ if (step instanceof ReplaceStep) {
1838
+ if (step.from === 0 && step.to === oldDoc.content.size) {
1839
+ return true
1840
+ }
1841
+ if (step.slice.content.size > 0) {
1842
+ let hasBlock = false;
1843
+ step.slice.content.forEach((node) => {
1844
+ if (node.isBlock) {
1845
+ hasBlock = true;
1846
+ }
1847
+ });
1848
+ if (hasBlock) {
1849
+ return true
1850
+ }
1851
+ } else if (step.to > step.from) {
1852
+ const $from = oldDoc.resolve(step.from);
1853
+ const $to = oldDoc.resolve(step.to);
1854
+ if ($from.depth === 0 && $to.depth === 0 && $from.index() !== $to.index()) {
1855
+ return true
1856
+ }
1857
+ }
1858
+ }
1859
+ }
1860
+ return false
1861
+ };
1862
+
1863
+ /**
1864
+ * Detect stale relative positions after structural changes that resolve to the
1865
+ * wrong text block or to the start of the correct block.
1866
+ *
1867
+ * @param {import('prosemirror-model').Node} oldDoc
1868
+ * @param {import('prosemirror-model').Node} newDoc
1869
+ * @param {number} oldAbs
1870
+ * @param {number|null} resolvedAbs
1871
+ * @return {boolean}
1872
+ */
1873
+ const isMisresolvedAfterStructuralChange = (oldDoc, newDoc, oldAbs, resolvedAbs) => {
1874
+ if (resolvedAbs === null) {
1875
+ return false
1876
+ }
1877
+ const $old = oldDoc.resolve(oldAbs);
1878
+ const $new = newDoc.resolve(resolvedAbs);
1879
+ if (!$old.parent.isTextblock || !$new.parent.isTextblock) {
1880
+ return false
1881
+ }
1882
+ if ($old.parent.textContent !== $new.parent.textContent) {
1883
+ return true
1884
+ }
1885
+ if ($old.parentOffset !== 0 && $new.parentOffset === 0) {
1886
+ return true
1887
+ }
1888
+ if ($old.parentOffset === 0 && $new.parentOffset === 0) {
1889
+ const expected = findAbsolutePositionAfterStructuralChange(oldDoc, newDoc, oldAbs);
1890
+ return expected !== null && expected !== resolvedAbs
1891
+ }
1892
+ return false
1645
1893
  };
1646
1894
 
1647
1895
  /**
@@ -2060,7 +2308,7 @@ const yCursorPlugin = (
2060
2308
  selectionBuilder
2061
2309
  )
2062
2310
  },
2063
- apply (tr, prevState, _oldState, newState) {
2311
+ apply (tr, prevState, oldState, newState) {
2064
2312
  const ystate = ySyncPluginKey.getState(newState);
2065
2313
  const yCursorState = tr.getMeta(yCursorPluginKey);
2066
2314
  if (
@@ -2075,7 +2323,19 @@ const yCursorPlugin = (
2075
2323
  selectionBuilder
2076
2324
  )
2077
2325
  }
2078
- return prevState.map(tr.mapping, tr.doc)
2326
+ if (tr.docChanged) {
2327
+ if (isStructuralTransaction(tr, oldState.doc)) {
2328
+ return createDecorations(
2329
+ newState,
2330
+ awareness,
2331
+ awarenessStateFilter,
2332
+ cursorBuilder,
2333
+ selectionBuilder
2334
+ )
2335
+ }
2336
+ return prevState.map(tr.mapping, tr.doc)
2337
+ }
2338
+ return prevState
2079
2339
  }
2080
2340
  },
2081
2341
  props: {
@@ -2090,15 +2350,19 @@ const yCursorPlugin = (
2090
2350
  setMeta(view, yCursorPluginKey, { awarenessUpdated: true });
2091
2351
  }
2092
2352
  };
2093
- const updateCursorInfo = () => {
2094
- const ystate = ySyncPluginKey.getState(view.state);
2353
+ /**
2354
+ * @param {import('prosemirror-view').EditorView} editorView
2355
+ * @param {{ force?: boolean }} [opts]
2356
+ */
2357
+ const updateCursorInfo = (editorView, opts = {}) => {
2358
+ const ystate = ySyncPluginKey.getState(editorView.state);
2095
2359
  // `ystate` is undefined during sync-plugin init and Y.Doc transitions;
2096
2360
  // reading from it here would throw and break cursor tracking for the view.
2097
2361
  if (!ystate || !ystate.binding) return
2098
2362
  // @note We make implicit checks when checking for the cursor property
2099
2363
  const current = awareness.getLocalState() || {};
2100
- if (view.hasFocus()) {
2101
- const selection = getSelection(view.state);
2364
+ if (editorView.hasFocus()) {
2365
+ const selection = getSelection(editorView.state);
2102
2366
  /**
2103
2367
  * @type {Y.RelativePosition}
2104
2368
  */
@@ -2116,6 +2380,7 @@ const yCursorPlugin = (
2116
2380
  ystate.binding.mapping
2117
2381
  );
2118
2382
  if (
2383
+ opts.force ||
2119
2384
  current.cursor == null ||
2120
2385
  !Y.compareRelativePositions(
2121
2386
  Y.createRelativePositionFromJSON(current.cursor.anchor),
@@ -2144,14 +2409,23 @@ const yCursorPlugin = (
2144
2409
  awareness.setLocalStateField(cursorStateField, null);
2145
2410
  }
2146
2411
  };
2412
+ const onFocusChange = () => updateCursorInfo(view);
2147
2413
  awareness.on('change', awarenessListener);
2148
- view.dom.addEventListener('focusin', updateCursorInfo);
2149
- view.dom.addEventListener('focusout', updateCursorInfo);
2414
+ view.dom.addEventListener('focusin', onFocusChange);
2415
+ view.dom.addEventListener('focusout', onFocusChange);
2150
2416
  return {
2151
- update: updateCursorInfo,
2417
+ update: (editorView, prevState) => {
2418
+ const ystate = ySyncPluginKey.getState(editorView.state);
2419
+ const forceAwarenessUpdate = !!(
2420
+ ystate?.isChangeOrigin &&
2421
+ prevState?.doc &&
2422
+ !prevState.doc.eq(editorView.state.doc)
2423
+ );
2424
+ updateCursorInfo(editorView, { force: forceAwarenessUpdate });
2425
+ },
2152
2426
  destroy: () => {
2153
- view.dom.removeEventListener('focusin', updateCursorInfo);
2154
- view.dom.removeEventListener('focusout', updateCursorInfo);
2427
+ view.dom.removeEventListener('focusin', onFocusChange);
2428
+ view.dom.removeEventListener('focusout', onFocusChange);
2155
2429
  awareness.off('change', awarenessListener);
2156
2430
  awareness.setLocalStateField(cursorStateField, null);
2157
2431
  }
@@ -2273,5 +2547,5 @@ const yUndoPlugin = ({ protectedNodes = defaultProtectedNodes, trackedOrigins =
2273
2547
  }
2274
2548
  });
2275
2549
 
2276
- export { ProsemirrorBinding, absolutePositionToRelativePosition, createDecorations, defaultAwarenessStateFilter, defaultCursorBuilder, defaultDeleteFilter, defaultProtectedNodes, defaultSelectionBuilder, getRelativeSelection, initProseMirrorDoc, isVisible, prosemirrorJSONToYDoc, prosemirrorJSONToYXmlFragment, prosemirrorToYDoc, prosemirrorToYXmlFragment, redo, relativePositionToAbsolutePosition, setMeta, undo, updateYFragment, yCursorPlugin, yCursorPluginKey, yDocToProsemirror, yDocToProsemirrorJSON, ySyncPlugin, ySyncPluginKey, yUndoPlugin, yUndoPluginKey, yXmlFragmentToProseMirrorFragment, yXmlFragmentToProseMirrorRootNode, yXmlFragmentToProsemirror, yXmlFragmentToProsemirrorJSON };
2550
+ export { ProsemirrorBinding, absolutePositionToRelativePosition, createDecorations, defaultAwarenessStateFilter, defaultCursorBuilder, defaultDeleteFilter, defaultProtectedNodes, defaultSelectionBuilder, findAbsolutePositionAfterStructuralChange, getRelativeSelection, initProseMirrorDoc, isMisresolvedAfterStructuralChange, isMisresolvedTextPosition, isStructuralTransaction, isVisible, prosemirrorJSONToYDoc, prosemirrorJSONToYXmlFragment, prosemirrorToYDoc, prosemirrorToYXmlFragment, redo, relativePositionToAbsolutePosition, setMeta, undo, updateYFragment, yCursorPlugin, yCursorPluginKey, yDocToProsemirror, yDocToProsemirrorJSON, ySyncPlugin, ySyncPluginKey, yUndoPlugin, yUndoPluginKey, yXmlFragmentToProseMirrorFragment, yXmlFragmentToProseMirrorRootNode, yXmlFragmentToProsemirror, yXmlFragmentToProsemirrorJSON };
2277
2551
  //# sourceMappingURL=y-tiptap.js.map