@atlaskit/editor-plugin-show-diff 16.0.17 → 16.1.1

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 (32) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/afm-products/tsconfig.json +3 -0
  3. package/dist/cjs/pm-plugins/decorations/createNodeChangedDecorationWidget.js +83 -3
  4. package/dist/cjs/pm-plugins/decorations/utils/absorbFirstChildMarginReset.js +33 -0
  5. package/dist/cjs/pm-plugins/decorations/utils/createMarginAbsorber.js +43 -0
  6. package/dist/cjs/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.js +76 -0
  7. package/dist/cjs/pm-plugins/decorations/utils/safeResolve.js +24 -0
  8. package/dist/cjs/pm-plugins/main.js +20 -18
  9. package/dist/cjs/ui/ContributorTag/contributorTagController.js +4 -4
  10. package/dist/es2019/pm-plugins/decorations/createNodeChangedDecorationWidget.js +83 -3
  11. package/dist/es2019/pm-plugins/decorations/utils/absorbFirstChildMarginReset.js +29 -0
  12. package/dist/es2019/pm-plugins/decorations/utils/createMarginAbsorber.js +39 -0
  13. package/dist/es2019/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.js +69 -0
  14. package/dist/es2019/pm-plugins/decorations/utils/safeResolve.js +18 -0
  15. package/dist/es2019/pm-plugins/main.js +19 -17
  16. package/dist/es2019/ui/ContributorTag/contributorTagController.js +4 -4
  17. package/dist/esm/pm-plugins/decorations/createNodeChangedDecorationWidget.js +83 -3
  18. package/dist/esm/pm-plugins/decorations/utils/absorbFirstChildMarginReset.js +28 -0
  19. package/dist/esm/pm-plugins/decorations/utils/createMarginAbsorber.js +37 -0
  20. package/dist/esm/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.js +70 -0
  21. package/dist/esm/pm-plugins/decorations/utils/safeResolve.js +18 -0
  22. package/dist/esm/pm-plugins/main.js +20 -18
  23. package/dist/esm/ui/ContributorTag/contributorTagController.js +4 -4
  24. package/dist/types/entry-points/show-diff-plugin-type.d.ts +1 -1
  25. package/dist/types/pm-plugins/decorations/colorSchemes/attributions.d.ts +2 -2
  26. package/dist/types/pm-plugins/decorations/utils/absorbFirstChildMarginReset.d.ts +23 -0
  27. package/dist/types/pm-plugins/decorations/utils/createMarginAbsorber.d.ts +23 -0
  28. package/dist/types/pm-plugins/decorations/utils/createNodeShapedMarginSpacer.d.ts +26 -0
  29. package/dist/types/pm-plugins/decorations/utils/safeResolve.d.ts +13 -0
  30. package/dist/types/pm-plugins/main.d.ts +7 -1
  31. package/dist/types/showDiffPluginType.d.ts +17 -2
  32. package/package.json +14 -6
