@tiptap/y-tiptap 3.0.6 → 3.0.7

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.cjs CHANGED
@@ -315,6 +315,31 @@ const ySyncPlugin = (yXmlFragment, {
315
315
  return plugin
316
316
  };
317
317
 
318
+ /**
319
+ * Resolves one text-selection endpoint after a remote update. Falls back to
320
+ * content-based matching when the Yjs resolution is missing or lands in the
321
+ * wrong block, and keeps the Yjs resolution when the fallback finds nothing.
322
+ *
323
+ * @param {import('prosemirror-model').Node} newDoc
324
+ * @param {import('prosemirror-model').Node} oldDoc
325
+ * @param {number|null|undefined} oldAbs
326
+ * @param {number|null} resolved
327
+ * @return {number|null}
328
+ */
329
+ const recoverSelectionEndpoint = (newDoc, oldDoc, oldAbs, resolved) => {
330
+ if (oldAbs == null) {
331
+ return resolved
332
+ }
333
+ const misresolved = resolved === null ||
334
+ (oldAbs > 1 && resolved <= 1) ||
335
+ isMisresolvedAfterStructuralChange(oldDoc, newDoc, oldAbs, resolved);
336
+ if (!misresolved) {
337
+ return resolved
338
+ }
339
+ const recovered = findAbsolutePositionAfterStructuralChange(oldDoc, newDoc, oldAbs);
340
+ return recovered !== null ? recovered : resolved
341
+ };
342
+
318
343
  /**
319
344
  * @param {import('prosemirror-state').Transaction} tr
320
345
  * @param {ReturnType<typeof getRelativeSelection>} relSel
@@ -367,28 +392,17 @@ const restoreRelativeSelection = (tr, relSel, binding, oldDoc) => {
367
392
  relSel.head,
368
393
  binding.mapping
369
394
  );
370
- const needsContentFallback = oldDoc != null &&
371
- relSel.absAnchor != null &&
372
- relSel.absHead != null &&
373
- (
374
- anchor === null ||
375
- head === null ||
376
- (relSel.absAnchor > 1 && anchor !== null && anchor <= 1) ||
377
- (relSel.absHead > 1 && head !== null && head <= 1) ||
378
- isMisresolvedAfterStructuralChange(oldDoc, tr.doc, relSel.absAnchor, anchor) ||
379
- isMisresolvedAfterStructuralChange(oldDoc, tr.doc, relSel.absHead, head)
380
- );
381
- if (needsContentFallback) {
382
- anchor = findAbsolutePositionAfterStructuralChange(
383
- oldDoc,
384
- tr.doc,
385
- relSel.absAnchor
386
- );
387
- head = findAbsolutePositionAfterStructuralChange(
388
- oldDoc,
389
- tr.doc,
390
- relSel.absHead
391
- );
395
+ if (oldDoc != null) {
396
+ anchor = recoverSelectionEndpoint(tr.doc, oldDoc, relSel.absAnchor, anchor);
397
+ head = recoverSelectionEndpoint(tr.doc, oldDoc, relSel.absHead, head);
398
+ }
399
+ // Collapse to the surviving endpoint instead of dropping the selection;
400
+ // an unset selection maps through the full-doc replace to the doc start.
401
+ if (anchor === null) {
402
+ anchor = head;
403
+ }
404
+ if (head === null) {
405
+ head = anchor;
392
406
  }
393
407
  if (anchor !== null && head !== null) {
394
408
  tr.setSelection(prosemirrorState.TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(head)));
@@ -1804,6 +1818,73 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1804
1818
  return absPos
1805
1819
  };
1806
1820
 
1821
+ /**
1822
+ * Shallow attrs comparison. Attr values are primitives in most schemas;
1823
+ * non-primitive values fail the check and callers fall back to text matching.
1824
+ *
1825
+ * @param {Object<string, any>} a
1826
+ * @param {Object<string, any>} b
1827
+ * @return {boolean}
1828
+ */
1829
+ const attrsEqual = (a, b) => {
1830
+ if (a === b) {
1831
+ return true
1832
+ }
1833
+ const aKeys = Object.keys(a);
1834
+ return aKeys.length === Object.keys(b).length && aKeys.every((k) => a[k] === b[k])
1835
+ };
1836
+
1837
+ /**
1838
+ * Returns true when any attr deviates from its spec default or has none.
1839
+ * Default-only attrs cannot tell same-type siblings apart.
1840
+ *
1841
+ * @param {import('prosemirror-model').Node} node
1842
+ * @return {boolean}
1843
+ */
1844
+ const hasDistinctiveAttrs = (node) => {
1845
+ const specAttrs = node.type.spec.attrs || {};
1846
+ return Object.keys(node.attrs).some((key) => {
1847
+ const spec = specAttrs[key];
1848
+ return spec == null ||
1849
+ !Object.prototype.hasOwnProperty.call(spec, 'default') ||
1850
+ spec.default !== node.attrs[key]
1851
+ })
1852
+ };
1853
+
1854
+ /**
1855
+ * Remaps a position into a matched block by walking the same child-index path
1856
+ * it had in the old block. A raw byte offset would overshoot into a sibling
1857
+ * inner textblock when the old block contains local keystrokes that are not
1858
+ * yet part of the rebuilt document.
1859
+ *
1860
+ * @param {import('prosemirror-model').ResolvedPos} $oldPos
1861
+ * @param {number} newBlockStart
1862
+ * @param {import('prosemirror-model').Node} newBlock
1863
+ * @return {number|null}
1864
+ */
1865
+ const remapIntoBlock = ($oldPos, newBlockStart, newBlock) => {
1866
+ let pos = newBlockStart + 1;
1867
+ let node = newBlock;
1868
+ for (let depth = 1; depth < $oldPos.depth; depth++) {
1869
+ const idx = $oldPos.index(depth);
1870
+ if (idx >= node.childCount) {
1871
+ return null
1872
+ }
1873
+ for (let i = 0; i < idx; i++) {
1874
+ pos += node.child(i).nodeSize;
1875
+ }
1876
+ pos += 1;
1877
+ node = node.child(idx);
1878
+ if (node.type !== $oldPos.node(depth + 1).type) {
1879
+ return null
1880
+ }
1881
+ }
1882
+ if (!node.isTextblock) {
1883
+ return null
1884
+ }
1885
+ return pos + Math.min($oldPos.parentOffset, node.content.size)
1886
+ };
1887
+
1807
1888
  /**
1808
1889
  * @param {import('prosemirror-model').Node} oldDoc
1809
1890
  * @param {import('prosemirror-model').Node} newDoc
@@ -1824,32 +1905,100 @@ const findAbsolutePositionAfterStructuralChange = (oldDoc, newDoc, absPos) => {
1824
1905
  return null
1825
1906
  }
1826
1907
  const targetChild = oldDoc.child(targetIdx);
1827
- const offsetInChild = absPos - pos;
1908
+ const $oldPos = oldDoc.resolve(absPos);
1828
1909
 
1829
- let occurrence = 0;
1830
- for (let i = 0; i <= targetIdx; i++) {
1831
- const child = oldDoc.child(i);
1832
- if (child.type === targetChild.type && child.textContent === targetChild.textContent) {
1833
- occurrence++;
1910
+ /**
1911
+ * @param {number} newBlockStart
1912
+ * @param {import('prosemirror-model').Node} newBlock
1913
+ * @return {number|null}
1914
+ */
1915
+ const place = (newBlockStart, newBlock) => {
1916
+ // Positions between top-level blocks carry no inner path; clamp them just
1917
+ // inside the matched block like the previous raw-offset remap did.
1918
+ if ($oldPos.depth === 0) {
1919
+ const remapped = newBlockStart + (absPos - pos);
1920
+ const contentStart = newBlockStart + 1;
1921
+ const contentEnd = newBlockStart + newBlock.nodeSize - 1;
1922
+ return Math.max(contentStart, Math.min(remapped, contentEnd))
1834
1923
  }
1835
- }
1924
+ return remapIntoBlock($oldPos, newBlockStart, newBlock)
1925
+ };
1836
1926
 
