@atlaskit/editor-plugin-show-diff 14.2.5 → 14.2.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @atlaskit/editor-plugin-show-diff
2
2
 
3
+ ## 14.2.7
4
+
5
+ ### Patch Changes
6
+
7
+ - [`68281638de8fc`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/68281638de8fc) -
8
+ Group overlapping diff decorations into a single customer-facing edit and highlight the full edit
9
+ when navigating changes, gated by `confluence_ncs_step_diffing_version_history`.
10
+
11
+ ## 14.2.6
12
+
13
+ ### Patch Changes
14
+
15
+ - Updated dependencies
16
+
3
17
  ## 14.2.5
4
18
 
5
19
  ### Patch Changes
@@ -131,6 +131,20 @@ var leafTextblockRanges = function leafTextblockRanges(doc, from, to) {
131
131
  // once it has more leaf textblocks (~cells) than this threshold. Small tables
132
132
  // keep the precise per-cell path.
133
133
  var LARGE_TABLE_LEAF_THRESHOLD = 40;
134
+
135
+ /**
136
+ * An active scrollable change may contain several underlying diff changes. Treat ranges that
137
+ * overlap or touch the active range as part of the same active customer-facing edit.
138
+ */
139
+ var isRangeActive = function isRangeActive(activeIndexPos, from, to) {
140
+ if (activeIndexPos === undefined) {
141
+ return undefined;
142
+ }
143
+ if (!(0, _fg.fg)('confluence_ncs_step_diffing_version_history')) {
144
+ return from === activeIndexPos.from && to === activeIndexPos.to;
145
+ }
146
+ return from <= activeIndexPos.to && activeIndexPos.from <= to;
147
+ };
134
148
  var isLargeInsertedTableRange = function isLargeInsertedTableRange(doc, from, to) {
135
149
  var containsTable = false;
136
150
  var leafCount = 0;
@@ -198,7 +212,7 @@ var calculateNodesForBlockDecoration = function calculateNodesForBlockDecoration
198
212
  }
199
213
  if (node.isBlock && (!(0, _isExtendedEnabled.isExtendedEnabled)(diffType) || pos + node.nodeSize <= to)) {
200
214
  var nodeEnd = pos + node.nodeSize;
201
- var isActive = activeIndexPos && pos === activeIndexPos.from && nodeEnd === activeIndexPos.to;
215
+ var isActive = isRangeActive(activeIndexPos, pos, nodeEnd);
202
216
  decorations.push.apply(decorations, (0, _toConsumableArray2.default)((0, _createBlockChangedDecoration.createBlockChangedDecoration)({
203
217
  change: {
204
218
  from: pos,
@@ -431,7 +445,7 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
431
445
  var isInserted = !isInverted;
432
446
  var createDecorationsForChange = function createDecorationsForChange(change) {
433
447
  var changeColorScheme = attributionColors ? (0, _attributions.getColorSchemeForChange)(change, attributedChanges, attributionColors, colorScheme) : colorScheme;
434
- var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
448
+ var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
435
449
 
436
450
  // Hoisted because it decides BOTH where the deleted widget is anchored and — since the
437
451
  // widget pins whichever end of the range it sits at — how the indicator anchors below are
@@ -559,11 +573,22 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
559
573
  changes.forEach(function (change) {
560
574
  createDecorationsForChange(change);
561
575
  });
562
- (0, _getMarkChangeRanges.getMarkChangeRanges)(steps).forEach(function (change) {
563
- var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
576
+ // The attribute-aware token encoder already reports mark-only changes as regular changes in the
577
+ // smart path. Only add the legacy step-range decoration when no equivalent changeset range was
578
+ // produced; otherwise the same content receives two inline decorations (and two navigation
579
+ // changes), usually one attribution colour and one default purple decoration.
580
+ (0, _getMarkChangeRanges.getMarkChangeRanges)(steps).filter(function (_ref10) {
581
+ var fromB = _ref10.fromB,
582
+ toB = _ref10.toB;
583
+ return !(0, _fg.fg)('confluence_ncs_step_diffing_version_history') || !changes.some(function (change) {
584
+ return change.fromB === fromB && change.toB === toB;
585
+ });
586
+ }).forEach(function (change) {
587
+ var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
564
588
  decorations.push.apply(decorations, (0, _toConsumableArray2.default)((0, _createInlineChangedDecoration.createInlineChangedDecoration)({
565
589
  change: change,
566
590
  colorScheme: colorScheme,
591
+ diffType: diffType,
567
592
  isActive: isActive,
568
593
  isInserted: true
569
594
  })));
@@ -571,7 +596,7 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
571
596
  (0, _getAttrChangeRanges.getAttrChangeRanges)(tr.doc, attrStepContexts, originalDoc).forEach(function (change) {
572
597
  if (change.isInline) {
573
598
  // Inline nodes (e.g. date, emoji, mention, status) need an inline decoration rather than a block decoration
574
- var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
599
+ var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
575
600
  var isAtomicInlineNodeFlag = (0, _deprecatedPlatformFeatureExperiments.isExperimentEnabled)('platform_editor_improve_inline_diffs', function () {
576
601
  return (0, _expValEquals.expValEquals)('platform_editor_improve_inline_diffs', 'isEnabled', true);
577
602
  });
@@ -658,42 +683,42 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
658
683
  };
659
684
  var calculateDiffDecorations = exports.calculateDiffDecorations = (0, _memoizeOne.default)(calculateDiffDecorationsInner,
660
685
  // Cache results unless relevant inputs change
661
- function (_ref10, _ref11) {
662
- var _ref15;
663
- var _ref12 = (0, _slicedToArray2.default)(_ref10, 1),
664
- _ref12$ = _ref12[0],
665
- pluginState = _ref12$.pluginState,
666
- state = _ref12$.state,
667
- colorScheme = _ref12$.colorScheme,
668
- intl = _ref12$.intl,
669
- activeIndexPos = _ref12$.activeIndexPos,
670
- isInverted = _ref12$.isInverted,
671
- diffType = _ref12$.diffType,
672
- hideDeletedDiffs = _ref12$.hideDeletedDiffs,
673
- hideAddedDiffsUnderline = _ref12$.hideAddedDiffsUnderline,
674
- showIndicators = _ref12$.showIndicators,
675
- smartThresholds = _ref12$.smartThresholds,
676
- deletedDiffPlacement = _ref12$.deletedDiffPlacement,
677
- inlineDeletedDiffPlacement = _ref12$.inlineDeletedDiffPlacement;
686
+ function (_ref11, _ref12) {
687
+ var _ref16;
678
688
  var _ref13 = (0, _slicedToArray2.default)(_ref11, 1),
679
689
  _ref13$ = _ref13[0],
680
- lastPluginState = _ref13$.pluginState,
681
- lastState = _ref13$.state,
682
- lastColorScheme = _ref13$.colorScheme,
683
- lastIntl = _ref13$.intl,
684
- lastActiveIndexPos = _ref13$.activeIndexPos,
685
- lastIsInverted = _ref13$.isInverted,
686
- lastDiffType = _ref13$.diffType,
687
- lastHideDeletedDiffs = _ref13$.hideDeletedDiffs,
688
- lastHideAddedDiffsUnderline = _ref13$.hideAddedDiffsUnderline,
689
- lastShowIndicators = _ref13$.showIndicators,
690
- lastSmartThresholds = _ref13$.smartThresholds,
691
- lastDeletedDiffPlacement = _ref13$.deletedDiffPlacement,
692
- lastInlineDeletedDiffPlacement = _ref13$.inlineDeletedDiffPlacement;
690
+ pluginState = _ref13$.pluginState,
691
+ state = _ref13$.state,
692
+ colorScheme = _ref13$.colorScheme,
693
+ intl = _ref13$.intl,
694
+ activeIndexPos = _ref13$.activeIndexPos,
695
+ isInverted = _ref13$.isInverted,
696
+ diffType = _ref13$.diffType,
697
+ hideDeletedDiffs = _ref13$.hideDeletedDiffs,
698
+ hideAddedDiffsUnderline = _ref13$.hideAddedDiffsUnderline,
699
+ showIndicators = _ref13$.showIndicators,
700
+ smartThresholds = _ref13$.smartThresholds,
701
+ deletedDiffPlacement = _ref13$.deletedDiffPlacement,
702
+ inlineDeletedDiffPlacement = _ref13$.inlineDeletedDiffPlacement;
703
+ var _ref14 = (0, _slicedToArray2.default)(_ref12, 1),
704
+ _ref14$ = _ref14[0],
705
+ lastPluginState = _ref14$.pluginState,
706
+ lastState = _ref14$.state,
707
+ lastColorScheme = _ref14$.colorScheme,
708
+ lastIntl = _ref14$.intl,
709
+ lastActiveIndexPos = _ref14$.activeIndexPos,
710
+ lastIsInverted = _ref14$.isInverted,
711
+ lastDiffType = _ref14$.diffType,
712
+ lastHideDeletedDiffs = _ref14$.hideDeletedDiffs,
713
+ lastHideAddedDiffsUnderline = _ref14$.hideAddedDiffsUnderline,
714
+ lastShowIndicators = _ref14$.showIndicators,
715
+ lastSmartThresholds = _ref14$.smartThresholds,
716
+ lastDeletedDiffPlacement = _ref14$.deletedDiffPlacement,
717
+ lastInlineDeletedDiffPlacement = _ref14$.inlineDeletedDiffPlacement;
693
718
  var originalDocIsSame = lastPluginState.originalDoc && pluginState.originalDoc && pluginState.originalDoc.eq(lastPluginState.originalDoc);
694
719
  if ((0, _isExtendedEnabled.isExtendedEnabled)(diffType)) {
695
- var _ref14;
696
- return (_ref14 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && (0, _isEqual.default)(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && (0, _isEqual.default)(pluginState.steps, lastPluginState.steps) && (0, _isEqual.default)(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && hideAddedDiffsUnderline === lastHideAddedDiffsUnderline && showIndicators === lastShowIndicators && (0, _isEqual.default)(smartThresholds, lastSmartThresholds) && deletedDiffPlacement === lastDeletedDiffPlacement && inlineDeletedDiffPlacement === lastInlineDeletedDiffPlacement) !== null && _ref14 !== void 0 ? _ref14 : false;
720
+ var _ref15;
721
+ return (_ref15 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && (0, _isEqual.default)(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && (0, _isEqual.default)(pluginState.steps, lastPluginState.steps) && (0, _isEqual.default)(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && hideAddedDiffsUnderline === lastHideAddedDiffsUnderline && showIndicators === lastShowIndicators && (0, _isEqual.default)(smartThresholds, lastSmartThresholds) && deletedDiffPlacement === lastDeletedDiffPlacement && inlineDeletedDiffPlacement === lastInlineDeletedDiffPlacement) !== null && _ref15 !== void 0 ? _ref15 : false;
697
722
  }
698
- return (_ref15 = originalDocIsSame && (0, _isEqual.default)(pluginState.steps, lastPluginState.steps) && (0, _isEqual.default)(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && (0, _isEqual.default)(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref15 !== void 0 ? _ref15 : false;
723
+ return (_ref16 = originalDocIsSame && (0, _isEqual.default)(pluginState.steps, lastPluginState.steps) && (0, _isEqual.default)(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && (0, _isEqual.default)(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref16 !== void 0 ? _ref16 : false;
699
724
  });
@@ -122,7 +122,7 @@ var createNodeChangedDecorationWidget = exports.createNodeChangedDecorationWidge
122
122
  // node renders beneath its replacement; otherwise anchor at the start (change.fromB).
123
123
  var anchorPos = placeBelow ? change.toB : change.fromB;
124
124
  var safeInsertPos = (0, _findSafeInsertPos.findSafeInsertPos)(newDoc, anchorPos, slice);
125
- var isActive = activeIndexPos && safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to;
125
+ var isActive = activeIndexPos && ((0, _fg.fg)('confluence_ncs_step_diffing_version_history') ? safeInsertPos >= activeIndexPos.from && safeInsertPos <= activeIndexPos.to : safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to);
126
126
  if (slice.content.content.length === 0 || shouldSkipDeletedEmptyParagraphDecoration) {
127
127
  return [];
128
128
  }
@@ -8,6 +8,8 @@ exports.getScrollableDecorations = void 0;
8
8
  exports.isInlineDiffDecorationRenderableInDoc = isInlineDiffDecorationRenderableInDoc;
9
9
  var _toConsumableArray2 = _interopRequireDefault(require("@babel/runtime/helpers/toConsumableArray"));
10
10
  var _typeof2 = _interopRequireDefault(require("@babel/runtime/helpers/typeof"));
11
+ var _view = require("@atlaskit/editor-prosemirror/view");
12
+ var _fg = require("@atlaskit/platform-feature-flags/fg");
11
13
  var _decorationKeys = require("./decorations/decorationKeys");
12
14
  var _isExtendedEnabled = require("./isExtendedEnabled");
13
15
  /**
@@ -40,10 +42,6 @@ function isInlineDiffDecorationRenderableInDoc(doc, from, to) {
40
42
  return false;
41
43
  }
42
44
  }
43
-
44
- /**
45
- * Checks if range1 is fully contained within range2
46
- */
47
45
  function isRangeFullyInside(range1Start, range1End, range2Start, range2End) {
48
46
  return range2Start <= range1Start && range1End <= range2End;
49
47
  }
@@ -51,6 +49,61 @@ function specHasDiffKeyPrefix(spec, keyPrefix) {
51
49
  return Boolean(spec && (0, _typeof2.default)(spec) === 'object' && 'key' in spec && typeof spec.key === 'string' && spec.key.startsWith(keyPrefix));
52
50
  }
53
51
 
52
+ /**
53
+ * Collapses decorations that represent one continuous customer-facing edit.
54
+ *
55
+ * Decorations are kept separate in the DecorationSet so each one can retain its own visual
56
+ * styling. This list is only used for change counting and navigation, where an insertion and a
57
+ * deletion at the same location should be treated as one replacement. Zero-width decorations
58
+ * (normally deleted-content widgets) can join a group, but cannot extend its range and therefore
59
+ * cannot bridge two otherwise separate edits.
60
+ */
61
+ function groupTouchingDecorations(decorations, isInlineDecoration) {
62
+ if (decorations.length < 2) {
63
+ return decorations;
64
+ }
65
+ var groups = [];
66
+ var currentGroup = [];
67
+ var currentGroupFrom = 0;
68
+ var currentGroupTo = 0;
69
+ var sortedDecorations = (0, _toConsumableArray2.default)(decorations).sort(function (a, b) {
70
+ return a.from === b.from ? a.to - b.to : a.from - b.from;
71
+ });
72
+ sortedDecorations.forEach(function (decoration) {
73
+ if (currentGroup.length === 0 || decoration.from > currentGroupTo) {
74
+ currentGroup = [decoration];
75
+ currentGroupFrom = decoration.from;
76
+ currentGroupTo = decoration.to;
77
+ groups.push({
78
+ decorations: currentGroup,
79
+ from: currentGroupFrom,
80
+ to: currentGroupTo
81
+ });
82
+ return;
83
+ }
84
+ currentGroup.push(decoration);
85
+ // A zero-width decoration must not extend the group and bridge a gap.
86
+ currentGroupTo = Math.max(currentGroupTo, decoration.to);
87
+ groups[groups.length - 1].to = currentGroupTo;
88
+ });
89
+ return groups.map(function (_ref) {
90
+ var _ref2, _group$find;
91
+ var group = _ref.decorations,
92
+ from = _ref.from,
93
+ to = _ref.to;
94
+ var representative = (_ref2 = (_group$find = group.find(isInlineDecoration)) !== null && _group$find !== void 0 ? _group$find : group.find(function (decoration) {
95
+ return decoration.from !== decoration.to;
96
+ })) !== null && _ref2 !== void 0 ? _ref2 : group[0];
97
+ if (!representative || representative.from === from && representative.to === to) {
98
+ return representative;
99
+ }
100
+
101
+ // This decoration is only used for navigation and active-range calculation. The actual
102
+ // visual decorations remain in the DecorationSet with their original ranges and styles.
103
+ return isInlineDecoration(representative) ? _view.Decoration.inline(from, to, {}, representative.spec) : _view.Decoration.node(from, to, {}, representative.spec);
104
+ });
105
+ }
106
+
54
107
  /**
55
108
  * Gets scrollable decorations from a DecorationSet, filtering out overlapping decorations
56
109
  * and applying various rules for diff visualization.
@@ -61,9 +114,11 @@ function specHasDiffKeyPrefix(spec, keyPrefix) {
61
114
  * 3. Deduplicates diff-block decorations with same from, to and nodeName
62
115
  * 4. When `doc` is passed: excludes diff-inline decorations whose range has no inline content
63
116
  * (invalid positions, or block-only slices with no text/atoms — e.g. empty blocks)
64
- * 5. Excludes diff-inline decorations that are fully contained within a diff-block
65
- * 6. Excludes diff-block decorations that are fully contained within a diff-inline
66
- * 7. Results are sorted by from position, then by to position
117
+ * 5. When `confluence_ncs_step_diffing_version_history` is enabled, groups overlapping or
118
+ * directly touching ranges across decoration types into one result, using the union of all
119
+ * grouped ranges
120
+ * (zero-width widgets can join a group without extending it)
121
+ * 6. Results are sorted by from position, then by to position
67
122
  *
68
123
  * @param set - The DecorationSet to extract scrollable decorations from
69
124
  * @param doc - Current document; when set, diff-inline ranges are validated against this doc
@@ -114,25 +169,19 @@ var getScrollableDecorations = exports.getScrollableDecorations = function getSc
114
169
  return isInlineDiffDecorationRenderableInDoc(doc, d.from, d.to);
115
170
  }) : rawInlines;
116
171
  var widgets = filtered.filter(isWidgetDecoration);
117
-
118
- // Second pass: exclude overlapping decorations
119
- // Rules:
120
- // - If an inline is fully inside a block, exclude the block (inline takes priority)
121
- // - If a block is fully inside an inline, exclude the block (inline takes priority)
122
- var nonOverlappingBlocks = blocks.filter(function (block) {
123
- // Exclude block if:
124
- // 1. It's fully contained within any inline, OR
125
- // 2. It fully contains any inline
126
- return !inlines.some(function (inline) {
127
- return isRangeFullyInside(block.from, block.to, inline.from, inline.to) ||
128
- // block inside inline
129
- isRangeFullyInside(inline.from, inline.to, block.from, block.to);
130
- } // inline inside block
131
- );
132
- });
133
-
134
- // Combine all non-overlapping decorations
135
- var result = [].concat((0, _toConsumableArray2.default)(nonOverlappingBlocks), (0, _toConsumableArray2.default)(inlines), (0, _toConsumableArray2.default)(widgets));
172
+ var result;
173
+ if ((0, _fg.fg)('confluence_ncs_step_diffing_version_history')) {
174
+ // Group overlapping or directly touching ranges into one customer-facing edit.
175
+ result = groupTouchingDecorations([].concat((0, _toConsumableArray2.default)(blocks), (0, _toConsumableArray2.default)(inlines), (0, _toConsumableArray2.default)(widgets)), isInlineDecoration);
176
+ } else {
177
+ // Legacy behavior: exclude blocks that contain or are contained by an inline decoration.
178
+ var nonOverlappingBlocks = blocks.filter(function (block) {
179
+ return !inlines.some(function (inline) {
180
+ return isRangeFullyInside(block.from, block.to, inline.from, inline.to) || isRangeFullyInside(inline.from, inline.to, block.from, block.to);
181
+ });
182
+ });
183
+ result = [].concat((0, _toConsumableArray2.default)(nonOverlappingBlocks), (0, _toConsumableArray2.default)(inlines), (0, _toConsumableArray2.default)(widgets));
184
+ }
136
185
 
137
186
  // Sort by from position, then by to position
138
187
  result.sort(function (a, b) {
@@ -118,6 +118,20 @@ const leafTextblockRanges = (doc, from, to) => {
118
118
  // once it has more leaf textblocks (~cells) than this threshold. Small tables
119
119
  // keep the precise per-cell path.
120
120
  const LARGE_TABLE_LEAF_THRESHOLD = 40;
121
+
122
+ /**
123
+ * An active scrollable change may contain several underlying diff changes. Treat ranges that
124
+ * overlap or touch the active range as part of the same active customer-facing edit.
125
+ */
126
+ const isRangeActive = (activeIndexPos, from, to) => {
127
+ if (activeIndexPos === undefined) {
128
+ return undefined;
129
+ }
130
+ if (!fg('confluence_ncs_step_diffing_version_history')) {
131
+ return from === activeIndexPos.from && to === activeIndexPos.to;
132
+ }
133
+ return from <= activeIndexPos.to && activeIndexPos.from <= to;
134
+ };
121
135
  const isLargeInsertedTableRange = (doc, from, to) => {
122
136
  let containsTable = false;
123
137
  let leafCount = 0;
@@ -175,7 +189,7 @@ const calculateNodesForBlockDecoration = ({
175
189
  }
176
190
  if (node.isBlock && (!isExtendedEnabled(diffType) || pos + node.nodeSize <= to)) {
177
191
  const nodeEnd = pos + node.nodeSize;
178
- const isActive = activeIndexPos && pos === activeIndexPos.from && nodeEnd === activeIndexPos.to;
192
+ const isActive = isRangeActive(activeIndexPos, pos, nodeEnd);
179
193
  decorations.push(...createBlockChangedDecoration({
180
194
  change: {
181
195
  from: pos,
@@ -393,7 +407,7 @@ const calculateDiffDecorationsInner = ({
393
407
  const isInserted = !isInverted;
394
408
  const createDecorationsForChange = change => {
395
409
  const changeColorScheme = attributionColors ? getColorSchemeForChange(change, attributedChanges, attributionColors, colorScheme) : colorScheme;
396
- const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
410
+ const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
397
411
 
398
412
  // Hoisted because it decides BOTH where the deleted widget is anchored and — since the
399
413
  // widget pins whichever end of the range it sits at — how the indicator anchors below are
@@ -512,11 +526,19 @@ const calculateDiffDecorationsInner = ({
512
526
  changes.forEach(change => {
513
527
  createDecorationsForChange(change);
514
528
  });
515
- getMarkChangeRanges(steps).forEach(change => {
516
- const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
529
+ // The attribute-aware token encoder already reports mark-only changes as regular changes in the
530
+ // smart path. Only add the legacy step-range decoration when no equivalent changeset range was
531
+ // produced; otherwise the same content receives two inline decorations (and two navigation
532
+ // changes), usually one attribution colour and one default purple decoration.
533
+ getMarkChangeRanges(steps).filter(({
534
+ fromB,
535
+ toB
536
+ }) => !fg('confluence_ncs_step_diffing_version_history') || !changes.some(change => change.fromB === fromB && change.toB === toB)).forEach(change => {
537
+ const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
517
538
  decorations.push(...createInlineChangedDecoration({
518
539
  change,
519
540
  colorScheme,
541
+ diffType,
520
542
  isActive,
521
543
  isInserted: true
522
544
  }));
@@ -524,7 +546,7 @@ const calculateDiffDecorationsInner = ({
524
546
  getAttrChangeRanges(tr.doc, attrStepContexts, originalDoc).forEach(change => {
525
547
  if (change.isInline) {
526
548
  // Inline nodes (e.g. date, emoji, mention, status) need an inline decoration rather than a block decoration
527
- const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
549
+ const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
528
550
  const isAtomicInlineNodeFlag = isExperimentEnabled('platform_editor_improve_inline_diffs', () => expValEquals('platform_editor_improve_inline_diffs', 'isEnabled', true));
529
551
  decorations.push(...createInlineChangedDecoration({
530
552
  change,
@@ -115,7 +115,7 @@ export const createNodeChangedDecorationWidget = ({
115
115
  // node renders beneath its replacement; otherwise anchor at the start (change.fromB).
116
116
  const anchorPos = placeBelow ? change.toB : change.fromB;
117
117
  const safeInsertPos = findSafeInsertPos(newDoc, anchorPos, slice);
118
- const isActive = activeIndexPos && safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to;
118
+ const isActive = activeIndexPos && (fg('confluence_ncs_step_diffing_version_history') ? safeInsertPos >= activeIndexPos.from && safeInsertPos <= activeIndexPos.to : safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to);
119
119
  if (slice.content.content.length === 0 || shouldSkipDeletedEmptyParagraphDecoration) {
120
120
  return [];
121
121
  }
@@ -1,3 +1,5 @@
1
+ import { Decoration } from '@atlaskit/editor-prosemirror/view';
2
+ import { fg } from '@atlaskit/platform-feature-flags/fg';
1
3
  import { DiffDecorationKey, isDiffDecoration, isDiffDecorationSpec } from './decorations/decorationKeys';
2
4
  import { isExtendedEnabled } from './isExtendedEnabled';
3
5
 
@@ -31,10 +33,6 @@ export function isInlineDiffDecorationRenderableInDoc(doc, from, to) {
31
33
  return false;
32
34
  }
33
35
  }
34
-
35
- /**
36
- * Checks if range1 is fully contained within range2
37
- */
38
36
  function isRangeFullyInside(range1Start, range1End, range2Start, range2End) {
39
37
  return range2Start <= range1Start && range1End <= range2End;
40
38
  }
@@ -42,6 +40,58 @@ function specHasDiffKeyPrefix(spec, keyPrefix) {
42
40
  return Boolean(spec && typeof spec === 'object' && 'key' in spec && typeof spec.key === 'string' && spec.key.startsWith(keyPrefix));
43
41
  }
44
42
 
43
+ /**
44
+ * Collapses decorations that represent one continuous customer-facing edit.
45
+ *
46
+ * Decorations are kept separate in the DecorationSet so each one can retain its own visual
47
+ * styling. This list is only used for change counting and navigation, where an insertion and a
48
+ * deletion at the same location should be treated as one replacement. Zero-width decorations
49
+ * (normally deleted-content widgets) can join a group, but cannot extend its range and therefore
50
+ * cannot bridge two otherwise separate edits.
51
+ */
52
+ function groupTouchingDecorations(decorations, isInlineDecoration) {
53
+ if (decorations.length < 2) {
54
+ return decorations;
55
+ }
56
+ const groups = [];
57
+ let currentGroup = [];
58
+ let currentGroupFrom = 0;
59
+ let currentGroupTo = 0;
60
+ const sortedDecorations = [...decorations].sort((a, b) => a.from === b.from ? a.to - b.to : a.from - b.from);
61
+ sortedDecorations.forEach(decoration => {
62
+ if (currentGroup.length === 0 || decoration.from > currentGroupTo) {
63
+ currentGroup = [decoration];
64
+ currentGroupFrom = decoration.from;
65
+ currentGroupTo = decoration.to;
66
+ groups.push({
67
+ decorations: currentGroup,
68
+ from: currentGroupFrom,
69
+ to: currentGroupTo
70
+ });
71
+ return;
72
+ }
73
+ currentGroup.push(decoration);
74
+ // A zero-width decoration must not extend the group and bridge a gap.
75
+ currentGroupTo = Math.max(currentGroupTo, decoration.to);
76
+ groups[groups.length - 1].to = currentGroupTo;
77
+ });
78
+ return groups.map(({
79
+ decorations: group,
80
+ from,
81
+ to
82
+ }) => {
83
+ var _ref, _group$find;
84
+ const representative = (_ref = (_group$find = group.find(isInlineDecoration)) !== null && _group$find !== void 0 ? _group$find : group.find(decoration => decoration.from !== decoration.to)) !== null && _ref !== void 0 ? _ref : group[0];
85
+ if (!representative || representative.from === from && representative.to === to) {
86
+ return representative;
87
+ }
88
+
89
+ // This decoration is only used for navigation and active-range calculation. The actual
90
+ // visual decorations remain in the DecorationSet with their original ranges and styles.
91
+ return isInlineDecoration(representative) ? Decoration.inline(from, to, {}, representative.spec) : Decoration.node(from, to, {}, representative.spec);
92
+ });
93
+ }
94
+
45
95
  /**
46
96
  * Gets scrollable decorations from a DecorationSet, filtering out overlapping decorations
47
97
  * and applying various rules for diff visualization.
@@ -52,9 +102,11 @@ function specHasDiffKeyPrefix(spec, keyPrefix) {
52
102
  * 3. Deduplicates diff-block decorations with same from, to and nodeName
53
103
  * 4. When `doc` is passed: excludes diff-inline decorations whose range has no inline content
54
104
  * (invalid positions, or block-only slices with no text/atoms — e.g. empty blocks)
55
- * 5. Excludes diff-inline decorations that are fully contained within a diff-block
56
- * 6. Excludes diff-block decorations that are fully contained within a diff-inline
57
- * 7. Results are sorted by from position, then by to position
105
+ * 5. When `confluence_ncs_step_diffing_version_history` is enabled, groups overlapping or
106
+ * directly touching ranges across decoration types into one result, using the union of all
107
+ * grouped ranges
108
+ * (zero-width widgets can join a group without extending it)
109
+ * 6. Results are sorted by from position, then by to position
58
110
  *
59
111
  * @param set - The DecorationSet to extract scrollable decorations from
60
112
  * @param doc - Current document; when set, diff-inline ranges are validated against this doc
@@ -101,23 +153,15 @@ export const getScrollableDecorations = (set, doc, diffType) => {
101
153
  const rawInlines = filtered.filter(isInlineDecoration);
102
154
  const inlines = doc !== undefined ? rawInlines.filter(d => isInlineDiffDecorationRenderableInDoc(doc, d.from, d.to)) : rawInlines;
103
155
  const widgets = filtered.filter(isWidgetDecoration);
104
-
105
- // Second pass: exclude overlapping decorations
106
- // Rules:
107
- // - If an inline is fully inside a block, exclude the block (inline takes priority)
108
- // - If a block is fully inside an inline, exclude the block (inline takes priority)
109
- const nonOverlappingBlocks = blocks.filter(block => {
110
- // Exclude block if:
111
- // 1. It's fully contained within any inline, OR
112
- // 2. It fully contains any inline
113
- return !inlines.some(inline => isRangeFullyInside(block.from, block.to, inline.from, inline.to) ||
114
- // block inside inline
115
- isRangeFullyInside(inline.from, inline.to, block.from, block.to) // inline inside block
116
- );
117
- });
118
-
119
- // Combine all non-overlapping decorations
120
- const result = [...nonOverlappingBlocks, ...inlines, ...widgets];
156
+ let result;
157
+ if (fg('confluence_ncs_step_diffing_version_history')) {
158
+ // Group overlapping or directly touching ranges into one customer-facing edit.
159
+ result = groupTouchingDecorations([...blocks, ...inlines, ...widgets], isInlineDecoration);
160
+ } else {
161
+ // Legacy behavior: exclude blocks that contain or are contained by an inline decoration.
162
+ const nonOverlappingBlocks = blocks.filter(block => !inlines.some(inline => isRangeFullyInside(block.from, block.to, inline.from, inline.to) || isRangeFullyInside(inline.from, inline.to, block.from, block.to)));
163
+ result = [...nonOverlappingBlocks, ...inlines, ...widgets];
164
+ }
121
165
 
122
166
  // Sort by from position, then by to position
123
167
  result.sort((a, b) => a.from === b.from ? a.to - b.to : a.from - b.from);
@@ -124,6 +124,20 @@ var leafTextblockRanges = function leafTextblockRanges(doc, from, to) {
124
124
  // once it has more leaf textblocks (~cells) than this threshold. Small tables
125
125
  // keep the precise per-cell path.
126
126
  var LARGE_TABLE_LEAF_THRESHOLD = 40;
127
+
128
+ /**
129
+ * An active scrollable change may contain several underlying diff changes. Treat ranges that
130
+ * overlap or touch the active range as part of the same active customer-facing edit.
131
+ */
132
+ var isRangeActive = function isRangeActive(activeIndexPos, from, to) {
133
+ if (activeIndexPos === undefined) {
134
+ return undefined;
135
+ }
136
+ if (!fg('confluence_ncs_step_diffing_version_history')) {
137
+ return from === activeIndexPos.from && to === activeIndexPos.to;
138
+ }
139
+ return from <= activeIndexPos.to && activeIndexPos.from <= to;
140
+ };
127
141
  var isLargeInsertedTableRange = function isLargeInsertedTableRange(doc, from, to) {
128
142
  var containsTable = false;
129
143
  var leafCount = 0;
@@ -191,7 +205,7 @@ var calculateNodesForBlockDecoration = function calculateNodesForBlockDecoration
191
205
  }
192
206
  if (node.isBlock && (!isExtendedEnabled(diffType) || pos + node.nodeSize <= to)) {
193
207
  var nodeEnd = pos + node.nodeSize;
194
- var isActive = activeIndexPos && pos === activeIndexPos.from && nodeEnd === activeIndexPos.to;
208
+ var isActive = isRangeActive(activeIndexPos, pos, nodeEnd);
195
209
  decorations.push.apply(decorations, _toConsumableArray(createBlockChangedDecoration({
196
210
  change: {
197
211
  from: pos,
@@ -424,7 +438,7 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
424
438
  var isInserted = !isInverted;
425
439
  var createDecorationsForChange = function createDecorationsForChange(change) {
426
440
  var changeColorScheme = attributionColors ? getColorSchemeForChange(change, attributedChanges, attributionColors, colorScheme) : colorScheme;
427
- var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
441
+ var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
428
442
 
429
443
  // Hoisted because it decides BOTH where the deleted widget is anchored and — since the
430
444
  // widget pins whichever end of the range it sits at — how the indicator anchors below are
@@ -552,11 +566,22 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
552
566
  changes.forEach(function (change) {
553
567
  createDecorationsForChange(change);
554
568
  });
555
- getMarkChangeRanges(steps).forEach(function (change) {
556
- var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
569
+ // The attribute-aware token encoder already reports mark-only changes as regular changes in the
570
+ // smart path. Only add the legacy step-range decoration when no equivalent changeset range was
571
+ // produced; otherwise the same content receives two inline decorations (and two navigation
572
+ // changes), usually one attribution colour and one default purple decoration.
573
+ getMarkChangeRanges(steps).filter(function (_ref10) {
574
+ var fromB = _ref10.fromB,
575
+ toB = _ref10.toB;
576
+ return !fg('confluence_ncs_step_diffing_version_history') || !changes.some(function (change) {
577
+ return change.fromB === fromB && change.toB === toB;
578
+ });
579
+ }).forEach(function (change) {
580
+ var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
557
581
  decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration({
558
582
  change: change,
559
583
  colorScheme: colorScheme,
584
+ diffType: diffType,
560
585
  isActive: isActive,
561
586
  isInserted: true
562
587
  })));
@@ -564,7 +589,7 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
564
589
  getAttrChangeRanges(tr.doc, attrStepContexts, originalDoc).forEach(function (change) {
565
590
  if (change.isInline) {
566
591
  // Inline nodes (e.g. date, emoji, mention, status) need an inline decoration rather than a block decoration
567
- var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
592
+ var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
568
593
  var isAtomicInlineNodeFlag = isExperimentEnabled('platform_editor_improve_inline_diffs', function () {
569
594
  return expValEquals('platform_editor_improve_inline_diffs', 'isEnabled', true);
570
595
  });
@@ -651,42 +676,42 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref7
651
676
  };
652
677
  export var calculateDiffDecorations = memoizeOne(calculateDiffDecorationsInner,
653
678
  // Cache results unless relevant inputs change
654
- function (_ref10, _ref11) {
655
- var _ref15;
656
- var _ref12 = _slicedToArray(_ref10, 1),
657
- _ref12$ = _ref12[0],
658
- pluginState = _ref12$.pluginState,
659
- state = _ref12$.state,
660
- colorScheme = _ref12$.colorScheme,
661
- intl = _ref12$.intl,
662
- activeIndexPos = _ref12$.activeIndexPos,
663
- isInverted = _ref12$.isInverted,
664
- diffType = _ref12$.diffType,
665
- hideDeletedDiffs = _ref12$.hideDeletedDiffs,
666
- hideAddedDiffsUnderline = _ref12$.hideAddedDiffsUnderline,
667
- showIndicators = _ref12$.showIndicators,
668
- smartThresholds = _ref12$.smartThresholds,
669
- deletedDiffPlacement = _ref12$.deletedDiffPlacement,
670
- inlineDeletedDiffPlacement = _ref12$.inlineDeletedDiffPlacement;
679
+ function (_ref11, _ref12) {
680
+ var _ref16;
671
681
  var _ref13 = _slicedToArray(_ref11, 1),
672
682
  _ref13$ = _ref13[0],
673
- lastPluginState = _ref13$.pluginState,
674
- lastState = _ref13$.state,
675
- lastColorScheme = _ref13$.colorScheme,
676
- lastIntl = _ref13$.intl,
677
- lastActiveIndexPos = _ref13$.activeIndexPos,
678
- lastIsInverted = _ref13$.isInverted,
679
- lastDiffType = _ref13$.diffType,
680
- lastHideDeletedDiffs = _ref13$.hideDeletedDiffs,
681
- lastHideAddedDiffsUnderline = _ref13$.hideAddedDiffsUnderline,
682
- lastShowIndicators = _ref13$.showIndicators,
683
- lastSmartThresholds = _ref13$.smartThresholds,
684
- lastDeletedDiffPlacement = _ref13$.deletedDiffPlacement,
685
- lastInlineDeletedDiffPlacement = _ref13$.inlineDeletedDiffPlacement;
683
+ pluginState = _ref13$.pluginState,
684
+ state = _ref13$.state,
685
+ colorScheme = _ref13$.colorScheme,
686
+ intl = _ref13$.intl,
687
+ activeIndexPos = _ref13$.activeIndexPos,
688
+ isInverted = _ref13$.isInverted,
689
+ diffType = _ref13$.diffType,
690
+ hideDeletedDiffs = _ref13$.hideDeletedDiffs,
691
+ hideAddedDiffsUnderline = _ref13$.hideAddedDiffsUnderline,
692
+ showIndicators = _ref13$.showIndicators,
693
+ smartThresholds = _ref13$.smartThresholds,
694
+ deletedDiffPlacement = _ref13$.deletedDiffPlacement,
695
+ inlineDeletedDiffPlacement = _ref13$.inlineDeletedDiffPlacement;
696
+ var _ref14 = _slicedToArray(_ref12, 1),
697
+ _ref14$ = _ref14[0],
698
+ lastPluginState = _ref14$.pluginState,
699
+ lastState = _ref14$.state,
700
+ lastColorScheme = _ref14$.colorScheme,
701
+ lastIntl = _ref14$.intl,
702
+ lastActiveIndexPos = _ref14$.activeIndexPos,
703
+ lastIsInverted = _ref14$.isInverted,
704
+ lastDiffType = _ref14$.diffType,
705
+ lastHideDeletedDiffs = _ref14$.hideDeletedDiffs,
706
+ lastHideAddedDiffsUnderline = _ref14$.hideAddedDiffsUnderline,
707
+ lastShowIndicators = _ref14$.showIndicators,
708
+ lastSmartThresholds = _ref14$.smartThresholds,
709
+ lastDeletedDiffPlacement = _ref14$.deletedDiffPlacement,
710
+ lastInlineDeletedDiffPlacement = _ref14$.inlineDeletedDiffPlacement;
686
711
  var originalDocIsSame = lastPluginState.originalDoc && pluginState.originalDoc && pluginState.originalDoc.eq(lastPluginState.originalDoc);
687
712
  if (isExtendedEnabled(diffType)) {
688
- var _ref14;
689
- return (_ref14 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && isEqual(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && isEqual(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && hideAddedDiffsUnderline === lastHideAddedDiffsUnderline && showIndicators === lastShowIndicators && isEqual(smartThresholds, lastSmartThresholds) && deletedDiffPlacement === lastDeletedDiffPlacement && inlineDeletedDiffPlacement === lastInlineDeletedDiffPlacement) !== null && _ref14 !== void 0 ? _ref14 : false;
713
+ var _ref15;
714
+ return (_ref15 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && isEqual(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && isEqual(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && hideAddedDiffsUnderline === lastHideAddedDiffsUnderline && showIndicators === lastShowIndicators && isEqual(smartThresholds, lastSmartThresholds) && deletedDiffPlacement === lastDeletedDiffPlacement && inlineDeletedDiffPlacement === lastInlineDeletedDiffPlacement) !== null && _ref15 !== void 0 ? _ref15 : false;
690
715
  }
691
- return (_ref15 = originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && isEqual(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isEqual(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref15 !== void 0 ? _ref15 : false;
716
+ return (_ref16 = originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && isEqual(pluginState.stepAttributions, lastPluginState.stepAttributions) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isEqual(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref16 !== void 0 ? _ref16 : false;
692
717
  });
@@ -115,7 +115,7 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
115
115
  // node renders beneath its replacement; otherwise anchor at the start (change.fromB).
116
116
  var anchorPos = placeBelow ? change.toB : change.fromB;
117
117
  var safeInsertPos = findSafeInsertPos(newDoc, anchorPos, slice);
118
- var isActive = activeIndexPos && safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to;
118
+ var isActive = activeIndexPos && (fg('confluence_ncs_step_diffing_version_history') ? safeInsertPos >= activeIndexPos.from && safeInsertPos <= activeIndexPos.to : safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to);
119
119
  if (slice.content.content.length === 0 || shouldSkipDeletedEmptyParagraphDecoration) {
120
120
  return [];
121
121
  }
@@ -1,5 +1,7 @@
1
1
  import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
2
2
  import _typeof from "@babel/runtime/helpers/typeof";
3
+ import { Decoration } from '@atlaskit/editor-prosemirror/view';
4
+ import { fg } from '@atlaskit/platform-feature-flags/fg';
3
5
  import { DiffDecorationKey, isDiffDecoration, isDiffDecorationSpec } from './decorations/decorationKeys';
4
6
  import { isExtendedEnabled } from './isExtendedEnabled';
5
7
 
@@ -33,10 +35,6 @@ export function isInlineDiffDecorationRenderableInDoc(doc, from, to) {
33
35
  return false;
34
36
  }
35
37
  }
36
-
37
- /**
38
- * Checks if range1 is fully contained within range2
39
- */
40
38
  function isRangeFullyInside(range1Start, range1End, range2Start, range2End) {
41
39
  return range2Start <= range1Start && range1End <= range2End;
42
40
  }
@@ -44,6 +42,61 @@ function specHasDiffKeyPrefix(spec, keyPrefix) {
44
42
  return Boolean(spec && _typeof(spec) === 'object' && 'key' in spec && typeof spec.key === 'string' && spec.key.startsWith(keyPrefix));
45
43
  }
46
44
 
45
+ /**
46
+ * Collapses decorations that represent one continuous customer-facing edit.
47
+ *
48
+ * Decorations are kept separate in the DecorationSet so each one can retain its own visual
49
+ * styling. This list is only used for change counting and navigation, where an insertion and a
50
+ * deletion at the same location should be treated as one replacement. Zero-width decorations
51
+ * (normally deleted-content widgets) can join a group, but cannot extend its range and therefore
52
+ * cannot bridge two otherwise separate edits.
53
+ */
54
+ function groupTouchingDecorations(decorations, isInlineDecoration) {
55
+ if (decorations.length < 2) {
56
+ return decorations;
57
+ }
58
+ var groups = [];
59
+ var currentGroup = [];
60
+ var currentGroupFrom = 0;
61
+ var currentGroupTo = 0;
62
+ var sortedDecorations = _toConsumableArray(decorations).sort(function (a, b) {
63
+ return a.from === b.from ? a.to - b.to : a.from - b.from;
64
+ });
65
+ sortedDecorations.forEach(function (decoration) {
66
+ if (currentGroup.length === 0 || decoration.from > currentGroupTo) {
67
+ currentGroup = [decoration];
68
+ currentGroupFrom = decoration.from;
69
+ currentGroupTo = decoration.to;
70
+ groups.push({
71
+ decorations: currentGroup,
72
+ from: currentGroupFrom,
73
+ to: currentGroupTo
74
+ });
75
+ return;
76
+ }
77
+ currentGroup.push(decoration);
78
+ // A zero-width decoration must not extend the group and bridge a gap.
79
+ currentGroupTo = Math.max(currentGroupTo, decoration.to);
80
+ groups[groups.length - 1].to = currentGroupTo;
81
+ });
82
+ return groups.map(function (_ref) {
83
+ var _ref2, _group$find;
84
+ var group = _ref.decorations,
85
+ from = _ref.from,
86
+ to = _ref.to;
87
+ var representative = (_ref2 = (_group$find = group.find(isInlineDecoration)) !== null && _group$find !== void 0 ? _group$find : group.find(function (decoration) {
88
+ return decoration.from !== decoration.to;
89
+ })) !== null && _ref2 !== void 0 ? _ref2 : group[0];
90
+ if (!representative || representative.from === from && representative.to === to) {
91
+ return representative;
92
+ }
93
+
94
+ // This decoration is only used for navigation and active-range calculation. The actual
95
+ // visual decorations remain in the DecorationSet with their original ranges and styles.
96
+ return isInlineDecoration(representative) ? Decoration.inline(from, to, {}, representative.spec) : Decoration.node(from, to, {}, representative.spec);
97
+ });
98
+ }
99
+
47
100
  /**
48
101
  * Gets scrollable decorations from a DecorationSet, filtering out overlapping decorations
49
102
  * and applying various rules for diff visualization.
@@ -54,9 +107,11 @@ function specHasDiffKeyPrefix(spec, keyPrefix) {
54
107
  * 3. Deduplicates diff-block decorations with same from, to and nodeName
55
108
  * 4. When `doc` is passed: excludes diff-inline decorations whose range has no inline content
56
109
  * (invalid positions, or block-only slices with no text/atoms — e.g. empty blocks)
57
- * 5. Excludes diff-inline decorations that are fully contained within a diff-block
58
- * 6. Excludes diff-block decorations that are fully contained within a diff-inline
59
- * 7. Results are sorted by from position, then by to position
110
+ * 5. When `confluence_ncs_step_diffing_version_history` is enabled, groups overlapping or
111
+ * directly touching ranges across decoration types into one result, using the union of all
112
+ * grouped ranges
113
+ * (zero-width widgets can join a group without extending it)
114
+ * 6. Results are sorted by from position, then by to position
60
115
  *
61
116
  * @param set - The DecorationSet to extract scrollable decorations from
62
117
  * @param doc - Current document; when set, diff-inline ranges are validated against this doc
@@ -107,25 +162,19 @@ export var getScrollableDecorations = function getScrollableDecorations(set, doc
107
162
  return isInlineDiffDecorationRenderableInDoc(doc, d.from, d.to);
108
163
  }) : rawInlines;
109
164
  var widgets = filtered.filter(isWidgetDecoration);
110
-
111
- // Second pass: exclude overlapping decorations
112
- // Rules:
113
- // - If an inline is fully inside a block, exclude the block (inline takes priority)
114
- // - If a block is fully inside an inline, exclude the block (inline takes priority)
115
- var nonOverlappingBlocks = blocks.filter(function (block) {
116
- // Exclude block if:
117
- // 1. It's fully contained within any inline, OR
118
- // 2. It fully contains any inline
119
- return !inlines.some(function (inline) {
120
- return isRangeFullyInside(block.from, block.to, inline.from, inline.to) ||
121
- // block inside inline
122
- isRangeFullyInside(inline.from, inline.to, block.from, block.to);
123
- } // inline inside block
124
- );
125
- });
126
-
127
- // Combine all non-overlapping decorations
128
- var result = [].concat(_toConsumableArray(nonOverlappingBlocks), _toConsumableArray(inlines), _toConsumableArray(widgets));
165
+ var result;
166
+ if (fg('confluence_ncs_step_diffing_version_history')) {
167
+ // Group overlapping or directly touching ranges into one customer-facing edit.
168
+ result = groupTouchingDecorations([].concat(_toConsumableArray(blocks), _toConsumableArray(inlines), _toConsumableArray(widgets)), isInlineDecoration);
169
+ } else {
170
+ // Legacy behavior: exclude blocks that contain or are contained by an inline decoration.
171
+ var nonOverlappingBlocks = blocks.filter(function (block) {
172
+ return !inlines.some(function (inline) {
173
+ return isRangeFullyInside(block.from, block.to, inline.from, inline.to) || isRangeFullyInside(inline.from, inline.to, block.from, block.to);
174
+ });
175
+ });
176
+ result = [].concat(_toConsumableArray(nonOverlappingBlocks), _toConsumableArray(inlines), _toConsumableArray(widgets));
177
+ }
129
178
 
130
179
  // Sort by from position, then by to position
131
180
  result.sort(function (a, b) {
@@ -1,5 +1,5 @@
1
1
  import type { Node as PMNode } from '@atlaskit/editor-prosemirror/model';
2
- import type { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
2
+ import { Decoration, type DecorationSet } from '@atlaskit/editor-prosemirror/view';
3
3
  import type { DiffType } from '../showDiffPluginType';
4
4
  /**
5
5
  * Returns true when an inline decoration's [from, to) range can actually show in the document:
@@ -17,9 +17,11 @@ export declare function isInlineDiffDecorationRenderableInDoc(doc: PMNode, from:
17
17
  * 3. Deduplicates diff-block decorations with same from, to and nodeName
18
18
  * 4. When `doc` is passed: excludes diff-inline decorations whose range has no inline content
19
19
  * (invalid positions, or block-only slices with no text/atoms — e.g. empty blocks)
20
- * 5. Excludes diff-inline decorations that are fully contained within a diff-block
21
- * 6. Excludes diff-block decorations that are fully contained within a diff-inline
22
- * 7. Results are sorted by from position, then by to position
20
+ * 5. When `confluence_ncs_step_diffing_version_history` is enabled, groups overlapping or
21
+ * directly touching ranges across decoration types into one result, using the union of all
22
+ * grouped ranges
23
+ * (zero-width widgets can join a group without extending it)
24
+ * 6. Results are sorted by from position, then by to position
23
25
  *
24
26
  * @param set - The DecorationSet to extract scrollable decorations from
25
27
  * @param doc - Current document; when set, diff-inline ranges are validated against this doc
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-show-diff",
3
- "version": "14.2.5",
3
+ "version": "14.2.7",
4
4
  "description": "ShowDiff plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "@atlaskit/editor-tables": "^3.0.0",
29
29
  "@atlaskit/platform-feature-experiments": "^0.3.0",
30
30
  "@atlaskit/platform-feature-flags": "^2.1.0",
31
- "@atlaskit/tmp-editor-statsig": "^167.0.0",
31
+ "@atlaskit/tmp-editor-statsig": "^168.0.0",
32
32
  "@atlaskit/tokens": "^16.9.0",
33
33
  "@babel/runtime": "^7.0.0",
34
34
  "@compiled/react": "^1.0.2",