@atlaskit/editor-plugin-paste 18.0.4 → 18.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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @atlaskit/editor-plugin-paste
2
2
 
3
+ ## 18.0.5
4
+
5
+ ### Patch Changes
6
+
7
+ - [`4a3a84237c82a`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/4a3a84237c82a) -
8
+ Behind the `platform_editor_paste_autoconvert_robustness` experiment: paste auto-conversion no
9
+ longer waits for the smart link provider before inserting pasted content, and the macro
10
+ replacement range is derived from the applied transaction steps instead of the slice size so the
11
+ pasted link can no longer be left behind alongside the macro. If the pasted link can no longer be
12
+ located when the request settles (for example it was unlinked in the meantime), nothing is
13
+ replaced — content is never matched by proximity to a stale position, which could otherwise
14
+ convert a different, unrelated link to the same URL. Behaviour is unchanged when the experiment is
15
+ off.
16
+ - Updated dependencies
17
+
3
18
  ## 18.0.4
4
19
 
5
20
  ### Patch Changes
@@ -417,7 +417,7 @@ function createPlugin(schema, dispatchAnalyticsEvent, dispatch, featureFlags, pl
417
417
  }
418
418
 
419
419
  // run macro autoconvert prior to other conversions
420
- if ((0, _handlers.handleMacroAutoConvert)(text, markdownSlice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c = pluginInjectionApi.card) === null || _pluginInjectionApi$c === void 0 || (_pluginInjectionApi$c = _pluginInjectionApi$c.actions) === null || _pluginInjectionApi$c === void 0 ? void 0 : _pluginInjectionApi$c.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e = pluginInjectionApi.extension) === null || _pluginInjectionApi$e === void 0 || (_pluginInjectionApi$e = _pluginInjectionApi$e.actions) === null || _pluginInjectionApi$e === void 0 ? void 0 : _pluginInjectionApi$e.runMacroAutoConvert, cardOptions, extensionAutoConverter)(state, dispatch, view)) {
420
+ if ((0, _handlers.handleMacroAutoConvert)(text, markdownSlice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c = pluginInjectionApi.card) === null || _pluginInjectionApi$c === void 0 || (_pluginInjectionApi$c = _pluginInjectionApi$c.actions) === null || _pluginInjectionApi$c === void 0 ? void 0 : _pluginInjectionApi$c.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e = pluginInjectionApi.extension) === null || _pluginInjectionApi$e === void 0 || (_pluginInjectionApi$e = _pluginInjectionApi$e.actions) === null || _pluginInjectionApi$e === void 0 ? void 0 : _pluginInjectionApi$e.runMacroAutoConvert, cardOptions, extensionAutoConverter, editorAnalyticsAPI)(state, dispatch, view)) {
421
421
  // TODO: ED-26959 - handleMacroAutoConvert dispatch twice, so we can't use the helper
422
422
  (0, _analytics2.sendPasteAnalyticsEvent)(editorAnalyticsAPI)(view, event, markdownSlice, {
423
423
  type: _analytics.PasteTypes.markdown
@@ -515,7 +515,7 @@ function createPlugin(schema, dispatchAnalyticsEvent, dispatch, featureFlags, pl
515
515
  }
516
516
 
517
517
  // run macro autoconvert prior to other conversions
518
- if ((0, _handlers.handleMacroAutoConvert)(text, slice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c2 = pluginInjectionApi.card) === null || _pluginInjectionApi$c2 === void 0 || (_pluginInjectionApi$c2 = _pluginInjectionApi$c2.actions) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e2 = pluginInjectionApi.extension) === null || _pluginInjectionApi$e2 === void 0 || (_pluginInjectionApi$e2 = _pluginInjectionApi$e2.actions) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.runMacroAutoConvert, cardOptions, extensionAutoConverter)(state, dispatch, view)) {
518
+ if ((0, _handlers.handleMacroAutoConvert)(text, slice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c2 = pluginInjectionApi.card) === null || _pluginInjectionApi$c2 === void 0 || (_pluginInjectionApi$c2 = _pluginInjectionApi$c2.actions) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e2 = pluginInjectionApi.extension) === null || _pluginInjectionApi$e2 === void 0 || (_pluginInjectionApi$e2 = _pluginInjectionApi$e2.actions) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.runMacroAutoConvert, cardOptions, extensionAutoConverter, editorAnalyticsAPI)(state, dispatch, view)) {
519
519
  // TODO: ED-26959 - handleMacroAutoConvert dispatch twice, so we can't use the helper
520
520
  (0, _analytics2.sendPasteAnalyticsEvent)(editorAnalyticsAPI)(view, event, slice, {
521
521
  type: _analytics.PasteTypes.richText
@@ -642,7 +642,7 @@ function _getSmartLinkAdf() {
642
642
  }));
643
643
  return _getSmartLinkAdf.apply(this, arguments);
644
644
  }
