UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

517 lines (508 loc) 20.7 kB
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; });