@atlaskit/editor-plugin-show-diff 16.1.0 → 16.2.0

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 (64) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/afm-products/tsconfig.json +3 -0
  3. package/dist/cjs/pm-plugins/calculateDiff/calculateDiffDecorations.js +67 -22
  4. package/dist/cjs/pm-plugins/calculateDiff/simplifyChangesWithAttribution.js +242 -26
  5. package/dist/cjs/pm-plugins/decorations/colorSchemes/factory.js +71 -37
  6. package/dist/cjs/pm-plugins/decorations/colorSchemes/schemes.js +5 -1
  7. package/dist/cjs/pm-plugins/decorations/colorSchemes/types.js +0 -2
  8. package/dist/cjs/pm-plugins/decorations/createBlockChangedDecoration.js +21 -14
  9. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +83 -3
  10. package/dist/cjs/pm-plugins/decorations/extractContributorTags.js +123 -43
  11. package/dist/cjs/pm-plugins/decorations/revealStyles.js +5 -1
  12. package/dist/cjs/pm-plugins/decorations/utils/absorbFirstChildMarginReset.js +33 -0
  13. package/dist/cjs/pm-plugins/decorations/utils/createMarginAbsorber.js +43 -0
  14. package/dist/cjs/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.js +76 -0
  15. package/dist/cjs/pm-plugins/decorations/utils/safeResolve.js +24 -0
  16. package/dist/cjs/pm-plugins/getScrollableDecorations.js +48 -19
  17. package/dist/cjs/pm-plugins/utils/baseNodeName.js +25 -0
  18. package/dist/cjs/pm-plugins/utils/taggableBlockNodes.js +37 -0
  19. package/dist/cjs/ui/ContributorTag/contributorTagController.js +4 -4
  20. package/dist/es2019/pm-plugins/calculateDiff/calculateDiffDecorations.js +39 -2
  21. package/dist/es2019/pm-plugins/calculateDiff/simplifyChangesWithAttribution.js +196 -10
  22. package/dist/es2019/pm-plugins/decorations/colorSchemes/factory.js +51 -19
  23. package/dist/es2019/pm-plugins/decorations/colorSchemes/schemes.js +3 -0
  24. package/dist/es2019/pm-plugins/decorations/colorSchemes/types.js +0 -2
  25. package/dist/es2019/pm-plugins/decorations/createBlockChangedDecoration.js +23 -14
  26. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +83 -3
  27. package/dist/es2019/pm-plugins/decorations/extractContributorTags.js +60 -7
  28. package/dist/es2019/pm-plugins/decorations/revealStyles.js +5 -1
  29. package/dist/es2019/pm-plugins/decorations/utils/absorbFirstChildMarginReset.js +29 -0
  30. package/dist/es2019/pm-plugins/decorations/utils/createMarginAbsorber.js +39 -0
  31. package/dist/es2019/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.js +69 -0
  32. package/dist/es2019/pm-plugins/decorations/utils/safeResolve.js +18 -0
  33. package/dist/es2019/pm-plugins/getScrollableDecorations.js +44 -19
  34. package/dist/es2019/pm-plugins/utils/baseNodeName.js +18 -0
  35. package/dist/es2019/pm-plugins/utils/taggableBlockNodes.js +28 -0
  36. package/dist/es2019/ui/ContributorTag/contributorTagController.js +4 -4
  37. package/dist/esm/pm-plugins/calculateDiff/calculateDiffDecorations.js +67 -22
  38. package/dist/esm/pm-plugins/calculateDiff/simplifyChangesWithAttribution.js +243 -26
  39. package/dist/esm/pm-plugins/decorations/colorSchemes/factory.js +71 -37
  40. package/dist/esm/pm-plugins/decorations/colorSchemes/schemes.js +5 -1
  41. package/dist/esm/pm-plugins/decorations/colorSchemes/types.js +0 -2
  42. package/dist/esm/pm-plugins/decorations/createBlockChangedDecoration.js +21 -14
  43. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +83 -3
  44. package/dist/esm/pm-plugins/decorations/extractContributorTags.js +123 -43
  45. package/dist/esm/pm-plugins/decorations/revealStyles.js +5 -1
  46. package/dist/esm/pm-plugins/decorations/utils/absorbFirstChildMarginReset.js +28 -0
  47. package/dist/esm/pm-plugins/decorations/utils/createMarginAbsorber.js +37 -0
  48. package/dist/esm/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.js +70 -0
  49. package/dist/esm/pm-plugins/decorations/utils/safeResolve.js +18 -0
  50. package/dist/esm/pm-plugins/getScrollableDecorations.js +48 -19
  51. package/dist/esm/pm-plugins/utils/baseNodeName.js +20 -0
  52. package/dist/esm/pm-plugins/utils/taggableBlockNodes.js +32 -0
  53. package/dist/esm/ui/ContributorTag/contributorTagController.js +4 -4
  54. package/dist/types/pm-plugins/calculateDiff/simplifyChangesWithAttribution.d.ts +20 -1
  55. package/dist/types/pm-plugins/decorations/colorSchemes/factory.d.ts +4 -11
  56. package/dist/types/pm-plugins/decorations/colorSchemes/types.d.ts +9 -8
  57. package/dist/types/pm-plugins/decorations/utils/absorbFirstChildMarginReset.d.ts +23 -0
  58. package/dist/types/pm-plugins/decorations/utils/createMarginAbsorber.d.ts +23 -0
  59. package/dist/types/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.d.ts +26 -0
  60. package/dist/types/pm-plugins/decorations/utils/safeResolve.d.ts +13 -0
  61. package/dist/types/pm-plugins/getScrollableDecorations.d.ts +2 -2
  62. package/dist/types/pm-plugins/utils/baseNodeName.d.ts +16 -0
  63. package/dist/types/pm-plugins/utils/taggableBlockNodes.d.ts +14 -0
  64. package/package.json +14 -5
