@atlaskit/editor-plugin-show-diff 10.1.10 → 10.1.12

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +118 -31
  3. package/dist/cjs/pm-plugins/calculateDiff/diffBySteps.js +124 -1
  4. package/dist/cjs/pm-plugins/decorations/colorSchemes/standard.js +6 -1
  5. package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +17 -0
  6. package/dist/cjs/pm-plugins/decorations/createGranularBlockReferenceWidget.js +125 -0
  7. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +50 -105
  8. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +79 -1
  9. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +91 -7
  10. package/dist/es2019/pm-plugins/calculateDiff/diffBySteps.js +104 -0
  11. package/dist/es2019/pm-plugins/decorations/colorSchemes/standard.js +5 -0
  12. package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +18 -1
  13. package/dist/es2019/pm-plugins/decorations/createGranularBlockReferenceWidget.js +120 -0
  14. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +41 -89
  15. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +74 -2
  16. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +119 -32
  17. package/dist/esm/pm-plugins/calculateDiff/diffBySteps.js +123 -0
  18. package/dist/esm/pm-plugins/decorations/colorSchemes/standard.js +5 -0
  19. package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +18 -1
  20. package/dist/esm/pm-plugins/decorations/createGranularBlockReferenceWidget.js +120 -0
  21. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +46 -100
  22. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +79 -2
  23. package/dist/types/pm-plugins/calculateDiff/diffBySteps.d.ts +20 -0
  24. package/dist/types/pm-plugins/decorations/colorSchemes/standard.d.ts +1 -0
  25. package/dist/types/pm-plugins/decorations/createGranularBlockReferenceWidget.d.ts +36 -0
  26. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +15 -0
  27. package/package.json +2 -2
