@atlaskit/editor-plugin-show-diff 17.0.0 → 17.0.2

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 (37) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +126 -49
  3. package/dist/cjs/pm-plugins/calculateDiff/groupDeletedColumnChanges.js +265 -0
  4. package/dist/cjs/pm-plugins/decorations/createChangedRowDecorationWidgets.js +100 -34
  5. package/dist/cjs/pm-plugins/decorations/createContributorTagWidget.js +18 -13
  6. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +82 -20
  7. package/dist/cjs/pm-plugins/decorations/utils/createDeletedLineBreakWidget.js +106 -0
  8. package/dist/cjs/pm-plugins/decorations/utils/tableDiffMode.js +36 -0
  9. package/dist/cjs/pm-plugins/decorations/utils/wrapBlockNodeView.js +17 -6
  10. package/dist/cjs/pm-plugins/utils/emptyTextBlocks.js +66 -0
  11. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +61 -1
  12. package/dist/es2019/pm-plugins/calculateDiff/groupDeletedColumnChanges.js +210 -0
  13. package/dist/es2019/pm-plugins/decorations/createChangedRowDecorationWidgets.js +79 -13
  14. package/dist/es2019/pm-plugins/decorations/createContributorTagWidget.js +8 -2
  15. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +85 -15
  16. package/dist/es2019/pm-plugins/decorations/utils/createDeletedLineBreakWidget.js +100 -0
  17. package/dist/es2019/pm-plugins/decorations/utils/tableDiffMode.js +27 -0
  18. package/dist/es2019/pm-plugins/decorations/utils/wrapBlockNodeView.js +15 -5
  19. package/dist/es2019/pm-plugins/utils/emptyTextBlocks.js +58 -0
  20. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +127 -50
  21. package/dist/esm/pm-plugins/calculateDiff/groupDeletedColumnChanges.js +258 -0
  22. package/dist/esm/pm-plugins/decorations/createChangedRowDecorationWidgets.js +100 -34
  23. package/dist/esm/pm-plugins/decorations/createContributorTagWidget.js +18 -13
  24. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +84 -22
  25. package/dist/esm/pm-plugins/decorations/utils/createDeletedLineBreakWidget.js +100 -0
  26. package/dist/esm/pm-plugins/decorations/utils/tableDiffMode.js +30 -0
  27. package/dist/esm/pm-plugins/decorations/utils/wrapBlockNodeView.js +16 -5
  28. package/dist/esm/pm-plugins/utils/emptyTextBlocks.js +60 -0
  29. package/dist/types/pm-plugins/calculateDiff/groupDeletedColumnChanges.d.ts +9 -0
  30. package/dist/types/pm-plugins/decorations/createChangedRowDecorationWidgets.d.ts +3 -1
  31. package/dist/types/pm-plugins/decorations/createContributorTagWidget.d.ts +5 -1
  32. package/dist/types/pm-plugins/decorations/createNodeChangedDecorationWidget.d.ts +2 -1
  33. package/dist/types/pm-plugins/decorations/utils/createDeletedLineBreakWidget.d.ts +50 -0
  34. package/dist/types/pm-plugins/decorations/utils/tableDiffMode.d.ts +26 -0
  35. package/dist/types/pm-plugins/decorations/utils/wrapBlockNodeView.d.ts +4 -0
  36. package/dist/types/pm-plugins/utils/emptyTextBlocks.d.ts +16 -0
  37. package/package.json +4 -4
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.emptyTextBlockContentPositions = exports.countEmptyTextBlockOnlySlice = void 0;
7
+ /**
8
+ * The textblock types whose empty form is a blank line the author put there deliberately, and so
9
+ * worth marking when a change removes it.
10
+ *
11
+ * Deliberately not every textblock: an empty `codeBlock` is an empty code block, which renders as a
12
+ * visible box of its own and needs no stand-in.
13
+ */
14
+ var BLANK_LINE_NODE_NAMES = new Set(['paragraph', 'heading']);
15
+
16
+ /**
17
+ * Structurally empty: no children at all, not merely no measurable content. The two coincide for a
18
+ * real node, and testing both keeps a node whose children report no size from passing as blank.
19
+ */
20
+ var isEmptyTextBlock = function isEmptyTextBlock(node) {
21
+ return BLANK_LINE_NODE_NAMES.has(node.type.name) && node.content.childCount === 0 && node.content.size === 0;
22
+ };
23
+
24
+ /**
25
+ * How many empty textblocks a slice holds, or 0 unless that is *all* it holds.
26
+ *
27
+ * Counted rather than tested for one, because a run of adjacent blank lines is reported as a single
28
+ * change covering all of them — the case that made a single-block test miss.
29
+ */
30
+ var countEmptyTextBlockOnlySlice = exports.countEmptyTextBlockOnlySlice = function countEmptyTextBlockOnlySlice(slice) {
31
+ var _slice$content$childC;
32
+ var childCount = (_slice$content$childC = slice === null || slice === void 0 ? void 0 : slice.content.childCount) !== null && _slice$content$childC !== void 0 ? _slice$content$childC : 0;
33
+ if (!slice || childCount === 0) {
34
+ return 0;
35
+ }
36
+ for (var index = 0; index < childCount; index++) {
37
+ if (!isEmptyTextBlock(slice.content.child(index))) {
38
+ return 0;
39
+ }
40
+ }
41
+ return childCount;
42
+ };
43
+
44
+ /**
45
+ * The content position of every empty textblock lying wholly within `[from, to]`.
46
+ *
47
+ * Used to place a marker on each blank line a change removes. Partially covered blocks are skipped:
48
+ * the change only took part of one, so it still has text of its own, which the inline decoration
49
+ * already marks.
50
+ */
51
+ var emptyTextBlockContentPositions = exports.emptyTextBlockContentPositions = function emptyTextBlockContentPositions(doc, from, to) {
52
+ var positions = [];
53
+ doc.nodesBetween(from, to, function (node, pos) {
54
+ if (node.isTextblock) {
55
+ if (isEmptyTextBlock(node) && pos >= from && pos + node.nodeSize <= to) {
56
+ // +1 steps over the block's own open token, into its (empty) content.
57
+ positions.push(pos + 1);
58
+ }
59
+ // A textblock holds inline content only, so there is nothing below it to visit.
60
+ return false;
61
+ }
62
+ // Keep descending: blank lines nest inside cells, columns and panels.
63
+ return true;
64
+ });
65
+ return positions;
66
+ };
@@ -4,7 +4,8 @@ import { ChangeSet, simplifyChanges } from 'prosemirror-changeset';
4
4
  import { areDocsEqualByBlockStructureAndText } from '@atlaskit/editor-common/utils/areDocsEqualByBlockStructureAndText';
