UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

222 lines (208 loc) 8.66 kB
import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document'; import { findParentNodeClosestToPos } from '@atlaskit/editor-prosemirror/utils'; import { Decoration } from '@atlaskit/editor-prosemirror/view'; import { TableMap } from '@atlaskit/editor-tables/table-map'; import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled'; import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals'; import { isExtendedEnabled } from '../isExtendedEnabled'; import { buildAddedCellOverlayRoundedStyle, buildAddedCellOverlayStyle, buildDeletedCellOverlayRoundedStyle, buildDeletedCellOverlayStyle, buildDeletedRowStyle } from './colorSchemes/factory'; import { colorSchemeRegistry } from './colorSchemes/schemes'; import { resolveCellOverlayStyleLegacy, resolveDeletedRowStyleLegacy } from './createChangedRowDecorationWidgets.styles.legacy'; import { buildDiffDecorationSpec } from './decorationKeys'; import { findSafeInsertPos } from './utils/findSafeInsertPos'; import { applyCellEdgeAttrs, getRowCellEdgeAttrs, getTableTopAndBottomCellEdgeAttrs } from './utils/tableCellEdgeAttrs'; const DEFAULT_COLOR_SCHEME = 'standard'; /** * Extracts information about deleted table rows from a change */ const extractChangedRows = ({ change, originalDoc, newDoc, diffType }) => { const changedRows = []; // Find the table in the original document const $fromPos = originalDoc.resolve(change.fromA); const tableOld = findParentNodeClosestToPos($fromPos, node => node.type.name === 'table'); if (!tableOld) { return changedRows; } const oldTableMap = TableMap.get(tableOld.node); // Find the table in the new document at the insertion point const $newPos = newDoc.resolve(change.fromB); const tableNew = findParentNodeClosestToPos($newPos, node => node.type.name === 'table'); if (!tableNew) { return changedRows; } const newTableMap = TableMap.get(tableNew.node); // If no rows were changed, return empty if (oldTableMap.height <= newTableMap.height || // For now ignore if there are column deletions as well oldTableMap.width !== newTableMap.width) { return changedRows; } // Find which rows were changed by analyzing the change range const changeStartInTable = change.fromA - tableOld.pos - 1; const changeEndInTable = change.toA - tableOld.pos - 1; const edgeAttrsByOffset = isExtendedEnabled(diffType) && expValEquals('platform_editor_table_q4_loveability', 'isEnabled', true) && expValEquals('platform_editor_table_diff_rounded_corners', 'isEnabled', true) ? getTableTopAndBottomCellEdgeAttrs(oldTableMap) : undefined; let currentOffset = 0; let rowIndex = 0; tableOld.node.content.forEach(rowNode => { const rowStart = currentOffset; const rowEnd = currentOffset + rowNode.nodeSize; // Check if this row overlaps with the deletion range const rowOverlapsChange = rowStart >= changeStartInTable && rowStart < changeEndInTable || rowEnd > changeStartInTable && rowEnd <= changeEndInTable || rowStart < changeStartInTable && rowEnd > changeEndInTable; if (rowOverlapsChange && rowNode.type.name === 'tableRow' && (isExtendedEnabled(diffType) || !isEmptyRow(rowNode))) { const cellEdgeAttrs = edgeAttrsByOffset ? getRowCellEdgeAttrs({ edgeAttrsByOffset, rowNode, rowStart }) : undefined; const startOfRow = newTableMap.mapByRow.slice().reverse().find(row => row[0] + tableNew.pos < change.fromB && change.fromB < row[row.length - 1] + tableNew.pos); changedRows.push({ rowIndex, rowNode, cellEdgeAttrs, fromA: tableOld.pos + 1 + rowStart, toA: tableOld.pos + 1 + rowEnd, fromB: startOfRow ? startOfRow[0] + tableNew.start : change.fromB }); } currentOffset += rowNode.nodeSize; if (rowNode.type.name === 'tableRow') { rowIndex++; } }); // Filter changes that never truly got deleted return changedRows.filter(changedRow => { return !tableNew.node.children.some(newRow => areNodesEqualIgnoreAttrs(newRow, changedRow.rowNode)); }); }; /** * Checks if a table row is empty (contains no meaningful content) */ const isEmptyRow = rowNode => { let isEmpty = true; rowNode.descendants(node => { if (!isEmpty) { return false; } // If we find any inline content with size > 0, the row is not empty if (node.isInline && node.nodeSize > 0) { isEmpty = false; return false; } // If we find text content, the row is not empty if (node.isText && node.text && node.text.trim() !== '') { isEmpty = false; return false; } return true; }); return isEmpty; }; /** * Creates a DOM representation of a deleted table row */ const createChangedRowDOM = (rowNode, cellEdgeAttrs, nodeViewSerializer, colorScheme, isInserted, diffType) => { const tr = document.createElement('tr'); const colors = colorSchemeRegistry[colorScheme !== null && colorScheme !== void 0 ? colorScheme : DEFAULT_COLOR_SCHEME]; // Inserted rows keep their natural styling; the row strikethrough is deletions only. if (!isExtendedEnabled(diffType) || !isInserted) { tr.setAttribute('style', isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? buildDeletedRowStyle(colors) : resolveDeletedRowStyleLegacy(colorScheme)); } tr.setAttribute('data-testid', 'show-diff-deleted-row'); // Serialize each cell in the row let cellIndex = 0; rowNode.content.forEach(cellNode => { if (cellNode.type.name === 'tableCell' || cellNode.type.name === 'tableHeader') { const nodeView = nodeViewSerializer.tryCreateNodeView(cellNode); if (nodeView) { if (nodeView instanceof HTMLElement) { applyCellEdgeAttrs(nodeView, cellEdgeAttrs === null || cellEdgeAttrs === void 0 ? void 0 : cellEdgeAttrs[cellIndex]); if (isExtendedEnabled(diffType)) { const overlay = document.createElement('span'); const isRoundedTable = expValEquals('platform_editor_table_diff_rounded_corners', 'isEnabled', true); const overlayStyle = isExperimentEnabled('platform_editor_show_diff_color_scheme_refactor') ? isInserted ? isRoundedTable ? buildAddedCellOverlayRoundedStyle(colors) : buildAddedCellOverlayStyle(colors) : isRoundedTable ? buildDeletedCellOverlayRoundedStyle(colors) : buildDeletedCellOverlayStyle(colors) : resolveCellOverlayStyleLegacy({ colorScheme, isInserted, isRoundedTable }); overlay.setAttribute('style', overlayStyle); nodeView.appendChild(overlay); } } tr.appendChild(nodeView); } else { // Fallback to fragment serialization const serializedContent = nodeViewSerializer.serializeFragment(cellNode.content); if (serializedContent) { tr.appendChild(serializedContent); } } cellIndex++; } }); return tr; }; /** * Expands a diff to include whole changed rows when table rows are affected */ const expandDiffForChangedRows = ({ changes, originalDoc, newDoc, diffType }) => { const rowInfo = []; for (const change of changes) { // Check if this change affects table content const changedRows = extractChangedRows({ change, originalDoc, newDoc, diffType }); if (changedRows.length > 0) { rowInfo.push(...changedRows); } } return rowInfo; }; /** * Main function to handle deleted rows - computes diff and creates decorations */ export const createChangedRowDecorationWidgets = ({ changes, originalDoc, newDoc, nodeViewSerializer, colorScheme, isInserted = false, diffType }) => { // First, expand the changes to include complete deleted rows const changedRows = expandDiffForChangedRows({ changes: changes.filter(change => change.deleted.length > 0), originalDoc, newDoc, diffType }); return changedRows.map(changedRow => { const rowDOM = createChangedRowDOM(changedRow.rowNode, changedRow.cellEdgeAttrs, nodeViewSerializer, colorScheme, isInserted, diffType); // Find safe insertion position for the deleted row const safeInsertPos = findSafeInsertPos(newDoc, changedRow.fromB - 1, // -1 to find the first safe position from the table originalDoc.slice(changedRow.fromA, changedRow.toA)); const diffId = crypto.randomUUID(); return Decoration.widget(safeInsertPos, rowDOM, { ...buildDiffDecorationSpec({ decorationType: 'widget', diffId, isActive: false, diffType }) }); }); };