@@ -0,0 +1,120 @@
1
+ import { Decoration } from '@atlaskit/editor-prosemirror/view';
2
+ import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
3
+ import { buildDiffDecorationSpec, buildAnchorDecorationKey } from './decorationKeys';
4
+ import { wrapBlockNodeView, injectInnerWrapper } from './utils/wrapBlockNodeView';
5
+
6
+ /**
7
+ * Creates a single block widget that renders a reference text block content
8
+ * beneath a granular diff set, for reference when deleted content is hidden.
9
+ *
10
+ * Since granular diffing only applies to singular isTextBlock=true nodes, this
11
+ * widget always renders exactly one block node and does not need the slice/fragment
12
+ * complexity of createNodeChangedDecorationWidget.
13
+ *
14
+ * Resolves which doc and positions to render based on isInverted:
15
+ * - !isInverted: renders originalDoc at A-side positions (what was there before)
16
+ * - isInverted: renders newDoc at B-side positions (the current/new content)
17
+ *
18
+ * The widget is always inserted at the B-side block boundary in newDoc since
19
+ * ProseMirror decorations are always anchored against the live (new) document.
20
+ */
21
+ export const createGranularBlockReferenceWidget = ({
22
+ change,
23
+ originalDoc,
24
+ newDoc,
25
+ isInverted,
26
+ nodeViewSerializer,
27
+ colorScheme,
28
+ intl,
29
+ activeIndexPos,
30
+ diffId,
31
+ showIndicators = false
32
+ }) => {
33
+ // Determine which doc and positions to use for rendering the reference block.
34
+ const renderDoc = isInverted ? newDoc : originalDoc;
35
+ const renderTo = isInverted ? change.toB : change.toA;
36
+
37
+ // The insertion point is always in newDoc — ProseMirror decorates against the live document.
38
+ // Walk up from toB to find the enclosing text block boundary for widget placement.
39
+ const insertResolvedPos = newDoc.resolve(change.toB);
40
+ let blockEnd = change.toB;
41
+ for (let depth = insertResolvedPos.depth; depth >= 0; depth--) {
42
+ const node = insertResolvedPos.node(depth);
43
+ if (node.isTextblock) {
44
+ blockEnd = insertResolvedPos.start(depth) + node.nodeSize - 1;
45
+ break;
46
+ }
47
+ }
48
+
49
+ // Find the block node to render from the render doc at the render positions.
50
+ const renderResolvedPos = renderDoc.resolve(renderTo);
51
+ let renderBlockNode = null;
52
+ for (let depth = renderResolvedPos.depth; depth >= 0; depth--) {
53
+ const node = renderResolvedPos.node(depth);
54
+ if (node.isTextblock) {
55
+ renderBlockNode = node;
56
+ break;
57
+ }
58
+ }
59
+ if (!renderBlockNode) {
60
+ return [];
61
+ }
62
+ const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
63
+
64
+ /**
65
+ * This will always be a block node as it's a textBlock-like node.
66
+ * We use div instead of span so we can add margins.
67
+ */
68
+ const dom = document.createElement('div');
69
+ dom.setAttribute('data-testid', 'show-diff-granular-block-reference');
70
+ dom.style.setProperty('margin-top', "var(--ds-space-200, 16px)");
71
+ const nodeView = nodeViewSerializer.tryCreateNodeView(renderBlockNode);
72
+ if (nodeView) {
73
+ wrapBlockNodeView({
74
+ dom,
75
+ nodeView,
76
+ targetNode: renderBlockNode,
77
+ colorScheme,
78
+ intl,
79
+ isActive: !!isActive,
80
+ isInserted: false
81
+ });
82
+ } else {
83
+ const serialized = nodeViewSerializer.serializeNode(renderBlockNode);
84
+ if (serialized && serialized instanceof HTMLElement) {
85
+ injectInnerWrapper({
86
+ node: serialized,
87
+ colorScheme,
88
+ isActive: !!isActive,
89
+ isInserted: false
90
+ });
91
+ dom.append(serialized);
92
+ } else if (serialized) {
93
+ dom.append(serialized);
94
+ }
95
+ }
96
+ if (dom.childNodes.length === 0) {
97
+ return [];
98
+ }
99
+ const decorations = [];
100
+ if (showIndicators) {
101
+ const leftAnchor = createLeftAnchorWidget({
102
+ doc: newDoc,
103
+ from: blockEnd,
104
+ diffId
105
+ });
106
+ dom.style.setProperty('anchor-name', `--${buildAnchorDecorationKey({
107
+ diffId
108
+ })}`);
109
+ if (leftAnchor) {
110
+ decorations.push(leftAnchor);
111
+ }
112
+ }
113
+ decorations.push(Decoration.widget(blockEnd, dom, buildDiffDecorationSpec({
114
+ decorationType: 'widget',
115
+ diffId,
116
+ // We want it to be as close to the granular diff as possible
117
+ side: -999
118
+ })));
119
+ return decorations;
120
+ };
@@ -1,90 +1,10 @@
1
- import { convertToInlineCss } from '@atlaskit/editor-common/lazy-node-view';
2
1
  import { Decoration } from '@atlaskit/editor-prosemirror/view';
3
2
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
4
- import { editingStyle, editingStyleExtended, editingStyleActive, editingStyleActiveExtended, deletedContentStyle, deletedContentStyleActive, deletedContentStyleNew, deletedContentStyleUnbounded, deletedInlineContentStyleExtended } from './colorSchemes/standard';
5
- import { traditionalInsertStyle, traditionalInsertStyleActive, getDeletedTraditionalInlineStyle, deletedTraditionalContentStyleUnbounded, deletedTraditionalContentStyleUnboundedActive } from './colorSchemes/traditional';
6
3
  import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
7
4
  import { createChangedRowDecorationWidgets } from './createChangedRowDecorationWidgets';
8
5
  import { buildDiffDecorationSpec, buildAnchorDecorationKey } from './decorationKeys';
9
6
  import { findSafeInsertPos } from './utils/findSafeInsertPos';
