@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
1,017 lines • 51 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
import _slicedToArray from "@babel/runtime/helpers/slicedToArray";
function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t.return || t.return(); } finally { if (u) throw o; } } }; }
function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } }
function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; }
import isEqual from 'lodash/isEqual';
import memoizeOne from 'memoize-one';
import { ChangeSet, simplifyChanges } from 'prosemirror-changeset';
import { areDocsEqualByBlockStructureAndText } from '@atlaskit/editor-common/utils/areDocsEqualByBlockStructureAndText';
import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document';
import { Mapping } from '@atlaskit/editor-prosemirror/transform';
import { Decoration, DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { TableMap } from '@atlaskit/editor-tables/table-map';
import { UNSAFE_expValNoExposure } from '@atlaskit/platform-feature-experiments/unsafe-exp-val-no-exposure';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { expValEqualsNoExposure } from '@atlaskit/tmp-editor-statsig/exp-val-equals-no-exposure';
import { createAttributionColorMap, getAttributionKey, getAttributionKeyForChange, getColorSchemeForChange, isAttributionColoringEnabled, isContributorTagsEnabled } from '../decorations/colorSchemes/attributions';
import { createDocMarginAnchorWidget } from '../decorations/createAnchorDecorationWidgets';
import { createBlockChangedDecoration } from '../decorations/createBlockChangedDecoration';
import { createInlineChangedDecoration } from '../decorations/createInlineChangedDecoration';
import { createNodeChangedDecorationWidget } from '../decorations/createNodeChangedDecorationWidget';
import { extractDiffDescriptors } from '../decorations/decorationKeys';
import { extractContributorTags } from '../decorations/extractContributorTags';
import { createDeletedLineBreakDecoration } from '../decorations/utils/createDeletedLineBreakWidget';
import { getAttrChangeRanges, stepIsValidAttrChange } from '../decorations/utils/getAttrChangeRanges';
import { getMarkChangeRanges } from '../decorations/utils/getMarkChangeRanges';
import { createRemovedLozenge } from '../decorations/utils/wrapBlockNodeView';
import { getDefaultDiffType } from '../getDefaultDiffType';
import { getScrollableDecorations } from '../getScrollableDecorations';
import { emptyTextBlockContentPositions } from '../utils/emptyTextBlocks';
import { diffBySteps } from './diffBySteps';
import { groupChangesByBlock } from './groupChangesByBlock';
import { CELL_CONTENT_OFFSET, groupDeletedColumnChanges } from './groupDeletedColumnChanges';
import { isMarkOnlyChange } from './isMarkOnlyChange';
import { isOpenTokenOnlyChange } from './isOpenTokenOnlyChange';
import { optimizeChanges } from './optimizeChanges';
import { selectTokenEncoder } from './selectTokenEncoder';
import { collapseOverlappingChanges, simplifyChangesWithAttribution } from './simplifyChangesWithAttribution';
import { simplifySteps, simplifyStepsWithAttribution } from './simplifySteps';
import { classifySmartChanges } from './smart/classifySmartChanges';
import { smartChangeLevel } from './smart/helpers';
var getChanges = function getChanges(_ref) {
var changeset = _ref.changeset,
originalDoc = _ref.originalDoc,
steppedDoc = _ref.steppedDoc,
diffType = _ref.diffType,
tr = _ref.tr,
steps = _ref.steps,
intl = _ref.intl,
smartThresholds = _ref.smartThresholds;
// The `smart` diff type is gated behind `platform_editor_ai_smart_diff`. When the gate is
// off, `smart` falls through to the default (`inline`) path below so behaviour degrades
// gracefully (see docs/smart-diff-design.md §3).
if (diffType === 'smart' && fg('platform_editor_ai_smart_diff')) {
var _changes = simplifyChanges(changeset.changes, tr.doc);
return classifySmartChanges({
changes: _changes,
originalDoc: originalDoc,
newDoc: tr.doc,
locale: intl.locale,
thresholds: smartThresholds
});
}
if (diffType === 'step') {
return diffBySteps(originalDoc, steps);
}
if (diffType === 'block') {
return groupChangesByBlock(changeset.changes, originalDoc, steppedDoc);
}
var changes = simplifyChanges(changeset.changes, tr.doc);
return optimizeChanges(changes);
};
var getAttributedChanges = function getAttributedChanges(_ref2) {
var attributedChanges = _ref2.attributedChanges,
changeset = _ref2.changeset,
originalDoc = _ref2.originalDoc,
steppedDoc = _ref2.steppedDoc,
diffType = _ref2.diffType,
tr = _ref2.tr;
// `smart` is deliberately absent here, even though `getChanges` applies it: the classifier drops
// the step attribution and promotes ranges to sentence/paragraph/node granularity, so a promoted
// range covers several actors' steps and the latest-writer rule credits all of it to one of them.
// The attributed changeset keeps step granularity instead.
if (diffType === 'step') {
return attributedChanges;
}
if (diffType === 'block') {
return groupChangesByBlock(changeset.changes, originalDoc, steppedDoc);
}
return simplifyChangesWithAttribution(attributedChanges, tr.doc);
};
/**
* Collect the inline-content ranges of every leaf text-bearing block (paragraph, heading, …)
* whose content overlaps `[from, to)`. Used to clip a node-level `smart` insertion highlight to
* the actual added text, so the inserted background/underline never spans structural gaps
* (list markers, empty item slots, cell/column boundaries) which would render as phantom rows.
*/
var leafTextblockRanges = function leafTextblockRanges(doc, from, to) {
var ranges = [];
doc.nodesBetween(from, to, function (node, pos) {
if (node.isTextblock && node.content.size > 0) {
var contentFrom = Math.max(pos + 1, from);
var contentTo = Math.min(pos + 1 + node.content.size, to);
if (contentTo > contentFrom) {
ranges.push([contentFrom, contentTo]);
}
// Textblocks have no block children to descend into.
return false;
}
return true;
});
return ranges;
};
// A large inserted table is treated with a coarse decoration path (see below)
// once it has more leaf textblocks (~cells) than this threshold. Small tables
// keep the precise per-cell path.
var LARGE_TABLE_LEAF_THRESHOLD = 40;
/**
* An active scrollable change may contain several underlying diff changes. With attribution, only
* ranges belonging to the selected contributor (including the anonymous contributor) are active.
*/
var isRangeActive = function isRangeActive(activeIndexPos, from, to, attributionKey) {
if (activeIndexPos === undefined) {
return undefined;
}
if (!fg('confluence_ncs_step_diffing_version_history')) {
return from === activeIndexPos.from && to === activeIndexPos.to;
}
return from <= activeIndexPos.to && activeIndexPos.from <= to && (!('attributionKey' in activeIndexPos) || activeIndexPos.attributionKey === attributionKey);
};
var isLargeInsertedTableRange = function isLargeInsertedTableRange(doc, from, to) {
var containsTable = false;
var leafCount = 0;
doc.nodesBetween(from, to, function (node) {
if (node.type.name === 'table') {
containsTable = true;
}
if (node.isTextblock) {
leafCount += 1;
return false;
}
return true;
});
return containsTable && leafCount > LARGE_TABLE_LEAF_THRESHOLD;
};
// Collect the [from, to) ranges of every `table` node overlapping `[from, to)`. The coarse
// large-table path must only suppress decorations inside these ranges
var tableRanges = function tableRanges(doc, from, to) {
var ranges = [];
doc.nodesBetween(from, to, function (node, pos) {
if (node.type.name === 'table') {
ranges.push([pos, pos + node.nodeSize]);
return false;
}
return true;
});
return ranges;
};
var isInsideTable = function isInsideTable(tables, pos) {
return tables.some(function (_ref3) {
var _ref4 = _slicedToArray(_ref3, 2),
tFrom = _ref4[0],
tTo = _ref4[1];
return pos >= tFrom && pos < tTo;
});
};
/**
* Where a block's content ends, excluding the run of close tokens that ends it: the block's own,
* then its last child's, and so on down to the last textblock. A change that keeps those trailing
* close tokens unchanged (because the other side ends in the same structure, e.g.
* `…</p></li></ul>`) still covers all of the block's content.
*/
var endBeforeTrailingCloseTokens = function endBeforeTrailingCloseTokens(node, pos) {
var end = pos + node.nodeSize;
var current = node;
while (current && !current.isLeaf) {
end -= 1;
if (current.isTextblock) {
break;
}
current = current.lastChild;
}
return end;
};
var calculateNodesForBlockDecoration = function calculateNodesForBlockDecoration(_ref5) {
var _ref5$allowTrailingCl = _ref5.allowTrailingCloseTokens,
allowTrailingCloseTokens = _ref5$allowTrailingCl === void 0 ? false : _ref5$allowTrailingCl,
attributionKey = _ref5.attributionKey,
doc = _ref5.doc,
from = _ref5.from,
to = _ref5.to,
colorScheme = _ref5.colorScheme,
_ref5$isInserted = _ref5.isInserted,
isInserted = _ref5$isInserted === void 0 ? true : _ref5$isInserted,
leftAnchorId = _ref5.leftAnchorId,
activeIndexPos = _ref5.activeIndexPos,
_ref5$shouldHideDelet = _ref5.shouldHideDeleted,
shouldHideDeleted = _ref5$shouldHideDelet === void 0 ? false : _ref5$shouldHideDelet,
_ref5$showContributor = _ref5.showContributorTags,
showContributorTags = _ref5$showContributor === void 0 ? false : _ref5$showContributor,
_ref5$showIndicators = _ref5.showIndicators,
showIndicators = _ref5$showIndicators === void 0 ? false : _ref5$showIndicators,
_ref5$coarseTableCell = _ref5.coarseTableCellsOnly,
coarseTableCellsOnly = _ref5$coarseTableCell === void 0 ? false : _ref5$coarseTableCell,
tagMountContext = _ref5.tagMountContext,
nodeRanges = _ref5.nodeRanges;
var decorations = [];
// Exact-nodes path: only reached for a split change (EDITOR-8932), which names the nodes it
// owns. Deliberately kept separate from the walk below rather than sharing it.
if (nodeRanges) {
var _iterator = _createForOfIteratorHelper(nodeRanges),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var _step$value = _step.value,
pos = _step$value.from,
nodeEnd = _step$value.to;
var node = doc.nodeAt(pos);
if (!(node !== null && node !== void 0 && node.isBlock)) {
continue;
}
var isActive = isRangeActive(activeIndexPos, pos, nodeEnd, attributionKey);
decorations.push.apply(decorations, _toConsumableArray(createBlockChangedDecoration({
attributionKey: attributionKey,
change: {
from: pos,
to: nodeEnd,
name: node.type.name
},
colorScheme: colorScheme,
isInserted: isInserted,
leftAnchorId: leftAnchorId,
isActive: isActive,
shouldHideDeleted: shouldHideDeleted,
showContributorTags: showContributorTags,
showIndicators: showIndicators,
doc: doc,
tagMountContext: tagMountContext
})));
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return decorations;
}
// Coarse path: inside the large table, only cell/header overlays are kept — the
// table/row/paragraph decorations there are redundant and cause expensive per-cell
// re-render. Blocks outside the table keep their normal decorations.
var coarseTables = coarseTableCellsOnly ? tableRanges(doc, from, to) : [];
// nodesBetween visits ancestors that start before the changed range.
// Only fully contained blocks should mark existing list bullets as new. Under patch 2 this also
// keeps the clean view from hiding a block the change only starts inside: that block holds
// unchanged text and the deleted-content widget, so `display: none` on it hid the whole diff.
var requireContainedBlocks = UNSAFE_expValNoExposure('platform_editor_ai_review_moment', 'isEnabled', false) === true || fg('platform_editor_ai_show_diff_patch_2');
// Under patch 2, a block whose content the change covers but whose trailing close tokens it does
// not (e.g. the last list item when both sides end in a list) is decorated like its siblings.
// Otherwise the clean view hides its text but leaves the block, e.g. an empty bullet.
var relaxTrailingCloseTokens = allowTrailingCloseTokens && fg('platform_editor_ai_show_diff_patch_2');
var isContained = function isContained(node, pos) {
if (requireContainedBlocks && pos < from) {
return false;
}
if (pos + node.nodeSize <= to) {
return true;
}
return relaxTrailingCloseTokens && pos >= from && endBeforeTrailingCloseTokens(node, pos) <= to;
};
// Iterate over the document nodes within the range
doc.nodesBetween(from, to, function (node, pos) {
if (coarseTableCellsOnly && isInsideTable(coarseTables, pos)) {
var name = node.type.name;
if (name !== 'tableCell' && name !== 'tableHeader') {
return;
}
}
if (node.isBlock && isContained(node, pos)) {
var _nodeEnd = pos + node.nodeSize;
var _isActive = isRangeActive(activeIndexPos, pos, _nodeEnd, attributionKey);
decorations.push.apply(decorations, _toConsumableArray(createBlockChangedDecoration({
attributionKey: attributionKey,
change: {
from: pos,
to: _nodeEnd,
name: node.type.name
},
colorScheme: colorScheme,
isInserted: isInserted,
leftAnchorId: leftAnchorId,
isActive: _isActive,
shouldHideDeleted: shouldHideDeleted,
showContributorTags: showContributorTags,
showIndicators: showIndicators,
doc: doc,
tagMountContext: tagMountContext
})));
}
});
return decorations;
};
/**
* Whether deleted content for `change` renders after the new content instead of before it.
*
* show-diff splits this across two options by change granularity, so the level decides which one
* applies:
* - node/paragraph-level: `deletedDiffPlacement` (default `'top'`). Pure deletions are promoted to
* node level, so they are governed here too.
* - inline/sentence-level: `inlineDeletedDiffPlacement` (default `'before'`).
*
* Callers are responsible for the `smart` diffType and gate checks.
*/
export var isDeletedContentPlacedBelow = function isDeletedContentPlacedBelow(_ref6) {
var change = _ref6.change,
deletedDiffPlacement = _ref6.deletedDiffPlacement,
inlineDeletedDiffPlacement = _ref6.inlineDeletedDiffPlacement;
var level = smartChangeLevel(change);
if (level === 'node' || level === 'paragraph') {
return deletedDiffPlacement === 'bottom';
}
// Inline-level (undefined) and sentence-level changes.
return inlineDeletedDiffPlacement === 'after';
};
/**
* Single place `platform_editor_diff_hide_pure_deletions` is read, shared by the widget-anchor
* calculation and the decoration push below so the two cannot drift.
*
* The clean view ("new state") historically only suppressed a deleted side when the same change
* also carried an insertion. A PURE deletion — text removed with nothing put in its place — fell
* through and leaked struck-through text into a view that is supposed to show none. `smart` never
* showed this because it pairs a rewrite into a single change that always carries an insertion;
* `inline` emits delete-only changes, so it did.
*
* With the gate off this returns exactly what both callers computed before it existed.
*
* Umbrella experiment: within the AI streaming UX M1 population this behaviour is only enabled
* for the treatment cohort. Non-AI callers (track-changes, etc.) are not enrolled and default
* to `true`, preserving the pre-umbrella baseline.
*/
var shouldHideDeletedSide = function shouldHideDeletedSide(_ref7) {
var change = _ref7.change,
hideDeletedDiffs = _ref7.hideDeletedDiffs,
isInverted = _ref7.isInverted;
return !isInverted && !!hideDeletedDiffs && (change.inserted.length > 0 || fg('platform_editor_diff_hide_pure_deletions') && UNSAFE_expValNoExposure('platform_editor_ai_streaming_ux_experience_m1', 'isEnabled', false) === true);
};
var calculateDiffDecorationsInner = function calculateDiffDecorationsInner(_ref8) {
var state = _ref8.state,
pluginState = _ref8.pluginState,
nodeViewSerializer = _ref8.nodeViewSerializer,
colorScheme = _ref8.colorScheme,
intl = _ref8.intl,
activeIndexPos = _ref8.activeIndexPos,
api = _ref8.api,
_ref8$isInverted = _ref8.isInverted,
isInverted = _ref8$isInverted === void 0 ? false : _ref8$isInverted,
_ref8$diffType = _ref8.diffType,
diffType = _ref8$diffType === void 0 ? getDefaultDiffType() : _ref8$diffType,
_ref8$hideDeletedDiff = _ref8.hideDeletedDiffs,
hideDeletedDiffs = _ref8$hideDeletedDiff === void 0 ? false : _ref8$hideDeletedDiff,
_ref8$hideAddedDiffsU = _ref8.hideAddedDiffsUnderline,
hideAddedDiffsUnderlineParam = _ref8$hideAddedDiffsU === void 0 ? false : _ref8$hideAddedDiffsU,
_ref8$showIndicators = _ref8.showIndicators,
showIndicators = _ref8$showIndicators === void 0 ? false : _ref8$showIndicators,
smartThresholds = _ref8.smartThresholds,
_ref8$deletedDiffPlac = _ref8.deletedDiffPlacement,
deletedDiffPlacement = _ref8$deletedDiffPlac === void 0 ? 'top' : _ref8$deletedDiffPlac,
_ref8$inlineDeletedDi = _ref8.inlineDeletedDiffPlacement,
inlineDeletedDiffPlacement = _ref8$inlineDeletedDi === void 0 ? 'before' : _ref8$inlineDeletedDi,
reveal = _ref8.reveal,
tagMountContext = _ref8.tagMountContext;
var originalDoc = pluginState.originalDoc,
steps = pluginState.steps,
stepAttributions = pluginState.stepAttributions,
contributors = pluginState.contributors,
isDisplayingChanges = pluginState.isDisplayingChanges;
if (!originalDoc || !isDisplayingChanges) {
return {
contributorTags: [],
decorations: DecorationSet.empty,
diffDescriptors: []
};
}
// Resolve the option against its gate once here, so every downstream inline/block builder
// receives the same value. When the gate is off the option is a no-op.
var hideAddedDiffsUnderline = hideAddedDiffsUnderlineParam && fg('platform_editor_ai_smart_diff');
var tr = state.tr;
var steppedDoc = originalDoc;
var attributionColoringEnabled = isAttributionColoringEnabled(stepAttributions);
var showContributorTags = isContributorTagsEnabled(stepAttributions, contributors);
// Tags need the attributed changeset even on diffs with too few actors to be worth colouring.
var attributedChangesetEnabled = attributionColoringEnabled || showContributorTags;
var simplifiedSteps;
var simplifiedStepAttributions = [];
if (attributedChangesetEnabled) {
var simplifiedStepsWithAttribution = simplifyStepsWithAttribution(steps, stepAttributions !== null && stepAttributions !== void 0 ? stepAttributions : [], originalDoc);
simplifiedSteps = simplifiedStepsWithAttribution.map(function (_ref9) {
var step = _ref9.step;
return step;
});
simplifiedStepAttributions = simplifiedStepsWithAttribution.map(function (_ref0) {
var stepAttribution = _ref0.stepAttribution;
return stepAttribution;
});
} else {
simplifiedSteps = simplifySteps(steps, originalDoc);
}
var pendingAttrSteps = [];
var stepMaps = [];
var _iterator2 = _createForOfIteratorHelper(simplifiedSteps.entries()),
_step2;
try {
for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) {
var _step2$value = _slicedToArray(_step2.value, 2),
stepIndex = _step2$value[0],
step = _step2$value[1];
var result = step.apply(steppedDoc);
if (result.failed === null && result.doc) {
if (stepIsValidAttrChange(step, steppedDoc, result.doc)) {
pendingAttrSteps.push({
attributionKey: getAttributionKey(simplifiedStepAttributions[stepIndex]),
afterNode: result.doc.nodeAt(step.pos),
beforeNode: steppedDoc.nodeAt(step.pos),
mapIndex: stepMaps.length,
step: step
});
}
stepMaps.push(step.getMap());
steppedDoc = result.doc;
}
}
// Mapped positions and step-time nodes are used together so an attr change is attributed to the
// node it actually happened on, even when later steps move it.
} catch (err) {
_iterator2.e(err);
} finally {
_iterator2.f();
}
var attrStepContexts = pendingAttrSteps.flatMap(function (_ref1) {
var afterNode = _ref1.afterNode,
attributionKey = _ref1.attributionKey,
beforeNode = _ref1.beforeNode,
mapIndex = _ref1.mapIndex,
step = _ref1.step;
var finalPosition = new Mapping(stepMaps.slice(mapIndex + 1)).mapResult(step.pos);
if (finalPosition.deleted) {
return [];
}
var originalPosition = new Mapping(stepMaps.slice(0, mapIndex)).invert().mapResult(step.pos);
return [_objectSpread(_objectSpread({
attributionKey: attributionKey,
afterNode: afterNode,
beforeNode: beforeNode,
finalPos: finalPosition.pos
}, originalPosition.deleted ? {} : {
originalPos: originalPosition.pos
}), {}, {
step: step
})];
});
// Rather than using .eq() we use a custom function that only checks for structural
// changes and ignores differences in attributes which don't affect decoration positions
if (!areNodesEqualIgnoreAttrs(steppedDoc, tr.doc)) {
var _api$analytics;
var recoveredViaContentEquality = areDocsEqualByBlockStructureAndText(steppedDoc, tr.doc);
api === null || api === void 0 || (_api$analytics = api.analytics) === null || _api$analytics === void 0 || _api$analytics.actions.fireAnalyticsEvent({
eventType: 'track',
action: 'nodesNotEqual',
actionSubject: 'showDiff',
attributes: {
docSizeEqual: steppedDoc.nodeSize === tr.doc.nodeSize,
colorScheme: colorScheme,
recoveredViaContentEquality: recoveredViaContentEquality
}
});
if (!recoveredViaContentEquality) {
return {
contributorTags: [],
decorations: DecorationSet.empty,
diffDescriptors: []
};
}
}
// The attribute-aware encoder is needed by the smart classifier and is gated with it.
// The NCS version-history path also uses it so the main diff calculation detects attribute-only
// node replacements, e.g. changing an emoji or mention without changing the node type.
var _selectTokenEncoder = selectTokenEncoder(diffType === 'smart' && fg('platform_editor_ai_smart_diff') || fg('confluence_ncs_step_diffing_version_history')),
tokenEncoder = _selectTokenEncoder.tokenEncoder,
shouldHideMarkOnlyDeletions = _selectTokenEncoder.shouldHideMarkOnlyDeletions;
var changes;
var contentChanges;
var attributedChanges = [];
var attributionColors;
if (attributedChangesetEnabled) {
var changeset = ChangeSet.create(originalDoc, function (left, right) {
return getAttributionKey(left) === getAttributionKey(right) ? left.stepIndex > right.stepIndex ? left : right :
// `prosemirror-changeset` uses null as its incompatible-data sentinel even
// though its public combine callback is typed as returning Data.
null;
}, tokenEncoder).addSteps(steppedDoc, stepMaps, simplifiedStepAttributions.map(function (attribution, stepIndex) {
return _objectSpread(_objectSpread({}, attribution), {}, {
stepIndex: stepIndex
});
}));
contentChanges = changeset.changes;
attributedChanges = diffType === 'step' ? diffBySteps(originalDoc, simplifiedSteps, simplifiedStepAttributions) : _toConsumableArray(changeset.changes);
// The attributed pipeline gives no guarantee that its output ranges are disjoint — see
// `collapseOverlappingChanges`.
var uncollapsedChanges = getAttributedChanges({
changeset: changeset,
originalDoc: originalDoc,
steppedDoc: steppedDoc,
diffType: diffType,
tr: tr,
attributedChanges: attributedChanges
});
changes = collapseOverlappingChanges(uncollapsedChanges, tr.doc);
// Still colours-only: a tags-only diff keeps the plain colour scheme.
if (attributionColoringEnabled) {
attributionColors = createAttributionColorMap(simplifiedStepAttributions, contributors);
}
} else {
var _changeset = ChangeSet.create(originalDoc, undefined, tokenEncoder).addSteps(steppedDoc, stepMaps, tr.doc);
contentChanges = _changeset.changes;
changes = getChanges({
changeset: _changeset,
originalDoc: originalDoc,
steppedDoc: steppedDoc,
diffType: diffType,
tr: tr,
steps: simplifiedSteps,
intl: intl,
smartThresholds: smartThresholds
});
}
var decorations = [];
/**
* If showIndicators is on, we create an anchor widget here to mark the doc margin.
*/
if (showIndicators) {
decorations.push(createDocMarginAnchorWidget());
}
// Our default operations are insertions, so it should match the opposite of isInverted.
var isInserted = !isInverted;
changes = groupDeletedColumnChanges(changes, originalDoc, tr.doc, new Mapping(stepMaps), isInverted);
// A whole-table change owns every mark and attribute range inside its bounds, so those ranges
// do not also get their own decoration.
var tableChanges = changes.filter(function (change) {
return change.deletedColumns !== undefined;
});
var isHandledByTable = function isHandledByTable(from, to) {
return tableChanges.some(function (change) {
return from >= change.fromB && to <= change.toB;
});
};
var createDecorationsForChange = function createDecorationsForChange(change) {
// Patch 2 makes all decorations generated for one replacement share this anchor. Their own
// IDs still identify their top/bottom bounds, while this ID makes their bars agree on one
// left edge. When the gate is off, factories retain their existing per-decoration anchors.
var leftAnchorId = fg('platform_editor_ai_show_diff_patch_2') ? "replacement-".concat(change.fromA, "-").concat(change.toA, "-").concat(change.fromB, "-").concat(change.toB) : undefined;
var changeColorScheme = attributionColors ? getColorSchemeForChange(change, attributedChanges, attributionColors, colorScheme) : colorScheme;
var attributionKey = showContributorTags ? getAttributionKeyForChange(change, attributedChanges) : undefined;
var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB, attributionKey);
var deletedColumns = change.deletedColumns;
// On an inverted diff the table that lost columns is the one in `tr.doc`, so the column
// indices address it directly and each label anchors inside its own cell.
var table = tr.doc.nodeAt(change.fromB);
if (isInverted && !hideDeletedDiffs && deletedColumns !== null && deletedColumns !== void 0 && deletedColumns.length && (table === null || table === void 0 ? void 0 : table.type.name) === 'table') {
var tableMap = TableMap.get(table);
deletedColumns.forEach(function (column) {
var offset = tableMap.positionAt(0, column, table);
decorations.push(Decoration.widget(change.fromB + CELL_CONTENT_OFFSET + offset, function () {
return createRemovedLozenge(intl, isActive, changeColorScheme, true);
}, {
side: -1
}));
});
}
// The deleted side of a mark-only change is a byte-identical copy of the new text, so it is
// suppressed in the clean view. `hideDeletedDiffs` is load-bearing: when false the reviewer
// asked to compare against the original, and the deleted side is the only place the
// previous formatting is visible.
var isMarkOnly = shouldHideMarkOnlyDeletions && hideDeletedDiffs && change.deleted.length > 0 && isMarkOnlyChange({
change: change,
originalDoc: originalDoc,
newDoc: tr.doc
});
// The deleted side of a change over a node's open token is an attribute state rather than
// content, so there is nothing for the deleted-content widget to draw. Decided here, with
// the other deleted-side suppressions, so the widget is never asked for a slice it can only
// render as an empty copy of the block (EDITOR-8912). Its inserted side is untouched, and
// the node's content change is a change of its own.
var hasNoDeletedContent = fg('platform_editor_ai_show_diff_patch_1') && change.deleted.length > 0 && isOpenTokenOnlyChange({
change: change,
originalDoc: originalDoc
});
// Hoisted because it decides BOTH where the deleted widget is anchored and — since the
// widget pins whichever end of the range it sits at — how the indicator anchors below are
// allowed to move.
var isDeletedWidgetBelow = diffType === 'smart' && fg('platform_editor_ai_smart_diff') && isDeletedContentPlacedBelow({
change: change,
deletedDiffPlacement: deletedDiffPlacement,
inlineDeletedDiffPlacement: inlineDeletedDiffPlacement
});
if (change.inserted.length > 0) {
// On an inverted diff the inserted side is visually the deleted side.
var shouldHideDeleted = isInverted && hideDeletedDiffs;
// A removed blank line — an empty paragraph or heading — has nothing to mark up. On an
// inverted diff, the shape AI suggested edits renders, the block is still in the displayed
// document, so the removal lands here on the inserted side rather than on the deleted side,
// and both the inline and the block decoration paint nothing: an empty textblock has no
// inline content to cover, and block decorations return no style for a paragraph or heading.
// The reviewer sees an unmarked blank line. Stand the struck-through return glyph in for each
// one, anchored inside the block so it renders on the blank line itself.
//
// One decoration per blank line rather than one per change: a run of adjacent blank lines is
// reported as a single change spanning all of them.
//
// Withheld in the clean view, where removals are hidden rather than struck through.
if (fg('platform_editor_ai_show_diff_patch_2') && !isInserted && !shouldHideDeleted) {
var _iterator3 = _createForOfIteratorHelper(emptyTextBlockContentPositions(tr.doc, change.fromB, change.toB)),
_step3;
try {
for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) {
var pos = _step3.value;
decorations.push(createDeletedLineBreakDecoration({
attributionKey: attributionKey,
colorScheme: changeColorScheme,
isActive: isActive,
pos: pos,
reveal: reveal,
side: -1
}));
}
} catch (err) {
_iterator3.e(err);
} finally {
_iterator3.f();
}
}
// For `smart` NODE-level promotions the change range spans a whole container
// (e.g. an entire list/table/layout, using outer node bounds). Applying a SINGLE
// inline decoration across that whole range would paint the inserted style across
// block boundaries and structural gaps (list markers, empty item slots), producing
// phantom "empty" rows above the real content. But skipping the inline highlight
// entirely leaves added text-bearing blocks (paragraphs/headings inside the added
// container) without the inserted background+underline, because block decorations
// return no style for paragraph/heading. So for node-level smart changes we instead
// emit ONE inline decoration per leaf text-bearing block within the range — the text
// gets highlighted, and structural gaps never do.
var isSmartNodeLevel = diffType === 'smart' && fg('platform_editor_ai_smart_diff') && smartChangeLevel(change) === 'node';
// For a large inserted table, skip the per-cell inline decorations that
// freeze the browser on re-render. Only active when Review moment is
// eligible (xstate + M1).
var useCoarseTableDecoration = isSmartNodeLevel && expValEqualsNoExposure('platform_editor_ai_xstate_migration', 'isEnabled', true) && UNSAFE_expValNoExposure('platform_editor_ai_streaming_ux_experience_m1', 'isEnabled', false) === true && isLargeInsertedTableRange(tr.doc, change.fromB, change.toB);
// Whether the deleted-content widget will actually be rendered for this
// change. Used to decide if indicator anchor positions should be adjusted
// inward — when the widget is present the anchor must stay at the block
// boundary to keep the indicator bar continuous with the deleted content.
var willRenderDeletedWidget = change.deleted.length > 0 && !isMarkOnly && !shouldHideDeletedSide({
change: change,
hideDeletedDiffs: hideDeletedDiffs,
isInverted: isInverted
});
if (isSmartNodeLevel) {
// For a large inserted table, skip the O(cells) inline decorations inside the
// table (the cell overlays added below provide the highlight without the
// expensive content re-render); leaf blocks outside the table still get theirs.
var coarseTables = useCoarseTableDecoration ? tableRanges(tr.doc, change.fromB, change.toB) : [];
var _iterator4 = _createForOfIteratorHelper(leafTextblockRanges(tr.doc, change.fromB, change.toB)),
_step4;
try {
for (_iterator4.s(); !(_step4 = _iterator4.n()).done;) {
var _step4$value = _slicedToArray(_step4.value, 2),
from = _step4$value[0],
to = _step4$value[1];
if (useCoarseTableDecoration && isInsideTable(coarseTables, from)) {
continue;
}
decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration({
attributionKey: attributionKey,
change: {
fromB: from,
toB: to
},
leftAnchorId: leftAnchorId,
doc: tr.doc,
colorScheme: changeColorScheme,
isActive: isActive,
isInserted: isInserted,
shouldHideDeleted: shouldHideDeleted,
showContributorTags: showContributorTags,
showIndicators: showIndicators,
hideAddedDiffsUnderline: hideAddedDiffsUnderline,
hasDeletedWidget: willRenderDeletedWidget,
isDeletedWidgetBelow: isDeletedWidgetBelow,
reveal: reveal,
tagMountContext: tagMountContext
})));
}
} catch (err) {
_iterator4.e(err);
} finally {
_iterator4.f();
}
} else {
decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration({
attributionKey: attributionKey,
change: change,
leftAnchorId: leftAnchorId,
doc: tr.doc,
colorScheme: changeColorScheme,
isActive: isActive,
isInserted: isInserted,
shouldHideDeleted: shouldHideDeleted,
showContributorTags: showContributorTags,
showIndicators: showIndicators,
hideAddedDiffsUnderline: hideAddedDiffsUnderline,
hasDeletedWidget: willRenderDeletedWidget,
isDeletedWidgetBelow: isDeletedWidgetBelow,
reveal: reveal,
tagMountContext: tagMountContext
})));
}
decorations.push.apply(decorations, _toConsumableArray(calculateNodesForBlockDecoration({
// A deleted widget placed below anchors at the change's end, inside the very block
// this would let the decoration claim.
allowTrailingCloseTokens: !isDeletedWidgetBelow,
attributionKey: attributionKey,
doc: tr.doc,
from: change.fromB,
to: change.toB,
// A split piece's own range no longer covers the container it opened (EDITOR-8932).
nodeRanges: change.blockNodeRangesB,
colorScheme: changeColorScheme,
isInserted: isInserted,
shouldHideDeleted: shouldHideDeleted,
showContributorTags: showContributorTags,
showIndicators: showIndicators,
tagMountContext: tagMountContext,
activeIndexPos: activeIndexPos,
intl: intl,
coarseTableCellsOnly: useCoarseTableDecoration,
leftAnchorId: leftAnchorId
})));
}
if (change.deleted.length > 0 && !isMarkOnly && !hasNoDeletedContent) {
var _shouldHideDeleted = shouldHideDeletedSide({
change: change,
hideDeletedDiffs: hideDeletedDiffs,
isInverted: isInverted
});
if (!_shouldHideDeleted) {
decorations.push.apply(decorations, _toConsumableArray(createNodeChangedDecorationWidget({
deletedColumns: deletedColumns,
// The deleted side has no inline decoration, so the widget carries the actor.
attributionKey: attributionKey,
reveal: reveal,
change: change,
doc: originalDoc,
nodeViewSerializer: nodeViewSerializer,
colorScheme: changeColorScheme,
newDoc: tr.doc,
intl: intl,
activeIndexPos: activeIndexPos,
leftAnchorId: leftAnchorId,
showContributorTags: showContributorTags,
tagMountContext: tagMountContext,
isInserted: !isInserted,
hideAddedDiffsUnderline: hideAddedDiffsUnderline,
placeBelow: isDeletedWidgetBelow,
showIndicators: showIndicators
})));
}
}
};
changes.forEach(function (change) {
createDecorationsForChange(change);
});
// Compare the content outside replacement ranges at matching positions in both documents.
// This preserves real formatting edits without highlighting an unchanged link mark reapplied
// while editing its display text.
var hasUnhighlightedMarkChanges = function hasUnhighlightedMarkChanges(_ref10) {
var fromB = _ref10.fromB,
toB = _ref10.toB;
var from = fromB;
var offset = 0;
var differs = function differs(to) {
return from < to && !originalDoc.slice(from + offset, to + offset).content.eq(tr.doc.slice(from, to).content);
};
var _iterator5 = _createForOfIteratorHelper(contentChanges),
_step5;
try {
for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) {
var change = _step5.value;
if (change.fromB >= toB) {
break;
}
if (change.toB > from) {
if (differs(change.fromB)) {
return true;
}
from = change.toB;
}
offset = change.toA - change.toB;
}
} catch (err) {
_iterator5.e(err);
} finally {
_iterator5.f();
}
return differs(toB);
};
// The attribute-aware token encoder already reports mark-only changes as regular changes in the
// smart path and the NCS version-history path. For NCS version history, only skip the step-range
// highlight if the main diff covers it or the remaining content has unchanged formatting.
getMarkChangeRanges(simplifiedSteps, simplifiedStepAttributions, fg('confluence_ncs_step_diffing_version_history') ? stepMaps : undefined).filter(function (_ref11) {
var fromB = _ref11.fromB,
toB = _ref11.toB;
return !isHandledByTable(fromB, toB);
}).filter(function (range) {
return !fg('confluence_ncs_step_diffing_version_history') || !changes.some(function (change) {
return change.fromB <= range.fromB && change.toB >= range.toB;
}) && hasUnhighlightedMarkChanges(range);
}).forEach(function (change) {
var _attributionColors$ge, _attributionColors;
var changeColorScheme = change.attributionKey ? (_attributionColors$ge = (_attributionColors = attributionColors) === null || _attributionColors === void 0 ? void 0 : _attributionColors.get(change.attributionKey)) !== null && _attributionColors$ge !== void 0 ? _attributionColors$ge : colorScheme : colorScheme;
var attributionKey = showContributorTags ? change.attributionKey : undefined;
var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB, attributionKey);
decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration({
attributionKey: attributionKey,
change: change,
colorScheme: changeColorScheme,
doc: tr.doc,
isActive: isActive,
isInserted: true,
reveal: reveal,
showContributorTags: showContributorTags,
tagMountContext: tagMountContext
})));
});
getAttrChangeRanges(tr.doc, attrStepContexts, originalDoc).filter(function (_ref12) {
var fromB = _ref12.fromB,
toB = _ref12.toB;
return !isHandledByTable(fromB, toB);
}).filter(function (_ref13) {
var fromB = _ref13.fromB,
toB = _ref13.toB,
isInline = _ref13.isInline;
return !fg('confluence_ncs_step_diffing_version_history') || !isInline || !changes.some(function (change) {
return change.fromB === fromB && change.toB === toB;
});
}).forEach(function (change) {
var _attributionColors$ge2, _attributionColors2;
var changeColorScheme = change.attributionKey ? (_attributionColors$ge2 = (_attributionColors2 = attributionColors) === null || _attributionColors2 === void 0 ? void 0 : _attributionColors2.get(change.attributionKey)) !== null && _attributionColors$ge2 !== void 0 ? _attributionColors$ge2 : colorScheme : colorScheme;
var attributionKey = showContributorTags ? change.attributionKey : undefined;
if (change.isInline) {
// Inline nodes (e.g. date, emoji, mention, status) need an inline decoration rather than a block decoration
var isActive = isRangeActive(activeIndexPos, change.fromB, change.toB, attributionKey);
decorations.push.apply(decorations, _toConsumableArray(createInlineChangedDecoration({
attributionKey: attributionKey,
change: change,
colorScheme: changeColorScheme,
doc: tr.doc,
isActive: isActive,
isInserted: true,
isAtomicInlineNode: true,
inlineNodeName: change.inlineNodeName,
showContributorTags: showContributorTags,
tagMountContext: tagMountContext,
// Suppress the border-bottom underline for atomic inline nodeviews —
// it doesn't render on custom nodeview DOM elements and is unnecessary noise.
hideAddedDiffsUnderline: true
})));
// If we have the original node position, also render the old node as a "deleted" widget
// so the user can see what it looked like before the change.
if (change.fromA !== undefined && change.toA !== undefined) {
decorations.push.apply(decorations, _toConsumableArray(createNodeChangedDecorationWidget({
change: {
fromA: change.fromA,
toA: change.toA,
fromB: change.fromB,
toB: change.toB,
deleted: []
},
doc: originalDoc,
attributionKey: attributionKey,
nodeViewSerializer: nodeViewSerializer,
colorScheme: changeColorScheme,
newDoc: tr.doc,
intl: intl,
activeIndexPos: activeIndexPos,
isInserted: false,
showContributorTags: showContributorTags,
tagMountContext: tagMountContext
})));
}
} else {
decorations.push.apply(decorations, _toConsumableArray(calculateNodesForBlockDecoration({
doc: tr.doc,
from: change.fromB,
to: change.toB,
attributionKey: attributionKey,
colorScheme: changeColorScheme,
isInserted: true,
activeIndexPos: activeIndexPos,
intl: intl,
showContributorTags: showContributorTags,
showIndicators: showIndicators,
tagMountContext: tagMountContext
})));
// If the original node position is known (e.g. a panel type change), also render
// the old block node as a "deleted" widget so the reviewer sees the before/after.
var isSmartNodeLevelAttrChange = diffType === 'smart' && fg('platform_editor_ai_smart_diff') && expValEqualsNoExposure('platform_editor_ai_xstate_migration', 'isEnabled', true) && UNSAFE_expValNoExposure('platform_editor_ai_streaming_ux_experience_m1', 'isEnabled', false) === true;
if (isSmartNodeLevelAttrChange && change.fromA !== undefined && change.toA !== undefined) {
var placeBelow = deletedDiffPlacement === 'bottom';
decorations.push.apply(decorations, _toConsumableArray(createNodeChangedDecorationWidget({
change: {
fromA: change.fromA,
toA: change.toA,
fromB: change.fromB,
toB: change.toB,
deleted: []
},
doc: originalDoc,
nodeViewSerializer: nodeViewSerializer,
attributionKey: attributionKey,
colorScheme: changeColorScheme,
newDoc: tr.doc,
intl: intl,
activeIndexPos: activeIndexPos,
isInserted: false,
hideAddedDiffsUnderline: hideAddedDiffsUnderline,
placeBelow: placeBelow,
showIndicators: showIndicators,
showContributorTags: showContributorTags,
tagMountContext: tagMountContext
})));
}
}
});
var decorationSet = DecorationSet.empty.add(tr.doc, decorations);
return {
contributorTags: showContributorTags ? extractContributorTags(decorationSet, contributors, activeIndexPos,
// The stops the step buttons walk, so a stop cannot hold a second tag for one
// contributor that navigation can never reach.
getScrollableDecorations(decorationSet, tr.doc)) : [],
decorations: decorationSet,
diffDescriptors: extractDiffDescriptors(decorationSet)
};
};
export var calculateDiffDecorations = memoizeOne(calculateDiffDecorationsInner,
// Cache results unless relevant inputs change. `tagMountContext` is deliberately absent: it is
// built once per plugin instance, so it can never be the reason a diff needs recalculating.
function (_ref14, _ref15) {
var _ref18;
var _ref16 = _slicedToArray(_ref14, 1),
_ref16$ = _ref16[0],
pluginState = _ref16$.pluginState,
state = _ref16$.state,
colorScheme = _ref16$.colorScheme,
intl = _ref16$.intl,
activeIndexPos = _ref16$.activeIndexPos,
isInverted = _ref16$.isInverted,
diffType = _ref16$.diffType,
hideDeletedDiffs = _ref16$.hideDeletedDiffs,
hideAddedDiffsUnderline = _ref16$.hideAddedDiffsUnderline,
showIndicators = _ref16$.showIndicators,
smartThresholds = _ref16$.smartThresholds,
deletedDiffPlacement = _ref16$.deletedDiffPlacement,
inlineDeletedDiffPlacement = _ref16$.inlineDeletedDiffPlacement,
reveal = _ref16$.reveal;
var _ref17 = _slicedToArray(_ref15, 1),
_ref17$ = _ref17[0],
lastPluginState = _ref17$.pluginState,
lastState = _ref17$.state,
lastColorScheme = _ref17$.colorScheme,
lastIntl = _ref17$.intl,
lastActiveIndexPos = _ref17$.activeIndexPos,
lastIsInverted = _ref17$.isInverted,
lastDiffType = _ref17$.diffType,
lastHideDeletedDiffs = _ref17$.hideDeletedDiffs,
lastHideAddedDiffsUnderline = _ref17$.hideAddedDiffsUnderline,
lastShowIndicators = _ref17$.showIndicators,
lastSmartThresholds = _ref17$.smartThresholds,
lastDeletedDiffPlacement = _ref17$.deletedDiffPlacement,
lastInlineDeletedDiffPlacement = _ref17$.inlineDeletedDiffPlacement,
lastReveal = _ref17$.reveal;
var originalDocIsSame = lastPluginState.originalDoc && pluginState.originalDoc && pluginState.originalDoc.eq(lastPluginState.originalDoc);
return (_ref18 = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && isEqual(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && isEqual(pluginState.stepAttributions, lastPluginState.stepAttributions) &&
// Contributors can arrive after the first calculation; without this, tags never appear.
isEqual(pluginState.contributors, lastPluginState.contributors) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && hideAddedDiffsUnderline === lastHideAddedDiffsUnderline && showIndicators === lastShowIndicators && isEqual(smartThresholds, lastSmartThresholds) && deletedDiffPlacement === lastDeletedDiffPlacement && inlineDeletedDiffPlacement === lastInlineDeletedDiffPlacement &&
// Turning the reveal on or off changes the emitted styles, so a cached result from the
// other setting would leave the diff painted in the wrong resting state.
isEqual(reveal, lastReveal)) !== null && _ref18 !== void 0 ? _ref18 : false;
});