@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
517 lines (508 loc) • 20.7 kB
JavaScript
import isEqual from 'lodash/isEqual';
import memoizeOne from 'memoize-one';
import { ChangeSet, simplifyChanges } from 'prosemirror-changeset';
import { isExperimentEnabled } from '@atlaskit/editor-common/deprecated-platform-feature-experiments';
import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document';
import { DecorationSet } from '@atlaskit/editor-prosemirror/view';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { areDocsEqualByBlockStructureAndText } from '../areDocsEqualByBlockStructureAndText';
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 { getAttrChangeRanges, stepIsValidAttrChange } from '../decorations/utils/getAttrChangeRanges';
import { getMarkChangeRanges } from '../decorations/utils/getMarkChangeRanges';
import { getDefaultDiffType, isExtendedEnabled } from '../isExtendedEnabled';
import { attrAwareTokenEncoder } from './attrAwareTokenEncoder';
import { diffBySteps } from './diffBySteps';
import { groupChangesByBlock } from './groupChangesByBlock';
import { optimizeChanges } from './optimizeChanges';
import { simplifySteps } from './simplifySteps';
import { classifySmartChanges } from './smart/classifySmartChanges';
import { smartChangeLevel } from './smart/helpers';
const getChanges = ({
changeset,
originalDoc,
steppedDoc,
diffType,
tr,
steps,
intl,
smartThresholds
}) => {
if (isExtendedEnabled(diffType)) {
// 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 changes = simplifyChanges(changeset.changes, tr.doc);
return optimizeChanges(changes);
};
/**
* 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;
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;
};
const calculateNodesForBlockDecoration = ({
doc,
from,
to,
colorScheme,
isInserted = true,
activeIndexPos,
shouldHideDeleted = false,
showIndicators = false,
diffType,
coarseTableCellsOnly = false
}) => {
const decorations = [];
// Iterate over the document nodes within the range
doc.nodesBetween(from, to, (node, pos) => {
// Coarse path: only cell/header overlays are visible; table/row/paragraph
// decorations are redundant and cause expensive per-cell re-render.
if (coarseTableCellsOnly) {
const name = node.type.name;
if (name !== 'tableCell' && name !== 'tableHeader') {
return;
}
}
if (node.isBlock && (!isExtendedEnabled(diffType) || pos + node.nodeSize <= to)) {
const nodeEnd = pos + node.nodeSize;
const isActive = activeIndexPos && pos === activeIndexPos.from && nodeEnd === activeIndexPos.to;
decorations.push(...createBlockChangedDecoration({
change: {
from: pos,
to: nodeEnd,
name: node.type.name
},
colorScheme,
isInserted,
isActive,
shouldHideDeleted,
showIndicators,
doc,
diffType
}));
}
});
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';
};
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'
}) => {
const {
originalDoc,
steps,
isDisplayingChanges
} = pluginState;
if (!originalDoc || !isDisplayingChanges) {
return {
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 attrSteps = [];
const simplifiedSteps = simplifySteps(steps, originalDoc);
const stepMaps = [];
for (const step of simplifiedSteps) {
const result = step.apply(steppedDoc);
if (result.failed === null && result.doc) {
if (stepIsValidAttrChange(step, steppedDoc, result.doc)) {
attrSteps.push(step);
}
stepMaps.push(step.getMap());
steppedDoc = result.doc;
}
}
// 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 {
decorations: DecorationSet.empty,
diffDescriptors: []
};
}
}
// The attribute-aware encoder is only needed by the smart classifier and is
// gated with it; other diff types keep the library default so their output is
// unchanged.
const tokenEncoder = diffType === 'smart' && fg('platform_editor_ai_smart_diff') ? attrAwareTokenEncoder : undefined;
const changeset = ChangeSet.create(originalDoc, undefined, tokenEncoder).addSteps(steppedDoc, stepMaps, tr.doc);
const changes = getChanges({
changeset,
originalDoc,
steppedDoc,
diffType,
tr,
steps,
intl,
smartThresholds
});
const decorations = [];
/**
* If showIndicators is on, we create an anchor widget here to mark the doc margin.
*/
if (showIndicators && isExtendedEnabled(diffType)) {
decorations.push(createDocMarginAnchorWidget());
}
// Our default operations are insertions, so it should match the opposite of isInverted.
const isInserted = !isInverted;
const createDecorationsForChange = change => {
const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
// 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 = isExtendedEnabled(diffType) ? isInverted && hideDeletedDiffs : false;
// 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 in the AIFC experience.
const useCoarseTableDecoration = isSmartNodeLevel && expValEquals('platform_editor_ai_new_aifc_editor_experience', 'isBackendReviewMomentEnabled', 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 && !(isExtendedEnabled(diffType) && !isInverted && hideDeletedDiffs && change.inserted.length > 0);
if (useCoarseTableDecoration) {
// Skip the O(cells) inline decorations; the cell overlays (added below)
// provide the highlight without the expensive content re-render.
} else if (isSmartNodeLevel) {
for (const [from, to] of leafTextblockRanges(tr.doc, change.fromB, change.toB)) {
decorations.push(...createInlineChangedDecoration({
change: {
fromB: from,
toB: to
},
doc: tr.doc,
colorScheme,
isActive,
diffType,
...(isExtendedEnabled(diffType) && {
isInserted,
shouldHideDeleted,
showIndicators,
hideAddedDiffsUnderline,
hasDeletedWidget: willRenderDeletedWidget,
isDeletedWidgetBelow
})
}));
}
} else {
decorations.push(...createInlineChangedDecoration({
change,
doc: tr.doc,
colorScheme,
isActive,
diffType,
...(isExtendedEnabled(diffType) && {
isInserted,
shouldHideDeleted,
showIndicators,
hideAddedDiffsUnderline,
hasDeletedWidget: willRenderDeletedWidget,
isDeletedWidgetBelow
})
}));
}
decorations.push(...calculateNodesForBlockDecoration({
doc: tr.doc,
from: change.fromB,
to: change.toB,
colorScheme,
...(isExtendedEnabled(diffType) && {
isInserted,
shouldHideDeleted,
showIndicators
}),
activeIndexPos,
intl,
diffType,
coarseTableCellsOnly: useCoarseTableDecoration
}));
}
if (change.deleted.length > 0) {
const shouldHideDeleted = isExtendedEnabled(diffType) ? !isInverted && hideDeletedDiffs && change.inserted.length > 0 : false;
if (!shouldHideDeleted) {
decorations.push(...createNodeChangedDecorationWidget({
change,
doc: originalDoc,
nodeViewSerializer,
colorScheme,
newDoc: tr.doc,
intl,
activeIndexPos,
...(isExtendedEnabled(diffType) && {
isInserted: !isInserted,
diffType,
hideAddedDiffsUnderline,
placeBelow: isDeletedWidgetBelow
}),
showIndicators
}));
}
}
};
changes.forEach(change => {
createDecorationsForChange(change);
});
getMarkChangeRanges(steps).forEach(change => {
const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
decorations.push(...createInlineChangedDecoration({
change,
colorScheme,
isActive,
isInserted: true
}));
});
getAttrChangeRanges(tr.doc, attrSteps, originalDoc).forEach(change => {
if (change.isInline) {
// Inline nodes (e.g. date, emoji, mention, status) need an inline decoration rather than a block decoration
const isActive = activeIndexPos && change.fromB === activeIndexPos.from && change.toB === activeIndexPos.to;
const isAtomicInlineNodeFlag = isExperimentEnabled('platform_editor_improve_inline_diffs', () => expValEquals('platform_editor_improve_inline_diffs', 'isEnabled', true));
decorations.push(...createInlineChangedDecoration({
change,
colorScheme,
isActive,
isInserted: true,
isAtomicInlineNode: isAtomicInlineNodeFlag,
inlineNodeName: change.inlineNodeName,
// Suppress the border-bottom underline for atomic inline nodeviews —
// it doesn't render on custom nodeview DOM elements and is unnecessary noise.
hideAddedDiffsUnderline: isAtomicInlineNodeFlag,
diffType
}));
// 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 && isExperimentEnabled('platform_editor_improve_inline_diffs', () => expValEquals('platform_editor_improve_inline_diffs', 'isEnabled', true))) {
decorations.push(...createNodeChangedDecorationWidget({
change: {
fromA: change.fromA,
toA: change.toA,
fromB: change.fromB,
toB: change.toB,
deleted: []
},
doc: originalDoc,
nodeViewSerializer,
colorScheme,
newDoc: tr.doc,
intl,
activeIndexPos,
isInserted: false
}));
}
} else {
decorations.push(...calculateNodesForBlockDecoration({
doc: tr.doc,
from: change.fromB,
to: change.toB,
colorScheme,
isInserted: true,
activeIndexPos,
intl,
showIndicators
}));
// 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') && expValEquals('platform_editor_ai_new_aifc_editor_experience', 'isEnabled', 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,
colorScheme,
newDoc: tr.doc,
intl,
activeIndexPos,
isInserted: false,
...(isExtendedEnabled(diffType) && {
diffType,
hideAddedDiffsUnderline,
placeBelow
}),
showIndicators
}));
}
}
});
const decorationSet = DecorationSet.empty.add(tr.doc, decorations);
return {
decorations: decorationSet,
diffDescriptors: extractDiffDescriptors(decorationSet)
};
};
export const calculateDiffDecorations = memoizeOne(calculateDiffDecorationsInner,
// Cache results unless relevant inputs change
([{
pluginState,
state,
colorScheme,
intl,
activeIndexPos,
isInverted,
diffType,
hideDeletedDiffs,
hideAddedDiffsUnderline,
showIndicators,
smartThresholds,
deletedDiffPlacement,
inlineDeletedDiffPlacement
}], [{
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
}]) => {
var _ref2;
const originalDocIsSame = lastPluginState.originalDoc && pluginState.originalDoc && pluginState.originalDoc.eq(lastPluginState.originalDoc);
if (isExtendedEnabled(diffType)) {
var _ref;
return (_ref = colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isInverted === lastIsInverted && diffType === lastDiffType && isEqual(activeIndexPos, lastActiveIndexPos) && originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && state.doc.eq(lastState.doc) && hideDeletedDiffs === lastHideDeletedDiffs && hideAddedDiffsUnderline === lastHideAddedDiffsUnderline && showIndicators === lastShowIndicators && isEqual(smartThresholds, lastSmartThresholds) && deletedDiffPlacement === lastDeletedDiffPlacement && inlineDeletedDiffPlacement === lastInlineDeletedDiffPlacement) !== null && _ref !== void 0 ? _ref : false;
}
return (_ref2 = originalDocIsSame && isEqual(pluginState.steps, lastPluginState.steps) && state.doc.eq(lastState.doc) && colorScheme === lastColorScheme && intl.locale === lastIntl.locale && isEqual(activeIndexPos, lastActiveIndexPos) && hideDeletedDiffs === lastHideDeletedDiffs) !== null && _ref2 !== void 0 ? _ref2 : false;
});