10
- import { wrapBlockNodeView } from './utils/wrapBlockNodeView';
11
- const getDeletedContentStyleUnbounded = (colorScheme, isActive = false) => {
12
- if (colorScheme === 'traditional' && isActive) {
13
- return deletedTraditionalContentStyleUnboundedActive;
14
- }
15
- return colorScheme === 'traditional' ? deletedTraditionalContentStyleUnbounded : deletedContentStyleUnbounded;
16
- };
17
- const getInsertedContentStyle = (colorScheme, isActive = false) => {
18
- if (colorScheme === 'traditional') {
19
- if (isActive) {
20
- return traditionalInsertStyleActive;
21
- }
22
- return traditionalInsertStyle;
23
- }
24
- if (isActive) {
25
- return expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ? editingStyleActiveExtended : editingStyleActive;
26
- }
27
- return expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ? editingStyleExtended : editingStyle;
28
- };
29
- const getDeletedContentStyle = (colorScheme, isActive = false) => {
30
- if (colorScheme === 'traditional') {
31
- return getDeletedTraditionalInlineStyle(isActive);
32
- }
33
- // Merge into existing styles when cleaning up
34
- if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
35
- if (isActive) {
36
- return deletedContentStyleActive + deletedInlineContentStyleExtended;
37
- }
38
- return deletedContentStyleNew + deletedInlineContentStyleExtended;
39
- }
40
- if (isActive) {
41
- return deletedContentStyleActive;
42
- }
43
- return expValEquals('platform_editor_enghealth_a11y_jan_fixes', 'isEnabled', true) ? deletedContentStyleNew : deletedContentStyle;
44
- };
45
-
46
- /**
47
- * CSS backgrounds don't work when applied to a wrapper around a paragraph, so
48
- * the wrapper needs to be injected inside the node around the child content
49
- */
50
- const injectInnerWrapper = ({
51
- node,
52
- colorScheme,
53
- isActive,
54
- isInserted
55
- }) => {
56
- const wrapper = document.createElement('span');
57
- wrapper.setAttribute('style', isInserted ? getInsertedContentStyle(colorScheme, isActive) : getDeletedContentStyle(colorScheme, isActive));
58
- [...node.childNodes].forEach(child => {
59
- const removedChild = node.removeChild(child);
60
- wrapper.append(removedChild);
61
- });
62
- node.appendChild(wrapper);
63
- return node;
64
- };
65
- const createContentWrapper = (colorScheme, isActive = false, isInserted = false) => {
66
- const wrapper = document.createElement('span');
67
- const baseStyle = convertToInlineCss({
68
- position: 'relative',
69
- width: 'fit-content'
70
- });
71
- if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
72
- if (isInserted) {
73
- wrapper.setAttribute('style', `${baseStyle}${getInsertedContentStyle(colorScheme, isActive)}`);
74
- } else {
75
- wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive)}`);
76
- const strikethrough = document.createElement('span');
77
- strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
78
- wrapper.append(strikethrough);
79
- }
80
- } else {
81
- wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive)}`);
82
- const strikethrough = document.createElement('span');
83
- strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
84
- wrapper.append(strikethrough);
85
- }
86
- return wrapper;
87
- };
7
+ import { wrapBlockNodeView, injectInnerWrapper, createContentWrapper } from './utils/wrapBlockNodeView';
88
8
 
