@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.js CHANGED
@@ -281,6 +281,31 @@ const ySyncPlugin = (yXmlFragment, {
281
281
  return plugin
282
282
  };
283
283
 
284
+ /**
285
+ * Resolves one text-selection endpoint after a remote update. Falls back to
286
+ * content-based matching when the Yjs resolution is missing or lands in the
287
+ * wrong block, and keeps the Yjs resolution when the fallback finds nothing.
288
+ *
289
+ * @param {import('prosemirror-model').Node} newDoc
290
+ * @param {import('prosemirror-model').Node} oldDoc
291
+ * @param {number|null|undefined} oldAbs
292
+ * @param {number|null} resolved
293
+ * @return {number|null}
294
+ */
295
+ const recoverSelectionEndpoint = (newDoc, oldDoc, oldAbs, resolved) => {
296
+ if (oldAbs == null) {
297
+ return resolved
298
+ }
299
+ const misresolved = resolved === null ||
300
+ (oldAbs > 1 && resolved <= 1) ||
301
+ isMisresolvedAfterStructuralChange(oldDoc, newDoc, oldAbs, resolved);
302
+ if (!misresolved) {
303
+ return resolved
304
+ }
305
+ const recovered = findAbsolutePositionAfterStructuralChange(oldDoc, newDoc, oldAbs);
306
+ return recovered !== null ? recovered : resolved
307
+ };
308
+
284
309
  /**
285
310
  * @param {import('prosemirror-state').Transaction} tr
286
311
  * @param {ReturnType<typeof getRelativeSelection>} relSel
@@ -333,28 +358,17 @@ const restoreRelativeSelection = (tr, relSel, binding, oldDoc) => {
333
358
  relSel.head,
334
359
  binding.mapping
335
360
  );
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
- );
361
+ if (oldDoc != null) {
362
+ anchor = recoverSelectionEndpoint(tr.doc, oldDoc, relSel.absAnchor, anchor);
363
+ head = recoverSelectionEndpoint(tr.doc, oldDoc, relSel.absHead, head);
364
+ }
365
+ // Collapse to the surviving endpoint instead of dropping the selection;
366
+ // an unset selection maps through the full-doc replace to the doc start.
367
+ if (anchor === null) {
368
+ anchor = head;
369
+ }
370
+ if (head === null) {
371
+ head = anchor;
358
372
  }
359
373
  if (anchor !== null && head !== null) {
360
374
  tr.setSelection(TextSelection.between(tr.doc.resolve(anchor), tr.doc.resolve(head)));
@@ -1770,6 +1784,73 @@ const relativePositionToAbsolutePosition = (y, documentType, relPos, mapping) =>
1770
1784
  return absPos
1771
1785
  };
1772
1786
 
1787
+ /**
1788
+ * Shallow attrs comparison. Attr values are primitives in most schemas;
1789
+ * non-primitive values fail the check and callers fall back to text matching.
1790
+ *
1791
+ * @param {Object<string, any>} a
1792
+ * @param {Object<string, any>} b
1793
+ * @return {boolean}
1794
+ */
1795
+ const attrsEqual = (a, b) => {
1796
+ if (a === b) {
1797
+ return true
1798
+ }
1799
+ const aKeys = Object.keys(a);
1800
+ return aKeys.length === Object.keys(b).length && aKeys.every((k) => a[k] === b[k])
1801
+ };
1802
+
1803
+ /**
1804
+ * Returns true when any attr deviates from its spec default or has none.
1805
+ * Default-only attrs cannot tell same-type siblings apart.
1806
+ *
1807
+ * @param {import('prosemirror-model').Node} node
1808
+ * @return {boolean}
1809
+ */
1810
+ const hasDistinctiveAttrs = (node) => {
1811
+ const specAttrs = node.type.spec.attrs || {};
1812
+ return Object.keys(node.attrs).some((key) => {
1813
+ const spec = specAttrs[key];
1814
+ return spec == null ||
1815
+ !Object.prototype.hasOwnProperty.call(spec, 'default') ||
1816
+ spec.default !== node.attrs[key]
1817
+ })
1818
+ };
1819
+
1820
+ /**
1821
+ * Remaps a position into a matched block by walking the same child-index path
1822
+ * it had in the old block. A raw byte offset would overshoot into a sibling
1823
+ * inner textblock when the old block contains local keystrokes that are not
1824
+ * yet part of the rebuilt document.
1825
+ *
1826
+ * @param {import('prosemirror-model').ResolvedPos} $oldPos
1827
+ * @param {number} newBlockStart
1828
+ * @param {import('prosemirror-model').Node} newBlock
1829
+ * @return {number|null}
1830
+ */
1831
+ const remapIntoBlock = ($oldPos, newBlockStart, newBlock) => {
1832
+ let pos = newBlockStart + 1;
1833
+ let node = newBlock;
1834
+ for (let depth = 1; depth < $oldPos.depth; depth++) {
1835
+ const idx = $oldPos.index(depth);
1836
+ if (idx >= node.childCount) {
1837
+ return null
1838
+ }
1839
+ for (let i = 0; i < idx; i++) {
1840
+ pos += node.child(i).nodeSize;
1841
+ }
1842
+ pos += 1;
1843
+ node = node.child(idx);
1844
+ if (node.type !== $oldPos.node(depth + 1).type) {
1845
+ return null
1846
+ }
1847
+ }
1848
+ if (!node.isTextblock) {
1849
+ return null
1850
+ }
1851
+ return pos + Math.min($oldPos.parentOffset, node.content.size)
1852
+ };
1853
+
1773
1854
  /**
1774
1855
  * @param {import('prosemirror-model').Node} oldDoc
1775
1856
  * @param {import('prosemirror-model').Node} newDoc
@@ -1790,32 +1871,100 @@ const findAbsolutePositionAfterStructuralChange = (oldDoc, newDoc, absPos) => {
1790
1871
  return null
1791
1872
  }
1792
1873
  const targetChild = oldDoc.child(targetIdx);
1793
- const offsetInChild = absPos - pos;
1874
+ const $oldPos = oldDoc.resolve(absPos);
1794
1875
 
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++;
1876
+ /**
1877
+ * @param {number} newBlockStart
1878
+ * @param {import('prosemirror-model').Node} newBlock
1879
+ * @return {number|null}
1880
+ */
1881
+ const place = (newBlockStart, newBlock) => {
1882
+ // Positions between top-level blocks carry no inner path; clamp them just
1883
+ // inside the matched block like the previous raw-offset remap did.
1884
+ if ($oldPos.depth === 0) {
1885
+ const remapped = newBlockStart + (absPos - pos);
1886
+ const contentStart = newBlockStart + 1;
1887
+ const contentEnd = newBlockStart + newBlock.nodeSize - 1;
1888
+ return Math.max(contentStart, Math.min(remapped, contentEnd))
1800
1889
  }
1801
- }
1890
+ return remapIntoBlock($oldPos, newBlockStart, newBlock)
1891
+ };
1802
1892
 
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))
1893
+ /**
1894
+ * Finds the Nth block in newDoc matching `pred`, where N is the number of
1895
+ * matching blocks in oldDoc up to and including the target block.
1896
+ *
1897
+ * @param {function(import('prosemirror-model').Node): boolean} pred
1898
+ * @param {boolean} requireUnique
1899
+ * @return {number|null}
1900
+ */
1901
+ const findByPredicate = (pred, requireUnique = false) => {
1902
+ let occurrence = 0;
1903
+ for (let i = 0; i <= targetIdx; i++) {
1904
+ if (pred(oldDoc.child(i))) {
1905
+ occurrence++;
1814
1906
  }
1815
1907
  }
1816
- newPos += child.nodeSize;
1908
+ let matchCount = 0;
1909
+ let matchStart = -1;
1910
+ let matchBlock = null;
1911
+ let newPos = 0;
1912
+ for (let i = 0; i < newDoc.childCount; i++) {
1913
+ const child = newDoc.child(i);
1914
+ if (pred(child)) {
1915
+ matchCount++;
1916
+ if (matchCount === occurrence) {
1917
+ matchStart = newPos;
1918
+ matchBlock = child;
1919
+ }
1920
+ }
1921
+ newPos += child.nodeSize;
1922
+ }
1923
+ if (matchBlock === null || (requireUnique && (occurrence !== 1 || matchCount !== 1))) {
1924
+ return null
1925
+ }
1926
+ return place(matchStart, matchBlock)
1927
+ };
1928
+
1929
+ /**
1930
+ * @param {import('prosemirror-model').Node} child
1931
+ * @return {boolean}
1932
+ */
1933
+ const sameTypeAndAttrs = (child) =>
1934
+ child.type === targetChild.type && attrsEqual(child.attrs, targetChild.attrs);
1935
+ const oldText = targetChild.textContent;
1936
+
1937
+ const byAll = findByPredicate((child) => sameTypeAndAttrs(child) && child.textContent === oldText);
1938
+ if (byAll !== null) {
1939
+ return byAll
1940
+ }
1941
+
1942
+ // Text must be matched before attrs: after a remote attr-only edit, the
1943
+ // attrs pass would steer the cursor into a sibling that kept the old attrs.
1944
+ const byText = findByPredicate(
1945
+ (child) => child.type === targetChild.type && child.textContent === oldText
1946
+ );
1947
+ if (byText !== null) {
1948
+ return byText
1949
+ }
1950
+
1951
+ // In-flight local typing diverges the text between both docs. Distinctive
1952
+ // attrs still identify the block; default-only attrs match every sibling.
1953
+ if (hasDistinctiveAttrs(targetChild)) {
1954
+ const byAttrs = findByPredicate(sameTypeAndAttrs, true);
1955
+ if (byAttrs !== null) {
1956
+ return byAttrs
1957
+ }
1817
1958
  }
