UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

375 lines (365 loc) 18.4 kB
import _defineProperty from "@babel/runtime/helpers/defineProperty"; 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; } 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'; var isHeadingLevel = function isHeadingLevel(level) { return 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). */ var createTableCellContentWidgets = function createTableCellContentWidgets(_ref) { var slice = _ref.slice, newDoc = _ref.newDoc, change = _ref.change, nodeViewSerializer = _ref.nodeViewSerializer, colorScheme = _ref.colorScheme, isInserted = _ref.isInserted, diffType = _ref.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. var newCellInnerPositions = []; newDoc.nodesBetween(change.fromB, change.toB, function (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; }); var decorations = []; slice.content.forEach(function (cellNode, _offset, index) { var targetPos = newCellInnerPositions[index]; if (targetPos === undefined || cellNode.content.size === 0) { return; } var 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(function (blockNode) { var nodeView = nodeViewSerializer.tryCreateNodeView(blockNode); var wrapper = createContentWrapper(colorScheme, false, isInserted, diffType); if (nodeView) { wrapper.append(nodeView); } else { var 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, _objectSpread({}, buildDiffDecorationSpec(_objectSpread({ decorationType: 'widget', diffId: crypto.randomUUID(), diffType: 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 var createNodeChangedDecorationWidget = function createNodeChangedDecorationWidget(_ref2) { var _slice$content, _slice$content2, _slice$content3, _$safeInsertPos$nodeB, _slice$content$firstC, _newDoc$firstChild, _slice$content$firstC2; var change = _ref2.change, doc = _ref2.doc, nodeViewSerializer = _ref2.nodeViewSerializer, colorScheme = _ref2.colorScheme, newDoc = _ref2.newDoc, intl = _ref2.intl, activeIndexPos = _ref2.activeIndexPos, _ref2$isInserted = _ref2.isInserted, isInserted = _ref2$isInserted === void 0 ? false : _ref2$isInserted, _ref2$showIndicators = _ref2.showIndicators, showIndicators = _ref2$showIndicators === void 0 ? false : _ref2$showIndicators, _ref2$placeBelow = _ref2.placeBelow, placeBelow = _ref2$placeBelow === void 0 ? false : _ref2$placeBelow, diffType = _ref2.diffType, _ref2$hideAddedDiffsU = _ref2.hideAddedDiffsUnderline, hideAddedDiffsUnderline = _ref2$hideAddedDiffsU === void 0 ? false : _ref2$hideAddedDiffsU; var slice = doc.slice(change.fromA, change.toA); var shouldSkipDeletedEmptyParagraphDecoration = !isInserted && (slice === null || slice === void 0 || (_slice$content = slice.content) === null || _slice$content === void 0 ? void 0 : _slice$content.childCount) === 1 && (slice === null || slice === void 0 || (_slice$content2 = slice.content) === null || _slice$content2 === void 0 || (_slice$content2 = _slice$content2.firstChild) === null || _slice$content2 === void 0 ? void 0 : _slice$content2.type.name) === 'paragraph' && (slice === null || slice === void 0 || (_slice$content3 = slice.content) === null || _slice$content3 === void 0 || (_slice$content3 = _slice$content3.firstChild) === null || _slice$content3 === void 0 ? void 0 : _slice$content3.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). var anchorPos = placeBelow ? change.toB : change.fromB; var safeInsertPos = findSafeInsertPos(newDoc, anchorPos, slice); var isActive = activeIndexPos && safeInsertPos === activeIndexPos.from && safeInsertPos === activeIndexPos.to; if (slice.content.content.length === 0 || shouldSkipDeletedEmptyParagraphDecoration) { return []; } var isTableCellContent = slice.content.content.some(function () { return slice.content.content.some(function (siblingNode) { return ['tableHeader', 'tableCell'].includes(siblingNode.type.name); }); }); var isTableRowContent = slice.content.content.some(function () { return slice.content.content.some(function (siblingNode) { return ['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: slice, newDoc: newDoc, change: change, nodeViewSerializer: nodeViewSerializer, colorScheme: colorScheme, isInserted: isInserted, diffType: diffType }); } if (isTableCellContent) { // Deleted whole cells: nothing to render (see note above). return []; } if (isTableRowContent) { return createChangedRowDecorationWidgets({ changes: [change], originalDoc: doc, newDoc: newDoc, nodeViewSerializer: nodeViewSerializer, colorScheme: colorScheme, isInserted: isInserted, diffType: diffType }); } var serializer = nodeViewSerializer; // For non-table content, use the existing span wrapper approach var dom = document.createElement('span'); var $safeInsertPos = newDoc.resolve(safeInsertPos); var isTopLevelInsert = $safeInsertPos.depth === 0; var hasPreviousBlock = ((_$safeInsertPos$nodeB = $safeInsertPos.nodeBefore) === null || _$safeInsertPos$nodeB === void 0 ? void 0 : _$safeInsertPos$nodeB.isBlock) === true; var 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. var constrainMediaObserver; if (isExtendedEnabled(diffType)) { constrainMediaObserver = new MutationObserver(function () { var richMediaItems = dom.querySelectorAll('.rich-media-item'); richMediaItems.forEach(function (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 || _constrainMediaObserv.disconnect(); } }); constrainMediaObserver.observe(dom, { childList: true, subtree: true }); } var diffId = crypto.randomUUID(); var 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(function (node) { // Helper function to handle multiple child nodes var handleMultipleChildNodes = function handleMultipleChildNodes(node) { if (node.content.childCount > 1 && node.type.inlineContent) { node.content.forEach(function (childNode) { var childNodeView = serializer.tryCreateNodeView(childNode); if (childNodeView) { var lineBreak = document.createElement('br'); dom.append(lineBreak); var wrapper = createContentWrapper(colorScheme, isActive, isInserted, diffType); wrapper.append(childNodeView); dom.append(wrapper); } else { // Fallback to serializing the individual child node var serializedChild = serializer.serializeNode(childNode); if (serializedChild) { var _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 var isFirst = slice.content.firstChild === node; var isLast = slice.content.lastChild === node; var hasInlineContent = node.content.childCount > 0 && node.type.inlineContent === true; var fallbackSerialization; if (handleMultipleChildNodes(node)) { return; } if ((isFirst || isLast && slice.content.childCount > 2) && hasInlineContent) { fallbackSerialization = function fallbackSerialization() { return serializer.serializeFragment(node.content); }; } else if (isLast && slice.content.childCount === 2) { fallbackSerialization = function fallbackSerialization() { if (node.type.name === 'text') { return document.createTextNode(node.text || ''); } if (node.type.name === 'paragraph') { var lineBreak = document.createElement('br'); dom.append(lineBreak); return serializer.serializeFragment(node.content); } return serializer.serializeFragment(node.content); }; } else { fallbackSerialization = function fallbackSerialization() { return serializer.serializeNode(node); }; } // Try to create node view, fallback to serialization var nodeView = serializer.tryCreateNodeView(node); if (nodeView) { if (node.isInline) { var wrapper = createContentWrapper(colorScheme, isActive, isInserted, diffType); wrapper.append(nodeView); dom.append(wrapper); } else { // Handle all block nodes with unified function wrapBlockNodeView({ dom: dom, nodeView: nodeView, targetNode: node, colorScheme: colorScheme, intl: intl, isActive: isActive, isInserted: isInserted, diffType: diffType, hideAddedDiffsUnderline: 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 { var fallbackNode = fallbackSerialization(); if (fallbackNode) { if (fallbackNode instanceof HTMLElement) { var injectedNode = injectInnerWrapper({ node: fallbackNode, colorScheme: colorScheme, isActive: isActive, isInserted: isInserted, diffType: diffType }); dom.append(injectedNode); } else { var _wrapper2 = createContentWrapper(colorScheme, isActive, isInserted, diffType); _wrapper2.append(fallbackNode); dom.append(_wrapper2); } } } }); dom.setAttribute('data-testid', 'show-diff-deleted-decoration'); if (showIndicators && isExtendedEnabled(diffType)) { var leftAnchor = createLeftAnchorWidget({ doc: newDoc, from: safeInsertPos, diffId: diffId }); dom.style.setProperty('anchor-name', "--".concat(buildAnchorDecorationKey({ diffId: diffId }))); if (leftAnchor) { decorations.push(leftAnchor); } } decorations.push(Decoration.widget(safeInsertPos, dom, _objectSpread(_objectSpread({}, buildDiffDecorationSpec(_objectSpread({ decorationType: 'widget', diffId: diffId, isActive: isActive, diffType: 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: function 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. var isPureDeletion = change.fromB === change.toB; var isSingleBlock = slice.content.childCount === 1 && ((_slice$content$firstC2 = slice.content.firstChild) === null || _slice$content$firstC2 === void 0 ? void 0 : _slice$content$firstC2.isBlock); var isDiffWidgetAtStartOfDoc = $safeInsertPos.depth === 0 && $safeInsertPos.index(0) === 0; if (isDiffWidgetAtStartOfDoc && isSingleBlock && isPureDeletion && isExtendedEnabled(diffType)) { var followingNode = $safeInsertPos.nodeAfter; var headingLevel = (followingNode === null || followingNode === void 0 ? void 0 : followingNode.type.name) === 'heading' ? followingNode.attrs.level : undefined; if (isHeadingLevel(headingLevel)) { var nodeLikeSpacer = dom.ownerDocument.createElement("h".concat(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) { var 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, _objectSpread({}, buildDiffDecorationSpec({ decorationType: 'widget', diffId: crypto.randomUUID(), diffType: diffType })))); } } return decorations; };