@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
645 lines (625 loc) • 23.1 kB
JavaScript
import { convertToInlineCss } from '@atlaskit/editor-common/lazy-node-view';
import { trackChangesMessages } from '@atlaskit/editor-common/messages';
import { getBaseNodeTypeName } from '@atlaskit/editor-common/utils/node-type-utils';
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 { getAtomicInlineChangedAttrs } from '../createInlineChangedDecoration';
import { applyRevealToElement } from '../revealStyles';
import { isInlineAttrChangeNodeName } from './getAttrChangeRanges';
import { applyTableCellEdgeAttrs } from './tableCellEdgeAttrs';
import { getChangedContentStyle, getChangedNodeStyle, getDeletedContentStyle, getDeletedContentStyleUnbounded, getInsertedContentStyle, hasRestingDeletedRing, isMultiContainerBlockNode, isTextLikeBlockNode, resolveCellOverlayStyle, resolveDeletedNodeCSSVariables, resolveNestedInsertedNodeStyle, resolveRemovedLozengeStyle } from './wrapBlockNodeViewStyles';
const shouldShowRemovedLozenge = nodeName => {
switch (nodeName) {
case 'expand':
case 'codeBlock':
case 'mediaSingle':
case 'panel':
case 'decisionList':
case 'embedCard':
case 'blockquote':
return true;
default:
return false;
}
};
const shouldAddShowDiffDeletedNodeClass = nodeName => {
switch (nodeName) {
case 'mediaSingle':
case 'embedCard':
case 'blockquote':
return true;
default:
return false;
}
};
/** Scroll-nav “new” ring (4px red subtlest) for media/embed; styled in editor-core smartCardStyles. */
const maybeAddDeletedOutlineNewClass = ({
nodeView,
targetNode,
colorScheme,
isActive = false
}) => {
const name = targetNode.type.name;
if (name !== 'mediaSingle' && name !== 'embedCard') {
return;
}
// Only schemes with a resting ring get the class.
if (hasRestingDeletedRing(colorScheme) && !isActive) {
nodeView.classList.add('show-diff-deleted-outline-new');
}
};
/**
* Pre-refactor markup for a deleted media/embed/blockquote nodeview: one class per scheme, and no
* custom properties — the colours come from the matching per-scheme selectors in editor-core.
*
* Delete this together with those selectors at experiment cleanup (EDITOR-8281).
*/
const applyDeletedNodeMarkupLegacy = ({
nodeView,
targetNode,
colorScheme,
isActive = false
}) => {
nodeView.classList.add(colorScheme === 'traditional' ? 'show-diff-deleted-node-traditional' : 'show-diff-deleted-node');
if (isActive) {
nodeView.classList.add('show-diff-deleted-active');
}
maybeAddDeletedOutlineNewClass({
nodeView,
targetNode,
colorScheme,
isActive
});
};
/**
* Registry-driven markup: one scheme-agnostic class, plus the scheme's colours as custom properties,
* so editor-core needs one selector per visual role instead of one per scheme.
*
* `show-diff-deleted-node-vars` *replaces* the legacy classes rather than joining them. Both sets of
* selectors live in editor-core for the life of the experiment, and they have equal specificity, so
* a shared base class would leave the winner up to source order.
*
* The `-vars` suffix names what the class means — this node carries its scheme colours as
* `--diff-delete-*` custom properties — rather than when it arrived, so it still reads correctly
* once the legacy classes are gone.
*/
const applyDeletedNodeMarkupNext = ({
nodeView,
targetNode,
colorScheme,
isActive = false
}) => {
nodeView.classList.add('show-diff-deleted-node-vars');
if (isActive) {
nodeView.classList.add('show-diff-deleted-active');
}
maybeAddDeletedOutlineNewClass({
nodeView,
targetNode,
colorScheme,
isActive
});
const currentStyle = nodeView.getAttribute('style') || '';
const separator = currentStyle && !currentStyle.trimEnd().endsWith(';') ? ';' : '';
nodeView.setAttribute('style', `${currentStyle}${separator}${resolveDeletedNodeCSSVariables(colorScheme)}`);
};
/** Single gate for the deleted-node classes and custom properties. */
const applyDeletedNodeMarkup = args => isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? applyDeletedNodeMarkupNext(args) : applyDeletedNodeMarkupLegacy(args);
const applyCellOverlayStyles = ({
element,
colorScheme,
isInserted
}) => {
const isRoundedTable = isExperimentEnabled('platform_editor_table_diff_rounded_corners');
const overlayStyle = resolveCellOverlayStyle({
colorScheme,
isInserted,
isRoundedTable
});
element.querySelectorAll('td, th').forEach(cell => {
const overlay = document.createElement('span');
overlay.setAttribute('style', overlayStyle);
cell.appendChild(overlay);
});
};
/**
* Creates a "Removed" lozenge to be displayed at the top right corner of deleted block nodes
*/
export const createRemovedLozenge = (intl, isActive = false, colorScheme, inCell = false) => {
const container = document.createElement('span');
const containerStyle = convertToInlineCss({
position: 'absolute',
top: "var(--ds-space-075, 6px)",
right: "var(--ds-space-075, 6px)",
// Rounded table-cell overlays use z-index 2 and are appended after the cell content.
// Keep the lozenge above them so the overlay cannot tint or obscure it.
zIndex: 3,
pointerEvents: 'none',
display: 'flex'
});
// Create vanilla HTML lozenge element with Atlaskit Lozenge styling (visual refresh)
const lozengeElement = document.createElement('span');
// A column label sits over an already-grey deleted cell, so use the red Removed treatment.
const lozengeInnerStyle = resolveRemovedLozengeStyle(colorScheme, inCell || isActive);
lozengeElement.setAttribute('style', lozengeInnerStyle);
lozengeElement.textContent = intl.formatMessage(trackChangesMessages.removed).toUpperCase();
if (inCell) {
lozengeElement.setAttribute('style', `${lozengeInnerStyle};${containerStyle}`);
lozengeElement.setAttribute('data-testid', 'show-diff-removed-lozenge');
lozengeElement.contentEditable = 'false';
return lozengeElement;
}
container.setAttribute('style', containerStyle);
container.setAttribute('data-testid', 'show-diff-removed-lozenge');
container.appendChild(lozengeElement);
return container;
};
/**
* Wraps a block node in a container with relative positioning to support absolute positioned lozenge
*/
const createBlockNodeWrapper = () => {
const wrapper = document.createElement('div');
const baseStyle = convertToInlineCss({
position: 'relative',
display: 'block',
opacity: 1
});
wrapper.setAttribute('style', baseStyle);
return wrapper;
};
const applyMultiContainerLikeStyles = ({
element,
targetNode,
colorScheme,
isActive,
isInserted,
hideAddedDiffsUnderline = false
}) => {
const currentStyle = element.getAttribute('style') || '';
const targetNodeName = expValEquals('platform_editor_nest_table_in_panel', 'isEnabled', true) ? getBaseNodeTypeName(targetNode.type) : targetNode.type.name;
const nodeSpecificStyle = getChangedNodeStyle(targetNodeName, colorScheme, isInserted, isActive, hideAddedDiffsUnderline) || '';
if (targetNode.type.name === 'decisionList') {
const nestedInsertedNodeStyle = resolveNestedInsertedNodeStyle(colorScheme);
element.querySelectorAll('li').forEach(listItem => {
const currentListItemStyle = listItem.getAttribute('style') || '';
listItem.setAttribute('style', fg('platform_editor_ai_show_diff_patch_1') ? combineStyles(currentListItemStyle, nestedInsertedNodeStyle) : `${currentListItemStyle}${nestedInsertedNodeStyle}`);
});
} else if (targetNode.type.name === 'layoutSection') {
const nestedInsertedNodeStyle = resolveNestedInsertedNodeStyle(colorScheme);
element.querySelectorAll('[data-layout-column="true"]').forEach(section => {
const currentSectionStyle = section.getAttribute('style') || '';
section.setAttribute('style', fg('platform_editor_ai_show_diff_patch_1') ? combineStyles(currentSectionStyle, nestedInsertedNodeStyle) : `${currentSectionStyle}${nestedInsertedNodeStyle}`);
});
} else if (targetNode.type.name === 'taskList') {
const nestedInsertedNodeStyle = resolveNestedInsertedNodeStyle(colorScheme);
element.querySelectorAll('li').forEach(listItem => {
const currentListItemStyle = listItem.getAttribute('style') || '';
listItem.setAttribute('style', `${currentListItemStyle}${nestedInsertedNodeStyle}`);
});
}
element.setAttribute('style', fg('platform_editor_ai_show_diff_patch_1') ? combineStyles(currentStyle, nodeSpecificStyle) : `${currentStyle};${nodeSpecificStyle}`);
};
const combineStyles = (currentStyle, appendedStyle) => {
const separator = currentStyle && appendedStyle && !currentStyle.trimEnd().endsWith(';') ? '; ' : '';
return `${currentStyle}${separator}${appendedStyle}`;
};
const appendStyleToElement = (element, style) => {
const currentStyle = element.getAttribute('style') || '';
element.setAttribute('style', combineStyles(currentStyle, style));
};
const wrapAtomicInlineNode = ({
element,
className,
style
}) => {
const wrapper = document.createElement('span');
wrapper.className = className;
wrapper.setAttribute('style', style);
element.replaceWith(wrapper);
wrapper.append(element);
};
const applyInlineLeafNodeStyles = ({
element,
targetNode,
contentStyle,
colorScheme
}) => {
// NodeViewSerializer appends one DOM node per direct ProseMirror child, preserving order.
const childDomNodes = [...element.childNodes];
targetNode.content.forEach((childNode, _offset, index) => {
if (childNode.isText || !childNode.isLeaf) {
return;
}
const childElement = childDomNodes[index];
if (!(childElement instanceof HTMLElement)) {
return;
}
if (!isInlineAttrChangeNodeName(childNode.type.name)) {
appendStyleToElement(childElement, contentStyle);
return;
}
const {
className,
styleSuffix
} = getAtomicInlineChangedAttrs(childNode.type.name, colorScheme);
// Inline decorations use an outer span so node-specific descendant selectors match.
wrapAtomicInlineNode({
element: childElement,
className,
style: combineStyles(contentStyle, styleSuffix)
});
});
};
/**
* Editor CSS makes `blockquote` `inline-block` for its block formatting context. Inside a widget
* that collapses the indicator bar: the container is an inline `span`, so its anchor rect comes from
* font metrics, not from an inline-level child. `flow-root` keeps the formatting context but is
* block-level — unlike `block`, which lets the child paragraph's top margin collapse out.
*/
const quoteBlockLevelStyle = convertToInlineCss({
display: 'flow-root'
});
const applyTextLikeBlockNodeStyles = ({
element,
targetNode,
colorScheme,
isActive,
isInserted,
hideAddedDiffsUnderline = false,
highlightInlineLeafNodes = false
}) => {
const currentStyle = element.getAttribute('style') || '';
const nodeSpecificStyle = getChangedNodeStyle(targetNode.type.name, colorScheme, isInserted, isActive, hideAddedDiffsUnderline) || '';
if (nodeSpecificStyle) {
element.setAttribute('style', `${currentStyle}${nodeSpecificStyle}`);
}
const contentStyle = getChangedContentStyle(colorScheme, isActive, isInserted, hideAddedDiffsUnderline);
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const textNodesToWrap = [];
let currentNode = walker.nextNode();
while (currentNode) {
if (currentNode instanceof Text && currentNode.textContent !== '' && currentNode.parentElement) {
textNodesToWrap.push(currentNode);
}
currentNode = walker.nextNode();
}
textNodesToWrap.forEach(textNode => {
const contentWrapper = document.createElement('span');
contentWrapper.setAttribute('style', contentStyle);
textNode.replaceWith(contentWrapper);
contentWrapper.append(textNode);
});
if (highlightInlineLeafNodes && targetNode.type.inlineContent) {
applyInlineLeafNodeStyles({
element,
targetNode,
contentStyle,
colorScheme
});
}
};
/**
* Creates a content wrapper with deleted styles for a block node
*/
/**
* The element that spans breakout width, for nodes carrying a `breakout` mark. The wrapper divs
* stay at editor-column width, so anything that should align to the node's visible edges —
* outlines, absolutely positioned lozenges — has to attach here instead.
*/
const findBreakoutElement = (targetNode, nodeView) => {
var _targetNode$marks;
const hasBreakoutMark = (_targetNode$marks = targetNode.marks) === null || _targetNode$marks === void 0 ? void 0 : _targetNode$marks.some(mark => mark.type.name === 'breakout');
if (!hasBreakoutMark || !(nodeView instanceof HTMLElement)) {
return null;
}
return nodeView.matches('[data-node-type]') ? nodeView : nodeView.querySelector('[data-node-type]');
};
const createBlockNodeContentWrapper = ({
nodeView,
targetNode,
colorScheme,
isActive,
isInserted,
hideAddedDiffsUnderline = false
}) => {
const contentWrapper = document.createElement('div');
const targetNodeName = expValEquals('platform_editor_nest_table_in_panel', 'isEnabled', true) ? getBaseNodeTypeName(targetNode.type) : targetNode.type.name;
const nodeStyle = getChangedNodeStyle(targetNodeName, colorScheme, isInserted, isActive, hideAddedDiffsUnderline);
// When the extended experiment is enabled and the content is inserted,
// block widget nodes that already have dedicated node-level styling (e.g. boxShadow outline)
// should not also get the inline content style (borderBottom underline) on their container.
const shouldSkipContentStyle = isInserted && nodeStyle !== undefined;
const contentStyle = shouldSkipContentStyle ? '' : getChangedContentStyle(colorScheme, isActive, isInserted, hideAddedDiffsUnderline);
// A `breakout` node renders wider than the editor column while the wrapper divs
// stay at column width, so paint the outline on the node element that spans that width.
const breakoutTarget = nodeStyle ? findBreakoutElement(targetNode, nodeView) : null;
if (fg('platform_editor_ai_show_diff_patch_2') && breakoutTarget) {
appendStyleToElement(breakoutTarget, nodeStyle !== null && nodeStyle !== void 0 ? nodeStyle : '');
contentWrapper.setAttribute('style', contentStyle || '');
} else {
contentWrapper.setAttribute('style', `${contentStyle}${nodeStyle || ''}`);
}
contentWrapper.append(nodeView);
return contentWrapper;
};
/**
* Handles embedCard node rendering with lozenge attached to the rich-media-item container.
* Since embedCard content loads asynchronously, we use a MutationObserver
* to wait for the rich-media-item to appear before attaching the lozenge.
* @returns true if embedCard was handled
*/
const handleEmbedCardWithLozenge = ({
dom,
nodeView,
targetNode,
lozenge,
colorScheme,
isActive = false
}) => {
if (targetNode.type.name !== 'embedCard' || !(nodeView instanceof HTMLElement)) {
return false;
}
const richMediaItem = nodeView.querySelector('.rich-media-item');
if (richMediaItem instanceof HTMLElement) {
richMediaItem.appendChild(lozenge);
} else {
const observer = new MutationObserver((_, obs) => {
const loadedRichMedia = nodeView.querySelector('.rich-media-item');
if (loadedRichMedia instanceof HTMLElement) {
loadedRichMedia.appendChild(lozenge);
obs.disconnect();
}
});
observer.observe(nodeView, {
childList: true,
subtree: true
});
}
if (shouldAddShowDiffDeletedNodeClass(targetNode.type.name)) {
applyDeletedNodeMarkup({
nodeView,
targetNode,
colorScheme,
isActive
});
}
dom.append(nodeView);
return true;
};
/**
* Handles special mediaSingle node rendering with lozenge on child media element
* @returns true if mediaSingle was handled, false otherwise
*/
const handleMediaSingleWithLozenge = ({
dom,
nodeView,
targetNode,
lozenge,
colorScheme,
isActive = false
}) => {
if (targetNode.type.name !== 'mediaSingle' || !(nodeView instanceof HTMLElement)) {
return false;
}
const mediaNode = nodeView.querySelector('[data-prosemirror-node-name="media"]');
if (!mediaNode || !(mediaNode instanceof HTMLElement)) {
return false;
}
// Add relative positioning to media node to anchor lozenge
const currentStyle = mediaNode.getAttribute('style') || '';
const relativePositionStyle = convertToInlineCss({
position: 'relative'
});
mediaNode.setAttribute('style', `${currentStyle}${relativePositionStyle}`);
mediaNode.append(lozenge);
// Add deleted node class if needed
if (shouldAddShowDiffDeletedNodeClass(targetNode.type.name)) {
applyDeletedNodeMarkup({
nodeView,
targetNode,
colorScheme,
isActive
});
}
dom.append(nodeView);
return true;
};
/**
* Appends a block node with wrapper, lozenge, and appropriate styling
*/
const wrapBlockNode = ({
dom,
nodeView,
targetNode,
colorScheme,
intl,
isActive = false,
isInserted = false,
hideAddedDiffsUnderline = false
}) => {
const blockWrapper = createBlockNodeWrapper();
const targetNodeName = expValEquals('platform_editor_nest_table_in_panel', 'isEnabled', true) ? getBaseNodeTypeName(targetNode.type) : targetNode.type.name;
if (shouldShowRemovedLozenge(targetNodeName) && !isInserted) {
const lozenge = createRemovedLozenge(intl, isActive, colorScheme);
if (handleEmbedCardWithLozenge({
dom,
nodeView,
targetNode,
lozenge,
colorScheme,
isActive
})) {
return;
}
if (handleMediaSingleWithLozenge({
dom,
nodeView,
targetNode,
lozenge,
colorScheme,
isActive
})) {
return;
}
// `lozenge` is absolutely positioned, so it lands on the nearest positioned ancestor. For a
// breakout node that is the column-width wrapper, leaving the pill short of the node's
// right edge — host it on the breakout element, which must establish a containing block.
const breakoutHost = fg('platform_editor_ai_show_diff_patch_2') ? findBreakoutElement(targetNode, nodeView) : null;
if (breakoutHost) {
appendStyleToElement(breakoutHost, convertToInlineCss({
position: 'relative'
}));
breakoutHost.append(lozenge);
} else {
blockWrapper.append(lozenge);
}
}
const contentWrapper = createBlockNodeContentWrapper({
nodeView,
targetNode,
colorScheme,
isActive,
isInserted,
hideAddedDiffsUnderline
});
blockWrapper.append(contentWrapper);
if (nodeView instanceof HTMLElement && shouldAddShowDiffDeletedNodeClass(targetNode.type.name)) {
applyDeletedNodeMarkup({
nodeView,
targetNode,
colorScheme,
isActive
});
}
dom.append(blockWrapper);
};
/**
* Handles all block node rendering with appropriate deleted styling.
* For heading nodes, applies styles directly to preserve natural margins.
* For other block nodes, uses wrapper approach with optional lozenge.
*/
export const wrapBlockNodeView = ({
dom,
nodeView,
targetNode,
colorScheme,
intl,
isActive = false,
isInserted = false,
hideAddedDiffsUnderline = false,
highlightInlineLeafNodes = false
}) => {
if (nodeView instanceof HTMLElement) {
if (isInserted && isMultiContainerBlockNode(targetNode.type.name)) {
applyMultiContainerLikeStyles({
element: nodeView,
targetNode,
colorScheme,
isActive,
isInserted,
hideAddedDiffsUnderline
});
dom.append(nodeView);
return;
}
if (isTextLikeBlockNode(targetNode.type.name)) {
applyTextLikeBlockNodeStyles({
element: nodeView,
targetNode,
colorScheme,
isActive,
isInserted,
hideAddedDiffsUnderline,
highlightInlineLeafNodes
});
if (targetNode.type.name === 'blockquote' && fg('platform_editor_ai_show_diff_patch_1')) {
appendStyleToElement(nodeView, quoteBlockLevelStyle);
}
dom.append(nodeView);
return;
}
if (targetNode.type.name === 'table') {
if (expValEquals('platform_editor_table_q4_loveability', 'isEnabled', true) && isExperimentEnabled('platform_editor_table_diff_rounded_corners')) {
applyTableCellEdgeAttrs({
element: nodeView,
tableNode: targetNode
});
}
applyCellOverlayStyles({
element: nodeView,
colorScheme,
isInserted
});
dom.append(nodeView);
return;
}
}
wrapBlockNode({
dom,
nodeView,
targetNode,
colorScheme,
intl,
isActive,
isInserted,
hideAddedDiffsUnderline
});
};
/**
* Injects a styled inner wrapper span around the children of a block node element.
* CSS backgrounds don't work when applied to a wrapper around a paragraph, so
* the wrapper needs to be injected inside the node around the child content.
*/
export const injectInnerWrapper = ({
node,
colorScheme,
isActive,
isInserted,
reveal
}) => {
const wrapper = document.createElement('span');
const revealStyle = applyRevealToElement({
colorScheme,
element: wrapper,
isActive: isActive !== null && isActive !== void 0 ? isActive : false,
isInserted: isInserted !== null && isInserted !== void 0 ? isInserted : false,
reveal
});
wrapper.setAttribute('style', (isInserted ? getInsertedContentStyle(colorScheme, isActive) : getDeletedContentStyle(colorScheme, isActive, Boolean(revealStyle))) + revealStyle);
[...node.childNodes].forEach(child => {
const removedChild = node.removeChild(child);
wrapper.append(removedChild);
});
node.appendChild(wrapper);
return node;
};
/**
* Creates a styled span wrapper for inline content within a change decoration.
*/
export const createContentWrapper = (colorScheme, isActive = false, isInserted = false, reveal) => {
const wrapper = document.createElement('span');
const baseStyle = convertToInlineCss({
position: 'relative',
width: 'fit-content'
});
// Empty unless the reveal is running, in which case the static highlight below is withheld so
// the animation can wipe it in instead.
const revealStyle = applyRevealToElement({
colorScheme,
element: wrapper,
isActive,
isInserted,
reveal
});
const deletedStyle = getDeletedContentStyle(colorScheme, isActive, Boolean(revealStyle));
if (isInserted) {
wrapper.setAttribute('style', `${baseStyle}${getInsertedContentStyle(colorScheme, isActive)}${revealStyle}`);
} else {
wrapper.setAttribute('style', `${baseStyle}${deletedStyle}${revealStyle}`);
const strikethrough = document.createElement('span');
strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
wrapper.append(strikethrough);
}
return wrapper;
};