89
9
  /**
90
10
  * This function is used to create a decoration widget to show content
@@ -131,6 +51,32 @@ export const createNodeChangedDecorationWidget = ({
131
51
 
132
52
  // For non-table content, use the existing span wrapper approach
133
53
  const dom = document.createElement('span');
54
+ // When mediaSingle nodes are rendered inside a widget decoration (e.g. as part of
55
+ // a replaced panel), the centering CSS (margin-left: 50%; transform: translateX(-50%))
56
+ // incorrectly applies because isNestedNode resolves to false (getPos returns 0).
57
+ // Observe DOM mutations and override the transform on .rich-media-item elements
58
+ // after React mounts to prevent the image from shifting outside its parent container.
59
+ let constrainMediaObserver;
60
+ if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
61
+ constrainMediaObserver = new MutationObserver(() => {
62
+ const richMediaItems = dom.querySelectorAll('.rich-media-item');
63
+ richMediaItems.forEach(el => {
64
+ if (el instanceof HTMLElement) {
65
+ el.style.transform = 'none';
66
+ el.style.marginLeft = '0';
67
+ el.style.maxWidth = '100%';
68
+ }
69
+ });
70
+ if (richMediaItems.length > 0) {
71
+ var _constrainMediaObserv;
72
+ (_constrainMediaObserv = constrainMediaObserver) === null || _constrainMediaObserv === void 0 ? void 0 : _constrainMediaObserv.disconnect();
73
+ }
74
+ });
75
+ constrainMediaObserver.observe(dom, {
76
+ childList: true,
77
+ subtree: true
78
+ });
79
+ }
134
80
  const diffId = crypto.randomUUID();
135
81
  const decorations = [];
136
82
 
@@ -247,14 +193,20 @@ export const createNodeChangedDecorationWidget = ({
247
193
  decorations.push(leftAnchor);
248
194
  }
249
195
  }
250
- decorations.push(Decoration.widget(safeInsertPos, dom, buildDiffDecorationSpec({
251
- decorationType: 'widget',
252
- diffId,
253
- isActive,
254
- ...(expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) && {
255
- side: -1
256
- })
257
- })));
196
+ decorations.push(Decoration.widget(safeInsertPos, dom, {
197
+ ...buildDiffDecorationSpec({
198
+ decorationType: 'widget',
199
+ diffId,
200
+ isActive,
201
+ ...(expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) && {
202
+ side: -1
203
+ })
204
+ }),
205
+ destroy: () => {
206
+ var _constrainMediaObserv2;
207
+ return (_constrainMediaObserv2 = constrainMediaObserver) === null || _constrainMediaObserv2 === void 0 ? void 0 : _constrainMediaObserv2.disconnect();
208
+ }
209
+ }));
258
210
 
259
211
  // When a single block node is purely deleted at the very start of the doc (first child),
260
212
  // the deleted widget decoration overlaps with the existing first child's decoration.
@@ -2,8 +2,8 @@ import { convertToInlineCss } from '@atlaskit/editor-common/lazy-node-view';
2
2
  import { trackChangesMessages } from '@atlaskit/editor-common/messages';
3
3
  import { getBaseNodeTypeName } from '@atlaskit/editor-common/utils/node-type-utils';
4
4
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
5
- import { deletedBlockOutline, deletedBlockOutlineActive, deletedBlockOutlineRounded, deletedBlockOutlineRoundedActive, deletedContentStyle, deletedContentStyleActive, deletedContentStyleNew, deletedStyleQuoteNodeWithLozenge, deletedStyleQuoteNodeWithLozengeActive, editingContentStyleInBlockExtended, editingStyleExtended, editingStyleActiveExtended, editingStyleNode, addedCellOverlayStyle, deletedCellOverlayStyle } from '../colorSchemes/standard';
6
- import { deletedTraditionalBlockOutlineActive, deletedTraditionalBlockOutlineNew, deletedTraditionalBlockOutlineRoundedActive, deletedTraditionalBlockOutlineRoundedNew, getDeletedTraditionalInlineStyle, deletedTraditionalStyleQuoteNode, deletedTraditionalStyleQuoteNodeActive, traditionalInsertStyle, traditionalInsertStyleActive, traditionalStyleNodeActive, traditionalStyleNodeNew, traditionalAddedCellOverlayStyleNew, deletedTraditionalCellOverlayStyle } from '../colorSchemes/traditional';
5
+ import { deletedBlockOutline, deletedBlockOutlineActive, deletedBlockOutlineRounded, deletedBlockOutlineRoundedActive, deletedContentStyle, deletedContentStyleActive, deletedContentStyleNew, deletedContentStyleUnbounded, deletedInlineContentStyleExtended, deletedStyleQuoteNodeWithLozenge, deletedStyleQuoteNodeWithLozengeActive, editingContentStyleInBlockExtended, editingStyleExtended, editingStyleActiveExtended, editingStyleNode, addedCellOverlayStyle, deletedCellOverlayStyle } from '../colorSchemes/standard';
6
+ import { deletedTraditionalBlockOutlineActive, deletedTraditionalBlockOutlineNew, deletedTraditionalBlockOutlineRoundedActive, deletedTraditionalBlockOutlineRoundedNew, deletedTraditionalContentStyleUnbounded, deletedTraditionalContentStyleUnboundedActive, getDeletedTraditionalInlineStyle, deletedTraditionalStyleQuoteNode, deletedTraditionalStyleQuoteNodeActive, traditionalInsertStyle, traditionalInsertStyleActive, traditionalStyleNodeActive, traditionalStyleNodeNew, traditionalAddedCellOverlayStyleNew, deletedTraditionalCellOverlayStyle } from '../colorSchemes/traditional';
7
7
  const lozengeStyle = convertToInlineCss({
8
8
  display: 'inline-flex',
9
9
  boxSizing: 'border-box',
@@ -542,4 +542,76 @@ export const wrapBlockNodeView = ({
542
542
  });
543
543
  }
544
544
  }
545
+ };
546
+ const getDeletedContentStyleUnbounded = (colorScheme, isActive = false) => {
547
+ if (colorScheme === 'traditional' && isActive) {
548
+ return deletedTraditionalContentStyleUnboundedActive;
549
+ }
550
+ return colorScheme === 'traditional' ? deletedTraditionalContentStyleUnbounded : deletedContentStyleUnbounded;
551
+ };
552
+ const getInsertedContentStyle = (colorScheme, isActive = false) => {
553
+ if (colorScheme === 'traditional') {
554
+ return isActive ? traditionalInsertStyleActive : traditionalInsertStyle;
555
+ }
556
+ if (isActive) {
557
+ return editingStyleActiveExtended;
558
+ }
559
+ return editingStyleExtended;
560
+ };
561
+ const getDeletedContentStyle = (colorScheme, isActive = false) => {
562
+ if (colorScheme === 'traditional') {
563
+ return getDeletedTraditionalInlineStyle(isActive);
564
+ }
565
+ if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
566
+ return (isActive ? deletedContentStyleActive : deletedContentStyleNew) + deletedInlineContentStyleExtended;
567
+ }
568
+ return isActive ? deletedContentStyleActive : deletedContentStyleNew;
569
+ };
570
+
571
+ /**
572
+ * Injects a styled inner wrapper span around the children of a block node element.
573
+ * CSS backgrounds don't work when applied to a wrapper around a paragraph, so
574
+ * the wrapper needs to be injected inside the node around the child content.
575
+ */
576
+ export const injectInnerWrapper = ({
577
+ node,
578
+ colorScheme,
579
+ isActive,
580
+ isInserted
581
+ }) => {
582
+ const wrapper = document.createElement('span');
583
+ wrapper.setAttribute('style', isInserted ? getInsertedContentStyle(colorScheme, isActive) : getDeletedContentStyle(colorScheme, isActive));
584
+ [...node.childNodes].forEach(child => {
585
+ const removedChild = node.removeChild(child);
586
+ wrapper.append(removedChild);
587
+ });
588
+ node.appendChild(wrapper);
589
+ return node;
590
+ };
591
+
592
+ /**
593
+ * Creates a styled span wrapper for inline content within a change decoration.
594
+ */
595
+ export const createContentWrapper = (colorScheme, isActive = false, isInserted = false) => {
596
+ const wrapper = document.createElement('span');
597
+ const baseStyle = convertToInlineCss({
598
+ position: 'relative',
599
+ width: 'fit-content'
600
+ });
601
+ if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
602
+ if (isInserted) {
603
+ wrapper.setAttribute('style', `${baseStyle}${getInsertedContentStyle(colorScheme, isActive)}`);
604
+ } else {
605
+ wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive)}`);
606
+ const strikethrough = document.createElement('span');
607
+ strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
608
+ wrapper.append(strikethrough);
609
+ }
610
+ } else {
611
+ wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive)}`);
612
+ const strikethrough = document.createElement('span');
613
+ strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
614
+ wrapper.append(strikethrough);
615
+ }
616
+ return wrapper;
545
617
  };
