@atlaskit/editor-plugin-show-diff 17.0.1 → 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 +15 -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 +2 -2
@@ -1,17 +1,20 @@
1
1
  import { Decoration } from '@atlaskit/editor-prosemirror/view';
2
2
  import { fg } from '@atlaskit/platform-feature-flags/fg';
3
3
  import { isExtendedEnabled } from '../isExtendedEnabled';
4
+ import { countEmptyTextBlockOnlySlice } from '../utils/emptyTextBlocks';
4
5
  import { isEmptyParagraphSlice } from '../utils/isEmptyParagraphSlice';
5
6
  import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
6
7
  import { createChangedRowDecorationWidgets } from './createChangedRowDecorationWidgets';
7
8
  import { createContributorTagHost, unmountContributorTag } from './createContributorTagWidget';
8
- import { buildDiffDecorationSpec, buildAnchorDecorationKey, scrollMarginTopValue } from './decorationKeys';
9
+ import { AnchorTypeKey, buildDiffDecorationSpec, buildAnchorDecorationKey, scrollMarginTopValue } from './decorationKeys';
9
10
  import { absorbFirstChildMarginReset } from './utils/absorbFirstChildMarginReset';
11
+ import { createDeletedLineBreakDecoration } from './utils/createDeletedLineBreakWidget';
10
12
  import { createMarginAbsorber } from './utils/createMarginAbsorber';
11
13
  import { createNodeShapedMarginSpacer } from './utils/createNodeShapedMarginSpacer';
12
14
  import { findSafeInsertPos } from './utils/findSafeInsertPos';
13
15
  import { safeResolve } from './utils/safeResolve';
14
- import { wrapBlockNodeView, injectInnerWrapper, createContentWrapper } from './utils/wrapBlockNodeView';
16
+ import { getTableDiffMode } from './utils/tableDiffMode';
17
+ import { wrapBlockNodeView, createRemovedLozenge, injectInnerWrapper, createContentWrapper } from './utils/wrapBlockNodeView';
15
18
  const isHeadingLevel = level => typeof level === 'number' && level >= 1 && level <= 6;
16
19
 
