@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
367 lines (357 loc) • 16.6 kB
JavaScript
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { isExtendedEnabled } from '../isExtendedEnabled';
import { createLeftAnchorWidget } from './createAnchorDecorationWidgets';
import { createChangedRowDecorationWidgets } from './createChangedRowDecorationWidgets';
import { buildDiffDecorationSpec, buildAnchorDecorationKey } from './decorationKeys';
import { findSafeInsertPos } from './utils/findSafeInsertPos';
import { wrapBlockNodeView, injectInnerWrapper, createContentWrapper } from './utils/wrapBlockNodeView';
const isHeadingLevel = level => typeof level === 'number' && level >= 1 && level <= 6;
/**
* Render one content widget per table cell, positioned inside the matching cell in
* `newDoc`, so a whole-cell replacement shows its added content in-place instead of
* being dropped (EDITOR-8442).
*/
const createTableCellContentWidgets = ({
slice,
newDoc,
change,
nodeViewSerializer,
colorScheme,
isInserted,
diffType
}) => {
// Collect the inner content position of each cell in the new document within
// the change range. Whole-cell replacement preserves cell count/order, so the
// nth new-doc cell corresponds to the nth cell in the slice.
const newCellInnerPositions = [];
newDoc.nodesBetween(change.fromB, change.toB, (node, pos) => {
if (node.type.name === 'tableHeader' || node.type.name === 'tableCell') {
// Skip malformed cells with no block child: `pos + 2` would be out of bounds.
if (node.childCount === 0) {
return false;
}
// +1 into the cell, +1 into its first child (the paragraph) = inner content.
newCellInnerPositions.push(pos + 2);
return false;
}
return true;
});
const decorations = [];
slice.content.forEach((cellNode, _offset, index) => {
const targetPos = newCellInnerPositions[index];
if (targetPos === undefined || cellNode.content.size === 0) {
return;
}
const dom = document.createElement('span');
// A cell contains block content (paragraphs); serialize the cell's content
// so every block inside the cell is rendered.
cellNode.content.forEach(blockNode => {
const nodeView = nodeViewSerializer.tryCreateNodeView(blockNode);
const wrapper = createContentWrapper(colorScheme, false, isInserted, diffType);
if (nodeView) {
wrapper.append(nodeView);
} else {
const serialized = blockNode.type.inlineContent ? nodeViewSerializer.serializeFragment(blockNode.content) : nodeViewSerializer.serializeNode(blockNode);
if (serialized) {
wrapper.append(serialized);
}
}
dom.append(wrapper);
});
if (dom.childNodes.length === 0) {
return;
}
// This helper only runs for added content, so use the "changed" testid — not
// "deleted", which page models match as removed content.
dom.setAttribute('data-testid', 'show-diff-changed-decoration');
// Skip findSafeInsertPos: it walks forward to a schema-valid cell-insert point
// and pushes the widget into the next cell. `targetPos` is the correct spot.
decorations.push(Decoration.widget(targetPos, dom, {
...buildDiffDecorationSpec({
decorationType: 'widget',
diffId: crypto.randomUUID(),
diffType,
...(isExtendedEnabled(diffType) && {
side: -1
})
})
}));
});
return decorations;
};
/**
* This function is used to create a decoration widget to show content
* that is not in the current document.
*/
export const createNodeChangedDecorationWidget = ({
change,
doc,
nodeViewSerializer,
colorScheme,
newDoc,
intl,
activeIndexPos,
// This is false by default as this is generally used to show deleted content
isInserted = false,
showIndicators = false,
// When true, render the deleted content *after* (below) the new content instead of
// before it. Used for `smart` node-level changes so the deleted node appears beneath
// its replacement (gray + strikethrough).
placeBelow = false,
diffType,
hideAddedDiffsUnderline = false
}) => {
var _slice$content, _slice$content2, _slice$content2$first, _slice$content3, _slice$content3$first, _$safeInsertPos$nodeB, _slice$content$firstC, _newDoc$firstChild, _slice$content$firstC2;
const slice = doc.slice(change.fromA, change.toA);
const shouldSkipDeletedEmptyParagraphDecoration = !isInserted && (slice === null || slice === void 0 ? void 0 : (_slice$content = slice.content) === null || _slice$content === void 0 ? void 0 : _slice$content.childCount) === 1 && (slice === null || slice === void 0 ? void 0 : (_slice$content2 = slice.content) === null || _slice$content2 === void 0 ? void 0 : (_slice$content2$first = _slice$content2.firstChild) === null || _slice$content2$first === void 0 ? void 0 : _slice$content2$first.type.name) === 'paragraph' && (slice === null || slice === void 0 ? void 0 : (_slice$content3 = slice.content) === null || _slice$content3 === void 0 ? void 0 : (_slice$content3$first = _slice$content3.firstChild) === null || _slice$content3$first === void 0 ? void 0 : _slice$content3$first.content.size) === 0;
// Widget decoration used for deletions as the content is not in the document
// and we want to display the deleted content with a style.
// For `placeBelow`, anchor at the END of the new content (change.toB) so the deleted
// node renders beneath its replacement; otherwise anchor at the start (change.fromB).
const anchorPos = placeBelow ? change.toB : change.fromB;
const safeInsertPos = findSafeInsertPos(newDoc, anchorPos, slice);
const isActive = activeIndexPos && safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to;
if (slice.content.content.length === 0 || shouldSkipDeletedEmptyParagraphDecoration) {
return [];
}
const isTableCellContent = slice.content.content.some(() => slice.content.content.some(siblingNode => ['tableHeader', 'tableCell'].includes(siblingNode.type.name)));
const isTableRowContent = slice.content.content.some(() => slice.content.content.some(siblingNode => ['tableRow'].includes(siblingNode.type.name)));
// Added whole cells (e.g. "Add table headers"): render per-cell content widgets.
// Deleted whole cells (delete row/col) fall through — they don't exist in `newDoc`,
// so there's nowhere to anchor content and we keep the original "render nothing".
if (isTableCellContent && isInserted) {
return createTableCellContentWidgets({
slice,
newDoc,
change,
nodeViewSerializer,
colorScheme,
isInserted,
diffType
});
}
if (isTableCellContent) {
// Deleted whole cells: nothing to render (see note above).
return [];
}
if (isTableRowContent) {
return createChangedRowDecorationWidgets({
changes: [change],
originalDoc: doc,
newDoc,
nodeViewSerializer,
colorScheme,
isInserted,
diffType
});
}
const serializer = nodeViewSerializer;
// For non-table content, use the existing span wrapper approach
const dom = document.createElement('span');
const $safeInsertPos = newDoc.resolve(safeInsertPos);
const isTopLevelInsert = $safeInsertPos.depth === 0;
const hasPreviousBlock = ((_$safeInsertPos$nodeB = $safeInsertPos.nodeBefore) === null || _$safeInsertPos$nodeB === void 0 ? void 0 : _$safeInsertPos$nodeB.isBlock) === true;
const isFirstDocHeadingReplacement = isExtendedEnabled(diffType) && !placeBelow && change.fromB === 0 && ((_slice$content$firstC = slice.content.firstChild) === null || _slice$content$firstC === void 0 ? void 0 : _slice$content$firstC.type.name) === 'heading' && ((_newDoc$firstChild = newDoc.firstChild) === null || _newDoc$firstChild === void 0 ? void 0 : _newDoc$firstChild.type.name) === 'heading';
if (isExtendedEnabled(diffType) && isTopLevelInsert && hasPreviousBlock && !isFirstDocHeadingReplacement) {
// Editor CSS removes the top margin from a block when it is its parent's first child.
// Keep the serialized block as a subsequent child so each block type retains its own margin.
dom.append(dom.ownerDocument.createElement('div'));
}
if (isFirstDocHeadingReplacement) {
// The replacement widget is the visual first block, while the real first document node has
// its top margin reset. Add a compact gap after the widget so the two diff blocks do not overlap.
dom.style.display = 'block';
dom.style.marginBottom = "var(--ds-space-200, 16px)";
}
// When mediaSingle nodes are rendered inside a widget decoration (e.g. as part of
// a replaced panel), the centering CSS (margin-left: 50%; transform: translateX(-50%))
// incorrectly applies because isNestedNode resolves to false (getPos returns 0).
// Observe DOM mutations and override the transform on .rich-media-item elements
// after React mounts to prevent the image from shifting outside its parent container.
let constrainMediaObserver;
if (isExtendedEnabled(diffType)) {
constrainMediaObserver = new MutationObserver(() => {
const richMediaItems = dom.querySelectorAll('.rich-media-item');
richMediaItems.forEach(el => {
if (el instanceof HTMLElement) {
el.style.transform = 'none';
el.style.marginLeft = '0';
el.style.maxWidth = '100%';
}
});
if (richMediaItems.length > 0) {
var _constrainMediaObserv;
(_constrainMediaObserv = constrainMediaObserver) === null || _constrainMediaObserv === void 0 ? void 0 : _constrainMediaObserv.disconnect();
}
});
constrainMediaObserver.observe(dom, {
childList: true,
subtree: true
});
}
const diffId = crypto.randomUUID();
const decorations = [];
/*
* The thinking is we separate out the fragment we got from doc.slice
* and if it's the first or last content, we go in however many the sliced Open
* or sliced End depth is and match only the entire node.
*/
slice.content.forEach(node => {
// Helper function to handle multiple child nodes
const handleMultipleChildNodes = node => {
if (node.content.childCount > 1 && node.type.inlineContent) {
node.content.forEach(childNode => {
const childNodeView = serializer.tryCreateNodeView(childNode);
if (childNodeView) {
const lineBreak = document.createElement('br');
dom.append(lineBreak);
const wrapper = createContentWrapper(colorScheme, isActive, isInserted, diffType);
wrapper.append(childNodeView);
dom.append(wrapper);
} else {
// Fallback to serializing the individual child node
const serializedChild = serializer.serializeNode(childNode);
if (serializedChild) {
const wrapper = createContentWrapper(colorScheme, isActive, isInserted, diffType);
wrapper.append(serializedChild);
dom.append(wrapper);
}
}
});
return true; // Indicates we handled multiple children
}
return false; // Indicates single child, continue with normal logic
};
// Determine which node to use and how to serialize
const isFirst = slice.content.firstChild === node;
const isLast = slice.content.lastChild === node;
const hasInlineContent = node.content.childCount > 0 && node.type.inlineContent === true;
let fallbackSerialization;
if (handleMultipleChildNodes(node)) {
return;
}
if ((isFirst || isLast && slice.content.childCount > 2) && hasInlineContent) {
fallbackSerialization = () => serializer.serializeFragment(node.content);
} else if (isLast && slice.content.childCount === 2) {
fallbackSerialization = () => {
if (node.type.name === 'text') {
return document.createTextNode(node.text || '');
}
if (node.type.name === 'paragraph') {
const lineBreak = document.createElement('br');
dom.append(lineBreak);
return serializer.serializeFragment(node.content);
}
return serializer.serializeFragment(node.content);
};
} else {
fallbackSerialization = () => serializer.serializeNode(node);
}
// Try to create node view, fallback to serialization
const nodeView = serializer.tryCreateNodeView(node);
if (nodeView) {
if (node.isInline) {
const wrapper = createContentWrapper(colorScheme, isActive, isInserted, diffType);
wrapper.append(nodeView);
dom.append(wrapper);
} else {
// Handle all block nodes with unified function
wrapBlockNodeView({
dom,
nodeView,
targetNode: node,
colorScheme,
intl,
isActive,
isInserted,
diffType,
hideAddedDiffsUnderline
});
}
} else if (nodeViewSerializer.getFilteredNodeViewBlocklist(['paragraph', 'tableRow']).has(node.type.name)) {
// Skip the case where the node is a paragraph or table row that way it can still be rendered and delete the entire table
return;
} else {
const fallbackNode = fallbackSerialization();
if (fallbackNode) {
if (fallbackNode instanceof HTMLElement) {
const injectedNode = injectInnerWrapper({
node: fallbackNode,
colorScheme,
isActive,
isInserted,
diffType
});
dom.append(injectedNode);
} else {
const wrapper = createContentWrapper(colorScheme, isActive, isInserted, diffType);
wrapper.append(fallbackNode);
dom.append(wrapper);
}
}
}
});
dom.setAttribute('data-testid', 'show-diff-deleted-decoration');
if (showIndicators && isExtendedEnabled(diffType)) {
const leftAnchor = createLeftAnchorWidget({
doc: newDoc,
from: safeInsertPos,
diffId
});
dom.style.setProperty('anchor-name', `--${buildAnchorDecorationKey({
diffId
})}`);
if (leftAnchor) {
decorations.push(leftAnchor);
}
}
decorations.push(Decoration.widget(safeInsertPos, dom, {
...buildDiffDecorationSpec({
decorationType: 'widget',
diffId,
isActive,
diffType,
...(isExtendedEnabled(diffType) && {
// placeBelow anchors at the end of the new content, so render on the
// trailing side (1); otherwise render before the new content (-1).
side: placeBelow ? 1 : -1
})
}),
destroy: () => {
var _constrainMediaObserv2;
return (_constrainMediaObserv2 = constrainMediaObserver) === null || _constrainMediaObserv2 === void 0 ? void 0 : _constrainMediaObserv2.disconnect();
}
}));
// The first document node keeps its margin-top reset even when an inverted diff renders a visible
// block before it. Add an empty widget matching the following heading so the editor's own heading
// styles supply the appropriate spacing. For other node types, retain the existing generic spacer.
const isPureDeletion = change.fromB === change.toB;
const isSingleBlock = slice.content.childCount === 1 && ((_slice$content$firstC2 = slice.content.firstChild) === null || _slice$content$firstC2 === void 0 ? void 0 : _slice$content$firstC2.isBlock);
const isDiffWidgetAtStartOfDoc = $safeInsertPos.depth === 0 && $safeInsertPos.index(0) === 0;
if (isDiffWidgetAtStartOfDoc && isSingleBlock && isPureDeletion && isExtendedEnabled(diffType)) {
const followingNode = $safeInsertPos.nodeAfter;
const headingLevel = (followingNode === null || followingNode === void 0 ? void 0 : followingNode.type.name) === 'heading' ? followingNode.attrs.level : undefined;
if (isHeadingLevel(headingLevel)) {
const nodeLikeSpacer = dom.ownerDocument.createElement(`h${headingLevel}`);
nodeLikeSpacer.dataset.testid = 'show-diff-node-like-margin-spacer';
nodeLikeSpacer.setAttribute('aria-hidden', 'true');
nodeLikeSpacer.contentEditable = 'false';
decorations.push(Decoration.widget(safeInsertPos, nodeLikeSpacer, {
side: 0
}));
} else if (followingNode) {
const defaultSpacer = dom.ownerDocument.createElement('span');
defaultSpacer.dataset.testid = 'show-diff-default-margin-spacer';
defaultSpacer.style.display = 'block';
defaultSpacer.style.marginTop = "var(--ds-space-100, 8px)";
decorations.push(Decoration.widget(safeInsertPos, defaultSpacer, {
...buildDiffDecorationSpec({
decorationType: 'widget',
diffId: crypto.randomUUID(),
diffType
})
}));
}
}
return decorations;
};