@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
389 lines (376 loc) • 13 kB
JavaScript
import { getDarkModeLCHColor } from '@atlaskit/adf-schema/get-dark-mode-lch-color';
import { isRgb } from '@atlaskit/adf-schema/is-rgb';
import { rgbToHex } from '@atlaskit/adf-schema/rgb-to-hex';
import { getCellDomAttrs } from '@atlaskit/adf-schema/tableNodes';
import { convertToInlineCss } from '@atlaskit/editor-common/css-helper';
import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
import { createBlockChangedDecoration } from './createBlockChangedDecoration';
import { createInlineChangedDecoration } from './createInlineChangedDecoration';
import { buildAnchorDecorationKey, buildDiffDecorationSpec, scrollMarginTopValue } from './decorationKeys';
import { isCellAttrChanged, isCellBackgroundChanged, normalizeCellBackground } from './utils/cellAttrs';
import { injectInnerWrapper, wrapBlockNodeView } from './utils/wrapBlockNodeView';
const CELL_TYPE_NAMES = ['tableCell', 'tableHeader'];
/**
* Whether only close tokens follow `pos` in its cell: it is at the end of its parent, and so is each
* ancestor up to the cell.
*/
const isAtEndOfCell = (doc, pos) => {
const $pos = doc.resolve(pos);
if ($pos.parentOffset !== $pos.parent.content.size) {
return false;
}
for (let depth = $pos.depth; depth > 0; depth--) {
if (CELL_TYPE_NAMES.includes($pos.node(depth).type.name)) {
return true;
}
if ($pos.indexAfter(depth - 1) !== $pos.node(depth - 1).childCount) {
return false;
}
}
return false;
};
/**
* Pairs each cell of a whole-cell replacement with the live cell it replaces, or `[]` when the slice
* is not a run of whole cells matching the live ones. Whole-cell replacement preserves cell count and
* order, so the nth live cell in the range matches the nth cell in the slice.
*/
export const getReplacedCells = ({
slice,
doc,
fromB,
toB
}) => {
const otherCells = slice.content.content;
if (slice.openStart !== 0 || (!fg('platform_editor_ai_show_diff_patch_2_fix') || !isAtEndOfCell(doc, toB)) && slice.openEnd !== 0 || otherCells.length === 0 || !otherCells.every(node => CELL_TYPE_NAMES.includes(node.type.name))) {
return [];
}
const liveCells = [];
doc.nodesBetween(fromB, toB, (node, pos) => {
if (CELL_TYPE_NAMES.includes(node.type.name)) {
liveCells.push({
node,
pos
});
return false;
}
return true;
});
if (liveCells.length !== otherCells.length) {
return [];
}
return otherCells.map((otherCell, index) => ({
otherCell,
liveCell: liveCells[index].node,
liveCellPos: liveCells[index].pos
}));
};
/**
* Whether two cells hold the same content, ignoring the cells' own attributes and `localId`s, which
* a suggestion can regenerate. Such a replacement only changed the cell (e.g. its background), so
* there is no content to diff.
*/
const isCellContentEqual = (left, right) => {
if (left.childCount !== right.childCount) {
return false;
}
for (let index = 0; index < left.childCount; index++) {
if (!areNodesEqualIgnoreAttrs(left.child(index), right.child(index), ['localId'])) {
return false;
}
}
return true;
};
/**
* Whether the cell itself changed — its type, or an attribute that represents a change such as its
* background or span — as opposed to only its content.
*/
const isCellChanged = (left, right) => left.type !== right.type || isCellAttrChanged(left, right);
/**
* A custom (non-palette) cell colour as the schema renders it: inverted in dark mode so text stays
* readable, except for design tokens, which are already themed.
*/
const getCustomCellColor = background => {
const color = isRgb(background) && rgbToHex(background) || background;
if (color.startsWith('var(') || document.documentElement.getAttribute('data-color-mode') !== 'dark') {
return background;
}
return getDarkModeLCHColor(color);
};
/**
* The colour `cell` renders with. A palette colour is the editor's own themed variable for its colour
* name (e.g. for dark mode), the one `td[colorname]` cells use; a custom colour is the schema's own
* treatment of it; no colour is the editor's default.
*/
const getCellBackgroundColor = cell => {
const background = normalizeCellBackground(cell.attrs.background);
if (!background) {
return cell.type.name === 'tableHeader' ? "var(--ds-background-accent-gray-subtlest, #F0F1F2)" : "var(--ds-surface, #FFFFFF)";
}
const {
colorname
} = getCellDomAttrs(cell);
return colorname ? `var(--ak-editor-table-cell-background-${colorname.toLowerCase().split(' ').join('-')})` : getCustomCellColor(background);
};
/**
* A layer painting `color` over the cell's own background and under its content, so the cell reads
* as having the new background without any of the cell's own attributes being overridden: removing
* a decoration removes the attributes it set rather than restoring the ones they replaced.
*/
const createCellBackgroundLayer = color => {
const layer = document.createElement('div');
layer.setAttribute('aria-hidden', 'true');
layer.contentEditable = 'false';
layer.dataset.testid = 'show-diff-new-cell-background';
layer.setAttribute('style', convertToInlineCss({
position: 'absolute',
inset: 0,
zIndex: -1,
borderRadius: 'inherit',
pointerEvents: 'none',
backgroundColor: color
}));
return layer;
};
/** The other side's cell content, as blocks, for the top of the live cell, with its indicator anchor. */
const createCellContentWidgets = ({
cell,
doc,
pos,
nodeViewSerializer,
colorScheme,
intl,
isActive,
isInserted,
leftAnchorId,
showIndicators
}) => {
const dom = document.createElement('div');
cell.content.forEach(block => {
const nodeView = nodeViewSerializer.tryCreateNodeView(block, 0, undefined, isInserted, colorScheme);
if (nodeView) {
wrapBlockNodeView({
dom,
nodeView,
targetNode: block,
colorScheme,
intl,
isActive,
isInserted
});
return;
}
const serialized = nodeViewSerializer.serializeNode(block);
if (serialized instanceof HTMLElement) {
dom.append(injectInnerWrapper({
node: serialized,
colorScheme,
isActive,
isInserted
}));
}
});
if (dom.childNodes.length === 0) {
return [];
}
dom.setAttribute('data-testid', isInserted ? 'show-diff-changed-decoration' : 'show-diff-deleted-decoration');
if (fg('platform_editor_ai_show_diff_patch_2_fix')) {
dom.style.setProperty('scroll-margin-top', scrollMarginTopValue);
}
const diffId = crypto.randomUUID();
if (showIndicators && fg('platform_editor_ai_show_diff_patch_2_fix')) {
// The indicator bar of a widget anchors its top and bottom to the widget itself. Without this
// only the live cell's own bar resolves, which starts below the suggested content.
dom.style.setProperty('anchor-name', `--${buildAnchorDecorationKey({
diffId
})}`);
}
// Before the cell's own blocks, as a block of its own, so it never lands inside a paragraph the
// displayed side may hide.
const widget = Decoration.widget(pos + 1, dom, {
...buildDiffDecorationSpec({
colorScheme,
decorationType: 'widget',
diffId,
leftAnchorId,
isActive,
isInserted,
side: -1
}),
marks: []
});
// The bar aligns its left edge to this anchor. The live cell's inline decoration makes one too,
// but not when the clean view hides it, and the widget is still showing then.
const leftAnchor = showIndicators && fg('platform_editor_ai_show_diff_patch_2_fix') ? createLeftAnchorWidget({
doc,
from: pos,
diffId,
leftAnchorId
}) : undefined;
return leftAnchor ? [widget, leftAnchor] : [widget];
};
/**
* The other side of a whole-cell replacement, drawn inside the live cells so the table keeps its
* shape: each live cell takes the other cell's background when it differs, and its content when that
* differs. A cell whose content did not change has nothing to diff.
*/
export const createReplacedCellWidgets = ({
slice,
newDoc,
change,
nodeViewSerializer,
colorScheme,
intl,
isActive,
isInserted,
leftAnchorId,
showIndicators
}) => getReplacedCells({
slice,
doc: newDoc,
fromB: change.fromB,
toB: change.toB
}).flatMap(({
otherCell,
liveCell,
liveCellPos
}) => {
const decorations = [];
if (isInserted && isCellBackgroundChanged(otherCell, liveCell)) {
const cellEnd = liveCellPos + liveCell.nodeSize;
decorations.push(
// The cell becomes the layer's stacking context, so the layer sits above the cell's own
// background rather than behind it. Through `z-index` at the cell overlay's own level, not
// `isolation`: an isolated cell would trap its overlay below later cells, which then cover
// the overlay's outline where it overlaps their border. Properties the cell never sets, so
// removing them takes nothing of its own.
Decoration.node(liveCellPos, cellEnd, {
style: convertToInlineCss({
position: 'relative',
// The highest z-index a cell diff overlay takes (the rounded-corner variant).
zIndex: 2
})
}, {
key: `show-diff-new-cell-background-host-${liveCellPos}`
}),
// At the cell's end, beside its diff overlay, so the cell's first block stays its first child.
Decoration.widget(cellEnd - 1, createCellBackgroundLayer(getCellBackgroundColor(otherCell)), {
key: `show-diff-new-cell-background-${liveCellPos}`,
side: -1,
marks: [],
ignoreSelection: true
}));
}
if (!isCellContentEqual(otherCell, liveCell)) {
const contentWidgets = createCellContentWidgets({
cell: otherCell,
doc: newDoc,
pos: liveCellPos,
nodeViewSerializer,
colorScheme,
intl,
isActive,
isInserted,
leftAnchorId,
showIndicators
});
decorations.push(...contentWidgets);
}
return decorations;
});
/** Whether a cell holds only blank lines: empty paragraphs or headings, with nothing to show. */
const isBlankCell = cell => cell.childCount > 0 && cell.children.every(block => block.isTextblock && block.content.size === 0);
/**
* The displayed side of a whole-cell replacement. A table cell is never removed on its own, only
* replaced in place, so a live cell is never hidden — hiding it would leave the row a cell short. It
* takes the changed-cell treatment only when the cell itself changed; its content is diffed, and
* hidden in the clean view, only when that changed. A cell whose content stayed the same keeps
* showing it.
*/
export const createReplacedCellDecorations = ({
replacedCells,
change,
doc,
attributionKey,
colorScheme,
hasDeletedWidget,
hideAddedDiffsUnderline,
isActive,
isDeletedWidgetBelow,
isInserted,
leftAnchorId,
shouldHideDeleted,
showContributorTags,
showIndicators,
tagMountContext
}) => replacedCells.flatMap(({
otherCell,
liveCell,
liveCellPos
}) => {
const cellEnd = liveCellPos + liveCell.nodeSize;
const decorations = isCellChanged(otherCell, liveCell) ? createBlockChangedDecoration({
attributionKey,
change: {
from: liveCellPos,
to: cellEnd,
name: liveCell.type.name
},
colorScheme,
doc,
isActive,
// The cell stays either way, so it reads as changed, not removed.
isInserted: true,
leftAnchorId,
showContributorTags,
showIndicators,
tagMountContext
}) : [];
if (isCellContentEqual(otherCell, liveCell)) {
return decorations;
}
// A blank live cell has no content to strike through, so the suggestion takes its place, as
// in the clean view, rather than leaving a blank line below it.
const hidesLiveCell = shouldHideDeleted || !isInserted && isBlankCell(liveCell) && fg('platform_editor_ai_show_diff_patch_2_fix');
decorations.push(...createInlineChangedDecoration({
attributionKey,
change: {
...change,
fromB: liveCellPos,
toB: cellEnd
},
colorScheme,
doc,
hasDeletedWidget,
hideAddedDiffsUnderline,
isActive,
isDeletedWidgetBelow,
isInserted,
leftAnchorId,
shouldHideDeleted: hidesLiveCell,
showContributorTags,
showIndicators,
tagMountContext
}));
if (hidesLiveCell) {
liveCell.forEach((block, offset) => {
const blockPos = liveCellPos + 1 + offset;
decorations.push(...createBlockChangedDecoration({
change: {
from: blockPos,
to: blockPos + block.nodeSize,
name: block.type.name
},
colorScheme,
doc,
isActive,
isInserted,
leftAnchorId,
shouldHideDeleted: hidesLiveCell
}));
});
}
return decorations;
});