@@ -16,12 +16,13 @@ import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
16
16
  import { areDocsEqualByBlockStructureAndText } from '../areDocsEqualByBlockStructureAndText';
17
17
  import { createDocMarginAnchorWidget } from '../decorations/createAnchorDecorationWidgets';
18
18
  import { createBlockChangedDecoration } from '../decorations/createBlockChangedDecoration';
19
+ import { createGranularBlockReferenceWidget } from '../decorations/createGranularBlockReferenceWidget';
19
20
  import { createInlineChangedDecoration } from '../decorations/createInlineChangedDecoration';
20
21
  import { createNodeChangedDecorationWidget } from '../decorations/createNodeChangedDecorationWidget';
21
22
  import { extractDiffDescriptors } from '../decorations/decorationKeys';
22
23
  import { getAttrChangeRanges, stepIsValidAttrChange } from '../decorations/utils/getAttrChangeRanges';
23
24
  import { getMarkChangeRanges } from '../decorations/utils/getMarkChangeRanges';
24
- import { diffBySteps } from './diffBySteps';
25
+ import { diffBySteps, getStepChanges } from './diffBySteps';
25
26
  import { groupChangesByBlock } from './groupChangesByBlock';
26
27
  import { optimizeChanges } from './optimizeChanges';
27
28
  import { simplifySteps } from './simplifySteps';