1837
- let matchCount = 0;
1838
- let newPos = 0;
1839
- for (let i = 0; i < newDoc.childCount; i++) {
1840
- const child = newDoc.child(i);
1841
- if (child.type === targetChild.type && child.textContent === targetChild.textContent) {
1842
- matchCount++;
1843
- if (matchCount === occurrence) {
1844
- const remapped = newPos + offsetInChild;
1845
- const contentStart = newPos + 1;
1846
- const contentEnd = newPos + child.nodeSize - 1;
1847
- return Math.max(contentStart, Math.min(remapped, contentEnd))
1927
+ /**
1928
+ * Finds the Nth block in newDoc matching `pred`, where N is the number of
1929
+ * matching blocks in oldDoc up to and including the target block.
1930
+ *
1931
+ * @param {function(import('prosemirror-model').Node): boolean} pred
1932
+ * @param {boolean} requireUnique
1933
+ * @return {number|null}
1934
+ */
1935
+ const findByPredicate = (pred, requireUnique = false) => {
1936
+ let occurrence = 0;
1937
+ for (let i = 0; i <= targetIdx; i++) {
1938
+ if (pred(oldDoc.child(i))) {
1939
+ occurrence++;
1848
1940
  }
1849
1941
  }
1850
- newPos += child.nodeSize;
1942
+ let matchCount = 0;
1943
+ let matchStart = -1;
1944
+ let matchBlock = null;
1945
+ let newPos = 0;
1946
+ for (let i = 0; i < newDoc.childCount; i++) {
1947
+ const child = newDoc.child(i);
1948
+ if (pred(child)) {
1949
+ matchCount++;
1950
+ if (matchCount === occurrence) {
1951
+ matchStart = newPos;
1952
+ matchBlock = child;
1953
+ }
1954
+ }
1955
+ newPos += child.nodeSize;
1956
+ }
1957
+ if (matchBlock === null || (requireUnique && (occurrence !== 1 || matchCount !== 1))) {
1958
+ return null
1959
+ }
1960
+ return place(matchStart, matchBlock)
1961
+ };
1962
+
1963
+ /**
1964
+ * @param {import('prosemirror-model').Node} child
1965
+ * @return {boolean}
1966
+ */
1967
+ const sameTypeAndAttrs = (child) =>
1968
+ child.type === targetChild.type && attrsEqual(child.attrs, targetChild.attrs);
1969
+ const oldText = targetChild.textContent;
1970
+
1971
+ const byAll = findByPredicate((child) => sameTypeAndAttrs(child) && child.textContent === oldText);
1972
+ if (byAll !== null) {
1973
+ return byAll
1974
+ }
1975
+
1976
+ // Text must be matched before attrs: after a remote attr-only edit, the
1977
+ // attrs pass would steer the cursor into a sibling that kept the old attrs.
1978
+ const byText = findByPredicate(
1979
+ (child) => child.type === targetChild.type && child.textContent === oldText
1980
+ );
1981
+ if (byText !== null) {
1982
+ return byText
1983
+ }
1984
+
1985
+ // In-flight local typing diverges the text between both docs. Distinctive
1986
+ // attrs still identify the block; default-only attrs match every sibling.
1987
+ if (hasDistinctiveAttrs(targetChild)) {
1988
+ const byAttrs = findByPredicate(sameTypeAndAttrs, true);
1989
+ if (byAttrs !== null) {
1990
+ return byAttrs
1991
+ }
1851
1992
  }
1852
- return null
1993
+
1994
+ // Trailing in-flight keystrokes leave a prefix relation between old and new
1995
+ // text. Empty text is a prefix of everything and must never match.
1996
+ return findByPredicate(
1997
+ (child) => sameTypeAndAttrs(child) &&
1998
+ oldText !== '' && child.textContent !== '' &&
1999
+ (oldText.startsWith(child.textContent) || child.textContent.startsWith(oldText)),
2000
+ true
2001
+ )
1853
2002
  };
1854
2003
 
1855
2004
  /**
@@ -1910,16 +2059,27 @@ const isMisresolvedAfterStructuralChange = (oldDoc, newDoc, oldAbs, resolvedAbs)
1910
2059
  }
1911
2060
  const $old = oldDoc.resolve(oldAbs);
1912
2061
  const $new = newDoc.resolve(resolvedAbs);
1913
- if (!$old.parent.isTextblock || !$new.parent.isTextblock) {
2062
+ if (!$old.parent.isTextblock) {
1914
2063
  return false
1915
2064
  }
2065
+ // A textblock cursor cannot legitimately resolve into a non-textblock;
2066
+ // a structural reorder replaced the block via delete + insert.
2067
+ if (!$new.parent.isTextblock) {
2068
+ return true
2069
+ }
1916
2070
  if ($old.parent.textContent !== $new.parent.textContent) {
1917
2071
  return true
1918
2072
  }
1919
2073
  if ($old.parentOffset !== 0 && $new.parentOffset === 0) {
1920
2074
  return true
1921
2075
  }
1922
- if ($old.parentOffset === 0 && $new.parentOffset === 0) {
2076
+ const bothAtStart = $old.parentOffset === 0 && $new.parentOffset === 0;
2077
+ // A changed offset, type or attrs hints at a same-text sibling. When all
2078
+ // of them agree there is no signal left and the Yjs resolution must win.
2079
+ const suspicious = $old.parentOffset !== $new.parentOffset ||
2080
+ $old.parent.type !== $new.parent.type ||
2081
+ !attrsEqual($old.parent.attrs, $new.parent.attrs);
2082
+ if (bothAtStart || suspicious) {
1923
2083
  const expected = findAbsolutePositionAfterStructuralChange(oldDoc, newDoc, oldAbs);
1924
2084
  return expected !== null && expected !== resolvedAbs
1925
2085
  }
@@ -2345,8 +2505,18 @@ const yCursorPlugin = (
2345
2505
  apply (tr, prevState, oldState, newState) {
2346
2506
  const ystate = ySyncPluginKey.getState(newState);
2347
2507
  const yCursorState = tr.getMeta(yCursorPluginKey);
2508
+ const isRemoteChange = ystate && ystate.isChangeOrigin;
2348
2509
  if (
2349
- (ystate && ystate.isChangeOrigin) ||
2510
+ tr.docChanged &&
2511
+ !isRemoteChange &&
2512
+ isStructuralTransaction(tr, oldState.doc)
2513
+ ) {
2514
+ // The ProseMirror document leads the Yjs mapping during local moves.
2515
+ // Hide stale awareness until the collaborator publishes its new cursor.
2516
+ return prosemirrorView.DecorationSet.empty
2517
+ }
2518
+ if (
2519
+ isRemoteChange ||
2350
2520
  (yCursorState && yCursorState.awarenessUpdated)
2351
2521
  ) {
2352
2522
  return createDecorations(
@@ -2358,15 +2528,6 @@ const yCursorPlugin = (
2358
2528
  )
2359
2529
  }
2360
2530
  if (tr.docChanged) {
2361
- if (isStructuralTransaction(tr, oldState.doc)) {
2362
- return createDecorations(
2363
- newState,
2364
- awareness,
2365
- awarenessStateFilter,
2366
- cursorBuilder,
2367
- selectionBuilder
2368
- )
2369
- }
2370
2531
  return prevState.map(tr.mapping, tr.doc)
2371
2532
  }
2372
2533
  return prevState