@@ -6,7 +6,11 @@ import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
6
6
  import { createChangedRowDecorationWidgets } from './createChangedRowDecorationWidgets';
7
7
  import { createContributorTagHost, unmountContributorTag } from './createContributorTagWidget';
8
8
  import { buildDiffDecorationSpec, buildAnchorDecorationKey, scrollMarginTopValue } from './decorationKeys';
9
+ import { absorbFirstChildMarginReset } from './utils/absorbFirstChildMarginReset';
10
+ import { createMarginAbsorber } from './utils/createMarginAbsorber';
11
+ import { createNodeShapedMarginSpacer } from './utils/createNodeShapedMarginSpacer';
9
12
  import { findSafeInsertPos } from './utils/findSafeInsertPos';
13
+ import { safeResolve } from './utils/safeResolve';
10
14
  import { wrapBlockNodeView, injectInnerWrapper, createContentWrapper } from './utils/wrapBlockNodeView';
11
15
  const isHeadingLevel = level => typeof level === 'number' && level >= 1 && level <= 6;
12
16
 
@@ -174,6 +178,14 @@ export const createNodeChangedDecorationWidget = ({
174
178
  // For non-table content, use the existing span wrapper approach
175
179
  const dom = document.createElement('span');
176
180
  const $safeInsertPos = newDoc.resolve(safeInsertPos);
181
+
182
+ // Whether the widget renders above the very start of its parent's content — the document, a
183
+ // layout column, a table cell, a panel.
184
+ //
185
+ // `parentOffset === 0` is the whole test on the position side: prosemirror-view paints every
186
+ // widget at a position before the node starting there, so a leading widget at the parent's start
187
+ // always renders above its content.
188
+ const isVisuallyFirstInParent = !placeBelow && $safeInsertPos.parentOffset === 0;
177
189
  const isTopLevelInsert = $safeInsertPos.depth === 0;
178
190
  const hasPreviousBlock = ((_$safeInsertPos$nodeB = $safeInsertPos.nodeBefore) === null || _$safeInsertPos$nodeB === void 0 ? void 0 : _$safeInsertPos$nodeB.isBlock) === true;
179
191
  const isFirstDocHeadingReplacement = isExtendedEnabled(diffType) && !placeBelow && change.fromB === 0 && ((_slice$content$firstC = slice.content.firstChild) === null || _slice$content$firstC === void 0 ? void 0 : _slice$content$firstC.type.name) === 'heading' && ((_newDoc$firstChild = newDoc.firstChild) === null || _newDoc$firstChild === void 0 ? void 0 : _newDoc$firstChild.type.name) === 'heading';
@@ -225,6 +237,20 @@ export const createNodeChangedDecorationWidget = ({
225
237
  const firstReplacedNode = slice.content.firstChild;
226
238
  const lastReplacedNode = slice.content.lastChild;
227
239
  const showDiffPatch1 = fg('platform_editor_ai_show_diff_patch_1');
240
+ // Whether the slice's outermost textblocks still hold all of their original content.
241
+ //
242
+ // `openStart`/`openEnd` say whether the cut landed inside a block, but not whether it took
243
+ // any text with it — a deletion stopping exactly at a block's content boundary is still
244
+ // reported as open. Resolving the change's own ends separates the two, so a block whose text
245
+ // survived intact can render as a block even though its boundary was open.
246
+ //
247
+ // Both resolves sit behind the gate that consumes them, so this adds no position arithmetic to
248
+ // the ungated path, and an unresolvable position degrades to the `openStart`/`openEnd` test on
249
+ // its own — the block is then treated as partial, which is what it rendered as before the gate.
250
+ const $changeFromA = fg('platform_editor_ai_show_diff_patch_2') ? safeResolve(doc, change.fromA) : null;
251
+ const $changeToA = fg('platform_editor_ai_show_diff_patch_2') ? safeResolve(doc, change.toA) : null;
252
+ const isFirstNodeContentComplete = slice.openStart === 0 || ($changeFromA === null || $changeFromA === void 0 ? void 0 : $changeFromA.parentOffset) === 0;
253
+ const isLastNodeContentComplete = slice.openEnd === 0 || $changeToA !== null && $changeToA.parentOffset === $changeToA.parent.content.size;
228
254
  const isCompleteSameTypeReplacement = slice.content.childCount === 1 && firstReplacedNode !== null && replacementNode !== null && firstReplacedNode.type === replacementNode.type && replacementNode.nodeSize === change.toB - change.fromB;
229
255
 
230
256
  /*
@@ -237,10 +263,16 @@ export const createNodeChangedDecorationWidget = ({
237
263
  const isLast = lastReplacedNode === node;
238
264
  const isOpenAtSliceBoundary = isFirst && slice.openStart > 0 || isLast && slice.openEnd > 0;
239
265
  const shouldPreserveCompleteMultiInlineBlock = showDiffPatch1 && node.isBlock && node.type.inlineContent && node.content.childCount > 1 && !isOpenAtSliceBoundary && isCompleteSameTypeReplacement;
266
+ // A textblock is otherwise serialized as its inline content only, with no `<p>`/`<h2>` wrapper.
267
+ // That is right when the diff cuts into an existing block — the deleted text belongs on the same
268
+ // line as the text that replaced it — but when the whole block went away it drops the very
269
+ // element that carries the block's margin, so the deleted block renders flush against its
270
+ // neighbours. Only the first and last children can be partial; middle ones are always complete.
271
+ const shouldRenderAsBlockNode = fg('platform_editor_ai_show_diff_patch_2') && node.isTextblock && (!isFirst || isFirstNodeContentComplete) && (!isLast || isLastNodeContentComplete);
240
272
 
241
273
  // Helper function to handle multiple child nodes
242
274
  const handleMultipleChildNodes = node => {
243
- if (!shouldPreserveCompleteMultiInlineBlock && node.content.childCount > 1 && node.type.inlineContent) {
275
+ if (!shouldPreserveCompleteMultiInlineBlock && !shouldRenderAsBlockNode && node.content.childCount > 1 && node.type.inlineContent) {
244
276
  node.content.forEach(childNode => {
245
277
  const childNodeView = serializer.tryCreateNodeView(childNode);
246
278
  if (childNodeView) {
@@ -270,7 +302,7 @@ export const createNodeChangedDecorationWidget = ({
270
302
  if (handleMultipleChildNodes(node)) {
271
303
  return;
272
304
  }
273
- if (shouldPreserveCompleteMultiInlineBlock) {
305
+ if (shouldPreserveCompleteMultiInlineBlock || shouldRenderAsBlockNode) {
274
306
  fallbackSerialization = () => serializer.serializeNode(node);
275
307
  } else if ((isFirst || isLast && slice.content.childCount > 2) && hasInlineContent) {
276
308
  fallbackSerialization = () => serializer.serializeFragment(node.content);
@@ -341,6 +373,21 @@ export const createNodeChangedDecorationWidget = ({
341
373
  dom.style.setProperty('scroll-margin-top', scrollMarginTopValue);
342
374
  }
343
375
 
376
+ // A block node serialized into the widget is the widget's first child, and the editor's
377
+ // first-child reset is written against the parent element rather than the document position — so
378
+ // it zeroes the margin the block wrapper was kept for in the first place.
379
+ //
380
+ // Only away from the parent's start. There the reset is the correct outcome: the widget is the
381
+ // first thing in the document, column, cell or panel, and a leading gap above it would be wrong.
382
+ // The margin that matters at that position belongs to the node *below* the widget, which the
383
+ // shaped spacer pair supplies instead.
384
+ if (fg('platform_editor_ai_show_diff_patch_2') && !isVisuallyFirstInParent) {
385
+ absorbFirstChildMarginReset({
386
+ dom,
387
+ testId: 'show-diff-widget-margin-absorber'
388
+ });
389
+ }
390
+
344
391
  // Needed even when the indicator bar is off, because a contributor tag also anchors against the
345
392
  // widget.
346
393
  if ((showIndicators || showContributorTags) && isExtendedEnabled(diffType)) {
@@ -410,7 +457,40 @@ export const createNodeChangedDecorationWidget = ({
410
457
  const isPureDeletion = change.fromB === change.toB;
411
458
  const isSingleBlock = slice.content.childCount === 1 && ((_slice$content$firstC2 = slice.content.firstChild) === null || _slice$content$firstC2 === void 0 ? void 0 : _slice$content$firstC2.isBlock);
412
459
  const isDiffWidgetAtStartOfDoc = $safeInsertPos.depth === 0 && $safeInsertPos.index(0) === 0;
413
- if (isDiffWidgetAtStartOfDoc && isSingleBlock && isPureDeletion && isExtendedEnabled(diffType)) {
460
+
461
+ // A node at the start of its parent has its top margin reset, so a diff widget rendered above
462
+ // it sits flush against it. This applies wherever that parent is — the document, a layout
463
+ // column, a table cell — so it is decided from the anchor rather than from the document root
464
+ // (see `isVisuallyFirstInParent` above).
465
+ //
466
+ // Away from the parent's start the node below keeps its own margin, and it is the block *inside*
467
+ // the widget that loses one — handled by `absorbFirstChildMarginReset` above, not here.
468
+ const nodeAfterWidget = $safeInsertPos.nodeAfter;
469
+ if (fg('platform_editor_ai_show_diff_patch_2') && isExtendedEnabled(diffType) && isVisuallyFirstInParent && nodeAfterWidget) {
470
+ const shapedSpacer = createNodeShapedMarginSpacer({
471
+ node: nodeAfterWidget,
472
+ serializer,
473
+ testId: 'show-diff-shaped-margin-spacer'
474
+ });
475
+ if (shapedSpacer) {
476
+ // Two elements, because the two families of reset have to be handled differently. The
477
+ // absorber takes the document-level adjacent-sibling reset for a leading widget, whose
478
+ // `!important` would otherwise zero the shaped spacer's margin; the shaped spacer, now one
479
+ // position further along, is out of that rule's reach and keeps the margin it supplies.
480
+ // Inside containers the counting selectors skip both, so only the shaped one has an effect.
481
+ //
482
+ // Sides order the widgets against each other only — both still paint before the real node.
483
+ decorations.push(Decoration.widget(safeInsertPos, createMarginAbsorber({
484
+ testId: 'show-diff-margin-absorber'
485
+ }), {
486
+ side: 0
487
+ }));
488
+ decorations.push(Decoration.widget(safeInsertPos, shapedSpacer, {
489
+ side: 1
490
+ }));
491
+ }
492
+ }
493
+ if (!fg('platform_editor_ai_show_diff_patch_2') && isDiffWidgetAtStartOfDoc && isSingleBlock && isPureDeletion && isExtendedEnabled(diffType)) {
414
494
  const followingNode = $safeInsertPos.nodeAfter;
415
495
  const headingLevel = (followingNode === null || followingNode === void 0 ? void 0 : followingNode.type.name) === 'heading' ? followingNode.attrs.level : undefined;
416
496
  if (isHeadingLevel(headingLevel)) {
@@ -34,6 +34,12 @@ const leadsReplacement = widget => fg('confluence_ncs_step_diffing_version_histo
34
34
  */
35
35
  const isSameBlockChange = (inner, block) => inner.spec.attributionKey === block.spec.attributionKey && (block.from <= inner.from && inner.to <= block.to || inner.from <= block.from && block.to <= inner.to);
36
36
 
37
+ /**
38
+ * Whether `outer` is the box `inner` sits in, for one contributor. Nested blocks they inserted read
39
+ * as one change, so only the outermost keeps a tag (EDITOR-8932).
40
+ */
41
+ const containsBlock = (outer, inner) => outer.spec.attributionKey === inner.spec.attributionKey && outer.from <= inner.from && inner.to <= outer.to && outer.to - outer.from > inner.to - inner.from;
42
+
37
43
  /**
38
44
  * Which of two changes in the same range leads, and so is the one a tag captions. Position first,
39
45
  * then `side`, since deleted content shares its `from` with the content that replaced it and paints
@@ -52,6 +58,15 @@ const containedBy = (targets, {
52
58
  decoration
53
59
  }) => from <= decoration.from && decoration.to <= to).sort(byLeadingPosition);
54
60
 
61
+ /**
62
+ * The stop a target is stepped to by: the narrowest one that wholly covers it. A stop's range is
63
+ * the union of the decorations grouped into it, so one inserted run's inline highlight stretches
64
+ * its stop over every block the run spans — including blocks that are a stop of their own. Picking
65
+ * the narrowest keeps each of those blocks in the stop that actually reaches it, rather than
66
+ * letting the widest stop claim them all and fold away every tag but the first (EDITOR-8932).
67
+ */
68
+ const owningStop = (target, stops) => stops.reduce((narrowest, stop) => stop.from <= target.decoration.from && target.decoration.to <= stop.to && (narrowest === undefined || stop.to - stop.from < narrowest.to - narrowest.from) ? stop : narrowest, undefined);
69
+
55
70
  /**
56
71
  * The one tag a navigation step reveals. `spec.isActive` is no use here: it covers everything the
57
72
  * group's union range touches, so several tags revealed over each other (EDITOR-8971). Hence
@@ -108,6 +123,10 @@ export const extractContributorTags = (decorations, contributors, activeIndexPos
108
123
  }) => decoration.spec.decorationType === 'widget');
109
124
  const linkedDiffIds = new Map();
110
125
  const folded = new Set();
126
+
127
+ /**
128
+ * A folded target keeps no tag of its own — it becomes a hover target of the host instead.
129
+ */
111
130
  const fold = (host, target) => {
112
131
  var _linkedDiffIds$get, _linkedDiffIds$get2;
113
132
  folded.add(target);
@@ -158,20 +177,54 @@ export const extractContributorTags = (decorations, contributors, activeIndexPos
158
177
  }
159
178
  }
160
179
 
180
+ // A nested block folds into the outermost one of the same contributor, so nesting adds no tags.
181
+ if (fg('confluence_ncs_step_diffing_version_history')) {
182
+ const blocksByContributor = new Map();
183
+ for (const target of blockTargets) {
184
+ var _target$decoration$sp, _blocksByContributor$;
185
+ const key = (_target$decoration$sp = target.decoration.spec.attributionKey) !== null && _target$decoration$sp !== void 0 ? _target$decoration$sp : '';
186
+ blocksByContributor.set(key, [...((_blocksByContributor$ = blocksByContributor.get(key)) !== null && _blocksByContributor$ !== void 0 ? _blocksByContributor$ : []), target]);
187
+ }
188
+ for (const contributorBlocks of blocksByContributor.values()) {
189
+ // Block ranges nest rather than partially overlap, so ascending `from` then descending `to`
190
+ // reaches every block after the ones containing it. A stack of the blocks still open at
191
+ // this position then yields each block's container in one pass, rather than rescanning
192
+ // every block for the widest that contains it.
193
+ const nested = [...contributorBlocks].sort((left, right) => left.decoration.from - right.decoration.from || right.decoration.to - left.decoration.to);
194
+ const open = [];
195
+ for (const target of nested) {
196
+ while (open.length > 0 && !containsBlock(open[open.length - 1].decoration, target.decoration)) {
197
+ open.pop();
198
+ }
199
+ // Outermost first, so the bottom of the stack is the block the tag survives on.
200
+ if (open.length > 0) {
201
+ fold(open[0], target);
202
+ }
203
+ open.push(target);
204
+ }
205
+ }
206
+ }
207
+
161
208
  // One tag per contributor per navigation stop. The folds above only catch a replacement's two
162
209
  // halves and a block's own highlights; two of one contributor's changes that merely touch are a
163
210
  // single stop (`groupTouchingDecorations`) yet kept a tag each, so the stop's trailing tag was
164
211
  // drawn but could never be stepped to (EDITOR-8971). It folds into the leading tag of its
165
212
  // contributor, staying a hover target. Contributors are kept apart: a stop spanning two of them
166
213
  // still captions each, rather than crediting one for the other's change.
167
- for (const stop of stops !== null && stops !== void 0 ? stops : []) {
168
- const byContributor = new Map();
169
- for (const target of containedBy(targets.filter(target => !folded.has(target)), stop)) {
170
- var _target$decoration$sp, _byContributor$get;
171
- const key = (_target$decoration$sp = target.decoration.spec.attributionKey) !== null && _target$decoration$sp !== void 0 ? _target$decoration$sp : '';
172
- byContributor.set(key, [...((_byContributor$get = byContributor.get(key)) !== null && _byContributor$get !== void 0 ? _byContributor$get : []), target]);
214
+ // Each target is counted against one stop only see `owningStop`.
215
+ if (stops !== null && stops !== void 0 && stops.length) {
216
+ const byStopAndContributor = new Map();
217
+ for (const target of targets.filter(target => !folded.has(target))) {
218
+ var _target$decoration$sp2, _byStopAndContributor;
219
+ const stop = owningStop(target, stops);
220
+ if (stop === undefined) {
221
+ continue;
222
+ }
223
+ const key = `${stop.from}-${stop.to}|${(_target$decoration$sp2 = target.decoration.spec.attributionKey) !== null && _target$decoration$sp2 !== void 0 ? _target$decoration$sp2 : ''}`;
224
+ byStopAndContributor.set(key, [...((_byStopAndContributor = byStopAndContributor.get(key)) !== null && _byStopAndContributor !== void 0 ? _byStopAndContributor : []), target]);
173
225
  }
174
- for (const [leader, ...trailing] of byContributor.values()) {
226
+ for (const members of byStopAndContributor.values()) {
227
+ const [leader, ...trailing] = [...members].sort(byLeadingPosition);
175
228
  trailing.forEach(target => fold(leader, target));
176
229
  }
177
230
  }
@@ -1,3 +1,4 @@
1
+ import { agentBrandColorSchemes } from '@atlaskit/agent-color/agent-brand-color-schemes';
1
2
  import { convertToInlineCss } from '@atlaskit/editor-common/lazy-node-view';
2
3
  import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
3
4
  import { buildDeletedInlineStyleStandard, getDeletedInlineRevealColors, getInsertedInlineRevealColors } from './colorSchemes/factory';
@@ -96,6 +97,7 @@ export const resolveRevealStyle = ({
96
97
  return undefined;
97
98
  }
98
99
  const colors = getColorScheme(colorScheme);
100
+ const brand = isInserted && Object.prototype.hasOwnProperty.call(agentBrandColorSchemes, colors.insertColor) ? agentBrandColorSchemes[colors.insertColor] : undefined;
99
101
  const {
100
102
  background,
101
103
  border
@@ -106,7 +108,9 @@ export const resolveRevealStyle = ({
106
108
  });
107
109
  return {
108
110
  role: isInserted ? 'added' : 'deleted',
109
- style: (isInserted || !includeDeletedTextStyle ? '' : deletedTextOnlyStyle(colors, isActive)) + revealBaseStyle + buildWipeableBackground(background) + buildRevealVariables({
111
+ style: (brand ? convertToInlineCss({
112
+ color: brand.text
113
+ }) : '') + (isInserted || !includeDeletedTextStyle ? '' : deletedTextOnlyStyle(colors, isActive)) + revealBaseStyle + buildWipeableBackground(background) + buildRevealVariables({
110
114
  background,
111
115
  border
112
116
  })
@@ -0,0 +1,29 @@
1
+ import { createBoxlessMarginAbsorber } from './createMarginAbsorber';
2
+
3
+ /**
4
+ * Keeps a block node rendered inside a diff widget from losing its own top margin.
5
+ *
6
+ * The editor's leading-block margin reset is written against the parent element, not the document
7
+ * position, so it fires wherever a text block is a first child — including inside the widget's own
8
+ * `span`, which is what a whole-block deletion renders into. The widget then has no margin of its
9
+ * own, and the block sits flush against whatever is above it, regardless of where in the document
10
+ * the widget landed.
11
+ *
12
+ * The margin is not re-supplied; it is never taken away. Prepending an element the reset does not
13
+ * select moves the real block off the leading position, so the ordinary `.ProseMirror p`,
14
+ * `.ProseMirror h2` and similar rules go on applying to it untouched. That is why this needs no
15
+ * knowledge of which margin the block should have — unlike `createNodeShapedMarginSpacer`, which
16
+ * replicates a margin that has already been zeroed and cannot be read back.
17
+ *
18
+ * Unconditional, because the absorber generates no box: it costs nothing in a widget holding
19
+ * inline content, which the reset was never going to match anyway. Deciding here instead would mean
20
+ * restating the reset's selector list, and a copy of another package's CSS drifts silently.
21
+ */
22
+ export const absorbFirstChildMarginReset = ({
23
+ dom,
24
+ testId
25
+ }) => {
26
+ dom.prepend(createBoxlessMarginAbsorber({
27
+ testId
28
+ }));
29
+ };
@@ -0,0 +1,39 @@
1
+ /**
2
+ * An empty, invisible element whose only job is to occupy a position in the DOM.
3
+ *
4
+ * At the top of the document `firstBlockNodeStyles` zeroes the top margin of whatever element
5
+ * follows a leading `.ProseMirror-widget`, and it does so with `!important` — which no inline style
6
+ * can outrank. This takes that hit so the shaped spacer after it, one position further along, keeps
7
+ * the margin it is there to supply.
8
+ *
9
+ * It contributes no height (no content, border or padding) and margins collapse through it, so it
10
+ * cannot affect layout beyond the selector it absorbs.
11
+ */
12
+ export const createMarginAbsorber = ({
13
+ testId
14
+ }) => {
15
+ // Block-level: an empty inline element takes the match just as well, but collapses to a
16
+ // zero-height line box, and the following margin then lands in the wrong place.
17
+ const absorber = document.createElement('div');
18
+ absorber.dataset.testid = testId;
19
+ absorber.setAttribute('aria-hidden', 'true');
20
+ absorber.contentEditable = 'false';
21
+ return absorber;
22
+ };
23
+
24
+ /**
25
+ * An absorber that occupies a DOM position without generating a box.
26
+ *
27
+ * Not interchangeable with `createMarginAbsorber`: a rule that selects the *rendered* sibling of a
28
+ * leading widget needs an element that generates a box, and this one does not.
29
+ */
30
+ export const createBoxlessMarginAbsorber = ({
31
+ testId
32
+ }) => {
33
+ const absorber = document.createElement('div');
34
+ absorber.dataset.testid = testId;
35
+ absorber.setAttribute('aria-hidden', 'true');
36
+ absorber.contentEditable = 'false';
37
+ absorber.style.display = 'contents';
38
+ return absorber;
39
+ };
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Node types that can safely be reduced to an empty shell.
3
+ *
4
+ * A shell only replicates a margin if it collapses to nothing itself, which needs zero height and
5
+ * no vertical border or padding. Textblocks and lists qualify; nodes with intrinsic sizing (tables
6
+ * are `display: table`, media has a measured height) or a painted box (a zero-height panel is a
7
+ * visible stripe of background) do not, and are left alone.
8
+ */
9
+ const isShapeableNode = node => node.isTextblock || ['bulletList', 'orderedList'].includes(node.type.name);
10
+
11
+ /**
12
+ * An invisible element shaped like `node`, used to supply the top margin `node` no longer gets.
13
+ *
14
+ * A node at the start of its parent has its top margin reset, so a diff widget rendered above it
15
+ * sits flush against it. The reset cannot simply be taken off the node: the container variants skip
16
+ * widgets when counting — `nth-child(1 of :not(style, .ProseMirror-gapcursor, .ProseMirror-widget,
17
+ * span))` in layout columns, expands, sync blocks and table cells — so no amount of extra elements
18
+ * moves the match. That exclusion is what makes this work instead: the spacer is a
19
+ * `.ProseMirror-widget`, so it is never the counted first child and keeps its own margin, while the
20
+ * real node goes on taking the reset.
21
+ *
22
+ * The margin is not measured. Reading it off the real node is impossible — by then the reset has
23
+ * set it to `0`, and both `firstBlockNodeStyles` (`!important`) and block controls' `firstNodeDec`
24
+ * (an inline style) make it unrecoverable. Instead the spacer carries the node's own tag, classes
25
+ * and attributes, so the same rules that would have given the node its margin match the spacer
26
+ * (`.ProseMirror p`, `.ProseMirror h2`, the root-list rule, and so on).
27
+ *
28
+ * Returns `null` when the node is not safely shapeable or serialization fails.
29
+ */
30
+ export const createNodeShapedMarginSpacer = ({
31
+ node,
32
+ serializer,
33
+ testId
34
+ }) => {
35
+ var _node$type$createAndF;
36
+ if (!isShapeableNode(node)) {
37
+ return null;
38
+ }
39
+
40
+ // An empty node of the same type serializes to the same shell without walking real content.
41
+ const shellNode = (_node$type$createAndF = node.type.createAndFill(node.attrs)) !== null && _node$type$createAndF !== void 0 ? _node$type$createAndF : node;
42
+ const serialized = serializer.serializeNode(shellNode);
43
+ if (!(serialized instanceof HTMLElement)) {
44
+ return null;
45
+ }
46
+
47
+ // Keep the tag, classes and attributes the margin rules select on; drop anything that could
48
+ // occupy a line box.
49
+ serialized.replaceChildren();
50
+ serialized.dataset.testid = testId;
51
+ serialized.setAttribute('aria-hidden', 'true');
52
+ serialized.contentEditable = 'false';
53
+
54
+ // Zero height with no vertical border or padding keeps the spacer self-collapsing: its margins
55
+ // stay adjoining, so they collapse with the neighbours into a single margin equal to the largest
56
+ // rather than adding to them. That is what makes this safe in containers that never reset the
57
+ // first child — the spacer cannot double the gap there. Deliberately no `overflow: hidden`, which
58
+ // would open a block formatting context and stop that collapsing.
59
+ serialized.style.height = '0';
60
+ serialized.style.minHeight = '0';
61
+ serialized.style.paddingTop = '0';
62
+ serialized.style.paddingBottom = '0';
63
+ serialized.style.borderTopWidth = '0';
64
+ serialized.style.borderBottomWidth = '0';
65
+ // Only the top margin is being replicated. Left as-is, a larger bottom margin would win the
66
+ // self-collapse and overshoot the gap.
67
+ serialized.style.marginBottom = '0';
68
+ return serialized;
69
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Resolves a document position, or returns `null` if it cannot be resolved.
3
+ *
4
+ * `Node.resolve` throws a `RangeError` for a position outside the document. Diff decorations are
5
+ * built from change ranges computed against a document that may since have moved on, so a position
6
+ * arriving out of range is a normal outcome rather than a defect — but an exception escaping here
7
+ * takes down the whole decoration set, leaving the diff unrendered.
8
+ *
9
+ * Callers are expected to treat `null` as "cannot tell" and fall back to behaviour that does not
10
+ * need the resolved position.
11
+ */
12
+ export const safeResolve = (doc, pos) => {
13
+ try {
14
+ return doc.resolve(pos);
15
+ } catch {
16
+ return null;
17
+ }
18
+ };
@@ -49,6 +49,17 @@ function decorationSide(decoration) {
49
49
  return isDiffDecoration(decoration) && decoration.spec.side || 0;
50
50
  }
51
51
 
52
+ /**
53
+ * Whose change a decoration is part of, or `''` on an unattributed diff — where every decoration
54
+ * reads as one contributor and the grouping below is unchanged.
55
+ *
56
+ * Contributors are kept apart so one stop never covers two of them, since only the tag on the
57
+ * change stepped to reveals (EDITOR-8932).
58
+ */
59
+ function contributorOf(decoration) {
60
+ return isDiffDecoration(decoration) && decoration.spec.attributionKey || '';
61
+ }
62
+
52
63
  /**
53
64
  * Collapses decorations that represent one continuous customer-facing edit.
54
65
  *
@@ -57,32 +68,46 @@ function decorationSide(decoration) {
57
68
  * deletion at the same location should be treated as one replacement. Zero-width decorations
58
69
  * (normally deleted-content widgets) can join a group, but cannot extend its range and therefore
59
70
  * cannot bridge two otherwise separate edits.
71
+ *
72
+ * One group per contributor within a run of touching ranges — see `contributorOf`.
60
73
  */
61
74
  function groupTouchingDecorations(decorations, isInlineDecoration) {
62
75
  if (decorations.length < 2) {
63
76
  return decorations;
64
77
  }
65
78
  const groups = [];
66
- let currentGroup = [];
67
- let currentGroupFrom = 0;
68
- let currentGroupTo = 0;
79
+ // The groups a following decoration can still join — one per contributor, dropped at every gap.
80
+ let openGroups = [];
81
+ let clusterTo = 0;
69
82
  const sortedDecorations = [...decorations].sort((a, b) => a.from === b.from ? a.to - b.to : a.from - b.from);
70
83
  sortedDecorations.forEach(decoration => {
71
- if (currentGroup.length === 0 || decoration.from > currentGroupTo) {
72
- currentGroup = [decoration];
73
- currentGroupFrom = decoration.from;
74
- currentGroupTo = decoration.to;
75
- groups.push({
76
- decorations: currentGroup,
77
- from: currentGroupFrom,
78
- to: currentGroupTo
79
- });
80
- return;
84
+ var _openGroups$find;
85
+ // A gap ends the run: nothing before it can be joined.
86
+ if (decoration.from > clusterTo) {
87
+ openGroups = [];
81
88
  }
82
- currentGroup.push(decoration);
83
- // A zero-width decoration must not extend the group and bridge a gap.
84
- currentGroupTo = Math.max(currentGroupTo, decoration.to);
85
- groups[groups.length - 1].to = currentGroupTo;
89
+ const contributor = contributorOf(decoration);
90
+ // An unattributed decoration names nobody to keep apart, so it joins whatever is open — a
91
+ // decision item's own highlight must not split the stop from the tag on its list.
92
+ const openGroup = (_openGroups$find = openGroups.find(group => group.contributor === contributor)) !== null && _openGroups$find !== void 0 ? _openGroups$find : contributor === '' ? openGroups[0] : openGroups.find(group => group.contributor === '');
93
+ if (openGroup === undefined) {
94
+ const group = {
95
+ contributor,
96
+ decorations: [decoration],
97
+ from: decoration.from,
98
+ to: decoration.to
99
+ };
100
+ groups.push(group);
101
+ openGroups = [...openGroups, group];
102
+ } else {
103
+ // The first contributor to join claims the group, so the next one still starts its own.
104
+ openGroup.contributor = openGroup.contributor || contributor;
105
+ openGroup.decorations.push(decoration);
106
+ openGroup.to = Math.max(openGroup.to, decoration.to);
107
+ }
108
+
109
+ // A zero-width decoration must not extend the run and bridge a gap.
110
+ clusterTo = Math.max(clusterTo, decoration.to);
86
111
  });
87
112
  return groups.map(({
88
113
  decorations: group,
@@ -127,8 +152,8 @@ function groupTouchingDecorations(decorations, isInlineDecoration) {
127
152
  * 4. When `doc` is passed: excludes diff-inline decorations whose range has no inline content
128
153
  * (invalid positions, or block-only slices with no text/atoms — e.g. empty blocks)
129
154
  * 5. When `confluence_ncs_step_diffing_version_history` is enabled, groups overlapping or
130
- * directly touching ranges across decoration types into one result, using the union of all
131
- * grouped ranges
155
+ * directly touching ranges across decoration types into one result per contributor, using the
156
+ * union of all grouped ranges
132
157
  * (zero-width widgets can join a group without extending it). Under
133
158
  * `platform_editor_ai_show_diff_patch_1`, a group that starts with content painting above
134
159
  * the content that replaced it — reports that widget as its `scrollTarget` spec,
@@ -0,0 +1,18 @@
1
+ import { fg } from '@atlaskit/platform-feature-flags/fg';
2
+
3
+ /**
4
+ * A node type's base name, with a schema variant's suffix stripped: `panel`, `panel_c1` and
5
+ * `panel_c1_root_only` all resolve to `panel`.
6
+ *
7
+ * Package-local rather than `getBaseNodeTypeName` from editor-common, which enumerates the variants
8
+ * it happens to know (`panel_c1` alone) and is shared with features outside this one. Derived
9
+ * instead of listed, so a variant added later needs no change here: the schema generator names a
10
+ * variant `<type>_<variant>`, and every other node name in the ADF schema is camelCase, so the
11
+ * first `_` is the boundary.
12
+ *
13
+ * Gated, so the diff behaves exactly as before for anyone outside the rollout.
14
+ *
15
+ * For taggability only — this gate is weaker than the ones attribution needs, so resolving a variant
16
+ * in the styling decisions would move the legacy diff for readers who see no tags.
17
+ */
18
+ export const resolveBaseNodeName = name => fg('confluence_ncs_step_diffing_version_history') ? name.split('_')[0] : name;
@@ -0,0 +1,28 @@
1
+ import { resolveBaseNodeName } from './baseNodeName';
2
+
3
+ /**
4
+ * Block nodes that host a contributor tag of their own — those whose whole box reads as one change.
5
+ * Everything that draws a box whatever it holds, plus the two lists that are inserted whole: without
6
+ * them the tag fell through to the text inside the first item (EDITOR-8932). Bullet and ordered lists
7
+ * are left out, since their items accrue one at a time. Tables and list items decorate per cell and
8
+ * per item, with no one-tag-per-block design yet. `blockCard` covers the datasource variant, which is
9
+ * the same node type.
10
+ *
11
+ * Base names only — match with the predicates below, never against `type.name` directly.
12
+ */
13
+ const TAGGABLE_BLOCK_NODES = new Set(['blockCard', 'blockquote', 'bodiedExtension', 'codeBlock', 'decisionList', 'embedCard', 'expand', 'extension', 'media', 'multiBodiedExtension', 'panel', 'rule', 'taskList']);
14
+
15
+ /**
16
+ * Whether this node hosts a contributor tag of its own. Navigation grouping does not consult this:
17
+ * a run of these one contributor inserted is a single stop, tagged on the leading block.
18
+ *
19
+ * Resolved through `resolveBaseNodeName` so a schema variant counts as the type it is a variant of:
20
+ * `panel_c1` is the panel the table-in-panel schema inserts, and matching on `type.name` left it
21
+ * untagged (EDITOR-8932).
22
+ *
23
+ * By name, because the decoration spec carries `nodeName` rather than the type it came from.
24
+ */
25
+ export const isTaggableBlockNodeName = name => name !== undefined && TAGGABLE_BLOCK_NODES.has(resolveBaseNodeName(name));
26
+
27
+ /** As above, from the node type itself. */
28
+ export const isTaggableBlockNode = nodeType => isTaggableBlockNodeName(nodeType.name);
@@ -290,11 +290,11 @@ export class ContributorTagController {
290
290
 
291
291
  // Hand-rolled avatar stack: `@atlaskit/avatar-group` cannot render below 24px and the tag uses
292
292
  // 16px avatars.
293
- const stack = model.connectedContributor ? [...(user ? [{
294
- contributor: user,
295
- stackIndex: 1
296
- }] : []), ...(agent ? [{
293
+ const stack = model.connectedContributor ? [...(agent ? [{
297
294
  contributor: agent,
295
+ stackIndex: 1
296
+ }] : []), ...(user ? [{
297
+ contributor: user,
298
298
  stackIndex: 0
299
299
  }] : [])] : [{
300
300
  contributor: model.contributor