17
20
  /**
@@ -119,11 +122,20 @@ export const createNodeChangedDecorationWidget = ({
119
122
  diffType,
120
123
  hideAddedDiffsUnderline = false,
121
124
  reveal,
122
- tagMountContext
125
+ tagMountContext,
126
+ deletedColumns
123
127
  }) => {
124
128
  var _$safeInsertPos$nodeB, _slice$content$firstC, _newDoc$firstChild, _slice$content$firstC2;
125
129
  const slice = doc.slice(change.fromA, change.toA);
126
130
  const shouldSkipDeletedEmptyParagraphDecoration = !isInserted && isEmptyParagraphSlice(slice);
131
+ // How many blank lines the deletion took, when it took nothing else. Blank means an empty
132
+ // paragraph or heading — both are a line the author put there deliberately, and neither leaves
133
+ // the widget any content to serialize.
134
+ //
135
+ // Kept separate from `shouldSkipDeletedEmptyParagraphDecoration` and gated, so the ungated early
136
+ // return below still covers only the single-empty-paragraph case it always did. Everything else
137
+ // degrades to what it rendered before the gate: an empty block, serialized invisibly.
138
+ const deletedBlankLineCount = !isInserted && fg('platform_editor_ai_show_diff_patch_2') ? countEmptyTextBlockOnlySlice(slice) : 0;
127
139
  // Widget decoration used for deletions as the content is not in the document
128
140
  // and we want to display the deleted content with a style.
129
141
  // For `placeBelow`, anchor at the END of the new content (change.toB) so the deleted
@@ -131,16 +143,33 @@ export const createNodeChangedDecorationWidget = ({
131
143
  const anchorPos = placeBelow ? change.toB : change.fromB;
132
144
  const safeInsertPos = findSafeInsertPos(newDoc, anchorPos, slice);
133
145
  const isActive = activeIndexPos && (fg('confluence_ncs_step_diffing_version_history') ? safeInsertPos >= activeIndexPos.from && safeInsertPos <= activeIndexPos.to : safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to);
134
- if (slice.content.content.length === 0 || shouldSkipDeletedEmptyParagraphDecoration) {
146
+ if (deletedBlankLineCount > 0) {
147
+ // The blocks have no content to serialize, so the deletion is shown as one struck-through
148
+ // return glyph per line the author removed. A single widget, not one per line: the lines are
149
+ // gone from `newDoc`, so they all resolve to the same anchor and separate widgets would stack.
150
+ //
151
+ // No attribution: the glyph is not a tag host, matching `canTagWidget` below.
152
+ return [createDeletedLineBreakDecoration({
153
+ colorScheme,
154
+ count: deletedBlankLineCount,
155
+ diffType,
156
+ isActive,
157
+ pos: safeInsertPos,
158
+ reveal,
159
+ side: placeBelow ? 1 : -1
160
+ })];
161
+ }
162
+ if (shouldSkipDeletedEmptyParagraphDecoration) {
135
163
  return [];
136
164
  }
137
- const isTableCellContent = slice.content.content.some(() => slice.content.content.some(siblingNode => ['tableHeader', 'tableCell'].includes(siblingNode.type.name)));
138
- const isTableRowContent = slice.content.content.some(() => slice.content.content.some(siblingNode => ['tableRow'].includes(siblingNode.type.name)));
139
-
140
- // Added whole cells (e.g. "Add table headers"): render per-cell content widgets.
141
- // Deleted whole cells (delete row/col) fall through — they don't exist in `newDoc`,
142
- // so there's nowhere to anchor content and we keep the original "render nothing".
143
- if (isTableCellContent && isInserted) {
165
+ if (slice.content.content.length === 0) {
166
+ return [];
167
+ }
168
+ const tableDiffMode = getTableDiffMode({
169
+ slice,
170
+ isInserted
171
+ });
172
+ if (tableDiffMode.kind === 'cells') {
144
173
  return createTableCellContentWidgets({
145
174
  slice,
146
175
  newDoc,
@@ -151,11 +180,10 @@ export const createNodeChangedDecorationWidget = ({
151
180
  diffType
152
181
  });
153
182
  }
154
- if (isTableCellContent) {
155
- // Deleted whole cells: nothing to render (see note above).
183
+ if (tableDiffMode.kind === 'none') {
156
184
  return [];
157
185
  }
158
- if (isTableRowContent) {
186
+ if (tableDiffMode.kind === 'rows') {
159
187
  return createChangedRowDecorationWidgets({
160
188
  attributionKey,
161
189
  changes: [change],
@@ -166,6 +194,7 @@ export const createNodeChangedDecorationWidget = ({
166
194
  isActive,
167
195
  isInserted,
168
196
  diffType,
197
+ intl,
169
198
  // Needed for the row's own indicator anchor; this path returns before the
170
199
  // `anchor-name` assignment further down.
171
200
  showIndicators,
@@ -173,6 +202,9 @@ export const createNodeChangedDecorationWidget = ({
173
202
  tagMountContext
174
203
  });
175
204
  }
205
+ // `wholeTable` and `generic` both continue into the generic block widget below. Splitting the
206
+ // whole-table case into its own renderer is the next step of this refactor.
207
+
176
208
  const serializer = nodeViewSerializer;
177
209
 
178
210
  // For non-table content, use the existing span wrapper approach
@@ -368,6 +400,40 @@ export const createNodeChangedDecorationWidget = ({
368
400
  }
369
401
  }
370
402
  });
403
+ let contributorTagAnchorName;
404
+ if (!isInserted && deletedColumns !== null && deletedColumns !== void 0 && deletedColumns.length) {
405
+ const table = dom.querySelector('table');
406
+ const firstRow = table === null || table === void 0 ? void 0 : table.rows[0];
407
+ if (table && canTagWidget) {
408
+ contributorTagAnchorName = buildAnchorDecorationKey({
409
+ diffId,
410
+ anchorType: AnchorTypeKey.tag
411
+ });
412
+ // The widget host sits before the table's preserved block margin. Anchor the tag to the
413
+ // table itself so its bottom edge meets the table's top edge with no visual gap.
414
+ table.style.setProperty('anchor-name', `--${contributorTagAnchorName}`);
415
+ }
416
+ deletedColumns.forEach(column => {
417
+ var _table$rows;
418
+ Array.from((_table$rows = table === null || table === void 0 ? void 0 : table.rows) !== null && _table$rows !== void 0 ? _table$rows : []).forEach(row => {
419
+ var _cell$children;
420
+ const cell = row.cells[column];
421
+ Array.from((_cell$children = cell === null || cell === void 0 ? void 0 : cell.children) !== null && _cell$children !== void 0 ? _cell$children : []).forEach(child => {
422
+ if (child instanceof HTMLElement) {
423
+ child.style.setProperty('text-decoration-line', 'line-through');
424
+ }
425
+ });
426
+ });
427
+ const headerCell = firstRow === null || firstRow === void 0 ? void 0 : firstRow.cells[column];
428
+ if (headerCell) {
429
+ headerCell.style.position = 'relative';
430
+ // Append, never prepend. Editor CSS resets the top margin of a cell's first child, so
431
+ // a prepended label pushes the paragraph out of that reset and the cell grows.
432
+ // The label is absolutely positioned, so DOM order does not move it.
433
+ headerCell.append(createRemovedLozenge(intl, isActive, colorScheme, true));
434
+ }
435
+ });
436
+ }
371
437
  dom.setAttribute('data-testid', 'show-diff-deleted-decoration');
372
438
  if (fg('platform_editor_ai_show_diff_patch_1')) {
373
439
  dom.style.setProperty('scroll-margin-top', scrollMarginTopValue);
@@ -405,7 +471,11 @@ export const createNodeChangedDecorationWidget = ({
405
471
 
406
472
  // Hosted at the start of the deleted run rather than on `dom` itself: `dom` is inline, so once
407
473
  // the deleted content wraps its box is the union of its line fragments.
408
- const tagHost = createContributorTagHost(diffId, tagMountContext);
474
+ const tagHost = createContributorTagHost({
475
+ anchorName: contributorTagAnchorName,
476
+ diffId,
477
+ mountContext: tagMountContext
478
+ });
409
479
  if (tagHost) {
410
480
  dom.prepend(tagHost.host);
411
481
  tagMount = tagHost.mount;
@@ -0,0 +1,100 @@
1
+ import { Decoration } from '@atlaskit/editor-prosemirror/view';
2
+ import { fg } from '@atlaskit/platform-feature-flags/fg';
3
+ import { isExtendedEnabled } from '../../isExtendedEnabled';
4
+ import { buildDiffDecorationSpec, scrollMarginTopValue } from '../decorationKeys';
5
+ import { createContentWrapper } from './wrapBlockNodeView';
6
+
7
+ /**
8
+ * U+2936 ARROW POINTING DOWNWARDS THEN CURVING LEFTWARDS — the return-key glyph.
9
+ *
10
+ * Exported so tests assert against the glyph actually rendered rather than a copy of it.
11
+ */
12
+ export const RETURN_GLYPH = '⤶';
13
+
14
+ /**
15
+ * The DOM for a removed blank line: the return glyph, struck through in the deleted colours.
16
+ *
17
+ * An empty paragraph or heading is a line the author deliberately added, but it has no content for
18
+ * the deleted-content widget to serialize, so a deletion of one used to render nothing at all — the
19
+ * reviewer saw the surrounding text close up with no sign of what was removed. Standing the glyph
20
+ * in for the absent content keeps the deletion visible without inventing content that was not there.
21
+ *
22
+ * `createContentWrapper` supplies the highlight and the strikethrough line, so the glyph is styled
23
+ * exactly like any other run of deleted inline content, including the active and reveal states.
24
+ *
25
+ * Hidden from assistive technology: the glyph is a picture of a key, and read out it is announced as
26
+ * its Unicode name rather than as a removed line.
27
+ */
28
+ export const createDeletedLineBreakWidget = ({
29
+ colorScheme,
30
+ count = 1,
31
+ diffType,
32
+ isActive = false,
33
+ reveal
34
+ }) => {
35
+ const dom = document.createElement('span');
36
+ // The testid deleted content is matched by, so the glyph is found wherever deleted content is.
37
+ dom.setAttribute('data-testid', 'show-diff-deleted-decoration');
38
+ dom.setAttribute('aria-hidden', 'true');
39
+ dom.contentEditable = 'false';
40
+ // Scroll navigation aligns on the widget itself, as it does for every other deleted widget.
41
+ dom.style.setProperty('scroll-margin-top', scrollMarginTopValue);
42
+ for (let index = 0; index < Math.max(count, 1); index++) {
43
+ if (index > 0) {
44
+ // Each removed line gets its own line in the widget, so a run of blank lines reads as the
45
+ // number of lines it was. `br` rather than a block wrapper: the widget is inline, and the
46
+ // deleted-content wrapper is styled as an inline run.
47
+ dom.append(dom.ownerDocument.createElement('br'));
48
+ }
49
+ const wrapper = createContentWrapper(colorScheme, isActive, false, diffType, reveal);
50
+ wrapper.append(dom.ownerDocument.createTextNode(RETURN_GLYPH));
51
+ dom.append(wrapper);
52
+ }
53
+ return dom;
54
+ };
55
+
56
+ /**
57
+ * The glyph as a widget decoration anchored at `pos`.
58
+ *
59
+ * Shared by the two shapes a blank-line removal arrives in. On a forward diff the block is gone from
60
+ * the new document, so the change lands on the deleted side and the glyph is anchored where the
61
+ * block used to be. On an inverted diff — what AI suggested edits renders — the block is still in
62
+ * the displayed document and the change lands on the *inserted* side, so the glyph is anchored
63
+ * inside the surviving block, on the blank line itself.
64
+ */
65
+ export const createDeletedLineBreakDecoration = ({
66
+ attributionKey,
67
+ colorScheme,
68
+ count,
69
+ diffType,
70
+ isActive = false,
71
+ pos,
72
+ reveal,
73
+ side
74
+ }) => Decoration.widget(pos, createDeletedLineBreakWidget({
75
+ colorScheme,
76
+ count,
77
+ diffType,
78
+ isActive,
79
+ reveal
80
+ }), {
81
+ ...buildDiffDecorationSpec({
82
+ attributionKey,
83
+ colorScheme,
84
+ decorationType: 'widget',
85
+ diffId: crypto.randomUUID(),
86
+ isActive,
87
+ // The glyph always stands in for removed content, whichever side of the changeset the
88
+ // change itself landed on.
89
+ isInserted: false,
90
+ diffType,
91
+ ...(isExtendedEnabled(diffType) && side !== undefined && {
92
+ side
93
+ })
94
+ }),
95
+ // Without an explicit mark set, prosemirror-view wraps the widget in the marks of the
96
+ // adjacent text. The glyph carries its own deleted styling and must not inherit them.
97
+ ...(fg('platform_editor_diff_inline_mark_changes') && {
98
+ marks: []
99
+ })
100
+ });
@@ -0,0 +1,27 @@
1
+ const CELL_TYPE_NAMES = ['tableCell', 'tableHeader'];
2
+ export const getTableDiffMode = ({
3
+ slice,
4
+ isInserted
5
+ }) => {
6
+ const topLevelNodes = slice.content.content;
7
+ if (topLevelNodes.some(node => CELL_TYPE_NAMES.includes(node.type.name))) {
8
+ return isInserted ? {
9
+ kind: 'cells'
10
+ } : {
11
+ kind: 'none'
12
+ };
13
+ }
14
+ if (topLevelNodes.some(node => node.type.name === 'tableRow')) {
15
+ return {
16
+ kind: 'rows'
17
+ };
18
+ }
19
+ if (topLevelNodes.length === 1 && topLevelNodes[0].type.name === 'table' && slice.openStart === 0 && slice.openEnd === 0) {
20
+ return {
21
+ kind: 'wholeTable'
22
+ };
23
+ }
24
+ return {
25
+ kind: 'generic'
26
+ };
27
+ };
@@ -139,24 +139,34 @@ const applyCellOverlayStyles = ({
139
139
  /**
140
140
  * Creates a "Removed" lozenge to be displayed at the top right corner of deleted block nodes
141
141
  */
142
- const createRemovedLozenge = (intl, isActive = false, colorScheme) => {
142
+ export const createRemovedLozenge = (intl, isActive = false, colorScheme, inCell = false) => {
143
143
  const container = document.createElement('span');
144
144
  const containerStyle = convertToInlineCss({
145
145
  position: 'absolute',
146
146
  top: "var(--ds-space-075, 6px)",
147
147
  right: "var(--ds-space-075, 6px)",
148
- zIndex: 2,
148
+ // Rounded table-cell overlays use z-index 2 and are appended after the cell content.
149
+ // Keep the lozenge above them so the overlay cannot tint or obscure it.
150
+ zIndex: 3,
149
151
  pointerEvents: 'none',
150
152
  display: 'flex'
151
153
  });
152
- container.setAttribute('style', containerStyle);
153
- container.setAttribute('data-testid', 'show-diff-removed-lozenge');
154
154
 
155
155
  // Create vanilla HTML lozenge element with Atlaskit Lozenge styling (visual refresh)
156
156
  const lozengeElement = document.createElement('span');
157
- const lozengeInnerStyle = resolveRemovedLozengeStyle(colorScheme, isActive);
157
+
158
+ // A column label sits over an already-grey deleted cell, so use the red Removed treatment.
159
+ const lozengeInnerStyle = resolveRemovedLozengeStyle(colorScheme, inCell || isActive);
158
160
  lozengeElement.setAttribute('style', lozengeInnerStyle);
159
161
  lozengeElement.textContent = intl.formatMessage(trackChangesMessages.removed).toUpperCase();
162
+ if (inCell) {
163
+ lozengeElement.setAttribute('style', `${lozengeInnerStyle};${containerStyle}`);
164
+ lozengeElement.setAttribute('data-testid', 'show-diff-removed-lozenge');
165
+ lozengeElement.contentEditable = 'false';
166
+ return lozengeElement;
167
+ }
168
+ container.setAttribute('style', containerStyle);
169
+ container.setAttribute('data-testid', 'show-diff-removed-lozenge');
160
170
  container.appendChild(lozengeElement);
161
171
  return container;
162
172
  };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The textblock types whose empty form is a blank line the author put there deliberately, and so
3
+ * worth marking when a change removes it.
4
+ *
5
+ * Deliberately not every textblock: an empty `codeBlock` is an empty code block, which renders as a
6
+ * visible box of its own and needs no stand-in.
7
+ */
8
+ const BLANK_LINE_NODE_NAMES = new Set(['paragraph', 'heading']);
9
+
10
+ /**
11
+ * Structurally empty: no children at all, not merely no measurable content. The two coincide for a
12
+ * real node, and testing both keeps a node whose children report no size from passing as blank.
13
+ */
14
+ const isEmptyTextBlock = node => BLANK_LINE_NODE_NAMES.has(node.type.name) && node.content.childCount === 0 && node.content.size === 0;
15
+
16
+ /**
17
+ * How many empty textblocks a slice holds, or 0 unless that is *all* it holds.
18
+ *
19
+ * Counted rather than tested for one, because a run of adjacent blank lines is reported as a single
20
+ * change covering all of them — the case that made a single-block test miss.
21
+ */
22
+ export const countEmptyTextBlockOnlySlice = slice => {
23
+ var _slice$content$childC;
24
+ const childCount = (_slice$content$childC = slice === null || slice === void 0 ? void 0 : slice.content.childCount) !== null && _slice$content$childC !== void 0 ? _slice$content$childC : 0;
25
+ if (!slice || childCount === 0) {
26
+ return 0;
27
+ }
28
+ for (let index = 0; index < childCount; index++) {
29
+ if (!isEmptyTextBlock(slice.content.child(index))) {
30
+ return 0;
31
+ }
32
+ }
33
+ return childCount;
34
+ };
35
+
36
+ /**
37
+ * The content position of every empty textblock lying wholly within `[from, to]`.
38
+ *
39
+ * Used to place a marker on each blank line a change removes. Partially covered blocks are skipped:
40
+ * the change only took part of one, so it still has text of its own, which the inline decoration
41
+ * already marks.
42
+ */
43
+ export const emptyTextBlockContentPositions = (doc, from, to) => {
44
+ const positions = [];
45
+ doc.nodesBetween(from, to, (node, pos) => {
46
+ if (node.isTextblock) {
47
+ if (isEmptyTextBlock(node) && pos >= from && pos + node.nodeSize <= to) {
48
+ // +1 steps over the block's own open token, into its (empty) content.
49
+ positions.push(pos + 1);
50
+ }
51
+ // A textblock holds inline content only, so there is nothing below it to visit.
52
+ return false;
53
+ }
54
+ // Keep descending: blank lines nest inside cells, columns and panels.
55
+ return true;
56
+ });
57
+ return positions;
58
+ };