@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
928 lines (909 loc) • 40.3 kB
JavaScript
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';
const getChanges = ({
changeset,
originalDoc,
steppedDoc,
diffType,
tr,
steps,
intl,
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')) {
const changes = simplifyChanges(changeset.changes, tr.doc);
return classifySmartChanges({
changes,
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);
}
const changes = simplifyChanges(changeset.changes, tr.doc);
return optimizeChanges(changes);
};
const getAttributedChanges = ({
attributedChanges,
changeset,
originalDoc,
steppedDoc,
diffType,
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.
*/
const leafTextblockRanges = (doc, from, to) => {
const ranges = [];
doc.nodesBetween(from, to, (node, pos) => {
if (node.isTextblock && node.content.size > 0) {
const contentFrom = Math.max(pos + 1, from);
const 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.
const 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.
*/
const 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);
};
const isLargeInsertedTableRange = (doc, from, to) => {
let containsTable = false;
let leafCount = 0;
doc.nodesBetween(from, to, 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
const tableRanges = (doc, from, to) => {
const ranges = [];
doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name === 'table') {
ranges.push([pos, pos + node.nodeSize]);
return false;
}
return true;
});
return ranges;
};
const isInsideTable = (tables, pos) => tables.some(([tFrom, tTo]) => 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.
*/
const endBeforeTrailingCloseTokens = (node, pos) => {
let end = pos + node.nodeSize;
let current = node;
while (current && !current.isLeaf) {
end -= 1;
if (current.isTextblock) {
break;
}
current = current.lastChild;
}
return end;
};
const calculateNodesForBlockDecoration = ({
allowTrailingCloseTokens = false,
attributionKey,
doc,
from,
to,
colorScheme,
isInserted = true,
leftAnchorId,
activeIndexPos,
shouldHideDeleted = false,
showContributorTags = false,
showIndicators = false,
coarseTableCellsOnly = false,
tagMountContext,
nodeRanges
}) => {
const 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) {
for (const {
from: pos,
to: nodeEnd
} of nodeRanges) {
const node = doc.nodeAt(pos);
if (!(node !== null && node !== void 0 && node.isBlock)) {
continue;
}
const isActive = isRangeActive(activeIndexPos, pos, nodeEnd, attributionKey);
decorations.push(...createBlockChangedDecoration({
attributionKey,
change: {
from: pos,
to: nodeEnd,
name: node.type.name
},
colorScheme,
isInserted,
leftAnchorId,
isActive,
shouldHideDeleted,
showContributorTags,
showIndicators,
doc,
tagMountContext
}));
}
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.
const 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.
const 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.
const relaxTrailingCloseTokens = allowTrailingCloseTokens && fg('platform_editor_ai_show_diff_patch_2');
const 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, (node, pos) => {
if (coarseTableCellsOnly && isInsideTable(coarseTables, pos)) {
const name = node.type.name;
if (name !== 'tableCell' && name !== 'tableHeader') {
return;
}
}
if (node.isBlock && isContained(node, pos)) {
const nodeEnd = pos + node.nodeSize;
const isActive = isRangeActive(activeIndexPos, pos, nodeEnd, attributionKey);
decorations.push(...createBlockChangedDecoration({
attributionKey,
change: {
from: pos,
to: nodeEnd,
name: node.type.name
},
colorScheme,
isInserted,
leftAnchorId,
isActive,
shouldHideDeleted,
showContributorTags,
showIndicators,
doc,
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 const isDeletedContentPlacedBelow = ({
change,
deletedDiffPlacement,
inlineDeletedDiffPlacement
}) => {
const 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.
*/
const shouldHideDeletedSide = ({
change,
hideDeletedDiffs,
isInverted
}) => !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);
const calculateDiffDecorationsInner = ({
state,
pluginState,
nodeViewSerializer,
colorScheme,
intl,
activeIndexPos,
api,
isInverted = false,
diffType = getDefaultDiffType(),
hideDeletedDiffs = false,
hideAddedDiffsUnderline: hideAddedDiffsUnderlineParam = false,
showIndicators = false,
smartThresholds,
deletedDiffPlacement = 'top',
inlineDeletedDiffPlacement = 'before',
reveal,
tagMountContext
}) => {
const {
originalDoc,
steps,
stepAttributions,
contributors,
isDisplayingChanges
} = pluginState;
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.
const hideAddedDiffsUnderline = hideAddedDiffsUnderlineParam && fg('platform_editor_ai_smart_diff');
const {
tr
} = state;
let steppedDoc = originalDoc;
const attributionColoringEnabled = isAttributionColoringEnabled(stepAttributions);
const showContributorTags = isContributorTagsEnabled(stepAttributions, contributors);
// Tags need the attributed changeset even on diffs with too few actors to be worth colouring.
const attributedChangesetEnabled = attributionColoringEnabled || showContributorTags;
let simplifiedSteps;
let simplifiedStepAttributions = [];
if (attributedChangesetEnabled) {
const simplifiedStepsWithAttribution = simplifyStepsWithAttribution(steps, stepAttributions !== null && stepAttributions !== void 0 ? stepAttributions : [], originalDoc);
simplifiedSteps = simplifiedStepsWithAttribution.map(({
step
}) => step);
simplifiedStepAttributions = simplifiedStepsWithAttribution.map(({
stepAttribution
}) => stepAttribution);
} else {
simplifiedSteps = simplifySteps(steps, originalDoc);
}
const pendingAttrSteps = [];
const stepMaps = [];
for (const [stepIndex, step] of simplifiedSteps.entries()) {
const 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
});
}
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.
const attrStepContexts = pendingAttrSteps.flatMap(({
afterNode,
attributionKey,
beforeNode,
mapIndex,
step
}) => {
const finalPosition = new Mapping(stepMaps.slice(mapIndex + 1)).mapResult(step.pos);
if (finalPosition.deleted) {
return [];
}
const originalPosition = new Mapping(stepMaps.slice(0, mapIndex)).invert().mapResult(step.pos);
return [{
attributionKey,
afterNode,
beforeNode,
finalPos: finalPosition.pos,
...(originalPosition.deleted ? {} : {
originalPos: originalPosition.pos
}),
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;
const recoveredViaContentEquality = areDocsEqualByBlockStructureAndText(steppedDoc, tr.doc);
api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({
eventType: 'track',
action: 'nodesNotEqual',
actionSubject: 'showDiff',
attributes: {
docSizeEqual: steppedDoc.nodeSize === tr.doc.nodeSize,
colorScheme,
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.
const {
tokenEncoder,
shouldHideMarkOnlyDeletions
} = selectTokenEncoder(diffType === 'smart' && fg('platform_editor_ai_smart_diff') || fg('confluence_ncs_step_diffing_version_history'));
let changes;
let contentChanges;
let attributedChanges = [];
let attributionColors;
if (attributedChangesetEnabled) {
const changeset = ChangeSet.create(originalDoc, (left, right) => 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((attribution, stepIndex) => ({
...attribution,
stepIndex
})));
contentChanges = changeset.changes;
attributedChanges = diffType === 'step' ? diffBySteps(originalDoc, simplifiedSteps, simplifiedStepAttributions) : [...changeset.changes];
// The attributed pipeline gives no guarantee that its output ranges are disjoint — see
// `collapseOverlappingChanges`.
const uncollapsedChanges = getAttributedChanges({
changeset,
originalDoc,
steppedDoc,
diffType,
tr,
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 {
const changeset = ChangeSet.create(originalDoc, undefined, tokenEncoder).addSteps(steppedDoc, stepMaps, tr.doc);
contentChanges = changeset.changes;
changes = getChanges({
changeset,
originalDoc,
steppedDoc,
diffType,
tr,
steps: simplifiedSteps,
intl,
smartThresholds
});
}
const 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.
const 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.
const tableChanges = changes.filter(change => change.deletedColumns !== undefined);
const isHandledByTable = (from, to) => tableChanges.some(change => from >= change.fromB && to <= change.toB);
const 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.
const leftAnchorId = fg('platform_editor_ai_show_diff_patch_2') ? `replacement-${change.fromA}-${change.toA}-${change.fromB}-${change.toB}` : undefined;
const changeColorScheme = attributionColors ? getColorSchemeForChange(change, attributedChanges, attributionColors, colorScheme) : colorScheme;
const attributionKey = showContributorTags ? getAttributionKeyForChange(change, attributedChanges) : undefined;
const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB, attributionKey);
const {
deletedColumns
} = change;
// 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.
const 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') {
const tableMap = TableMap.get(table);
deletedColumns.forEach(column => {
const offset = tableMap.positionAt(0, column, table);
decorations.push(Decoration.widget(change.fromB + CELL_CONTENT_OFFSET + offset, () => 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.
const isMarkOnly = shouldHideMarkOnlyDeletions && hideDeletedDiffs && change.deleted.length > 0 && isMarkOnlyChange({
change,
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.
const hasNoDeletedContent = fg('platform_editor_ai_show_diff_patch_1') && change.deleted.length > 0 && isOpenTokenOnlyChange({
change,
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.
const isDeletedWidgetBelow = diffType === 'smart' && fg('platform_editor_ai_smart_diff') && isDeletedContentPlacedBelow({
change,
deletedDiffPlacement,
inlineDeletedDiffPlacement
});
if (change.inserted.length > 0) {
// On an inverted diff the inserted side is visually the deleted side.
const 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) {
for (const pos of emptyTextBlockContentPositions(tr.doc, change.fromB, change.toB)) {
decorations.push(createDeletedLineBreakDecoration({
attributionKey,
colorScheme: changeColorScheme,
isActive,
pos,
reveal,
side: -1
}));
}
}
// 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.
const 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).
const 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.
const willRenderDeletedWidget = change.deleted.length > 0 && !isMarkOnly && !shouldHideDeletedSide({
change,
hideDeletedDiffs,
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.
const coarseTables = useCoarseTableDecoration ? tableRanges(tr.doc, change.fromB, change.toB) : [];
for (const [from, to] of leafTextblockRanges(tr.doc, change.fromB, change.toB)) {
if (useCoarseTableDecoration && isInsideTable(coarseTables, from)) {
continue;
}
decorations.push(...createInlineChangedDecoration({
attributionKey,
change: {
fromB: from,
toB: to
},
leftAnchorId,
doc: tr.doc,
colorScheme: changeColorScheme,
isActive,
isInserted,
shouldHideDeleted,
showContributorTags,
showIndicators,
hideAddedDiffsUnderline,
hasDeletedWidget: willRenderDeletedWidget,
isDeletedWidgetBelow,
reveal,
tagMountContext
}));
}
} else {
decorations.push(...createInlineChangedDecoration({
attributionKey,
change,
leftAnchorId,
doc: tr.doc,
colorScheme: changeColorScheme,
isActive,
isInserted,
shouldHideDeleted,
showContributorTags,
showIndicators,
hideAddedDiffsUnderline,
hasDeletedWidget: willRenderDeletedWidget,
isDeletedWidgetBelow,
reveal,
tagMountContext
}));
}
decorations.push(...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,
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,
shouldHideDeleted,
showContributorTags,
showIndicators,
tagMountContext,
activeIndexPos,
intl,
coarseTableCellsOnly: useCoarseTableDecoration,
leftAnchorId
}));
}
if (change.deleted.length > 0 && !isMarkOnly && !hasNoDeletedContent) {
const shouldHideDeleted = shouldHideDeletedSide({
change,
hideDeletedDiffs,
isInverted
});
if (!shouldHideDeleted) {
decorations.push(...createNodeChangedDecorationWidget({
deletedColumns,
// The deleted side has no inline decoration, so the widget carries the actor.
attributionKey,
reveal,
change,
doc: originalDoc,
nodeViewSerializer,
colorScheme: changeColorScheme,
newDoc: tr.doc,
intl,
activeIndexPos,
leftAnchorId,
showContributorTags,
tagMountContext,
isInserted: !isInserted,
hideAddedDiffsUnderline,
placeBelow: isDeletedWidgetBelow,
showIndicators
}));
}
}
};
changes.forEach(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.
const hasUnhighlightedMarkChanges = ({
fromB,
toB
}) => {
let from = fromB;
let offset = 0;
const differs = to => from < to && !originalDoc.slice(from + offset, to + offset).content.eq(tr.doc.slice(from, to).content);
for (const change of contentChanges) {
if (change.fromB >= toB) {
break;
}
if (change.toB > from) {
if (differs(change.fromB)) {
return true;
}
from = change.toB;
}
offset = change.toA - change.toB;
}
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(({
fromB,
toB
}) => !isHandledByTable(fromB, toB)).filter(range => !fg('confluence_ncs_step_diffing_version_history') || !changes.some(change => change.fromB <= range.fromB && change.toB >= range.toB) && hasUnhighlightedMarkChanges(range)).forEach(change => {
var _attributionColors$ge, _attributionColors;
const changeColorScheme = change.attributionKey ? (_attributionColors$ge = (_attributionColors = attributionColors) === null || _attributionColors === void 0 ? void 0 : _attributionColors.get(change.attributionKey)) !== null && _attributionColors$ge !== void 0 ? _attributionColors$ge : colorScheme : colorScheme;
const attributionKey = showContributorTags ? change.attributionKey : undefined;
const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB, attributionKey);
decorations.push(...createInlineChangedDecoration({
attributionKey,
change,
colorScheme: changeColorScheme,
doc: tr.doc,
isActive,
isInserted: true,
reveal,
showContributorTags,
tagMountContext
}));
});
getAttrChangeRanges(tr.doc, attrStepContexts, originalDoc).filter(({
fromB,
toB
}) => !isHandledByTable(fromB, toB)).filter(({
fromB,
toB,
isInline
}) => !fg('confluence_ncs_step_diffing_version_history') || !isInline || !changes.some(change => change.fromB === fromB && change.toB === toB)).forEach(change => {
var _attributionColors$ge2, _attributionColors2;
const changeColorScheme = change.attributionKey ? (_attributionColors$ge2 = (_attributionColors2 = attributionColors) === null || _attributionColors2 === void 0 ? void 0 : _attributionColors2.get(change.attributionKey)) !== null && _attributionColors$ge2 !== void 0 ? _attributionColors$ge2 : colorScheme : colorScheme;
const 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
const isActive = isRangeActive(activeIndexPos, change.fromB, change.toB, attributionKey);
decorations.push(...createInlineChangedDecoration({
attributionKey,
change,
colorScheme: changeColorScheme,
doc: tr.doc,
isActive,
isInserted: true,
isAtomicInlineNode: true,
inlineNodeName: change.inlineNodeName,
showContributorTags,
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(...createNodeChangedDecorationWidget({
change: {
fromA: change.fromA,
toA: change.toA,
fromB: change.fromB,
toB: change.toB,
deleted: []
},
doc: originalDoc,
attributionKey,
nodeViewSerializer,
colorScheme: changeColorScheme,
newDoc: tr.doc,
intl,
activeIndexPos,
isInserted: false,
showContributorTags,
tagMountContext
}));
}
} else {
decorations.push(...calculateNodesForBlockDecoration({
doc: tr.doc,
from: change.fromB,
to: change.toB,
attributionKey,
colorScheme: changeColorScheme,
isInserted: true,
activeIndexPos,
intl,
showContributorTags,
showIndicators,
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.
const 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) {
const placeBelow = deletedDiffPlacement === 'bottom';
decorations.push(...createNodeChangedDecorationWidget({
change: {
fromA: change.fromA,
toA: change.toA,
fromB: change.fromB,
toB: change.toB,
deleted: []
},
doc: originalDoc,
nodeViewSerializer,
attributionKey,
colorScheme: changeColorScheme,
newDoc: tr.doc,
intl,
activeIndexPos,
isInserted: false,
hideAddedDiffsUnderline,
placeBelow,
showIndicators,
showContributorTags,
tagMountContext
}));
}
}
});
const 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 const 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.
([{
pluginState,
state,
colorScheme,
intl,
activeIndexPos,
isInverted,
diffType,
hideDeletedDiffs,
hideAddedDiffsUnderline,
showIndicators,
smartThresholds,
deletedDiffPlacement,
inlineDeletedDiffPlacement,
reveal
}], [{
pluginState: lastPluginState,
state: lastState,
colorScheme: lastColorScheme,
intl: lastIntl,
activeIndexPos: lastActiveIndexPos,
isInverted: lastIsInverted,
diffType: lastDiffType,
hideDeletedDiffs: lastHideDeletedDiffs,
hideAddedDiffsUnderline: lastHideAddedDiffsUnderline,
showIndicators: lastShowIndicators,
smartThresholds: lastSmartThresholds,
deletedDiffPlacement: lastDeletedDiffPlacement,
inlineDeletedDiffPlacement: lastInlineDeletedDiffPlacement,
reveal: lastReveal
}]) => {
var _ref;
const originalDocIsSame = lastPluginState.originalDoc && pluginState.originalDoc && pluginState.originalDoc.eq(lastPluginState.originalDoc);
return (_ref = 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 && _ref !== void 0 ? _ref : false;
});