@@ -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
+ };
@@ -83,7 +83,7 @@ export const createPlugin = (config, getIntl, api, onEditorView) => {
83
83
  let newPluginState = currentPluginState;
84
84
  if (meta) {
85
85
  if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'SHOW_DIFF' || (meta === null || meta === void 0 ? void 0 : meta.action) === 'REVEAL_COMPLETE') {
86
- var _newPluginState, _newPluginState2, _newPluginState3, _newPluginState4, _newPluginState5, _newPluginState6, _newPluginState7, _newPluginState8, _newPluginState9, _newPluginState0, _newPluginState1;
86
+ var _newPluginState$color, _newPluginState, _newPluginState2, _newPluginState3, _newPluginState4, _newPluginState5, _newPluginState6, _newPluginState7, _newPluginState8, _newPluginState9, _newPluginState0, _newPluginState1, _newPluginState10;
87
87
  // REVEAL_COMPLETE repaints with the reveal dropped, so the decorations render their
88
88
  // ordinary resting style. Without it the reveal stays in state indefinitely and the
89
89
  // next unrelated repaint re-emits the hidden, zero-width highlight with no animation
@@ -110,29 +110,29 @@ export const createPlugin = (config, getIntl, api, onEditorView) => {
110
110
  state: newState,
111
111
  pluginState: newPluginState,
112
112
  nodeViewSerializer,
113
- colorScheme: config === null || config === void 0 ? void 0 : config.colorScheme,
113
+ colorScheme: (_newPluginState$color = (_newPluginState = newPluginState) === null || _newPluginState === void 0 ? void 0 : _newPluginState.colorScheme) !== null && _newPluginState$color !== void 0 ? _newPluginState$color : config === null || config === void 0 ? void 0 : config.colorScheme,
114
114
  intl: getIntl(),
115
115
  activeIndexPos: newPluginState.activeIndexPos,
116
116
  api,
117
117
  tagMountContext,
118
- ...(isExtendedEnabled((_newPluginState = newPluginState) === null || _newPluginState === void 0 ? void 0 : _newPluginState.diffType) ? {
119
- isInverted: (_newPluginState2 = newPluginState) === null || _newPluginState2 === void 0 ? void 0 : _newPluginState2.isInverted,
120
- diffType: (_newPluginState3 = newPluginState) === null || _newPluginState3 === void 0 ? void 0 : _newPluginState3.diffType,
121
- hideDeletedDiffs: (_newPluginState4 = newPluginState) === null || _newPluginState4 === void 0 ? void 0 : _newPluginState4.hideDeletedDiffs,
122
- hideAddedDiffsUnderline: (_newPluginState5 = newPluginState) === null || _newPluginState5 === void 0 ? void 0 : _newPluginState5.hideAddedDiffsUnderline,
123
- showIndicators: (_newPluginState6 = newPluginState) === null || _newPluginState6 === void 0 ? void 0 : _newPluginState6.showIndicators,
124
- smartThresholds: (_newPluginState7 = newPluginState) === null || _newPluginState7 === void 0 ? void 0 : _newPluginState7.smartThresholds,
125
- deletedDiffPlacement: (_newPluginState8 = newPluginState) === null || _newPluginState8 === void 0 ? void 0 : _newPluginState8.deletedDiffPlacement,
126
- inlineDeletedDiffPlacement: (_newPluginState9 = newPluginState) === null || _newPluginState9 === void 0 ? void 0 : _newPluginState9.inlineDeletedDiffPlacement,
118
+ ...(isExtendedEnabled((_newPluginState2 = newPluginState) === null || _newPluginState2 === void 0 ? void 0 : _newPluginState2.diffType) ? {
119
+ isInverted: (_newPluginState3 = newPluginState) === null || _newPluginState3 === void 0 ? void 0 : _newPluginState3.isInverted,
120
+ diffType: (_newPluginState4 = newPluginState) === null || _newPluginState4 === void 0 ? void 0 : _newPluginState4.diffType,
121
+ hideDeletedDiffs: (_newPluginState5 = newPluginState) === null || _newPluginState5 === void 0 ? void 0 : _newPluginState5.hideDeletedDiffs,
122
+ hideAddedDiffsUnderline: (_newPluginState6 = newPluginState) === null || _newPluginState6 === void 0 ? void 0 : _newPluginState6.hideAddedDiffsUnderline,
123
+ showIndicators: (_newPluginState7 = newPluginState) === null || _newPluginState7 === void 0 ? void 0 : _newPluginState7.showIndicators,
124
+ smartThresholds: (_newPluginState8 = newPluginState) === null || _newPluginState8 === void 0 ? void 0 : _newPluginState8.smartThresholds,
125
+ deletedDiffPlacement: (_newPluginState9 = newPluginState) === null || _newPluginState9 === void 0 ? void 0 : _newPluginState9.deletedDiffPlacement,
126
+ inlineDeletedDiffPlacement: (_newPluginState0 = newPluginState) === null || _newPluginState0 === void 0 ? void 0 : _newPluginState0.inlineDeletedDiffPlacement,
127
127
  // SHOW_DIFF only. The scroll-to-next recalculation further down deliberately
128
128
  // omits this so stepping through changes cannot replay the choreography.
129
- reveal: (_newPluginState0 = newPluginState) === null || _newPluginState0 === void 0 ? void 0 : _newPluginState0.reveal
129
+ reveal: (_newPluginState1 = newPluginState) === null || _newPluginState1 === void 0 ? void 0 : _newPluginState1.reveal
130
130
  } : {})
131
131
  });
132
132
  // Update the decorations and their ids
133
133
  newPluginState.decorations = decorations;
134
134
  newPluginState.contributorTags = contributorTags;
135
- if (isExtendedEnabled((_newPluginState1 = newPluginState) === null || _newPluginState1 === void 0 ? void 0 : _newPluginState1.diffType)) {
135
+ if (isExtendedEnabled((_newPluginState10 = newPluginState) === null || _newPluginState10 === void 0 ? void 0 : _newPluginState10.diffType)) {
136
136
  newPluginState.diffDescriptors = diffDescriptors;
137
137
  }
138
138
  } else if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'HIDE_DIFF') {
@@ -144,6 +144,8 @@ export const createPlugin = (config, getIntl, api, onEditorView) => {
144
144
  activeIndex: undefined,
145
145
  contributorTags: [],
146
146
  reveal: undefined,
147
+ // Per-call override — do not let it leak into the next, unrelated `showDiff` call.
148
+ colorScheme: undefined,
147
149
  /**
148
150
  * Reset isInverted & diffType state when hiding diffs
149
151
  * Otherwise this should persist for the diff-showing session
@@ -157,11 +159,11 @@ export const createPlugin = (config, getIntl, api, onEditorView) => {
157
159
  } : {})
158
160
  };
159
161
  } else if ((meta === null || meta === void 0 ? void 0 : meta.action) === 'SCROLL_TO_NEXT' || (meta === null || meta === void 0 ? void 0 : meta.action) === 'SCROLL_TO_PREVIOUS') {
160
- var _newPluginState10;
162
+ var _newPluginState11;
161
163
  // Update the active index in plugin state and recalculate decorations
162
- const decorations = getScrollableDecorations(currentPluginState.decorations, newState.doc, (_newPluginState10 = newPluginState) === null || _newPluginState10 === void 0 ? void 0 : _newPluginState10.diffType);
164
+ const decorations = getScrollableDecorations(currentPluginState.decorations, newState.doc, (_newPluginState11 = newPluginState) === null || _newPluginState11 === void 0 ? void 0 : _newPluginState11.diffType);
163
165
  if (decorations.length > 0) {
164
- var _currentPluginState$a;
166
+ var _currentPluginState$a, _newPluginState$color2, _newPluginState12;
165
167
  // Initialize to -1 if undefined so that the first "next" scroll takes us to index 0 (first change).
166
168
  // This allows the UI to start with no selection and only highlight on first user interaction.
167
169
  let nextIndex = (_currentPluginState$a = currentPluginState.activeIndex) !== null && _currentPluginState$a !== void 0 ? _currentPluginState$a : -1;
@@ -194,7 +196,7 @@ export const createPlugin = (config, getIntl, api, onEditorView) => {
194
196
  state: newState,
195
197
  pluginState: newPluginState,
196
198
  nodeViewSerializer,
197
- colorScheme: config === null || config === void 0 ? void 0 : config.colorScheme,
199
+ colorScheme: (_newPluginState$color2 = (_newPluginState12 = newPluginState) === null || _newPluginState12 === void 0 ? void 0 : _newPluginState12.colorScheme) !== null && _newPluginState$color2 !== void 0 ? _newPluginState$color2 : config === null || config === void 0 ? void 0 : config.colorScheme,
198
200
  intl: getIntl(),
199
201
  activeIndexPos: newPluginState.activeIndexPos,
200
202
  api,
@@ -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
@@ -9,7 +9,11 @@ import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
9
9
  import { createChangedRowDecorationWidgets } from './createChangedRowDecorationWidgets';
10
10
  import { createContributorTagHost, unmountContributorTag } from './createContributorTagWidget';
11
11
  import { buildDiffDecorationSpec, buildAnchorDecorationKey, scrollMarginTopValue } from './decorationKeys';
12
+ import { absorbFirstChildMarginReset } from './utils/absorbFirstChildMarginReset';
13
+ import { createMarginAbsorber } from './utils/createMarginAbsorber';
14
+ import { createNodeShapedMarginSpacer } from './utils/createNodeShapedMarginSpacer';
12
15
  import { findSafeInsertPos } from './utils/findSafeInsertPos';
16
+ import { safeResolve } from './utils/safeResolve';
13
17
  import { wrapBlockNodeView, injectInnerWrapper, createContentWrapper } from './utils/wrapBlockNodeView';
14
18
  var isHeadingLevel = function isHeadingLevel(level) {
15
19
  return typeof level === 'number' && level >= 1 && level <= 6;
@@ -183,6 +187,14 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
183
187
  // For non-table content, use the existing span wrapper approach
184
188
  var dom = document.createElement('span');
185
189
  var $safeInsertPos = newDoc.resolve(safeInsertPos);
190
+
191
+ // Whether the widget renders above the very start of its parent's content — the document, a
192
+ // layout column, a table cell, a panel.
193
+ //
194
+ // `parentOffset === 0` is the whole test on the position side: prosemirror-view paints every
195
+ // widget at a position before the node starting there, so a leading widget at the parent's start
196
+ // always renders above its content.
197
+ var isVisuallyFirstInParent = !placeBelow && $safeInsertPos.parentOffset === 0;
186
198
  var isTopLevelInsert = $safeInsertPos.depth === 0;
187
199
  var hasPreviousBlock = ((_$safeInsertPos$nodeB = $safeInsertPos.nodeBefore) === null || _$safeInsertPos$nodeB === void 0 ? void 0 : _$safeInsertPos$nodeB.isBlock) === true;
188
200
  var 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';
@@ -234,6 +246,20 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
234
246
  var firstReplacedNode = slice.content.firstChild;
235
247
  var lastReplacedNode = slice.content.lastChild;
236
248
  var showDiffPatch1 = fg('platform_editor_ai_show_diff_patch_1');
249
+ // Whether the slice's outermost textblocks still hold all of their original content.
250
+ //
251
+ // `openStart`/`openEnd` say whether the cut landed inside a block, but not whether it took
252
+ // any text with it — a deletion stopping exactly at a block's content boundary is still
253
+ // reported as open. Resolving the change's own ends separates the two, so a block whose text
254
+ // survived intact can render as a block even though its boundary was open.
255
+ //
256
+ // Both resolves sit behind the gate that consumes them, so this adds no position arithmetic to
257
+ // the ungated path, and an unresolvable position degrades to the `openStart`/`openEnd` test on
258
+ // its own — the block is then treated as partial, which is what it rendered as before the gate.
259
+ var $changeFromA = fg('platform_editor_ai_show_diff_patch_2') ? safeResolve(doc, change.fromA) : null;
260
+ var $changeToA = fg('platform_editor_ai_show_diff_patch_2') ? safeResolve(doc, change.toA) : null;
261
+ var isFirstNodeContentComplete = slice.openStart === 0 || ($changeFromA === null || $changeFromA === void 0 ? void 0 : $changeFromA.parentOffset) === 0;
262
+ var isLastNodeContentComplete = slice.openEnd === 0 || $changeToA !== null && $changeToA.parentOffset === $changeToA.parent.content.size;
237
263
  var isCompleteSameTypeReplacement = slice.content.childCount === 1 && firstReplacedNode !== null && replacementNode !== null && firstReplacedNode.type === replacementNode.type && replacementNode.nodeSize === change.toB - change.fromB;
238
264
 
239
265
  /*
@@ -246,10 +272,16 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
246
272
  var isLast = lastReplacedNode === node;
247
273
  var isOpenAtSliceBoundary = isFirst && slice.openStart > 0 || isLast && slice.openEnd > 0;
248
274
  var shouldPreserveCompleteMultiInlineBlock = showDiffPatch1 && node.isBlock && node.type.inlineContent && node.content.childCount > 1 && !isOpenAtSliceBoundary && isCompleteSameTypeReplacement;
275
+ // A textblock is otherwise serialized as its inline content only, with no `<p>`/`<h2>` wrapper.
276
+ // That is right when the diff cuts into an existing block — the deleted text belongs on the same
277
+ // line as the text that replaced it — but when the whole block went away it drops the very
278
+ // element that carries the block's margin, so the deleted block renders flush against its
279
+ // neighbours. Only the first and last children can be partial; middle ones are always complete.
280
+ var shouldRenderAsBlockNode = fg('platform_editor_ai_show_diff_patch_2') && node.isTextblock && (!isFirst || isFirstNodeContentComplete) && (!isLast || isLastNodeContentComplete);
249
281
 
250
282
  // Helper function to handle multiple child nodes
251
283
  var handleMultipleChildNodes = function handleMultipleChildNodes(node) {
252
- if (!shouldPreserveCompleteMultiInlineBlock && node.content.childCount > 1 && node.type.inlineContent) {
284
+ if (!shouldPreserveCompleteMultiInlineBlock && !shouldRenderAsBlockNode && node.content.childCount > 1 && node.type.inlineContent) {
253
285
  node.content.forEach(function (childNode) {
254
286
  var childNodeView = serializer.tryCreateNodeView(childNode);
255
287
  if (childNodeView) {
@@ -279,7 +311,7 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
279
311
  if (handleMultipleChildNodes(node)) {
280
312
  return;
281
313
  }
282
- if (shouldPreserveCompleteMultiInlineBlock) {
314
+ if (shouldPreserveCompleteMultiInlineBlock || shouldRenderAsBlockNode) {
283
315
  fallbackSerialization = function fallbackSerialization() {
284
316
  return serializer.serializeNode(node);
285
317
  };
@@ -356,6 +388,21 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
356
388
  dom.style.setProperty('scroll-margin-top', scrollMarginTopValue);
357
389
  }
358
390
 
391
+ // A block node serialized into the widget is the widget's first child, and the editor's
392
+ // first-child reset is written against the parent element rather than the document position — so
393
+ // it zeroes the margin the block wrapper was kept for in the first place.
394
+ //
395
+ // Only away from the parent's start. There the reset is the correct outcome: the widget is the
396
+ // first thing in the document, column, cell or panel, and a leading gap above it would be wrong.
397
+ // The margin that matters at that position belongs to the node *below* the widget, which the
398
+ // shaped spacer pair supplies instead.
399
+ if (fg('platform_editor_ai_show_diff_patch_2') && !isVisuallyFirstInParent) {
400
+ absorbFirstChildMarginReset({
401
+ dom: dom,
402
+ testId: 'show-diff-widget-margin-absorber'
403
+ });
404
+ }
405
+
359
406
  // Needed even when the indicator bar is off, because a contributor tag also anchors against the
360
407
  // widget.
361
408
  if ((showIndicators || showContributorTags) && isExtendedEnabled(diffType)) {
@@ -418,7 +465,40 @@ export var createNodeChangedDecorationWidget = function createNodeChangedDecorat
418
465
  var isPureDeletion = change.fromB === change.toB;
419
466
  var isSingleBlock = slice.content.childCount === 1 && ((_slice$content$firstC2 = slice.content.firstChild) === null || _slice$content$firstC2 === void 0 ? void 0 : _slice$content$firstC2.isBlock);
420
467
  var isDiffWidgetAtStartOfDoc = $safeInsertPos.depth === 0 && $safeInsertPos.index(0) === 0;
421
- if (isDiffWidgetAtStartOfDoc && isSingleBlock && isPureDeletion && isExtendedEnabled(diffType)) {
468
+
469
+ // A node at the start of its parent has its top margin reset, so a diff widget rendered above
470
+ // it sits flush against it. This applies wherever that parent is — the document, a layout
471
+ // column, a table cell — so it is decided from the anchor rather than from the document root
472
+ // (see `isVisuallyFirstInParent` above).
473
+ //
474
+ // Away from the parent's start the node below keeps its own margin, and it is the block *inside*
475
+ // the widget that loses one — handled by `absorbFirstChildMarginReset` above, not here.
476
+ var nodeAfterWidget = $safeInsertPos.nodeAfter;
477
+ if (fg('platform_editor_ai_show_diff_patch_2') && isExtendedEnabled(diffType) && isVisuallyFirstInParent && nodeAfterWidget) {
478
+ var shapedSpacer = createNodeShapedMarginSpacer({
479
+ node: nodeAfterWidget,
480
+ serializer: serializer,
481
+ testId: 'show-diff-shaped-margin-spacer'
482
+ });
483
+ if (shapedSpacer) {
484
+ // Two elements, because the two families of reset have to be handled differently. The
485
+ // absorber takes the document-level adjacent-sibling reset for a leading widget, whose
486
+ // `!important` would otherwise zero the shaped spacer's margin; the shaped spacer, now one
487
+ // position further along, is out of that rule's reach and keeps the margin it supplies.
488
+ // Inside containers the counting selectors skip both, so only the shaped one has an effect.
489
+ //
490
+ // Sides order the widgets against each other only — both still paint before the real node.
491
+ decorations.push(Decoration.widget(safeInsertPos, createMarginAbsorber({
492
+ testId: 'show-diff-margin-absorber'
493
+ }), {
494
+ side: 0
495
+ }));
496
+ decorations.push(Decoration.widget(safeInsertPos, shapedSpacer, {
497
+ side: 1
498
+ }));
499
+ }
500
+ }
501
+ if (!fg('platform_editor_ai_show_diff_patch_2') && isDiffWidgetAtStartOfDoc && isSingleBlock && isPureDeletion && isExtendedEnabled(diffType)) {
422
502
  var followingNode = $safeInsertPos.nodeAfter;
423
503
  var headingLevel = (followingNode === null || followingNode === void 0 ? void 0 : followingNode.type.name) === 'heading' ? followingNode.attrs.level : undefined;
424
504
  if (isHeadingLevel(headingLevel)) {
@@ -0,0 +1,28 @@
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 var absorbFirstChildMarginReset = function absorbFirstChildMarginReset(_ref) {
23
+ var dom = _ref.dom,
24
+ testId = _ref.testId;
25
+ dom.prepend(createBoxlessMarginAbsorber({
26
+ testId: testId
27
+ }));
28
+ };
@@ -0,0 +1,37 @@
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 var createMarginAbsorber = function createMarginAbsorber(_ref) {
13
+ var testId = _ref.testId;
14
+ // Block-level: an empty inline element takes the match just as well, but collapses to a
15
+ // zero-height line box, and the following margin then lands in the wrong place.
16
+ var absorber = document.createElement('div');
17
+ absorber.dataset.testid = testId;
18
+ absorber.setAttribute('aria-hidden', 'true');
19
+ absorber.contentEditable = 'false';
20
+ return absorber;
21
+ };
22
+
23
+ /**
24
+ * An absorber that occupies a DOM position without generating a box.
25
+ *
26
+ * Not interchangeable with `createMarginAbsorber`: a rule that selects the *rendered* sibling of a
27
+ * leading widget needs an element that generates a box, and this one does not.
28
+ */
29
+ export var createBoxlessMarginAbsorber = function createBoxlessMarginAbsorber(_ref2) {
30
+ var testId = _ref2.testId;
31
+ var absorber = document.createElement('div');
32
+ absorber.dataset.testid = testId;
33
+ absorber.setAttribute('aria-hidden', 'true');
34
+ absorber.contentEditable = 'false';
35
+ absorber.style.display = 'contents';
36
+ return absorber;
37
+ };
@@ -0,0 +1,70 @@
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
+ var isShapeableNode = function isShapeableNode(node) {
10
+ return node.isTextblock || ['bulletList', 'orderedList'].includes(node.type.name);
11
+ };
12
+
13
+ /**
14
+ * An invisible element shaped like `node`, used to supply the top margin `node` no longer gets.
15
+ *
16
+ * A node at the start of its parent has its top margin reset, so a diff widget rendered above it
17
+ * sits flush against it. The reset cannot simply be taken off the node: the container variants skip
18
+ * widgets when counting — `nth-child(1 of :not(style, .ProseMirror-gapcursor, .ProseMirror-widget,
19
+ * span))` in layout columns, expands, sync blocks and table cells — so no amount of extra elements
20
+ * moves the match. That exclusion is what makes this work instead: the spacer is a
21
+ * `.ProseMirror-widget`, so it is never the counted first child and keeps its own margin, while the
22
+ * real node goes on taking the reset.
23
+ *
24
+ * The margin is not measured. Reading it off the real node is impossible — by then the reset has
25
+ * set it to `0`, and both `firstBlockNodeStyles` (`!important`) and block controls' `firstNodeDec`
26
+ * (an inline style) make it unrecoverable. Instead the spacer carries the node's own tag, classes
27
+ * and attributes, so the same rules that would have given the node its margin match the spacer
28
+ * (`.ProseMirror p`, `.ProseMirror h2`, the root-list rule, and so on).
29
+ *
30
+ * Returns `null` when the node is not safely shapeable or serialization fails.
31
+ */
32
+ export var createNodeShapedMarginSpacer = function createNodeShapedMarginSpacer(_ref) {
33
+ var _node$type$createAndF;
34
+ var node = _ref.node,
35
+ serializer = _ref.serializer,
36
+ testId = _ref.testId;
37
+ if (!isShapeableNode(node)) {
38
+ return null;
39
+ }
40
+
41
+ // An empty node of the same type serializes to the same shell without walking real content.
42
+ var shellNode = (_node$type$createAndF = node.type.createAndFill(node.attrs)) !== null && _node$type$createAndF !== void 0 ? _node$type$createAndF : node;
43
+ var serialized = serializer.serializeNode(shellNode);
44
+ if (!(serialized instanceof HTMLElement)) {
45
+ return null;
46
+ }
47
+
48
+ // Keep the tag, classes and attributes the margin rules select on; drop anything that could
49
+ // occupy a line box.
50
+ serialized.replaceChildren();
51
+ serialized.dataset.testid = testId;
52
+ serialized.setAttribute('aria-hidden', 'true');
53
+ serialized.contentEditable = 'false';
54
+
55
+ // Zero height with no vertical border or padding keeps the spacer self-collapsing: its margins
56
+ // stay adjoining, so they collapse with the neighbours into a single margin equal to the largest
57
+ // rather than adding to them. That is what makes this safe in containers that never reset the
58
+ // first child — the spacer cannot double the gap there. Deliberately no `overflow: hidden`, which
59
+ // would open a block formatting context and stop that collapsing.
60
+ serialized.style.height = '0';
61
+ serialized.style.minHeight = '0';
62
+ serialized.style.paddingTop = '0';
63
+ serialized.style.paddingBottom = '0';
64
+ serialized.style.borderTopWidth = '0';
65
+ serialized.style.borderBottomWidth = '0';
66
+ // Only the top margin is being replicated. Left as-is, a larger bottom margin would win the
67
+ // self-collapse and overshoot the gap.
68
+ serialized.style.marginBottom = '0';
69
+ return serialized;
70
+ };
@@ -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 var safeResolve = function safeResolve(doc, pos) {
13
+ try {
14
+ return doc.resolve(pos);
15
+ } catch (_unused) {
16
+ return null;
17
+ }
18
+ };