@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
401 lines (388 loc) • 20.7 kB
JavaScript
import _defineProperty from "@babel/runtime/helpers/defineProperty";
import _toConsumableArray from "@babel/runtime/helpers/toConsumableArray";
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 { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document';
import { findParentNodeClosestToPos } from '@atlaskit/editor-prosemirror/utils';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { TableMap } from '@atlaskit/editor-tables/table-map';
import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
import { fg } from '@atlaskit/platform-feature-flags/fg';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { buildAddedCellOverlayRoundedStyle, buildAddedCellOverlayStyle, buildDeletedCellOverlayRoundedStyle, buildDeletedCellOverlayStyle, buildDeletedRowStyle } from './colorSchemes/factory';
import { colorSchemeRegistry, getLegacyColorScheme } from './colorSchemes/schemes';
import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
import { resolveCellOverlayStyleLegacy, resolveDeletedRowStyleLegacy } from './createChangedRowDecorationWidgets.styles.legacy';
import { createContributorTagWidget, isContributorTagWidgetEnabled } from './createContributorTagWidget';
import { AnchorTypeKey, buildAnchorDecorationKey, buildDiffDecorationSpec, scrollMarginTopValue } from './decorationKeys';
import { findSafeInsertPos } from './utils/findSafeInsertPos';
import { applyCellEdgeAttrs, getRowCellEdgeAttrs, getTableTopAndBottomCellEdgeAttrs } from './utils/tableCellEdgeAttrs';
import { createRemovedLozenge } from './utils/wrapBlockNodeView';
/**
* Extracts information about deleted table rows from a change
*/
var extractChangedRows = function extractChangedRows(_ref) {
var change = _ref.change,
originalDoc = _ref.originalDoc,
newDoc = _ref.newDoc;
var changedRows = [];
// Find the table in the original document
var $fromPos = originalDoc.resolve(change.fromA);
var tableOld = findParentNodeClosestToPos($fromPos, function (node) {
return node.type.name === 'table';
});
if (!tableOld) {
return changedRows;
}
var oldTableMap = TableMap.get(tableOld.node);
// Find the table in the new document at the insertion point
var $newPos = newDoc.resolve(change.fromB);
var tableNew = findParentNodeClosestToPos($newPos, function (node) {
return node.type.name === 'table';
});
if (!tableNew) {
return changedRows;
}
var newTableMap = TableMap.get(tableNew.node);
// A row DELETION makes the table shorter. An in-place whole-row REPLACEMENT (`replaceNode` on a
// `tableRow`, which AI suggested edits emits) leaves the row count untouched — and this helper
// was written for deletions only, so it used to discard every replacement and the changed row's
// content was never rendered anywhere. The reviewer was left with just the removed side.
var isRowDeletion = oldTableMap.height > newTableMap.height;
var handleRowReplacement = oldTableMap.height === newTableMap.height && fg('platform_editor_ai_show_diff_patch_1');
// If no rows were changed, return empty
if (!(isRowDeletion || handleRowReplacement) ||
// For now ignore if there are column deletions as well
oldTableMap.width !== newTableMap.width) {
return changedRows;
}
// Find which rows were changed by analyzing the change range
var changeStartInTable = change.fromA - tableOld.pos - 1;
var changeEndInTable = change.toA - tableOld.pos - 1;
var edgeAttrsByOffset = expValEquals('platform_editor_table_q4_loveability', 'isEnabled', true) && isExperimentEnabled('platform_editor_table_diff_rounded_corners') ? getTableTopAndBottomCellEdgeAttrs(oldTableMap) : undefined;
var currentOffset = 0;
var rowIndex = 0;
tableOld.node.content.forEach(function (rowNode) {
var rowStart = currentOffset;
var rowEnd = currentOffset + rowNode.nodeSize;
// Check if this row overlaps with the deletion range
var rowOverlapsChange = rowStart >= changeStartInTable && rowStart < changeEndInTable || rowEnd > changeStartInTable && rowEnd <= changeEndInTable || rowStart < changeStartInTable && rowEnd > changeEndInTable;
if (rowOverlapsChange && rowNode.type.name === 'tableRow') {
var rowLocalId = rowNode.attrs.localId;
var isTableRowReplacement = rowLocalId && tableNew.node.children.some(function (candidateRow) {
return candidateRow.type.name === 'tableRow' && candidateRow.attrs.localId === rowLocalId;
});
var cellEdgeAttrs = edgeAttrsByOffset ? getRowCellEdgeAttrs({
edgeAttrsByOffset: edgeAttrsByOffset,
rowNode: rowNode,
rowStart: rowStart
}) : undefined;
var startOfRow = newTableMap.mapByRow.slice().reverse().find(function (row) {
return row[0] + tableNew.pos < change.fromB && change.fromB < row[row.length - 1] + tableNew.pos;
});
changedRows.push({
rowIndex: rowIndex,
rowNode: rowNode,
isTableRowReplacement: isTableRowReplacement,
cellEdgeAttrs: cellEdgeAttrs,
fromA: tableOld.pos + 1 + rowStart,
toA: tableOld.pos + 1 + rowEnd,
fromB: startOfRow ? startOfRow[0] + tableNew.start : change.fromB
});
}
currentOffset += rowNode.nodeSize;
if (rowNode.type.name === 'tableRow') {
rowIndex++;
}
});
// Filter changes that never truly got deleted. Compare the number of equivalent rows rather
// than only checking whether one still exists: deleting two of three identical empty rows should
// retain two changed rows even though the third remains in the new table.
var equivalentRowDeletionCounts = [];
return changedRows.filter(function (changedRow) {
// A replacement leaves every sibling row in place, so asking "does this row still exist
// ANYWHERE in the new table?" can drop a genuinely rewritten row that happens to match a
// sibling. Compare it against the row at the same index instead.
if (handleRowReplacement) {
var counterpart = tableNew.node.maybeChild(changedRow.rowIndex);
return !counterpart || !areNodesEqualIgnoreAttrs(counterpart, changedRow.rowNode);
}
var deletionCount = equivalentRowDeletionCounts.find(function (_ref2) {
var rowNode = _ref2.rowNode;
return areNodesEqualIgnoreAttrs(rowNode, changedRow.rowNode);
});
if (!deletionCount) {
var oldRowCount = tableOld.node.children.filter(function (oldRow) {
return areNodesEqualIgnoreAttrs(oldRow, changedRow.rowNode);
}).length;
var newRowCount = tableNew.node.children.filter(function (newRow) {
return areNodesEqualIgnoreAttrs(newRow, changedRow.rowNode);
}).length;
deletionCount = {
remaining: Math.max(0, oldRowCount - newRowCount),
rowNode: changedRow.rowNode
};
equivalentRowDeletionCounts.push(deletionCount);
}
if (deletionCount.remaining === 0) {
return false;
}
deletionCount.remaining--;
return true;
});
};
/**
* Creates a DOM representation of a deleted table row
*/
var createChangedRowDOM = function createChangedRowDOM(_ref3) {
var rowNode = _ref3.rowNode,
cellEdgeAttrs = _ref3.cellEdgeAttrs,
nodeViewSerializer = _ref3.nodeViewSerializer,
isTableRowReplacement = _ref3.isTableRowReplacement,
colorScheme = _ref3.colorScheme,
isInserted = _ref3.isInserted,
hasAnchoredRemovedLozenge = _ref3.hasAnchoredRemovedLozenge,
intl = _ref3.intl,
isActive = _ref3.isActive;
var tr = document.createElement('tr');
if (isTableRowReplacement && fg('platform_editor_ai_show_diff_patch_2')) {
tr.dataset.showDiffTableRowReplacement = 'true';
}
var colors = colorSchemeRegistry[colorScheme !== null && colorScheme !== void 0 ? colorScheme : 'standard'];
var deletedTreatment = isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? buildDeletedRowStyle(colors) : resolveDeletedRowStyleLegacy(getLegacyColorScheme(colorScheme));
var hostsRemovedLozenge = Boolean(intl) && !isInserted && !hasAnchoredRemovedLozenge;
// Inserted rows keep their natural styling; the row strikethrough is deletions only.
if (!isInserted) {
tr.setAttribute('style', deletedTreatment);
}
// Mirrors the strikethrough condition above: an `isInserted` row is ADDED content, so it must not claim the "deleted" testid — page models match that as
// removed content (same reasoning as `createTableCellContentWidgets`).
tr.setAttribute('data-testid', isInserted && fg('platform_editor_ai_show_diff_patch_1') ? 'show-diff-changed-row' : 'show-diff-deleted-row');
if (fg('platform_editor_ai_show_diff_patch_1')) {
tr.style.setProperty('scroll-margin-top', scrollMarginTopValue);
}
// Serialize each cell in the row
var cellIndex = 0;
rowNode.content.forEach(function (cellNode) {
if (cellNode.type.name === 'tableCell' || cellNode.type.name === 'tableHeader') {
var nodeView = nodeViewSerializer.tryCreateNodeView(cellNode, 0, undefined, isInserted, colorScheme);
if (nodeView) {
if (nodeView instanceof HTMLElement) {
applyCellEdgeAttrs(nodeView, cellEdgeAttrs === null || cellEdgeAttrs === void 0 ? void 0 : cellEdgeAttrs[cellIndex]);
var overlay = document.createElement('span');
var isRoundedTable = isExperimentEnabled('platform_editor_table_diff_rounded_corners');
var overlayStyle = isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? isInserted ? isRoundedTable ? buildAddedCellOverlayRoundedStyle(colors) : buildAddedCellOverlayStyle(colors) : isRoundedTable ? buildDeletedCellOverlayRoundedStyle(colors) : buildDeletedCellOverlayStyle(colors) : resolveCellOverlayStyleLegacy({
colorScheme: getLegacyColorScheme(colorScheme),
isInserted: isInserted,
isRoundedTable: isRoundedTable
});
overlay.setAttribute('style', overlayStyle);
nodeView.appendChild(overlay);
}
tr.appendChild(nodeView);
} else {
// Fallback to fragment serialization
var serializedContent = nodeViewSerializer.serializeFragment(cellNode.content);
if (serializedContent) {
tr.appendChild(serializedContent);
}
}
cellIndex++;
}
});
// A "Removed" label on the row's last cell, so it reads at the row's top right corner. Deletions
// only: an inserted row is added content and carries no removal label.
if (intl && hostsRemovedLozenge) {
var lastCell = tr.lastElementChild;
if (lastCell instanceof HTMLElement) {
lastCell.style.position = 'relative';
// Append, never prepend. Editor CSS resets the top margin of a cell's first child, so a
// prepended label pushes the paragraph out of that reset and the row grows taller.
lastCell.append(createRemovedLozenge(intl, isActive, colorScheme, true));
}
}
return tr;
};
var supportsAnchorPositioning = function supportsAnchorPositioning() {
return typeof CSS !== 'undefined' && typeof CSS.supports === 'function' && CSS.supports('top', 'anchor(--a top)') && CSS.supports('position-anchor', '--a');
};
var createAnchoredRemovedLozengeWidget = function createAnchoredRemovedLozengeWidget(_ref4) {
var anchorName = _ref4.anchorName,
colorScheme = _ref4.colorScheme,
from = _ref4.from,
intl = _ref4.intl,
isActive = _ref4.isActive;
var lozenge = createRemovedLozenge(intl, isActive, colorScheme, true);
var inset = lozenge.style.top;
lozenge.style.setProperty('position', 'fixed');
// Fixed labels only follow their default anchor on scroll, and escape scroll-container clipping.
lozenge.style.setProperty('position-anchor', "--".concat(anchorName));
lozenge.style.setProperty('position-visibility', 'anchors-visible');
lozenge.style.setProperty('top', "calc(anchor(--".concat(anchorName, " top) + ").concat(inset, ")"));
lozenge.style.setProperty('left', "calc(anchor(--".concat(anchorName, " right) - ").concat(inset, ")"));
lozenge.style.setProperty('right', 'auto');
lozenge.style.setProperty('transform', 'translateX(-100%)');
return Decoration.widget(from, lozenge, {
key: "removed-lozenge-".concat(anchorName),
side: -1
});
};
/**
* Expands a diff to include whole changed rows when table rows are affected
*/
var expandDiffForChangedRows = function expandDiffForChangedRows(_ref5) {
var changes = _ref5.changes,
originalDoc = _ref5.originalDoc,
newDoc = _ref5.newDoc;
var rowInfo = [];
var _iterator = _createForOfIteratorHelper(changes),
_step;
try {
for (_iterator.s(); !(_step = _iterator.n()).done;) {
var change = _step.value;
// Check if this change affects table content
var changedRows = extractChangedRows({
change: change,
originalDoc: originalDoc,
newDoc: newDoc
});
if (changedRows.length > 0) {
rowInfo.push.apply(rowInfo, _toConsumableArray(changedRows));
}
}
} catch (err) {
_iterator.e(err);
} finally {
_iterator.f();
}
return rowInfo;
};
/**
* Main function to handle deleted rows - computes diff and creates decorations
*/
export var createChangedRowDecorationWidgets = function createChangedRowDecorationWidgets(_ref6) {
var attributionKey = _ref6.attributionKey,
changes = _ref6.changes,
originalDoc = _ref6.originalDoc,
newDoc = _ref6.newDoc,
nodeViewSerializer = _ref6.nodeViewSerializer,
colorScheme = _ref6.colorScheme,
isActive = _ref6.isActive,
_ref6$isInserted = _ref6.isInserted,
isInserted = _ref6$isInserted === void 0 ? false : _ref6$isInserted,
leftAnchorId = _ref6.leftAnchorId,
intl = _ref6.intl,
_ref6$showIndicators = _ref6.showIndicators,
showIndicators = _ref6$showIndicators === void 0 ? false : _ref6$showIndicators,
_ref6$showContributor = _ref6.showContributorTags,
showContributorTags = _ref6$showContributor === void 0 ? false : _ref6$showContributor,
tagMountContext = _ref6.tagMountContext;
// First, expand the changes to include complete deleted rows
var changedRows = expandDiffForChangedRows({
changes: changes.filter(function (change) {
return change.deleted.length > 0;
}),
originalDoc: originalDoc,
newDoc: newDoc
});
return changedRows.flatMap(function (changedRow) {
// Keep the ID stable while contributor tags are enabled so recalculation preserves their state.
var diffId = showContributorTags ? "widget-row-".concat(changedRow.fromA, "-").concat(changedRow.toA) : crypto.randomUUID();
var hasAnchoredRemovedLozenge = Boolean(intl) && !isInserted && supportsAnchorPositioning();
var rowDOM = createChangedRowDOM({
rowNode: changedRow.rowNode,
isTableRowReplacement: changedRow.isTableRowReplacement,
cellEdgeAttrs: changedRow.cellEdgeAttrs,
nodeViewSerializer: nodeViewSerializer,
colorScheme: colorScheme,
isInserted: isInserted,
hasAnchoredRemovedLozenge: hasAnchoredRemovedLozenge,
intl: intl,
isActive: isActive
});
// Find safe insertion position for the deleted row
var safeInsertPos = findSafeInsertPos(newDoc, changedRow.fromB - 1,
// -1 to find the first safe position from the table
originalDoc.slice(changedRow.fromA, changedRow.toA));
var decorations = [];
var rowAnchorNames = [];
// `IndicatorBarContentComponent` renders a bar for every widget descriptor and anchors its
// top and bottom to `anchor-<diffId>`. Without an element carrying that anchor name the bar
// is still created but can never resolve a position, so it silently does not render and the
// bar covers only the row being changed. `createNodeChangedDecorationWidget` sets the same
// property on its own widget DOM for non-table content; this path returns before reaching it.
if (showIndicators && fg('platform_editor_ai_show_diff_patch_1')) {
rowAnchorNames.push(buildAnchorDecorationKey({
diffId: diffId
}));
// A table's content can extend past the doc margin, so the bar also needs a left anchor
// measured against the table itself or it is clipped once the table is resized.
var leftAnchor = createLeftAnchorWidget({
doc: newDoc,
from: safeInsertPos,
diffId: diffId,
leftAnchorId: leftAnchorId
});
if (leftAnchor) {
decorations.push(leftAnchor);
}
}
var tagAnchorName = showContributorTags && isContributorTagWidgetEnabled() ? buildAnchorDecorationKey({
diffId: diffId,
anchorType: AnchorTypeKey.tag
}) : undefined;
var removedLozengeAnchorName = hasAnchoredRemovedLozenge ? tagAnchorName !== null && tagAnchorName !== void 0 ? tagAnchorName : buildAnchorDecorationKey({
diffId: diffId,
anchorType: AnchorTypeKey.tag
}) : undefined;
var rowLabelAnchorName = tagAnchorName !== null && tagAnchorName !== void 0 ? tagAnchorName : removedLozengeAnchorName;
var tagWidget = tagAnchorName ? createContributorTagWidget({
anchorAtRangeStart: true,
anchorName: tagAnchorName,
diffId: diffId,
doc: newDoc,
from: safeInsertPos,
mountContext: tagMountContext,
to: safeInsertPos
}) : undefined;
if (rowLabelAnchorName) {
// Labels stay outside the table row so they do not inherit its deletion treatment.
rowAnchorNames.push(rowLabelAnchorName);
}
if (rowAnchorNames.length > 0) {
rowDOM.style.setProperty('anchor-name', rowAnchorNames.map(function (anchorName) {
return "--".concat(anchorName);
}).join(', '));
}
if (tagWidget) {
// Lets the contributor tag reveal when any part of the deleted row is hovered.
rowDOM.setAttribute('data-diff-id', diffId);
}
var rowWidget = Decoration.widget(safeInsertPos, rowDOM, _objectSpread({}, buildDiffDecorationSpec({
attributionKey: tagWidget ? attributionKey : undefined,
colorScheme: colorScheme,
decorationType: 'widget',
diffId: diffId,
leftAnchorId: leftAnchorId,
isActive: isActive,
isInserted: isInserted
})));
decorations.push(rowWidget);
if (intl && removedLozengeAnchorName) {
decorations.push(createAnchoredRemovedLozengeWidget({
anchorName: removedLozengeAnchorName,
colorScheme: colorScheme,
from: safeInsertPos,
intl: intl,
isActive: isActive
}));
}
if (tagWidget) {
decorations.push.apply(decorations, _toConsumableArray(tagWidget));
}
return decorations;
});
};