645
- function insertAutoMacro(slice, macro, view, from, to) {
645
+ function insertAutoMacroOld(slice, macro, view, from, to) {
646
646
  if (view) {
647
647
  // insert the text or linkified/md-converted clipboard data
648
648
  var selection = view.state.tr.selection;
@@ -669,7 +669,168 @@ function insertAutoMacro(slice, macro, view, from, to) {
669
669
  }
670
670
  return false;
671
671
  }
672
- function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter) {
672
+
673
+ /**
674
+ * Replacement for `insertAutoMacroOld` behind
675
+ * `platform_editor_paste_autoconvert_robustness`.
676
+ *
677
+ * Identical in shape, except that the range the macro replaces is derived from the steps
678
+ * that were actually applied rather than from `slice.size`. `slice.size` is the slice's
679
+ * own measure and does not account for ProseMirror's fitting behaviour in
680
+ * `replaceRange`/`replaceSelection`, so the two can disagree — and when they do the macro
681
+ * is inserted without removing the text it should replace, leaving both the pasted text
682
+ * and the macro in the document.
683
+ */
684
+ function insertAutoMacroNew(slice, macro, view, from, to) {
685
+ if (!view) {
686
+ return false;
687
+ }
688
+ var selection = view.state.selection;
689
+ var hasExplicitRange = typeof from === 'number' && typeof to === 'number';
690
+ var replaceFrom = hasExplicitRange ? from : selection.from;
691
+ var replaceTo = hasExplicitRange ? to : selection.to;
692
+
693
+ // insert the text or linkified/md-converted clipboard data
694
+ var tr = hasExplicitRange ? view.state.tr.replaceRange(replaceFrom, replaceTo, slice) : view.state.tr.replaceSelection(slice);
695
+ var insertedFrom = tr.mapping.map(replaceFrom, -1);
696
+ var insertedTo = tr.mapping.map(replaceTo, 1);
697
+ view.dispatch(tr);
698
+
699
+ // Leave the document alone rather than replacing an implausible range.
700
+ if (insertedFrom >= insertedTo || insertedTo > view.state.doc.content.size) {
701
+ return true;
702
+ }
703
+
704
+ // replace the text with the macro as a separate transaction
705
+ // so the autoconversion generates 2 undo steps
706
+ var macroTr = (0, _closeHistory.closeHistory)(view.state.tr).replaceRangeWith(insertedFrom, insertedTo, macro).scrollIntoView();
707
+ (0, _card.addLinkMetadata)(view.state.selection, macroTr, {
708
+ inputMethod: _analytics.INPUT_METHOD.CLIPBOARD,
709
+ cardAction: 'AUTO_CONVERT'
710
+ });
711
+ view.dispatch(macroTr);
712
+ return true;
713
+ }
714
+ function insertAutoMacro(slice, macro, view, from, to) {
715
+ return (0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_paste_autoconvert_robustness') ? insertAutoMacroNew(slice, macro, view, from, to) : insertAutoMacroOld(slice, macro, view, from, to);
716
+ }
717
+
718
+ /**
719
+ * Locates the range currently occupied by a link that we pasted earlier.
720
+ *
721
+ * Positions recorded before an async gap can drift or be invalidated entirely by
722
+ * concurrent edits, collaborative changes and `appendTransaction` hooks, so the
723
+ * recorded range is verified against the document before it is used. If it no longer
724
+ * holds the pasted link the link is re-located by href, preferring the occurrence
725
+ * closest to where it was last seen.
726
+ *
727
+ * Returns `undefined` when the link cannot be found, so callers can leave the
728
+ * document alone rather than replacing whatever happens to sit at a stale position.
729
+ */
730
+ /**
731
+ * Whether every text node spanning [from, to) carries a `link` mark pointing at `url`,
732
+ * with no gaps or uncovered portions.
733
+ */
734
+ function rangeIsExactLinkMark(doc, from, to, url, linkMarkType) {
735
+ var matches = true;
736
+ var coveredTo = from;
737
+ doc.nodesBetween(from, to, function (node, pos) {
738
+ if (!matches || pos >= to) {
739
+ return false;
740
+ }
741
+ if (!node.isLeaf) {
742
+ // A container node — e.g. the paragraph the link sits in. `nodesBetween` visits
743
+ // these on the way down to the text node(s) we actually care about; keep
744
+ // descending into it rather than treating it as a mismatch.
745
+ return true;
746
+ }
747
+ if (!node.isText) {
748
+ // A non-text leaf (e.g. an inline card) cannot carry the link mark.
749
+ matches = false;
750
+ return false;
751
+ }
752
+ var linkMark = linkMarkType.isInSet(node.marks);
753
+ if (!linkMark || linkMark.attrs.href !== url) {
754
+ matches = false;
755
+ return false;
756
+ }
757
+ coveredTo = Math.min(pos + node.nodeSize, to);
758
+ return true;
759
+ });
760
+ return matches && coveredTo === to;
761
+ }
762
+
763
+ /**
764
+ * `from`/`to` are not a one-off snapshot: they come from `pastedMacroPositions`, which the
765
+ * paste plugin's `mapping` hook (`plugin-factory.ts`) maps through every transaction
766
+ * applied while the smart link request is in flight, via `tr.mapping.map(position)`. So
767
+ * this covers every edit ProseMirror can express as a position mapping — typing,
768
+ * deleting, or moving content elsewhere in the same edit all keep `from`/`to` pointing at
769
+ * the same logical span.
770
+ *
771
+ * It stops covering that span only when the span itself no longer represents our pasted
772
+ * link — the link was unlinked, or its content was replaced outright. Deliberately no
773
+ * fallback search for "the nearest link with the same href" is done: with multiple links
774
+ * to the same URL in the document, a proximity search cannot tell which one we pasted, so
775
+ * it risks silently replacing an occurrence we were never asked to touch, and it requires
776
+ * an O(doc size) scan on every fallback to do it. Leaving the tracked link as a link is
777
+ * the safe outcome here, and it's what happens when this function returns `undefined`.
778
+ */
779
+ function findPastedLinkRange(state, url, from, to) {
780
+ var doc = state.doc,
781
+ schema = state.schema;
782
+ var linkMarkType = schema.marks.link;
783
+ var docSize = doc.content.size;
784
+ if (!linkMarkType || typeof from !== 'number' || typeof to !== 'number' || from < 0 || from >= to || to > docSize) {
785
+ return undefined;
786
+ }
787
+
788
+ // Text equality alone is not enough: if the link was unlinked (mark removed) while
789
+ // the smart link request was in flight, the text can still read as the URL even
790
+ // though it is no longer a link, and replacing it would destroy content that is no
791
+ // longer ours to replace.
792
+ if (doc.textBetween(from, to) === url && rangeIsExactLinkMark(doc, from, to, url, linkMarkType)) {
793
+ return {
794
+ from: from,
795
+ to: to
796
+ };
797
+ }
798
+ return undefined;
799
+ }
800
+
801
+ /**
802
+ * Swaps a link that was already pasted into the document for its macro equivalent.
803
+ *
804
+ * Used when smart links could not resolve the URL, so the macro autoconversion is the
805
+ * fallback. Does nothing if the link can no longer be found — for example because a
806
+ * smart card has already replaced it — which is preferable to replacing unrelated
807
+ * content at a stale position.
808
+ */
809
+ function replacePastedLinkWithMacro(view, macro, url, from, to, editorAnalyticsAPI) {
810
+ var range = findPastedLinkRange(view.state, url, from, to);
811
+ if (!range) {
812
+ // Metric for how often the tracked pasted link could no longer be found once the
813
+ // smart link request settled, so the assumption that this is rare is measurable
814
+ // rather than assumed.
815
+ editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 || editorAnalyticsAPI.fireAnalyticsEvent({
816
+ action: _analytics.ACTION.ERRORED,
817
+ actionSubject: _analytics.ACTION_SUBJECT.SMART_LINK,
818
+ eventType: _analytics.EVENT_TYPE.OPERATIONAL,
819
+ attributes: {
820
+ error: 'macro-auto-convert-pasted-link-not-found'
821
+ }
822
+ });
823
+ return false;
824
+ }
825
+ var macroTr = (0, _closeHistory.closeHistory)(view.state.tr).replaceRangeWith(range.from, range.to, macro).scrollIntoView();
826
+ (0, _card.addLinkMetadata)(view.state.selection, macroTr, {
827
+ inputMethod: _analytics.INPUT_METHOD.CLIPBOARD,
828
+ cardAction: 'AUTO_CONVERT'
829
+ });
830
+ view.dispatch(macroTr);
831
+ return true;
832
+ }
833
+ function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter, editorAnalyticsAPI) {
673
834
  return function (state, dispatch, view) {
674
835
  var macro = null;
675
836
 
@@ -697,23 +858,50 @@ function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAu
697
858
  if (!view) {
698
859
  throw new Error('View is missing');
699
860
  }
861
+ var autoConvertMacro = macro;
700
862
 
701
863
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
702
864
  var trackingId = (0, _uuid.v4)();
703
865
  var trackingFrom = "handleMacroAutoConvert-from-".concat(trackingId);
704
866
  var trackingTo = "handleMacroAutoConvert-to-".concat(trackingId);
867
+ if ((0, _isExperimentEnabled.isExperimentEnabled)('platform_editor_paste_autoconvert_robustness')) {
868
+ // Insert the pasted content straight away. The smart link provider is only
869
+ // consulted to decide whether the inserted link should later be swapped for
870
+ // a macro, so it does not need to gate the insert — otherwise every
871
+ // autoconvertible paste appears to do nothing until a network round trip
872
+ // completes.
873
+ var insertedRange;
874
+ handleMarkdownWithInsertedRange(slice, queueCardsFromChangedTr, function (range) {
875
+ insertedRange = range;
876
+ })(state, dispatch);
877
+ if (!insertedRange) {
878
+ return true;
879
+ }
880
+ (0, _commands.startTrackingPastedMacroPositions)((0, _defineProperty2.default)((0, _defineProperty2.default)({}, trackingFrom, insertedRange.from), trackingTo, insertedRange.to))(view.state, dispatch);
881
+ getSmartLinkAdf(text, 'inline', cardsOptions).catch(function () {
882
+ // Smart links could not resolve the URL, so fall back to the macro.
883
+ // we use view.state rather than state because state becomes a stale
884
+ // state reference after getSmartLinkAdf's async work
885
+ var _getPastePluginState = (0, _pluginFactory.getPluginState)(view.state),
886
+ pastedMacroPositions = _getPastePluginState.pastedMacroPositions;
887
+ replacePastedLinkWithMacro(view, autoConvertMacro, text, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo], editorAnalyticsAPI);
888
+ }).finally(function () {
889
+ (0, _commands.stopTrackingPastedMacroPositions)([trackingFrom, trackingTo])(view.state, dispatch);
890
+ });
891
+ return true;
892
+ }
705
893
  (0, _commands.startTrackingPastedMacroPositions)((0, _defineProperty2.default)((0, _defineProperty2.default)({}, trackingFrom, state.selection.from), trackingTo, state.selection.to))(state, dispatch);
706
894
  getSmartLinkAdf(text, 'inline', cardsOptions).then(function () {
707
895
  // we use view.state rather than state because state becomes a stale
708
896
  // state reference after getSmartLinkAdf's async work
709
- var _getPastePluginState = (0, _pluginFactory.getPluginState)(view.state),
710
- pastedMacroPositions = _getPastePluginState.pastedMacroPositions;
897
+ var _getPastePluginState2 = (0, _pluginFactory.getPluginState)(view.state),
898
+ pastedMacroPositions = _getPastePluginState2.pastedMacroPositions;
711
899
  if (dispatch) {
712
900
  handleMarkdown(slice, queueCardsFromChangedTr, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo])(view.state, dispatch);
713
901
  }
714
902
  }).catch(function () {
715
- var _getPastePluginState2 = (0, _pluginFactory.getPluginState)(view.state),
716
- pastedMacroPositions = _getPastePluginState2.pastedMacroPositions;
903
+ var _getPastePluginState3 = (0, _pluginFactory.getPluginState)(view.state),
904
+ pastedMacroPositions = _getPastePluginState3.pastedMacroPositions;
717
905
  insertAutoMacro(slice, macro, view, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo]);
718
906
  }).finally(function () {
719
907
  (0, _commands.stopTrackingPastedMacroPositions)([trackingFrom, trackingTo])(view.state, dispatch);
@@ -1007,6 +1195,36 @@ function resolveParagraphMarks(marks, destinationBlockMarkAttrs, shouldNormalize
1007
1195
  }
1008
1196
  return marks;
1009
1197
  }
1198
+
1199
+ /**
1200
+ * Variant of `handleMarkdown` used by macro auto-conversion behind
1201
+ * `platform_editor_paste_autoconvert_robustness`.
1202
+ *
1203
+ * Differs in two ways: the inserted range is derived from the steps that were applied
1204
+ * rather than from `markdownSlice.size`, which does not account for ProseMirror's fitting
1205
+ * behaviour; and that range is reported back through `onInsert` so the caller can track
1206
+ * the inserted link across the async gap before deciding whether to swap it for a macro.
1207
+ */
1208
+ function handleMarkdownWithInsertedRange(markdownSlice, queueCardsFromChangedTr, onInsert) {
1209
+ return function (state, dispatch) {
1210
+ var tr = (0, _closeHistory.closeHistory)(state.tr);
1211
+ var replaceFrom = tr.selection.from;
1212
+ var replaceTo = tr.selection.to;
1213
+ tr.replaceSelection(markdownSlice);
1214
+ var insertedFrom = tr.mapping.map(replaceFrom, -1);
1215
+ var insertedTo = tr.mapping.map(replaceTo, 1);
1216
+ tr.setSelection(_state.TextSelection.near(tr.doc.resolve(Math.min(insertedTo, tr.doc.content.size)), -1));
1217
+ queueCardsFromChangedTr === null || queueCardsFromChangedTr === void 0 || queueCardsFromChangedTr(state, tr, _analytics.INPUT_METHOD.CLIPBOARD);
1218
+ if (dispatch) {
1219
+ dispatch(tr.scrollIntoView());
1220
+ onInsert({
1221
+ from: insertedFrom,
1222
+ to: insertedTo
1223
+ });
1224
+ }
1225
+ return true;
1226
+ };
1227
+ }
1010
1228
  function handleParagraphBlockMarks(state, slice) {
1011
1229
  var _findParentNodeOfType2;
1012
1230
  if (slice.content.size === 0) {
@@ -376,7 +376,7 @@ export function createPlugin(schema, dispatchAnalyticsEvent, dispatch, featureFl
376
376
  }
377
377
 
378
378
  // run macro autoconvert prior to other conversions
379
- if (handleMacroAutoConvert(text, markdownSlice, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c = pluginInjectionApi.card) === null || _pluginInjectionApi$c === void 0 ? void 0 : (_pluginInjectionApi$c2 = _pluginInjectionApi$c.actions) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e = pluginInjectionApi.extension) === null || _pluginInjectionApi$e === void 0 ? void 0 : (_pluginInjectionApi$e2 = _pluginInjectionApi$e.actions) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.runMacroAutoConvert, cardOptions, extensionAutoConverter)(state, dispatch, view)) {
379
+ if (handleMacroAutoConvert(text, markdownSlice, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c = pluginInjectionApi.card) === null || _pluginInjectionApi$c === void 0 ? void 0 : (_pluginInjectionApi$c2 = _pluginInjectionApi$c.actions) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e = pluginInjectionApi.extension) === null || _pluginInjectionApi$e === void 0 ? void 0 : (_pluginInjectionApi$e2 = _pluginInjectionApi$e.actions) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.runMacroAutoConvert, cardOptions, extensionAutoConverter, editorAnalyticsAPI)(state, dispatch, view)) {
380
380
  // TODO: ED-26959 - handleMacroAutoConvert dispatch twice, so we can't use the helper
381
381
  sendPasteAnalyticsEvent(editorAnalyticsAPI)(view, event, markdownSlice, {
382
382
  type: PasteTypes.markdown
@@ -476,7 +476,7 @@ export function createPlugin(schema, dispatchAnalyticsEvent, dispatch, featureFl
476
476
  }
477
477
 
478
478
  // run macro autoconvert prior to other conversions
479
- if (handleMacroAutoConvert(text, slice, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c3 = pluginInjectionApi.card) === null || _pluginInjectionApi$c3 === void 0 ? void 0 : (_pluginInjectionApi$c4 = _pluginInjectionApi$c3.actions) === null || _pluginInjectionApi$c4 === void 0 ? void 0 : _pluginInjectionApi$c4.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e3 = pluginInjectionApi.extension) === null || _pluginInjectionApi$e3 === void 0 ? void 0 : (_pluginInjectionApi$e4 = _pluginInjectionApi$e3.actions) === null || _pluginInjectionApi$e4 === void 0 ? void 0 : _pluginInjectionApi$e4.runMacroAutoConvert, cardOptions, extensionAutoConverter)(state, dispatch, view)) {
479
+ if (handleMacroAutoConvert(text, slice, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$c3 = pluginInjectionApi.card) === null || _pluginInjectionApi$c3 === void 0 ? void 0 : (_pluginInjectionApi$c4 = _pluginInjectionApi$c3.actions) === null || _pluginInjectionApi$c4 === void 0 ? void 0 : _pluginInjectionApi$c4.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 ? void 0 : (_pluginInjectionApi$e3 = pluginInjectionApi.extension) === null || _pluginInjectionApi$e3 === void 0 ? void 0 : (_pluginInjectionApi$e4 = _pluginInjectionApi$e3.actions) === null || _pluginInjectionApi$e4 === void 0 ? void 0 : _pluginInjectionApi$e4.runMacroAutoConvert, cardOptions, extensionAutoConverter, editorAnalyticsAPI)(state, dispatch, view)) {
480
480
  // TODO: ED-26959 - handleMacroAutoConvert dispatch twice, so we can't use the helper
481
481
  sendPasteAnalyticsEvent(editorAnalyticsAPI)(view, event, slice, {
482
482
  type: PasteTypes.richText
@@ -1,7 +1,7 @@
1
1
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
2
2
  import { v4 as uuid } from 'uuid';
3
3
  import { transformContainerNodes } from '@atlaskit/adf-utils/transforms';
4
- import { INPUT_METHOD } from '@atlaskit/editor-common/analytics';
4
+ import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
5
5
  import { addLinkMetadata } from '@atlaskit/editor-common/card';
6
6
  import { insideTable } from '@atlaskit/editor-common/core-utils';
7
7
  import { getBlockMarkAttrs, getFirstParagraphBlockMarkAttrs, reconcileBlockMarkForContainerAtPos, reconcileBlockMarkForParagraphAtPos, reconcileBlockMarkInRange } from '@atlaskit/editor-common/lists';
@@ -608,7 +608,7 @@ async function getSmartLinkAdf(text, type, cardOptions) {
608
608
  const provider = await cardOptions.provider;
609
609
  return await provider.resolve(text, type);
610
610
  }
611
- function insertAutoMacro(slice, macro, view, from, to) {
611
+ function insertAutoMacroOld(slice, macro, view, from, to) {
612
612
  if (view) {
613
613
  // insert the text or linkified/md-converted clipboard data
614
614
  const selection = view.state.tr.selection;
@@ -635,7 +635,172 @@ function insertAutoMacro(slice, macro, view, from, to) {
635
635
  }
636
636
  return false;
637
637
  }
638
- export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter) {
638
+
639
+ /**
640
+ * Replacement for `insertAutoMacroOld` behind
641
+ * `platform_editor_paste_autoconvert_robustness`.
642
+ *
643
+ * Identical in shape, except that the range the macro replaces is derived from the steps
644
+ * that were actually applied rather than from `slice.size`. `slice.size` is the slice's
645
+ * own measure and does not account for ProseMirror's fitting behaviour in
646
+ * `replaceRange`/`replaceSelection`, so the two can disagree — and when they do the macro
647
+ * is inserted without removing the text it should replace, leaving both the pasted text
648
+ * and the macro in the document.
649
+ */
650
+ function insertAutoMacroNew(slice, macro, view, from, to) {
651
+ if (!view) {
652
+ return false;
653
+ }
654
+ const {
655
+ selection
656
+ } = view.state;
657
+ const hasExplicitRange = typeof from === 'number' && typeof to === 'number';
658
+ const replaceFrom = hasExplicitRange ? from : selection.from;
659
+ const replaceTo = hasExplicitRange ? to : selection.to;
660
+
661
+ // insert the text or linkified/md-converted clipboard data
662
+ const tr = hasExplicitRange ? view.state.tr.replaceRange(replaceFrom, replaceTo, slice) : view.state.tr.replaceSelection(slice);
663
+ const insertedFrom = tr.mapping.map(replaceFrom, -1);
664
+ const insertedTo = tr.mapping.map(replaceTo, 1);
665
+ view.dispatch(tr);
666
+
667
+ // Leave the document alone rather than replacing an implausible range.
668
+ if (insertedFrom >= insertedTo || insertedTo > view.state.doc.content.size) {
669
+ return true;
670
+ }
671
+
672
+ // replace the text with the macro as a separate transaction
673
+ // so the autoconversion generates 2 undo steps
674
+ const macroTr = closeHistory(view.state.tr).replaceRangeWith(insertedFrom, insertedTo, macro).scrollIntoView();
675
+ addLinkMetadata(view.state.selection, macroTr, {
676
+ inputMethod: INPUT_METHOD.CLIPBOARD,
677
+ cardAction: 'AUTO_CONVERT'
678
+ });
679
+ view.dispatch(macroTr);
680
+ return true;
681
+ }
682
+ function insertAutoMacro(slice, macro, view, from, to) {
683
+ return isExperimentEnabled('platform_editor_paste_autoconvert_robustness') ? insertAutoMacroNew(slice, macro, view, from, to) : insertAutoMacroOld(slice, macro, view, from, to);
684
+ }
685
+
686
+ /**
687
+ * Locates the range currently occupied by a link that we pasted earlier.
688
+ *
689
+ * Positions recorded before an async gap can drift or be invalidated entirely by
690
+ * concurrent edits, collaborative changes and `appendTransaction` hooks, so the
691
+ * recorded range is verified against the document before it is used. If it no longer
692
+ * holds the pasted link the link is re-located by href, preferring the occurrence
693
+ * closest to where it was last seen.
694
+ *
695
+ * Returns `undefined` when the link cannot be found, so callers can leave the
696
+ * document alone rather than replacing whatever happens to sit at a stale position.
697
+ */
698
+ /**
699
+ * Whether every text node spanning [from, to) carries a `link` mark pointing at `url`,
700
+ * with no gaps or uncovered portions.
701
+ */
702
+ function rangeIsExactLinkMark(doc, from, to, url, linkMarkType) {
703
+ let matches = true;
704
+ let coveredTo = from;
705
+ doc.nodesBetween(from, to, (node, pos) => {
706
+ if (!matches || pos >= to) {
707
+ return false;
708
+ }
709
+ if (!node.isLeaf) {
710
+ // A container node — e.g. the paragraph the link sits in. `nodesBetween` visits
711
+ // these on the way down to the text node(s) we actually care about; keep
712
+ // descending into it rather than treating it as a mismatch.
713
+ return true;
714
+ }
715
+ if (!node.isText) {
716
+ // A non-text leaf (e.g. an inline card) cannot carry the link mark.
717
+ matches = false;
718
+ return false;
719
+ }
720
+ const linkMark = linkMarkType.isInSet(node.marks);
721
+ if (!linkMark || linkMark.attrs.href !== url) {
722
+ matches = false;
723
+ return false;
724
+ }
725
+ coveredTo = Math.min(pos + node.nodeSize, to);
726
+ return true;
727
+ });
728
+ return matches && coveredTo === to;
729
+ }
730
+
731
+ /**
732
+ * `from`/`to` are not a one-off snapshot: they come from `pastedMacroPositions`, which the
733
+ * paste plugin's `mapping` hook (`plugin-factory.ts`) maps through every transaction
734
+ * applied while the smart link request is in flight, via `tr.mapping.map(position)`. So
735
+ * this covers every edit ProseMirror can express as a position mapping — typing,
736
+ * deleting, or moving content elsewhere in the same edit all keep `from`/`to` pointing at
737
+ * the same logical span.
738
+ *
739
+ * It stops covering that span only when the span itself no longer represents our pasted
740
+ * link — the link was unlinked, or its content was replaced outright. Deliberately no
741
+ * fallback search for "the nearest link with the same href" is done: with multiple links
742
+ * to the same URL in the document, a proximity search cannot tell which one we pasted, so
743
+ * it risks silently replacing an occurrence we were never asked to touch, and it requires
744
+ * an O(doc size) scan on every fallback to do it. Leaving the tracked link as a link is
745
+ * the safe outcome here, and it's what happens when this function returns `undefined`.
746
+ */
747
+ function findPastedLinkRange(state, url, from, to) {
748
+ const {
749
+ doc,
750
+ schema
751
+ } = state;
752
+ const linkMarkType = schema.marks.link;
753
+ const docSize = doc.content.size;
754
+ if (!linkMarkType || typeof from !== 'number' || typeof to !== 'number' || from < 0 || from >= to || to > docSize) {
755
+ return undefined;
756
+ }
757
+
758
+ // Text equality alone is not enough: if the link was unlinked (mark removed) while
759
+ // the smart link request was in flight, the text can still read as the URL even
760
+ // though it is no longer a link, and replacing it would destroy content that is no
761
+ // longer ours to replace.
762
+ if (doc.textBetween(from, to) === url && rangeIsExactLinkMark(doc, from, to, url, linkMarkType)) {
763
+ return {
764
+ from,
765
+ to
766
+ };
767
+ }
768
+ return undefined;
769
+ }
770
+
771
+ /**
772
+ * Swaps a link that was already pasted into the document for its macro equivalent.
773
+ *
774
+ * Used when smart links could not resolve the URL, so the macro autoconversion is the
775
+ * fallback. Does nothing if the link can no longer be found — for example because a
776
+ * smart card has already replaced it — which is preferable to replacing unrelated
777
+ * content at a stale position.
778
+ */
779
+ function replacePastedLinkWithMacro(view, macro, url, from, to, editorAnalyticsAPI) {
780
+ const range = findPastedLinkRange(view.state, url, from, to);
781
+ if (!range) {
782
+ // Metric for how often the tracked pasted link could no longer be found once the
783
+ // smart link request settled, so the assumption that this is rare is measurable
784
+ // rather than assumed.
785
+ editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.fireAnalyticsEvent({
786
+ action: ACTION.ERRORED,
787
+ actionSubject: ACTION_SUBJECT.SMART_LINK,
788
+ eventType: EVENT_TYPE.OPERATIONAL,
789
+ attributes: {
790
+ error: 'macro-auto-convert-pasted-link-not-found'
791
+ }
792
+ });
793
+ return false;
794
+ }
795
+ const macroTr = closeHistory(view.state.tr).replaceRangeWith(range.from, range.to, macro).scrollIntoView();
796
+ addLinkMetadata(view.state.selection, macroTr, {
797
+ inputMethod: INPUT_METHOD.CLIPBOARD,
798
+ cardAction: 'AUTO_CONVERT'
799
+ });
800
+ view.dispatch(macroTr);
801
+ return true;
802
+ }
803
+ export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter, editorAnalyticsAPI) {
639
804
  return (state, dispatch, view) => {
640
805
  let macro = null;
641
806
 
@@ -663,11 +828,42 @@ export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, run
663
828
  if (!view) {
664
829
  throw new Error('View is missing');
665
830
  }
831
+ const autoConvertMacro = macro;
666
832
 
667
833
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
668
834
  const trackingId = uuid();
669
835
  const trackingFrom = `handleMacroAutoConvert-from-${trackingId}`;
670
836
  const trackingTo = `handleMacroAutoConvert-to-${trackingId}`;
837
+ if (isExperimentEnabled('platform_editor_paste_autoconvert_robustness')) {
838
+ // Insert the pasted content straight away. The smart link provider is only
839
+ // consulted to decide whether the inserted link should later be swapped for
840
+ // a macro, so it does not need to gate the insert — otherwise every
841
+ // autoconvertible paste appears to do nothing until a network round trip
842
+ // completes.
843
+ let insertedRange;
844
+ handleMarkdownWithInsertedRange(slice, queueCardsFromChangedTr, range => {
845
+ insertedRange = range;
846
+ })(state, dispatch);
847
+ if (!insertedRange) {
848
+ return true;
849
+ }
850
+ startTrackingPastedMacroPositions({
851
+ [trackingFrom]: insertedRange.from,
852
+ [trackingTo]: insertedRange.to
853
+ })(view.state, dispatch);
854
+ getSmartLinkAdf(text, 'inline', cardsOptions).catch(() => {
855
+ // Smart links could not resolve the URL, so fall back to the macro.
856
+ // we use view.state rather than state because state becomes a stale
857
+ // state reference after getSmartLinkAdf's async work
858
+ const {
859
+ pastedMacroPositions
860
+ } = getPastePluginState(view.state);
861
+ replacePastedLinkWithMacro(view, autoConvertMacro, text, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo], editorAnalyticsAPI);
862
+ }).finally(() => {
863
+ stopTrackingPastedMacroPositions([trackingFrom, trackingTo])(view.state, dispatch);
864
+ });
865
+ return true;
866
+ }
671
867
  startTrackingPastedMacroPositions({
672
868
  [trackingFrom]: state.selection.from,
673
869
  [trackingTo]: state.selection.to
@@ -984,6 +1180,36 @@ function resolveParagraphMarks(marks, destinationBlockMarkAttrs, shouldNormalize
984
1180
  }
985
1181
  return marks;
986
1182
  }
1183
+
1184
+ /**
1185
+ * Variant of `handleMarkdown` used by macro auto-conversion behind
1186
+ * `platform_editor_paste_autoconvert_robustness`.
1187
+ *
1188
+ * Differs in two ways: the inserted range is derived from the steps that were applied
1189
+ * rather than from `markdownSlice.size`, which does not account for ProseMirror's fitting
1190
+ * behaviour; and that range is reported back through `onInsert` so the caller can track
1191
+ * the inserted link across the async gap before deciding whether to swap it for a macro.
1192
+ */
1193
+ function handleMarkdownWithInsertedRange(markdownSlice, queueCardsFromChangedTr, onInsert) {
1194
+ return (state, dispatch) => {
1195
+ const tr = closeHistory(state.tr);
1196
+ const replaceFrom = tr.selection.from;
1197
+ const replaceTo = tr.selection.to;
1198
+ tr.replaceSelection(markdownSlice);
1199
+ const insertedFrom = tr.mapping.map(replaceFrom, -1);
1200
+ const insertedTo = tr.mapping.map(replaceTo, 1);
1201
+ tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(insertedTo, tr.doc.content.size)), -1));
1202
+ queueCardsFromChangedTr === null || queueCardsFromChangedTr === void 0 ? void 0 : queueCardsFromChangedTr(state, tr, INPUT_METHOD.CLIPBOARD);
1203
+ if (dispatch) {
1204
+ dispatch(tr.scrollIntoView());
1205
+ onInsert({
1206
+ from: insertedFrom,
1207
+ to: insertedTo
1208
+ });
1209
+ }
1210
+ return true;
1211
+ };
1212
+ }
987
1213
  export function handleParagraphBlockMarks(state, slice) {
988
1214
  var _findParentNodeOfType2;
989
1215
  if (slice.content.size === 0) {
@@ -409,7 +409,7 @@ export function createPlugin(schema, dispatchAnalyticsEvent, dispatch, featureFl
409
409
  }
410
410
 
411
411
  // run macro autoconvert prior to other conversions
412
- if (handleMacroAutoConvert(text, markdownSlice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c = pluginInjectionApi.card) === null || _pluginInjectionApi$c === void 0 || (_pluginInjectionApi$c = _pluginInjectionApi$c.actions) === null || _pluginInjectionApi$c === void 0 ? void 0 : _pluginInjectionApi$c.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e = pluginInjectionApi.extension) === null || _pluginInjectionApi$e === void 0 || (_pluginInjectionApi$e = _pluginInjectionApi$e.actions) === null || _pluginInjectionApi$e === void 0 ? void 0 : _pluginInjectionApi$e.runMacroAutoConvert, cardOptions, extensionAutoConverter)(state, dispatch, view)) {
412
+ if (handleMacroAutoConvert(text, markdownSlice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c = pluginInjectionApi.card) === null || _pluginInjectionApi$c === void 0 || (_pluginInjectionApi$c = _pluginInjectionApi$c.actions) === null || _pluginInjectionApi$c === void 0 ? void 0 : _pluginInjectionApi$c.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e = pluginInjectionApi.extension) === null || _pluginInjectionApi$e === void 0 || (_pluginInjectionApi$e = _pluginInjectionApi$e.actions) === null || _pluginInjectionApi$e === void 0 ? void 0 : _pluginInjectionApi$e.runMacroAutoConvert, cardOptions, extensionAutoConverter, editorAnalyticsAPI)(state, dispatch, view)) {
413
413
  // TODO: ED-26959 - handleMacroAutoConvert dispatch twice, so we can't use the helper
414
414
  sendPasteAnalyticsEvent(editorAnalyticsAPI)(view, event, markdownSlice, {
415
415
  type: PasteTypes.markdown
@@ -507,7 +507,7 @@ export function createPlugin(schema, dispatchAnalyticsEvent, dispatch, featureFl
507
507
  }
508
508
 
509
509
  // run macro autoconvert prior to other conversions
510
- if (handleMacroAutoConvert(text, slice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c2 = pluginInjectionApi.card) === null || _pluginInjectionApi$c2 === void 0 || (_pluginInjectionApi$c2 = _pluginInjectionApi$c2.actions) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e2 = pluginInjectionApi.extension) === null || _pluginInjectionApi$e2 === void 0 || (_pluginInjectionApi$e2 = _pluginInjectionApi$e2.actions) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.runMacroAutoConvert, cardOptions, extensionAutoConverter)(state, dispatch, view)) {
510
+ if (handleMacroAutoConvert(text, slice, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$c2 = pluginInjectionApi.card) === null || _pluginInjectionApi$c2 === void 0 || (_pluginInjectionApi$c2 = _pluginInjectionApi$c2.actions) === null || _pluginInjectionApi$c2 === void 0 ? void 0 : _pluginInjectionApi$c2.queueCardsFromChangedTr, pluginInjectionApi === null || pluginInjectionApi === void 0 || (_pluginInjectionApi$e2 = pluginInjectionApi.extension) === null || _pluginInjectionApi$e2 === void 0 || (_pluginInjectionApi$e2 = _pluginInjectionApi$e2.actions) === null || _pluginInjectionApi$e2 === void 0 ? void 0 : _pluginInjectionApi$e2.runMacroAutoConvert, cardOptions, extensionAutoConverter, editorAnalyticsAPI)(state, dispatch, view)) {
511
511
  // TODO: ED-26959 - handleMacroAutoConvert dispatch twice, so we can't use the helper
512
512
  sendPasteAnalyticsEvent(editorAnalyticsAPI)(view, event, slice, {
513
513
  type: PasteTypes.richText
@@ -10,7 +10,7 @@ function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t =
10
10
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
11
11
  import { v4 as uuid } from 'uuid';
12
12
  import { transformContainerNodes } from '@atlaskit/adf-utils/transforms';
13
- import { INPUT_METHOD } from '@atlaskit/editor-common/analytics';
13
+ import { ACTION, ACTION_SUBJECT, EVENT_TYPE, INPUT_METHOD } from '@atlaskit/editor-common/analytics';
14
14
  import { addLinkMetadata } from '@atlaskit/editor-common/card';
15
15
  import { insideTable } from '@atlaskit/editor-common/core-utils';
16
16
  import { getBlockMarkAttrs, getFirstParagraphBlockMarkAttrs, reconcileBlockMarkForContainerAtPos, reconcileBlockMarkForParagraphAtPos, reconcileBlockMarkInRange } from '@atlaskit/editor-common/lists';
@@ -613,7 +613,7 @@ function _getSmartLinkAdf() {
613
613
  }));
614
614
  return _getSmartLinkAdf.apply(this, arguments);
615
615
  }
616
- function insertAutoMacro(slice, macro, view, from, to) {
616
+ function insertAutoMacroOld(slice, macro, view, from, to) {
617
617
  if (view) {
618
618
  // insert the text or linkified/md-converted clipboard data
619
619
  var selection = view.state.tr.selection;
@@ -640,7 +640,168 @@ function insertAutoMacro(slice, macro, view, from, to) {
640
640
  }
641
641
  return false;
642
642
  }
643
- export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter) {
643
+
644
+ /**
645
+ * Replacement for `insertAutoMacroOld` behind
646
+ * `platform_editor_paste_autoconvert_robustness`.
647
+ *
648
+ * Identical in shape, except that the range the macro replaces is derived from the steps
649
+ * that were actually applied rather than from `slice.size`. `slice.size` is the slice's
650
+ * own measure and does not account for ProseMirror's fitting behaviour in
651
+ * `replaceRange`/`replaceSelection`, so the two can disagree — and when they do the macro
652
+ * is inserted without removing the text it should replace, leaving both the pasted text
653
+ * and the macro in the document.
654
+ */
655
+ function insertAutoMacroNew(slice, macro, view, from, to) {
656
+ if (!view) {
657
+ return false;
658
+ }
659
+ var selection = view.state.selection;
660
+ var hasExplicitRange = typeof from === 'number' && typeof to === 'number';
661
+ var replaceFrom = hasExplicitRange ? from : selection.from;
662
+ var replaceTo = hasExplicitRange ? to : selection.to;
663
+
664
+ // insert the text or linkified/md-converted clipboard data
665
+ var tr = hasExplicitRange ? view.state.tr.replaceRange(replaceFrom, replaceTo, slice) : view.state.tr.replaceSelection(slice);
666
+ var insertedFrom = tr.mapping.map(replaceFrom, -1);
667
+ var insertedTo = tr.mapping.map(replaceTo, 1);
668
+ view.dispatch(tr);
669
+
670
+ // Leave the document alone rather than replacing an implausible range.
671
+ if (insertedFrom >= insertedTo || insertedTo > view.state.doc.content.size) {
672
+ return true;
673
+ }
674
+
675
+ // replace the text with the macro as a separate transaction
676
+ // so the autoconversion generates 2 undo steps
677
+ var macroTr = closeHistory(view.state.tr).replaceRangeWith(insertedFrom, insertedTo, macro).scrollIntoView();
678
+ addLinkMetadata(view.state.selection, macroTr, {
679
+ inputMethod: INPUT_METHOD.CLIPBOARD,
680
+ cardAction: 'AUTO_CONVERT'
681
+ });
682
+ view.dispatch(macroTr);
683
+ return true;
684
+ }
685
+ function insertAutoMacro(slice, macro, view, from, to) {
686
+ return isExperimentEnabled('platform_editor_paste_autoconvert_robustness') ? insertAutoMacroNew(slice, macro, view, from, to) : insertAutoMacroOld(slice, macro, view, from, to);
687
+ }
688
+
689
+ /**
690
+ * Locates the range currently occupied by a link that we pasted earlier.
691
+ *
692
+ * Positions recorded before an async gap can drift or be invalidated entirely by
693
+ * concurrent edits, collaborative changes and `appendTransaction` hooks, so the
694
+ * recorded range is verified against the document before it is used. If it no longer
695
+ * holds the pasted link the link is re-located by href, preferring the occurrence
696
+ * closest to where it was last seen.
697
+ *
698
+ * Returns `undefined` when the link cannot be found, so callers can leave the
699
+ * document alone rather than replacing whatever happens to sit at a stale position.
700
+ */
701
+ /**
702
+ * Whether every text node spanning [from, to) carries a `link` mark pointing at `url`,
703
+ * with no gaps or uncovered portions.
704
+ */
705
+ function rangeIsExactLinkMark(doc, from, to, url, linkMarkType) {
706
+ var matches = true;
707
+ var coveredTo = from;
708
+ doc.nodesBetween(from, to, function (node, pos) {
709
+ if (!matches || pos >= to) {
710
+ return false;
711
+ }
712
+ if (!node.isLeaf) {
713
+ // A container node — e.g. the paragraph the link sits in. `nodesBetween` visits
714
+ // these on the way down to the text node(s) we actually care about; keep
715
+ // descending into it rather than treating it as a mismatch.
716
+ return true;
717
+ }
718
+ if (!node.isText) {
719
+ // A non-text leaf (e.g. an inline card) cannot carry the link mark.
720
+ matches = false;
721
+ return false;
722
+ }
723
+ var linkMark = linkMarkType.isInSet(node.marks);
724
+ if (!linkMark || linkMark.attrs.href !== url) {
725
+ matches = false;
726
+ return false;
727
+ }
728
+ coveredTo = Math.min(pos + node.nodeSize, to);
729
+ return true;
730
+ });
731
+ return matches && coveredTo === to;
732
+ }
733
+
734
+ /**
735
+ * `from`/`to` are not a one-off snapshot: they come from `pastedMacroPositions`, which the
736
+ * paste plugin's `mapping` hook (`plugin-factory.ts`) maps through every transaction
737
+ * applied while the smart link request is in flight, via `tr.mapping.map(position)`. So
738
+ * this covers every edit ProseMirror can express as a position mapping — typing,
739
+ * deleting, or moving content elsewhere in the same edit all keep `from`/`to` pointing at
740
+ * the same logical span.
741
+ *
742
+ * It stops covering that span only when the span itself no longer represents our pasted
743
+ * link — the link was unlinked, or its content was replaced outright. Deliberately no
744
+ * fallback search for "the nearest link with the same href" is done: with multiple links
745
+ * to the same URL in the document, a proximity search cannot tell which one we pasted, so
746
+ * it risks silently replacing an occurrence we were never asked to touch, and it requires
747
+ * an O(doc size) scan on every fallback to do it. Leaving the tracked link as a link is
748
+ * the safe outcome here, and it's what happens when this function returns `undefined`.
749
+ */
750
+ function findPastedLinkRange(state, url, from, to) {
751
+ var doc = state.doc,
752
+ schema = state.schema;
753
+ var linkMarkType = schema.marks.link;
754
+ var docSize = doc.content.size;
755
+ if (!linkMarkType || typeof from !== 'number' || typeof to !== 'number' || from < 0 || from >= to || to > docSize) {
756
+ return undefined;
757
+ }
758
+
759
+ // Text equality alone is not enough: if the link was unlinked (mark removed) while
760
+ // the smart link request was in flight, the text can still read as the URL even
761
+ // though it is no longer a link, and replacing it would destroy content that is no
762
+ // longer ours to replace.
763
+ if (doc.textBetween(from, to) === url && rangeIsExactLinkMark(doc, from, to, url, linkMarkType)) {
764
+ return {
765
+ from: from,
766
+ to: to
767
+ };
768
+ }
769
+ return undefined;
770
+ }
771
+
772
+ /**
773
+ * Swaps a link that was already pasted into the document for its macro equivalent.
774
+ *
775
+ * Used when smart links could not resolve the URL, so the macro autoconversion is the
776
+ * fallback. Does nothing if the link can no longer be found — for example because a
777
+ * smart card has already replaced it — which is preferable to replacing unrelated
778
+ * content at a stale position.
779
+ */
780
+ function replacePastedLinkWithMacro(view, macro, url, from, to, editorAnalyticsAPI) {
781
+ var range = findPastedLinkRange(view.state, url, from, to);
782
+ if (!range) {
783
+ // Metric for how often the tracked pasted link could no longer be found once the
784
+ // smart link request settled, so the assumption that this is rare is measurable
785
+ // rather than assumed.
786
+ editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 || editorAnalyticsAPI.fireAnalyticsEvent({
787
+ action: ACTION.ERRORED,
788
+ actionSubject: ACTION_SUBJECT.SMART_LINK,
789
+ eventType: EVENT_TYPE.OPERATIONAL,
790
+ attributes: {
791
+ error: 'macro-auto-convert-pasted-link-not-found'
792
+ }
793
+ });
794
+ return false;
795
+ }
796
+ var macroTr = closeHistory(view.state.tr).replaceRangeWith(range.from, range.to, macro).scrollIntoView();
797
+ addLinkMetadata(view.state.selection, macroTr, {
798
+ inputMethod: INPUT_METHOD.CLIPBOARD,
799
+ cardAction: 'AUTO_CONVERT'
800
+ });
801
+ view.dispatch(macroTr);
802
+ return true;
803
+ }
804
+ export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, runMacroAutoConvert, cardsOptions, extensionAutoConverter, editorAnalyticsAPI) {
644
805
  return function (state, dispatch, view) {
645
806
  var macro = null;
646
807
 
@@ -668,23 +829,50 @@ export function handleMacroAutoConvert(text, slice, queueCardsFromChangedTr, run
668
829
  if (!view) {
669
830
  throw new Error('View is missing');
670
831
  }
832
+ var autoConvertMacro = macro;
671
833
 
672
834
  // eslint-disable-next-line @atlaskit/platform/prefer-crypto-random-uuid -- Use crypto.randomUUID instead
673
835
  var trackingId = uuid();
674
836
  var trackingFrom = "handleMacroAutoConvert-from-".concat(trackingId);
675
837
  var trackingTo = "handleMacroAutoConvert-to-".concat(trackingId);
838
+ if (isExperimentEnabled('platform_editor_paste_autoconvert_robustness')) {
839
+ // Insert the pasted content straight away. The smart link provider is only
840
+ // consulted to decide whether the inserted link should later be swapped for
841
+ // a macro, so it does not need to gate the insert — otherwise every
842
+ // autoconvertible paste appears to do nothing until a network round trip
843
+ // completes.
844
+ var insertedRange;
845
+ handleMarkdownWithInsertedRange(slice, queueCardsFromChangedTr, function (range) {
846
+ insertedRange = range;
847
+ })(state, dispatch);
848
+ if (!insertedRange) {
849
+ return true;
850
+ }
851
+ startTrackingPastedMacroPositions(_defineProperty(_defineProperty({}, trackingFrom, insertedRange.from), trackingTo, insertedRange.to))(view.state, dispatch);
852
+ getSmartLinkAdf(text, 'inline', cardsOptions).catch(function () {
853
+ // Smart links could not resolve the URL, so fall back to the macro.
854
+ // we use view.state rather than state because state becomes a stale
855
+ // state reference after getSmartLinkAdf's async work
856
+ var _getPastePluginState = getPastePluginState(view.state),
857
+ pastedMacroPositions = _getPastePluginState.pastedMacroPositions;
858
+ replacePastedLinkWithMacro(view, autoConvertMacro, text, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo], editorAnalyticsAPI);
859
+ }).finally(function () {
860
+ stopTrackingPastedMacroPositions([trackingFrom, trackingTo])(view.state, dispatch);
861
+ });
862
+ return true;
863
+ }
676
864
  startTrackingPastedMacroPositions(_defineProperty(_defineProperty({}, trackingFrom, state.selection.from), trackingTo, state.selection.to))(state, dispatch);
677
865
  getSmartLinkAdf(text, 'inline', cardsOptions).then(function () {
678
866
  // we use view.state rather than state because state becomes a stale
679
867
  // state reference after getSmartLinkAdf's async work
680
- var _getPastePluginState = getPastePluginState(view.state),
681
- pastedMacroPositions = _getPastePluginState.pastedMacroPositions;
868
+ var _getPastePluginState2 = getPastePluginState(view.state),
869
+ pastedMacroPositions = _getPastePluginState2.pastedMacroPositions;
682
870
  if (dispatch) {
683
871
  handleMarkdown(slice, queueCardsFromChangedTr, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo])(view.state, dispatch);
684
872
  }
685
873
  }).catch(function () {
686
- var _getPastePluginState2 = getPastePluginState(view.state),
687
- pastedMacroPositions = _getPastePluginState2.pastedMacroPositions;
874
+ var _getPastePluginState3 = getPastePluginState(view.state),
875
+ pastedMacroPositions = _getPastePluginState3.pastedMacroPositions;
688
876
  insertAutoMacro(slice, macro, view, pastedMacroPositions[trackingFrom], pastedMacroPositions[trackingTo]);
689
877
  }).finally(function () {
690
878
  stopTrackingPastedMacroPositions([trackingFrom, trackingTo])(view.state, dispatch);
@@ -978,6 +1166,36 @@ function resolveParagraphMarks(marks, destinationBlockMarkAttrs, shouldNormalize
978
1166
  }
979
1167
  return marks;
980
1168
  }
1169
+
1170
+ /**
1171
+ * Variant of `handleMarkdown` used by macro auto-conversion behind
1172
+ * `platform_editor_paste_autoconvert_robustness`.
1173
+ *
1174
+ * Differs in two ways: the inserted range is derived from the steps that were applied
1175
+ * rather than from `markdownSlice.size`, which does not account for ProseMirror's fitting
1176
+ * behaviour; and that range is reported back through `onInsert` so the caller can track
1177
+ * the inserted link across the async gap before deciding whether to swap it for a macro.
1178
+ */
1179
+ function handleMarkdownWithInsertedRange(markdownSlice, queueCardsFromChangedTr, onInsert) {
1180
+ return function (state, dispatch) {
1181
+ var tr = closeHistory(state.tr);
1182
+ var replaceFrom = tr.selection.from;
1183
+ var replaceTo = tr.selection.to;
1184
+ tr.replaceSelection(markdownSlice);
1185
+ var insertedFrom = tr.mapping.map(replaceFrom, -1);
1186
+ var insertedTo = tr.mapping.map(replaceTo, 1);
1187
+ tr.setSelection(TextSelection.near(tr.doc.resolve(Math.min(insertedTo, tr.doc.content.size)), -1));
1188
+ queueCardsFromChangedTr === null || queueCardsFromChangedTr === void 0 || queueCardsFromChangedTr(state, tr, INPUT_METHOD.CLIPBOARD);
1189
+ if (dispatch) {
1190
+ dispatch(tr.scrollIntoView());
1191
+ onInsert({
1192
+ from: insertedFrom,
1193
+ to: insertedTo
1194
+ });
1195
+ }
1196
+ return true;
1197
+ };
1198
+ }
981
1199
  export function handleParagraphBlockMarks(state, slice) {
982
1200
  var _findParentNodeOfType2;
983
1201
  if (slice.content.size === 0) {
@@ -16,7 +16,7 @@ export declare function handlePastePanelOrDecisionContentIntoList(slice: Slice,
16
16
  export declare function handlePasteLinkOnSelectedText(slice: Slice): Command;
17
17
  export declare function handlePasteAsPlainText(slice: Slice, _event: ClipboardEvent, editorAnalyticsAPI: EditorAnalyticsAPI | undefined): Command;
18
18
  export declare function handlePastePreservingMarks(slice: Slice, queueCardsFromChangedTr: QueueCardsFromTransactionAction | undefined): Command;
19
- export declare function handleMacroAutoConvert(text: string, slice: Slice, queueCardsFromChangedTr: QueueCardsFromTransactionAction | undefined, runMacroAutoConvert: RunMacroAutoConvert | undefined, cardsOptions?: CardOptions, extensionAutoConverter?: ExtensionAutoConvertHandler): Command;
19
+ export declare function handleMacroAutoConvert(text: string, slice: Slice, queueCardsFromChangedTr: QueueCardsFromTransactionAction | undefined, runMacroAutoConvert: RunMacroAutoConvert | undefined, cardsOptions?: CardOptions, extensionAutoConverter?: ExtensionAutoConvertHandler, editorAnalyticsAPI?: EditorAnalyticsAPI): Command;
20
20
  export declare function handleCodeBlock(text: string): Command;
21
21
  export declare function handleMediaSingle(inputMethod: InputMethodInsertMedia, insertMediaAsMediaSingle: InsertMediaAsMediaSingle | undefined): (slice: Slice) => Command;
22
22
  export declare function handleTableContentPasteInBodiedExtension(slice: Slice): Command;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-paste",
3
- "version": "18.0.4",
3
+ "version": "18.0.5",
4
4
  "description": "Paste plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -82,5 +82,11 @@
82
82
  "platform_editor_flexible_list_kill_switch_1": {
83
83
  "type": "boolean"
84
84
  }
85
+ },
86
+ "platform-experiments": {
87
+ "platform_editor_paste_autoconvert_robustness": {
88
+ "param": "isEnabled",
89
+ "defaultValue": false
90
+ }
85
91
  }
86
92
  }