@@ -165,12 +166,16 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref3
165
166
  if (showIndicators && expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
166
167
  decorations.push(createDocMarginAnchorWidget());
167
168
  }
168
- changes.forEach(function (change) {
169
+
170
+ // Our default operations are insertions, so it should match the opposite of isInverted.
171
+ var isInserted = !isInverted;
172
+ var createDecorationsForChange = function createDecorationsForChange(change, showGranularWithBlock) {
169
173
  var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
170
- // Our default operations are insertions, so it should match the opposite of isInverted.
171
- var isInserted = !isInverted;
172
174
  if (change.inserted.length > 0) {
173
- var shouldHideDeleted = expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ? isInverted && hideDeletedDiffs : false;
175
+ // shouldHideDeleted for block/node decorations: suppressed when isInverted + hideDeletedDiffs,
176
+ // or when showGranularWithBlock (block reference widget is shown instead).
177
+ // isInverted gates both — on an inverted diff the inserted side is visually the deleted side.
178
+ var shouldHideDeleted = expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ? isInverted && (hideDeletedDiffs || showGranularWithBlock) && change.deleted.length > 0 : false;
174
179
  decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration(_objectSpread({
175
180
  change: change,
176
181
  doc: tr.doc,
@@ -195,7 +200,7 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref3
195
200
  }))));
196
201
  }
197
202
  if (change.deleted.length > 0) {
198
- var _shouldHideDeleted = expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ? !isInverted && hideDeletedDiffs : false;
203
+ var _shouldHideDeleted = expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) ? !isInverted && (hideDeletedDiffs || showGranularWithBlock) && change.inserted.length > 0 : false;
199
204
  if (!_shouldHideDeleted) {
200
205
  decorations.push.apply(decorations, _toConsumableArray(createNodeChangedDecorationWidget(_objectSpread(_objectSpread({
201
206
  change: change,
@@ -213,7 +218,89 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref3
213
218
  }))));
214
219
  }
215
220
  }
216
- });
221
+ };
222
+ if (diffType === 'step' && expValEquals('platform_editor_diff_granular_extended', 'isEnabled', true)) {
223
+ // Uses getStepChanges instead of getChanges so that we have per-step granularity metadata.
224
+ // Specifically, we need to know how many granular changes each step produced in order to
225
+ // apply the shouldHideDeleted suppression threshold (> 3 granular changes per step).
226
+ // getChanges returns a flat Change[] with no per-step grouping, making this count impossible
227
+ // to derive after the fact without re-introducing per-change metadata.
228
+ var stepChanges = getStepChanges(originalDoc, steps);
229
+ stepChanges.forEach(function (_ref4) {
230
+ var isGranular = _ref4.isGranular,
231
+ stepChangeList = _ref4.changes;
232
+ var granularCount = isGranular ? stepChangeList.length : 0;
233
+
234
+ // Calculate the average ratio of changed content on both A (original) and B (new)
235
+ // sides of the diff. If 30% or more of the block has changed on average, we show
236
+ // the block reference widget even if the granular change count is below the threshold.
237
+ // Block length is derived from the enclosing text block boundaries rather than the
238
+ // first/last change positions, so unchanged words at the start/end are accounted for.
239
+ var avgChangedRatio = 0;
240
+ if (isGranular && stepChangeList.length > 0) {
241
+ var first = stepChangeList[0];
242
+ var last = stepChangeList[stepChangeList.length - 1];
243
+ var resolvedA = originalDoc.resolve(first.fromA);
244
+ var resolvedB = tr.doc.resolve(first.fromB);
245
+ var blockStartA = first.fromA;
246
+ var blockEndA = last.toA;
247
+ var blockStartB = first.fromB;
248
+ var blockEndB = last.toB;
249
+ for (var depth = resolvedA.depth; depth >= 0; depth--) {
250
+ var node = resolvedA.node(depth);
251
+ if (node.isTextblock) {
252
+ blockStartA = resolvedA.start(depth);
253
+ blockEndA = blockStartA + node.nodeSize - 2; // exclude open/close tokens
254
+ break;
255
+ }
256
+ }
257
+ for (var _depth = resolvedB.depth; _depth >= 0; _depth--) {
258
+ var _node = resolvedB.node(_depth);
259
+ if (_node.isTextblock) {
260
+ blockStartB = resolvedB.start(_depth);
261
+ blockEndB = blockStartB + _node.nodeSize - 2; // exclude open/close tokens
262
+ break;
263
+ }
264
+ }
265
+ var blockLengthA = blockEndA - blockStartA;
266
+ var blockLengthB = blockEndB - blockStartB;
267
+ var totalChangedA = stepChangeList.reduce(function (sum, c) {
268
+ return sum + (c.toA - c.fromA);
269
+ }, 0);
270
+ var totalChangedB = stepChangeList.reduce(function (sum, c) {
271
+ return sum + (c.toB - c.fromB);
272
+ }, 0);
273
+ var ratioA = blockLengthA > 0 ? totalChangedA / blockLengthA : 0;
274
+ var ratioB = blockLengthB > 0 ? totalChangedB / blockLengthB : 0;
275
+ avgChangedRatio = (ratioA + ratioB) / 2;
276
+ }
277
+ var showGranularWithBlock = isGranular && granularCount !== 1 && (granularCount > 3 || avgChangedRatio >= 0.3);
278
+ stepChangeList.forEach(function (change) {
279
+ createDecorationsForChange(change, showGranularWithBlock);
280
+ });
281
+ if (showGranularWithBlock && stepChangeList.length > 0) {
282
+ var lastChange = stepChangeList[stepChangeList.length - 1];
283
+ var granularBlockDiffId = crypto.randomUUID();
284
+ var blockWidgets = createGranularBlockReferenceWidget({
285
+ change: lastChange,
286
+ originalDoc: originalDoc,
287
+ newDoc: tr.doc,
288
+ isInverted: isInverted,
289
+ nodeViewSerializer: nodeViewSerializer,
290
+ colorScheme: colorScheme,
291
+ intl: intl,
292
+ activeIndexPos: activeIndexPos,
293
+ diffId: granularBlockDiffId,
294
+ showIndicators: showIndicators
295
+ });
296
+ decorations.push.apply(decorations, _toConsumableArray(blockWidgets));
297
+ }
298
+ });
299
+ } else {
300
+ changes.forEach(function (change) {
301
+ createDecorationsForChange(change, /* showGranularWithBlock */false);
302
+ });
303
+ }
217
304
  getMarkChangeRanges(steps).forEach(function (change) {
218
305
  var isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
219
306
  decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration({
@@ -253,34 +340,34 @@ var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref3
253
340
  };
254
341
  export var calculateDiffDecorations = memoizeOne(calculateDiffDecorationsInner,
255
342
  // Cache results unless relevant inputs change
256
- function (_ref4, _ref5) {
257
- var _ref9;
258
- var _ref6 = _slicedToArray(_ref4, 1),
259
- _ref6$ = _ref6[0],
260
- pluginState = _ref6$.pluginState,
261
- state = _ref6$.state,
262
- colorScheme = _ref6$.colorScheme,
263
- intl = _ref6$.intl,
264
- activeIndexPos = _ref6$.activeIndexPos,
265
- isInverted = _ref6$.isInverted,
266
- diffType = _ref6$.diffType,
267
- hideDeletedDiffs = _ref6$.hideDeletedDiffs,
268
- showIndicators = _ref6$.showIndicators;
343
+ function (_ref5, _ref6) {
344
+ var _ref0;
269
345
  var _ref7 = _slicedToArray(_ref5, 1),
270
346
  _ref7$ = _ref7[0],
271
- lastPluginState = _ref7$.pluginState,
272
- lastState = _ref7$.state,
273
- lastColorScheme = _ref7$.colorScheme,
274
- lastIntl = _ref7$.intl,
275
- lastActiveIndexPos = _ref7$.activeIndexPos,
276
- lastIsInverted = _ref7$.isInverted,
277
- lastDiffType = _ref7$.diffType,
278
- lastHideDeletedDiffs = _ref7$.hideDeletedDiffs,
279
- lastShowIndicators = _ref7$.showIndicators;
347
+ pluginState = _ref7$.pluginState,
348
+ state = _ref7$.state,
349
+ colorScheme = _ref7$.colorScheme,
350
+ intl = _ref7$.intl,
351
+ activeIndexPos = _ref7$.activeIndexPos,
352
+ isInverted = _ref7$.isInverted,
353
+ diffType = _ref7$.diffType,
354
+ hideDeletedDiffs = _ref7$.hideDeletedDiffs,
355
+ showIndicators = _ref7$.showIndicators;
356
+ var _ref8 = _slicedToArray(_ref6, 1),
357
+ _ref8$ = _ref8[0],
358
+ lastPluginState = _ref8$.pluginState,
359
+ lastState = _ref8$.state,
360
+ lastColorScheme = _ref8$.colorScheme,
361
+ lastIntl = _ref8$.intl,
362
+ lastActiveIndexPos = _ref8$.activeIndexPos,
363
+ lastIsInverted = _ref8$.isInverted,
364
+ lastDiffType = _ref8$.diffType,
365
+ lastHideDeletedDiffs = _ref8$.hideDeletedDiffs,
366
+ lastShowIndicators = _ref8$.showIndicators;
280
367
  var originalDocIsSame = lastPluginState.originalDoc && pluginState.originalDoc && pluginState.originalDoc.eq(lastPluginState.originalDoc);
281
368
  if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
282
- var _ref8;
283
- return (_ref8 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && isEqual(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && showIndicators === lastShowIndicators) !== null && _ref8 !== void 0 ? _ref8 : false;
369
+ var _ref9;
370
+ return (_ref9 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && isEqual(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && showIndicators === lastShowIndicators) !== null && _ref9 !== void 0 ? _ref9 : false;
284
371
  }
285
- return (_ref9 = originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isEqual(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref9 !== void 0 ? _ref9 : false;
372
+ return (_ref0 = originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isEqual(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref0 !== void 0 ? _ref0 : false;
286
373
  });