UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

531 lines (515 loc) 17.8 kB
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 { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { isExtendedEnabled } from '../../isExtendedEnabled'; import { applyTableCellEdgeAttrs } from './tableCellEdgeAttrs'; import { getChangedContentStyle, getChangedNodeStyle, getDeletedContentStyle, getDeletedContentStyleUnbounded, getInsertedContentStyle, hasRestingDeletedRing, isMultiContainerBlockNode, isTextLikeBlockNode, resolveCellOverlayStyle, 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'); } }; /** * Checks if a node should apply deleted styles directly without wrapper * to preserve natural block-level margins */ const shouldApplyStylesDirectly = nodeName => { return nodeName === 'heading'; }; const applyCellOverlayStyles = ({ element, colorScheme, isInserted }) => { const isRoundedTable = expValEquals('platform_editor_table_diff_rounded_corners', 'isEnabled', true); 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 */ const createRemovedLozenge = (intl, isActive = false, colorScheme) => { const container = document.createElement('span'); const containerStyle = convertToInlineCss({ position: 'absolute', top: "var(--ds-space-075, 6px)", right: "var(--ds-space-075, 6px)", zIndex: 2, pointerEvents: 'none', display: 'flex' }); container.setAttribute('style', containerStyle); container.setAttribute('data-testid', 'show-diff-removed-lozenge'); // Create vanilla HTML lozenge element with Atlaskit Lozenge styling (visual refresh) const lozengeElement = document.createElement('span'); const lozengeInnerStyle = resolveRemovedLozengeStyle(colorScheme, isActive); lozengeElement.setAttribute('style', lozengeInnerStyle); lozengeElement.textContent = intl.formatMessage(trackChangesMessages.removed).toUpperCase(); 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; }; /** * Applies styles directly to an HTML element by merging with existing styles */ const applyStylesToElement = ({ element, targetNode, colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline = false }) => { const currentStyle = element.getAttribute('style') || ''; const contentStyle = getChangedContentStyle(colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline); const targetNodeName = expValEquals('platform_editor_nest_table_in_panel', 'isEnabled', true) ? getBaseNodeTypeName(targetNode.type) : targetNode.type.name; const nodeSpecificStyle = getChangedNodeStyle(targetNodeName, colorScheme, isInserted, isActive, diffType, hideAddedDiffsUnderline) || ''; element.setAttribute('style', `${currentStyle}${contentStyle}${nodeSpecificStyle}`); }; const applyMultiContainerLikeStyles = ({ element, targetNode, colorScheme, isActive, isInserted, diffType, 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, diffType, hideAddedDiffsUnderline) || ''; if (targetNode.type.name === 'decisionList') { const nestedInsertedNodeStyle = resolveNestedInsertedNodeStyle(); element.querySelectorAll('li').forEach(listItem => { const currentListItemStyle = listItem.getAttribute('style') || ''; listItem.setAttribute('style', `${currentListItemStyle}${nestedInsertedNodeStyle}`); }); } else if (targetNode.type.name === 'layoutSection') { const nestedInsertedNodeStyle = resolveNestedInsertedNodeStyle(); element.querySelectorAll('[data-layout-column="true"]').forEach(section => { const currentSectionStyle = section.getAttribute('style') || ''; section.setAttribute('style', `${currentSectionStyle}${nestedInsertedNodeStyle}`); }); } else if (targetNode.type.name === 'taskList') { const nestedInsertedNodeStyle = resolveNestedInsertedNodeStyle(); element.querySelectorAll('li').forEach(listItem => { const currentListItemStyle = listItem.getAttribute('style') || ''; listItem.setAttribute('style', `${currentListItemStyle}${nestedInsertedNodeStyle}`); }); } element.setAttribute('style', `${currentStyle};${nodeSpecificStyle}`); }; const applyTextLikeBlockNodeStyles = ({ element, targetNode, colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline = false }) => { const currentStyle = element.getAttribute('style') || ''; const nodeSpecificStyle = getChangedNodeStyle(targetNode.type.name, colorScheme, isInserted, isActive, diffType, hideAddedDiffsUnderline) || ''; if (nodeSpecificStyle) { element.setAttribute('style', `${currentStyle}${nodeSpecificStyle}`); } const contentStyle = getChangedContentStyle(colorScheme, isActive, isInserted, diffType, 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); }); }; /** * Creates a content wrapper with deleted styles for a block node */ const createBlockNodeContentWrapper = ({ nodeView, targetNode, colorScheme, isActive, isInserted, diffType, 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, diffType, 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 = isExtendedEnabled(diffType) && isInserted && nodeStyle !== undefined; const contentStyle = shouldSkipContentStyle ? '' : getChangedContentStyle(colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline); 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)) { const showDiffDeletedNodeClass = colorScheme === 'traditional' ? 'show-diff-deleted-node-traditional' : 'show-diff-deleted-node'; nodeView.classList.add(showDiffDeletedNodeClass); if (isActive) { nodeView.classList.add('show-diff-deleted-active'); } maybeAddDeletedOutlineNewClass({ 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)) { const showDiffDeletedNodeClass = colorScheme === 'traditional' ? 'show-diff-deleted-node-traditional' : 'show-diff-deleted-node'; nodeView.classList.add(showDiffDeletedNodeClass); if (isActive) { nodeView.classList.add('show-diff-deleted-active'); } maybeAddDeletedOutlineNewClass({ 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, diffType, 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) && (!isExtendedEnabled(diffType) || !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; } blockWrapper.append(lozenge); } const contentWrapper = createBlockNodeContentWrapper({ nodeView, targetNode, colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline }); blockWrapper.append(contentWrapper); if (nodeView instanceof HTMLElement && shouldAddShowDiffDeletedNodeClass(targetNode.type.name)) { const showDiffDeletedNodeClass = colorScheme === 'traditional' ? 'show-diff-deleted-node-traditional' : 'show-diff-deleted-node'; nodeView.classList.add(showDiffDeletedNodeClass); if (isActive) { nodeView.classList.add('show-diff-deleted-active'); } maybeAddDeletedOutlineNewClass({ 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, diffType, hideAddedDiffsUnderline = false }) => { if (isExtendedEnabled(diffType)) { if (nodeView instanceof HTMLElement) { if (isInserted && isMultiContainerBlockNode(targetNode.type.name)) { applyMultiContainerLikeStyles({ element: nodeView, targetNode, colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline }); dom.append(nodeView); return; } if (isTextLikeBlockNode(targetNode.type.name)) { applyTextLikeBlockNodeStyles({ element: nodeView, targetNode, colorScheme, isActive, isInserted, diffType, hideAddedDiffsUnderline }); dom.append(nodeView); return; } if (targetNode.type.name === 'table') { if (expValEquals('platform_editor_table_q4_loveability', 'isEnabled', true) && expValEquals('platform_editor_table_diff_rounded_corners', 'isEnabled', true)) { applyTableCellEdgeAttrs({ element: nodeView, tableNode: targetNode }); } applyCellOverlayStyles({ element: nodeView, colorScheme, isInserted }); dom.append(nodeView); return; } } wrapBlockNode({ dom, nodeView, targetNode, colorScheme, intl, isActive, isInserted, diffType, hideAddedDiffsUnderline }); return; } else { if (shouldApplyStylesDirectly(targetNode.type.name) && nodeView instanceof HTMLElement) { // Apply deleted styles directly to preserve natural block-level margins applyStylesToElement({ element: nodeView, targetNode, colorScheme, isActive, isInserted, diffType }); dom.append(nodeView); } else { wrapBlockNode({ dom, nodeView, targetNode, colorScheme, intl, isActive, isInserted, diffType }); } } }; /** * 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, diffType }) => { const wrapper = document.createElement('span'); wrapper.setAttribute('style', isInserted ? getInsertedContentStyle(colorScheme, isActive) : getDeletedContentStyle(colorScheme, isActive, diffType)); [...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, diffType) => { const wrapper = document.createElement('span'); const baseStyle = convertToInlineCss({ position: 'relative', width: 'fit-content' }); if (isExtendedEnabled(diffType)) { if (isInserted) { wrapper.setAttribute('style', `${baseStyle}${getInsertedContentStyle(colorScheme, isActive)}`); } else { wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive, diffType)}`); const strikethrough = document.createElement('span'); strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive)); wrapper.append(strikethrough); } } else { wrapper.setAttribute('style', `${baseStyle}${getDeletedContentStyle(colorScheme, isActive, diffType)}`); const strikethrough = document.createElement('span'); strikethrough.setAttribute('style', getDeletedContentStyleUnbounded(colorScheme, isActive)); wrapper.append(strikethrough); } return wrapper; };