@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
246 lines (239 loc) • 11.1 kB
JavaScript
import { convertToInlineCss } from '@atlaskit/editor-common/lazy-node-view';
import { Decoration } from '@atlaskit/editor-prosemirror/view';
import { fg } from '@atlaskit/platform-feature-flags';
import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
import { editingStyle, editingStyleActive, deletedContentStyle, deletedContentStyleActive, deletedContentStyleNew, deletedContentStyleNewActive, deletedContentStyleUnbounded } from './colorSchemes/standard';
import { traditionalInsertStyle, traditionalInsertStyleActive, deletedTraditionalContentStyle, deletedTraditionalContentStyleActive, deletedTraditionalContentStyleUnbounded, deletedTraditionalContentStyleUnboundedActive } from './colorSchemes/traditional';
import { createChangedRowDecorationWidgets } from './createChangedRowDecorationWidgets';
import { findSafeInsertPos } from './utils/findSafeInsertPos';
import { wrapBlockNodeView } from './utils/wrapBlockNodeView';
const getDeletedContentStyleUnbounded = (colorScheme, isActive = false) => {
if (colorScheme === 'traditional' && isActive) {
return deletedTraditionalContentStyleUnboundedActive;
}
return colorScheme === 'traditional' ? deletedTraditionalContentStyleUnbounded : deletedContentStyleUnbounded;
};
const getInsertedContentStyle = (colorScheme, isActive = false) => {
if (colorScheme === 'traditional') {
if (isActive) {
return traditionalInsertStyleActive;
}
return traditionalInsertStyle;
}
if (isActive) {
return editingStyleActive;
}
return editingStyle;
};
const getDeletedContentStyle = (colorScheme, isActive = false) => {
if (colorScheme === 'traditional') {
return isActive ? deletedTraditionalContentStyleActive : deletedTraditionalContentStyle;
}
if (isActive) {
return expValEquals('platform_editor_enghealth_a11y_jan_fixes', 'isEnabled', true) ? deletedContentStyleNewActive : deletedContentStyleActive;
}
return expValEquals('platform_editor_enghealth_a11y_jan_fixes', 'isEnabled', true) ? deletedContentStyleNew : deletedContentStyle;
};
/**
* Wraps content with deleted styling without opacity (for use when content is a direct child of dom)
*/
const createDeletedStyleWrapperWithoutOpacity = (colorScheme, isActive) => {
const wrapper = document.createElement('span');
wrapper.setAttribute('style', getDeletedContentStyle(colorScheme, isActive));
return wrapper;
};
/**
* 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
*/
const injectInnerWrapper = ({
node,
colorScheme,
isActive,
isInserted
}) => {
const wrapper = document.createElement('span');
wrapper.setAttribute('style', isInserted ? getInsertedContentStyle(colorScheme, isActive) : getDeletedContentStyle(colorScheme, isActive));
[...node.childNodes].forEach(child => {
const removedChild = node.removeChild(child);
wrapper.append(removedChild);
});
node.appendChild(wrapper);
return node;
};
const createContentWrapper = (colorScheme, isActive = false, isInserted = false) => {
const wrapper = document.createElement('span');
const baseStyle = convertToInlineCss({
position: 'relative',
width: 'fit-content'
});
if (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true)) {
if (isInserted) {
wrapper.setAttribute('style', `${baseStyle}${getInsertedContentStyle(colorScheme, isActive)}`);
} else {
wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive)}`);
const strikethrough = document.createElement('span');
strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
wrapper.append(strikethrough);
}
} else {
wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive)}`);
const strikethrough = document.createElement('span');
strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive));
wrapper.append(strikethrough);
}
return wrapper;
};
/**
* 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
}) => {
var _slice$content, _slice$content2, _slice$content2$first, _slice$content3, _slice$content3$first;
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 && fg('platform_editor_show_diff_scroll_navigation');
// Widget decoration used for deletions as the content is not in the document
// and we want to display the deleted content with a style.
const safeInsertPos = findSafeInsertPos(newDoc, change.fromB, 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)));
if (isTableCellContent) {
return;
}
if (isTableRowContent) {
return createChangedRowDecorationWidgets({
changes: [change],
originalDoc: doc,
newDoc,
nodeViewSerializer,
colorScheme,
isInserted
});
}
const serializer = nodeViewSerializer;
// For non-table content, use the existing span wrapper approach
const dom = document.createElement('span');
/*
* 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);
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);
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);
wrapper.append(nodeView);
dom.append(wrapper);
} else {
// Handle all block nodes with unified function
wrapBlockNodeView({
dom,
nodeView,
targetNode: node,
colorScheme,
intl,
isActive,
isInserted
});
}
} 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 (expValEquals('platform_editor_diff_plugin_extended', 'isEnabled', true) || fg('platform_editor_show_diff_scroll_navigation')) {
if (fallbackNode instanceof HTMLElement) {
const injectedNode = injectInnerWrapper({
node: fallbackNode,
colorScheme,
isActive,
isInserted
});
dom.append(injectedNode);
} else {
const wrapper = createContentWrapper(colorScheme, isActive, isInserted);
wrapper.append(fallbackNode);
dom.append(wrapper);
}
} else {
const wrapper = createDeletedStyleWrapperWithoutOpacity(colorScheme, isActive);
wrapper.append(fallbackNode);
dom.append(wrapper);
}
}
}
});
dom.setAttribute('data-testid', 'show-diff-deleted-decoration');
const decorations = [];
decorations.push(Decoration.widget(safeInsertPos, dom, {
key: `diff-widget-${isActive ? 'active' : 'inactive'}`
}));
return decorations;
};