1818
- return null
1959
+
1960
+ // Trailing in-flight keystrokes leave a prefix relation between old and new
1961
+ // text. Empty text is a prefix of everything and must never match.
1962
+ return findByPredicate(
1963
+ (child) => sameTypeAndAttrs(child) &&
1964
+ oldText !== '' && child.textContent !== '' &&
1965
+ (oldText.startsWith(child.textContent) || child.textContent.startsWith(oldText)),
1966
+ true
1967
+ )
1819
1968
  };
1820
1969
 
1821
1970
  /**
@@ -1876,16 +2025,27 @@ const isMisresolvedAfterStructuralChange = (oldDoc, newDoc, oldAbs, resolvedAbs)
1876
2025
  }
1877
2026
  const $old = oldDoc.resolve(oldAbs);
1878
2027
  const $new = newDoc.resolve(resolvedAbs);
1879
- if (!$old.parent.isTextblock || !$new.parent.isTextblock) {
2028
+ if (!$old.parent.isTextblock) {
1880
2029
  return false
1881
2030
  }
2031
+ // A textblock cursor cannot legitimately resolve into a non-textblock;
2032
+ // a structural reorder replaced the block via delete + insert.
2033
+ if (!$new.parent.isTextblock) {
2034
+ return true
2035
+ }
1882
2036
  if ($old.parent.textContent !== $new.parent.textContent) {
1883
2037
  return true
1884
2038
  }
1885
2039
  if ($old.parentOffset !== 0 && $new.parentOffset === 0) {
1886
2040
  return true
1887
2041
  }
1888
- if ($old.parentOffset === 0 && $new.parentOffset === 0) {
2042
+ const bothAtStart = $old.parentOffset === 0 && $new.parentOffset === 0;
2043
+ // A changed offset, type or attrs hints at a same-text sibling. When all
2044
+ // of them agree there is no signal left and the Yjs resolution must win.
2045
+ const suspicious = $old.parentOffset !== $new.parentOffset ||
2046
+ $old.parent.type !== $new.parent.type ||
2047
+ !attrsEqual($old.parent.attrs, $new.parent.attrs);
2048
+ if (bothAtStart || suspicious) {
1889
2049
  const expected = findAbsolutePositionAfterStructuralChange(oldDoc, newDoc, oldAbs);
1890
2050
  return expected !== null && expected !== resolvedAbs
1891
2051
  }
@@ -2311,8 +2471,18 @@ const yCursorPlugin = (
2311
2471
  apply (tr, prevState, oldState, newState) {
2312
2472
  const ystate = ySyncPluginKey.getState(newState);
2313
2473
  const yCursorState = tr.getMeta(yCursorPluginKey);
2474
+ const isRemoteChange = ystate && ystate.isChangeOrigin;
2314
2475
  if (
2315
- (ystate && ystate.isChangeOrigin) ||
2476
+ tr.docChanged &&
2477
+ !isRemoteChange &&
2478
+ isStructuralTransaction(tr, oldState.doc)
2479
+ ) {
2480
+ // The ProseMirror document leads the Yjs mapping during local moves.
2481
+ // Hide stale awareness until the collaborator publishes its new cursor.
2482
+ return DecorationSet.empty
2483
+ }
2484
+ if (
2485
+ isRemoteChange ||
2316
2486
  (yCursorState && yCursorState.awarenessUpdated)
2317
2487
  ) {
2318
2488
  return createDecorations(
@@ -2324,15 +2494,6 @@ const yCursorPlugin = (
2324
2494
  )
2325
2495
  }
2326
2496
  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
2497
  return prevState.map(tr.mapping, tr.doc)
2337
2498
  }
2338
2499
  return prevState