5
5
  import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document';
6
6
  import { Mapping } from '@atlaskit/editor-prosemirror/transform';
7
- import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
7
+ import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
8
+ import { TableMap } from '@atlaskit/editor-tables/table-map';
8
9
  import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure';
9
10
  import { fg } from '@atlaskit/platform-feature-flags/fg';
10
11
  import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
@@ -15,15 +16,19 @@ import { createNodeChangedDecorationWidget } from '../decorations/createNodeChan
15
16
  import { createAttributionColorMap, getAttributionKey, getAttributionKeyForChange, getColorSchemeForChange, isAttributionColoringEnabled, isContributorTagsEnabled } from '../decorations/colorSchemes/attributions';
16
17
  import { extractDiffDescriptors } from '../decorations/decorationKeys';
17
18
  import { extractContributorTags } from '../decorations/extractContributorTags';
19
+ import { createDeletedLineBreakDecoration } from '../decorations/utils/createDeletedLineBreakWidget';
18
20
  import { getAttrChangeRanges, stepIsValidAttrChange } from '../decorations/utils/getAttrChangeRanges';
21
+ import { createRemovedLozenge } from '../decorations/utils/wrapBlockNodeView';
19
22
  import { getMarkChangeRanges } from '../decorations/utils/getMarkChangeRanges';
20
23
  import { getScrollableDecorations } from '../getScrollableDecorations';
21
24
  import { getDefaultDiffType, isExtendedEnabled } from '../isExtendedEnabled';
25
+ import { emptyTextBlockContentPositions } from '../utils/emptyTextBlocks';
22
26
  import { diffBySteps } from './diffBySteps';
23
27
  import { groupChangesByBlock } from './groupChangesByBlock';
24
28
  import { isMarkOnlyChange } from './isMarkOnlyChange';
25
29
  import { isOpenTokenOnlyChange } from './isOpenTokenOnlyChange';
26
30
  import { optimizeChanges } from './optimizeChanges';
31
+ import { CELL_CONTENT_OFFSET, groupDeletedColumnChanges } from './groupDeletedColumnChanges';
27
32
  import { selectTokenEncoder } from './selectTokenEncoder';
28
33
  import { collapseOverlappingChanges, simplifyChangesWithAttribution } from './simplifyChangesWithAttribution';
29
34
  import { simplifySteps, simplifyStepsWithAttribution } from './simplifySteps';
@@ -485,10 +490,32 @@ const calculateDiffDecorationsInner = ({
485
490
 
486
491
  // Our default operations are insertions, so it should match the opposite of isInverted.
487
492
  const isInserted = !isInverted;
493
+ if (isExtendedEnabled(diffType)) {
494
+ changes = groupDeletedColumnChanges(changes, originalDoc, tr.doc, new Mapping(stepMaps), isInverted);
495
+ }
496
+ // A whole-table change owns every mark and attribute range inside its bounds, so those ranges
497
+ // do not also get their own decoration.
498
+ const tableChanges = changes.filter(change => change.deletedColumns !== undefined);
499
+ const isHandledByTable = (from, to) => tableChanges.some(change => from >= change.fromB && to <= change.toB);
488
500
  const createDecorationsForChange = change => {
489
501
  const changeColorScheme = attributionColors ? getColorSchemeForChange(change, attributedChanges, attributionColors, colorScheme) : colorScheme;
490
502
  const attributionKey = showContributorTags ? getAttributionKeyForChange(change, attributedChanges) : undefined;
491
503
  const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB);
504
+ const {
505
+ deletedColumns
506
+ } = change;
507
+ // On an inverted diff the table that lost columns is the one in `tr.doc`, so the column
508
+ // indices address it directly and each label anchors inside its own cell.
509
+ const table = tr.doc.nodeAt(change.fromB);
510
+ if (isInverted && !hideDeletedDiffs && deletedColumns !== null && deletedColumns !== void 0 && deletedColumns.length && (table === null || table === void 0 ? void 0 : table.type.name) === 'table') {
511
+ const tableMap = TableMap.get(table);
512
+ deletedColumns.forEach(column => {
513
+ const offset = tableMap.positionAt(0, column, table);
514
+ decorations.push(Decoration.widget(change.fromB + CELL_CONTENT_OFFSET + offset, () => createRemovedLozenge(intl, isActive, changeColorScheme, true), {
515
+ side: -1
516
+ }));
517
+ });
518
+ }
492
519
 
493
520
  // The deleted side of a mark-only change is a byte-identical copy of the new text, so it is
494
521
  // suppressed in the clean view. `hideDeletedDiffs` is load-bearing: when false the reviewer
@@ -522,6 +549,32 @@ const calculateDiffDecorationsInner = ({
522
549
  // On an inverted diff the inserted side is visually the deleted side.
523
550
  const shouldHideDeleted = isExtendedEnabled(diffType) ? isInverted && hideDeletedDiffs : false;
524
551
 
552
+ // A removed blank line — an empty paragraph or heading — has nothing to mark up. On an
553
+ // inverted diff, the shape AI suggested edits renders, the block is still in the displayed
554
+ // document, so the removal lands here on the inserted side rather than on the deleted side,
555
+ // and both the inline and the block decoration paint nothing: an empty textblock has no
556
+ // inline content to cover, and block decorations return no style for a paragraph or heading.
557
+ // The reviewer sees an unmarked blank line. Stand the struck-through return glyph in for each
558
+ // one, anchored inside the block so it renders on the blank line itself.
559
+ //
560
+ // One decoration per blank line rather than one per change: a run of adjacent blank lines is
561
+ // reported as a single change spanning all of them.
562
+ //
563
+ // Withheld in the clean view, where removals are hidden rather than struck through.
564
+ if (fg('platform_editor_ai_show_diff_patch_2') && !isInserted && !shouldHideDeleted) {
565
+ for (const pos of emptyTextBlockContentPositions(tr.doc, change.fromB, change.toB)) {
566
+ decorations.push(createDeletedLineBreakDecoration({
567
+ attributionKey,
568
+ colorScheme: changeColorScheme,
569
+ diffType,
570
+ isActive,
571
+ pos,
572
+ reveal,
573
+ side: -1
574
+ }));
575
+ }
576
+ }
577
+
525
578
  // For `smart` NODE-level promotions the change range spans a whole container
526
579
  // (e.g. an entire list/table/layout, using outer node bounds). Applying a SINGLE
527
580
  // inline decoration across that whole range would paint the inserted style across
@@ -630,6 +683,7 @@ const calculateDiffDecorationsInner = ({
630
683
  });
631
684
  if (!shouldHideDeleted) {
632
685
  decorations.push(...createNodeChangedDecorationWidget({
686
+ deletedColumns,
633
687
  // The deleted side has no inline decoration, so the widget carries the actor.
634
688
  attributionKey,
635
689
  // Extended pipeline only, matching the inline path: the non-extended styles have no
@@ -665,6 +719,9 @@ const calculateDiffDecorationsInner = ({
665
719
  getMarkChangeRanges(simplifiedSteps, simplifiedStepAttributions, fg('confluence_ncs_step_diffing_version_history') ? stepMaps : undefined).filter(({
666
720
  fromB,
667
721
  toB
722
+ }) => !isHandledByTable(fromB, toB)).filter(({
723
+ fromB,
724
+ toB
668
725
  }) => !fg('confluence_ncs_step_diffing_version_history') || !changes.some(change => change.fromB === fromB && change.toB === toB)).forEach(change => {
669
726
  var _attributionColors$ge, _attributionColors;
670
727
  const changeColorScheme = change.attributionKey ? (_attributionColors$ge = (_attributionColors = attributionColors) === null || _attributionColors === void 0 ? void 0 : _attributionColors.get(change.attributionKey)) !== null && _attributionColors$ge !== void 0 ? _attributionColors$ge : colorScheme : colorScheme;
@@ -686,6 +743,9 @@ const calculateDiffDecorationsInner = ({
686
743
  getAttrChangeRanges(tr.doc, attrStepContexts, originalDoc).filter(({
687
744
  fromB,
688
745
  toB
746
+ }) => !isHandledByTable(fromB, toB)).filter(({
747
+ fromB,
748
+ toB
689
749
  }) => !fg('confluence_ncs_step_diffing_version_history') || !changes.some(change => change.fromB === fromB && change.toB === toB)).forEach(change => {
690
750
  var _attributionColors$ge2, _attributionColors2;
691
751
  const changeColorScheme = change.attributionKey ? (_attributionColors$ge2 = (_attributionColors2 = attributionColors) === null || _attributionColors2 === void 0 ? void 0 : _attributionColors2.get(change.attributionKey)) !== null && _attributionColors$ge2 !== void 0 ? _attributionColors$ge2 : colorScheme : colorScheme;
@@ -0,0 +1,210 @@
1
+ import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document';
2
+ import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model';
3
+ import { TableMap } from '@atlaskit/editor-tables/table-map';
4
+ export const CELL_CONTENT_OFFSET = 2;
5
+ const IGNORED_CELL_ATTRS = ['localId', 'colwidth'];
6
+ const tablesIn = doc => {
7
+ const tables = [];
8
+ doc.descendants((node, pos) => {
9
+ if (node.type.name === 'table') {
10
+ tables.push({
11
+ node,
12
+ pos
13
+ });
14
+ return false;
15
+ }
16
+ return true;
17
+ });
18
+ return tables;
19
+ };
20
+ const tableEnd = table => table.pos + table.node.nodeSize;
21
+ const documentsDifferOnlyByTable = (originalDoc, newDoc, before, after) => {
22
+ try {
23
+ return originalDoc.replace(before.pos, tableEnd(before), new Slice(Fragment.from(after.node), 0, 0)).eq(newDoc);
24
+ } catch {
25
+ return false;
26
+ }
27
+ };
28
+ const columnOffsets = (map, table) => Array.from({
29
+ length: map.width
30
+ }, (_, column) => map.positionAt(0, column, table));
31
+ const positionMatchedColumns = ({
32
+ before,
33
+ after,
34
+ afterToBefore,
35
+ beforeOffsets
36
+ }) => {
37
+ const afterMap = TableMap.get(after.node);
38
+ const surviving = new Set();
39
+ for (let column = 0; column < afterMap.width; column++) {
40
+ const offset = afterMap.positionAt(0, column, after.node);
41
+ const mapped = afterToBefore.mapResult(after.pos + CELL_CONTENT_OFFSET + offset);
42
+ const beforeColumn = beforeOffsets.indexOf(mapped.pos - before.pos - CELL_CONTENT_OFFSET);
43
+ if (mapped.deleted || beforeColumn === -1) {
44
+ return new Set();
45
+ }
46
+ surviving.add(beforeColumn);
47
+ }
48
+ return surviving;
49
+ };
50
+ const addContentMatchedColumns = (surviving, before, after) => {
51
+ var _before$node$firstChi, _before$node$firstChi2, _after$node$firstChil, _after$node$firstChil2;
52
+ const beforeCells = (_before$node$firstChi = (_before$node$firstChi2 = before.node.firstChild) === null || _before$node$firstChi2 === void 0 ? void 0 : _before$node$firstChi2.children) !== null && _before$node$firstChi !== void 0 ? _before$node$firstChi : [];
53
+ const afterCells = (_after$node$firstChil = (_after$node$firstChil2 = after.node.firstChild) === null || _after$node$firstChil2 === void 0 ? void 0 : _after$node$firstChil2.children) !== null && _after$node$firstChil !== void 0 ? _after$node$firstChil : [];
54
+ for (const afterCell of afterCells) {
55
+ const matches = beforeCells.flatMap((beforeCell, column) => areNodesEqualIgnoreAttrs(beforeCell, afterCell, IGNORED_CELL_ATTRS) ? [column] : []);
56
+ if (matches.length !== 1) {
57
+ return false;
58
+ }
59
+ surviving.add(matches[0]);
60
+ }
61
+ const ordered = [...surviving];
62
+ return !ordered.some((column, index) => index > 0 && column <= ordered[index - 1]);
63
+ };
64
+ const findDeletedColumns = (before, after, afterToBefore) => {
65
+ const beforeMap = TableMap.get(before.node);
66
+ const afterMap = TableMap.get(after.node);
67
+ // Merged or malformed tables do not have reliable column indices.
68
+ if (beforeMap.hasMergedCells() || afterMap.hasMergedCells() || beforeMap.problems || afterMap.problems) {
69
+ return [];
70
+ }
71
+ const beforeOffsets = columnOffsets(beforeMap, before.node);
72
+ const surviving = positionMatchedColumns({
73
+ before,
74
+ after,
75
+ afterToBefore,
76
+ beforeOffsets
77
+ });
78
+ if (surviving.size !== afterMap.width && !addContentMatchedColumns(surviving, before, after)) {
79
+ return [];
80
+ }
81
+ return beforeOffsets.flatMap((_, column) => surviving.has(column) ? [] : [column]);
82
+ };
83
+ const findCounterpart = ({
84
+ table,
85
+ index,
86
+ originalTables,
87
+ newTables,
88
+ mapping
89
+ }) => {
90
+ const from = mapping.map(table.pos, -1);
91
+ const to = mapping.map(tableEnd(table), 1);
92
+ const inside = mapping.mapResult(table.pos + 1);
93
+ const candidates = newTables.filter(candidate => inside.deleted ? candidate.pos >= from && tableEnd(candidate) <= to : candidate.pos < inside.pos && inside.pos < tableEnd(candidate));
94
+ if (candidates.length === 1) {
95
+ return candidates[0];
96
+ }
97
+ return originalTables.length === newTables.length ? newTables[index] : undefined;
98
+ };
99
+ const orient = ({
100
+ table,
101
+ counterpart,
102
+ mapping,
103
+ isInverted
104
+ }) => ({
105
+ before: isInverted ? counterpart : table,
106
+ after: isInverted ? table : counterpart,
107
+ afterToBefore: (isInverted ? mapping.invert() : mapping).invert()
108
+ });
109
+ const changesInTable = (changes, table) => changes.filter(change => change.fromA < tableEnd(table) && change.toA > table.pos);
110
+ const isNarrowingSafe = ({
111
+ replacedChanges,
112
+ table,
113
+ counterpart,
114
+ originalDoc,
115
+ newDoc
116
+ }) => {
117
+ const staysInside = replacedChanges.every(change => change.fromA >= table.pos && change.toA <= tableEnd(table) && change.fromB >= counterpart.pos && change.toB <= tableEnd(counterpart));
118
+ return staysInside || documentsDifferOnlyByTable(originalDoc, newDoc, table, counterpart);
119
+ };
120
+ const planTableChange = ({
121
+ table,
122
+ index,
123
+ originalTables,
124
+ newTables,
125
+ changes,
126
+ originalDoc,
127
+ newDoc,
128
+ mapping,
129
+ isInverted
130
+ }) => {
131
+ const counterpart = findCounterpart({
132
+ table,
133
+ index,
134
+ originalTables,
135
+ newTables,
136
+ mapping
137
+ });
138
+ if (!counterpart) {
139
+ return undefined;
140
+ }
141
+ const {
142
+ before,
143
+ after,
144
+ afterToBefore
145
+ } = orient({
146
+ table,
147
+ counterpart,
148
+ mapping,
149
+ isInverted
150
+ });
151
+ if (TableMap.get(before.node).width <= TableMap.get(after.node).width) {
152
+ return undefined;
153
+ }
154
+ const replacedChanges = changesInTable(changes, table);
155
+ if (!replacedChanges.length) {
156
+ return undefined;
157
+ }
158
+ if (!isNarrowingSafe({
159
+ replacedChanges,
160
+ table,
161
+ counterpart,
162
+ originalDoc,
163
+ newDoc
164
+ })) {
165
+ return undefined;
166
+ }
167
+ const spans = replacedChanges.flatMap(change => [...change.deleted, ...change.inserted]);
168
+ return {
169
+ replacedChanges,
170
+ tableChange: {
171
+ deletedColumns: findDeletedColumns(before, after, afterToBefore),
172
+ fromA: table.pos,
173
+ toA: tableEnd(table),
174
+ fromB: counterpart.pos,
175
+ toB: tableEnd(counterpart),
176
+ deleted: spans,
177
+ inserted: spans
178
+ }
179
+ };
180
+ };
181
+
182
+ /** Groups a deleted column's cell changes into one whole-table change. */
183
+ export const groupDeletedColumnChanges = (changes, originalDoc, newDoc, mapping, isInverted) => {
184
+ const originalTables = tablesIn(originalDoc);
185
+ const newTables = tablesIn(newDoc);
186
+ const plans = [];
187
+ const replaced = new Set();
188
+ originalTables.forEach((table, index) => {
189
+ const plan = planTableChange({
190
+ table,
191
+ index,
192
+ originalTables,
193
+ newTables,
194
+ changes: changes.filter(change => !replaced.has(change)),
195
+ originalDoc,
196
+ newDoc,
197
+ mapping,
198
+ isInverted
199
+ });
200
+ if (!plan) {
201
+ return;
202
+ }
203
+ plans.push(plan);
204
+ plan.replacedChanges.forEach(change => replaced.add(change));
205
+ });
206
+ if (!plans.length) {
207
+ return [...changes];
208
+ }
209
+ return [...changes.filter(change => !replaced.has(change)), ...plans.map(plan => plan.tableChange)].sort((left, right) => left.fromB - right.fromB);
210
+ };
@@ -13,6 +13,7 @@ import { resolveCellOverlayStyleLegacy, resolveDeletedRowStyleLegacy } from './c
13
13
  import { createContributorTagWidget, isContributorTagWidgetEnabled } from './createContributorTagWidget';
14
14
  import { AnchorTypeKey, buildAnchorDecorationKey, buildDiffDecorationSpec, scrollMarginTopValue } from './decorationKeys';
15
15
  import { findSafeInsertPos } from './utils/findSafeInsertPos';
16
+ import { createRemovedLozenge } from './utils/wrapBlockNodeView';
16
17
  import { applyCellEdgeAttrs, getRowCellEdgeAttrs, getTableTopAndBottomCellEdgeAttrs } from './utils/tableCellEdgeAttrs';
17
18
  /**
18
19
  * Extracts information about deleted table rows from a change
@@ -144,17 +145,28 @@ const isEmptyRow = rowNode => {
144
145
  });
145
146
  return isEmpty;
146
147
  };
147
-
148
148
  /**
149
149
  * Creates a DOM representation of a deleted table row
150
150
  */
151
- const createChangedRowDOM = (rowNode, cellEdgeAttrs, nodeViewSerializer, colorScheme, isInserted, diffType) => {
151
+ const createChangedRowDOM = ({
152
+ rowNode,
153
+ cellEdgeAttrs,
154
+ nodeViewSerializer,
155
+ colorScheme,
156
+ isInserted,
157
+ diffType,
158
+ hasAnchoredRemovedLozenge,
159
+ intl,
160
+ isActive
161
+ }) => {
152
162
  const tr = document.createElement('tr');
153
163
  const colors = colorSchemeRegistry[colorScheme !== null && colorScheme !== void 0 ? colorScheme : 'standard'];
164
+ const deletedTreatment = isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? buildDeletedRowStyle(colors) : resolveDeletedRowStyleLegacy(getLegacyColorScheme(colorScheme));
165
+ const hostsRemovedLozenge = Boolean(intl) && isExtendedEnabled(diffType) && !isInserted && !hasAnchoredRemovedLozenge;
154
166
 
155
167
  // Inserted rows keep their natural styling; the row strikethrough is deletions only.
156
168
  if (!isExtendedEnabled(diffType) || !isInserted) {
157
- tr.setAttribute('style', isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? buildDeletedRowStyle(colors) : resolveDeletedRowStyleLegacy(getLegacyColorScheme(colorScheme)));
169
+ tr.setAttribute('style', deletedTreatment);
158
170
  }
159
171
  // Mirrors the strikethrough condition above: under the extended experience an `isInserted` row
160
172
  // is ADDED content, so it must not claim the "deleted" testid — page models match that as
@@ -195,8 +207,40 @@ const createChangedRowDOM = (rowNode, cellEdgeAttrs, nodeViewSerializer, colorSc
195
207
  cellIndex++;
196
208
  }
197
209
  });
210
+
211
+ // A "Removed" label on the row's last cell, so it reads at the row's top right corner. Deletions
212
+ // only: an inserted row is added content and carries no removal label.
213
+ if (intl && hostsRemovedLozenge) {
214
+ const lastCell = tr.lastElementChild;
215
+ if (lastCell instanceof HTMLElement) {
216
+ lastCell.style.position = 'relative';
217
+ // Append, never prepend. Editor CSS resets the top margin of a cell's first child, so a
218
+ // prepended label pushes the paragraph out of that reset and the row grows taller.
219
+ lastCell.append(createRemovedLozenge(intl, isActive, colorScheme, true));
220
+ }
221
+ }
198
222
  return tr;
199
223
  };
224
+ const supportsAnchorPositioning = () => typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('top', 'anchor(--a top)');
225
+ const createAnchoredRemovedLozengeWidget = ({
226
+ anchorName,
227
+ colorScheme,
228
+ from,
229
+ intl,
230
+ isActive
231
+ }) => {
232
+ const lozenge = createRemovedLozenge(intl, isActive, colorScheme, true);
233
+ const inset = lozenge.style.top;
234
+ lozenge.style.setProperty('position', 'fixed');
235
+ lozenge.style.setProperty('top', `calc(anchor(--${anchorName} top) + ${inset})`);
236
+ lozenge.style.setProperty('left', `calc(anchor(--${anchorName} right) - ${inset})`);
237
+ lozenge.style.setProperty('right', 'auto');
238
+ lozenge.style.setProperty('transform', 'translateX(-100%)');
239
+ return Decoration.widget(from, lozenge, {
240
+ key: `removed-lozenge-${anchorName}`,
241
+ side: -1
242
+ });
243
+ };
200
244
 
201
245
  /**
202
246
  * Expands a diff to include whole changed rows when table rows are affected
@@ -236,6 +280,7 @@ export const createChangedRowDecorationWidgets = ({
236
280
  isActive,
237
281
  isInserted = false,
238
282
  diffType,
283
+ intl,
239
284
  showIndicators = false,
240
285
  showContributorTags = false,
241
286
  tagMountContext
@@ -248,16 +293,25 @@ export const createChangedRowDecorationWidgets = ({
248
293
  diffType
249
294
  });
250
295
  return changedRows.flatMap(changedRow => {
251
- const rowDOM = createChangedRowDOM(changedRow.rowNode, changedRow.cellEdgeAttrs, nodeViewSerializer, colorScheme, isInserted, diffType);
296
+ // Keep the ID stable while contributor tags are enabled so recalculation preserves their state.
297
+ const diffId = showContributorTags ? `widget-row-${changedRow.fromA}-${changedRow.toA}` : crypto.randomUUID();
298
+ const hasAnchoredRemovedLozenge = Boolean(intl) && isExtendedEnabled(diffType) && !isInserted && supportsAnchorPositioning();
299
+ const rowDOM = createChangedRowDOM({
300
+ rowNode: changedRow.rowNode,
301
+ cellEdgeAttrs: changedRow.cellEdgeAttrs,
302
+ nodeViewSerializer,
303
+ colorScheme,
304
+ isInserted,
305
+ diffType,
306
+ hasAnchoredRemovedLozenge,
307
+ intl,
308
+ isActive
309
+ });
252
310
 
253
311
  // Find safe insertion position for the deleted row
254
312
  const safeInsertPos = findSafeInsertPos(newDoc, changedRow.fromB - 1,
255
313
  // -1 to find the first safe position from the table
256
314
  originalDoc.slice(changedRow.fromA, changedRow.toA));
257
-
258
- // Stable while contributor tags are enabled so a decoration recalculation preserves the tag's
259
- // identity and controller state.
260
- const diffId = showContributorTags ? `widget-row-${changedRow.fromA}-${changedRow.toA}` : crypto.randomUUID();
261
315
  const decorations = [];
262
316
  const rowAnchorNames = [];
263
317
 
@@ -286,6 +340,11 @@ export const createChangedRowDecorationWidgets = ({
286
340
  diffId,
287
341
  anchorType: AnchorTypeKey.tag
288
342
  }) : undefined;
343
+ const removedLozengeAnchorName = hasAnchoredRemovedLozenge ? tagAnchorName !== null && tagAnchorName !== void 0 ? tagAnchorName : buildAnchorDecorationKey({
344
+ diffId,
345
+ anchorType: AnchorTypeKey.tag
346
+ }) : undefined;
347
+ const rowLabelAnchorName = tagAnchorName !== null && tagAnchorName !== void 0 ? tagAnchorName : removedLozengeAnchorName;
289
348
  const tagWidget = tagAnchorName ? createContributorTagWidget({
290
349
  anchorAtRangeStart: true,
291
350
  anchorName: tagAnchorName,
@@ -295,11 +354,9 @@ export const createChangedRowDecorationWidgets = ({
295
354
  mountContext: tagMountContext,
296
355
  to: safeInsertPos
297
356
  }) : undefined;
298
- if (tagAnchorName) {
299
- // The tag host is a separate, table-safe widget. CSS anchor positioning aligns it to this
300
- // synthetic row without placing non-cell DOM inside the `<tr>` or inheriting its deletion
301
- // opacity and strikethrough.
302
- rowAnchorNames.push(tagAnchorName);
357
+ if (rowLabelAnchorName) {
358
+ // Labels stay outside the table row so they do not inherit its deletion treatment.
359
+ rowAnchorNames.push(rowLabelAnchorName);
303
360
  }
304
361
  if (rowAnchorNames.length > 0) {
305
362
  rowDOM.style.setProperty('anchor-name', rowAnchorNames.map(anchorName => `--${anchorName}`).join(', '));
@@ -320,6 +377,15 @@ export const createChangedRowDecorationWidgets = ({
320
377
  })
321
378
  });
322
379
  decorations.push(rowWidget);
380
+ if (intl && removedLozengeAnchorName) {
381
+ decorations.push(createAnchoredRemovedLozengeWidget({
382
+ anchorName: removedLozengeAnchorName,
383
+ colorScheme,
384
+ from: safeInsertPos,
385
+ intl,
386
+ isActive
387
+ }));
388
+ }
323
389
  if (tagWidget) {
324
390
  decorations.push(tagWidget);
325
391
  }
@@ -88,14 +88,20 @@ const supportsAnchorPositioning = () => typeof CSS !== 'undefined' && typeof CSS
88
88
  * The host for a tag on deleted content, which renders inside its own widget decoration rather than
89
89
  * needing one of its own. Unmount with `unmountContributorTag` from that widget's `destroy`.
90
90
  */
91
- export const createContributorTagHost = (diffId, mountContext) => {
91
+ export const createContributorTagHost = ({
92
+ anchorName,
93
+ diffId,
94
+ mountContext
95
+ }) => {
92
96
  if (!isContributorTagWidgetEnabled()) {
93
97
  return undefined;
94
98
  }
95
- const host = buildContributorTagHost(diffId);
99
+ const tagAnchorName = anchorName && supportsAnchorPositioning() ? anchorName : undefined;
100
+ const host = buildContributorTagHost(diffId, tagAnchorName);
96
101
  return {
97
102
  host,
98
103
  mount: mountContributorTag({
104
+ anchorName: tagAnchorName,
99
105
  diffId,
100
106
  host,
101
107
  mountContext