UNPKG

@atlaskit/editor-plugin-show-diff

Version:

ShowDiff plugin for @atlaskit/editor-core

281 lines (276 loc) • 9.66 kB
import { areNodesEqualIgnoreAttrs } from '@atlaskit/editor-common/utils/document'; import { Fragment, Slice } from '@atlaskit/editor-prosemirror/model'; import { TableMap } from '@atlaskit/editor-tables/table-map'; export const CELL_CONTENT_OFFSET = 2; const tablesIn = doc => { const tables = []; doc.descendants((node, pos) => { if (node.type.name === 'table') { tables.push({ node, pos }); return false; } return true; }); return tables; }; const tableEnd = table => table.pos + table.node.nodeSize; const documentsDifferOnlyByTable = (originalDoc, newDoc, before, after) => { try { return originalDoc.replace(before.pos, tableEnd(before), new Slice(Fragment.from(after.node), 0, 0)).eq(newDoc); } catch { return false; } }; const columnOffsets = (map, table) => Array.from({ length: map.width }, (_, column) => map.positionAt(0, column, table)); const positionMatchedColumns = ({ before, after, afterToBefore, beforeOffsets }) => { const afterMap = TableMap.get(after.node); const surviving = new Set(); for (let column = 0; column < afterMap.width; column++) { const offset = afterMap.positionAt(0, column, after.node); const mapped = afterToBefore.mapResult(after.pos + CELL_CONTENT_OFFSET + offset); const beforeColumn = beforeOffsets.indexOf(mapped.pos - before.pos - CELL_CONTENT_OFFSET); if (mapped.deleted || beforeColumn === -1) { return new Set(); } surviving.add(beforeColumn); } return surviving; }; const headerLocalIds = table => { var _table$node$firstChil, _table$node$firstChil2; return ((_table$node$firstChil = (_table$node$firstChil2 = table.node.firstChild) === null || _table$node$firstChil2 === void 0 ? void 0 : _table$node$firstChil2.children) !== null && _table$node$firstChil !== void 0 ? _table$node$firstChil : []).map(cell => { var _cell$attrs$localId; return (_cell$attrs$localId = cell.attrs.localId) !== null && _cell$attrs$localId !== void 0 ? _cell$attrs$localId : undefined; }); }; /** * Cells keep their `localId` when a column is deleted, so the first row's ids name each remaining * column even when the steps replaced the whole table. Missing or repeated ids, such as in content * pasted from elsewhere, cannot name a column. */ const localIdMatchedColumns = (before, after) => { const beforeIds = headerLocalIds(before); if (beforeIds.some(id => !id) || new Set(beforeIds).size !== beforeIds.length) { return undefined; } const surviving = new Set(); let previousColumn = -1; for (const id of headerLocalIds(after)) { const column = id ? beforeIds.indexOf(id) : -1; if (column <= previousColumn) { return undefined; } surviving.add(column); previousColumn = column; } return surviving; }; const IGNORED_CELL_ATTRS = ['localId', 'colwidth']; /** Rows may also have been deleted, so the remaining cells only need to appear in order. */ const isColumnKept = (before, beforeColumn, after, afterColumn) => { let beforeRow = 0; return after.node.children.every(afterRow => { const afterCell = afterRow.maybeChild(afterColumn); while (beforeRow < before.node.childCount) { const beforeCell = before.node.child(beforeRow++).maybeChild(beforeColumn); if (afterCell && beforeCell && areNodesEqualIgnoreAttrs(beforeCell, afterCell, IGNORED_CELL_ATTRS)) { return true; } } return false; }); }; /** * Pairs each remaining column with an equal original column, keeping their order, and takes each * pair from the `direction` end first. Returns the original column of each remaining column. */ const pairColumnsInOrder = (beforeWidth, afterWidth, isMatch, direction) => { const step = direction === 'left' ? 1 : -1; const pairing = []; let beforeColumn = direction === 'left' ? 0 : beforeWidth - 1; let afterColumn = direction === 'left' ? 0 : afterWidth - 1; for (; afterColumn >= 0 && afterColumn < afterWidth; afterColumn += step) { while (beforeColumn >= 0 && beforeColumn < beforeWidth && !isMatch(beforeColumn, afterColumn)) { beforeColumn += step; } if (beforeColumn < 0 || beforeColumn >= beforeWidth) { return undefined; } pairing.push(beforeColumn); beforeColumn += step; } return direction === 'left' ? pairing : pairing.reverse(); }; /** * For tables whose cells have no usable ids, such as content created before cells had ids. Every * valid pairing of remaining columns to original ones lies between the leftmost and the rightmost * pairing, so the columns are only reported when those two agree. Otherwise, such as when several * original columns are identical, which of them were removed cannot be known. */ const uniquelyContentMatchedColumns = (before, after, beforeWidth, afterWidth) => { const isMatch = (beforeColumn, afterColumn) => isColumnKept(before, beforeColumn, after, afterColumn); const leftmost = pairColumnsInOrder(beforeWidth, afterWidth, isMatch, 'left'); const rightmost = pairColumnsInOrder(beforeWidth, afterWidth, isMatch, 'right'); if (!leftmost || !rightmost || leftmost.some((column, index) => column !== rightmost[index])) { return undefined; } return new Set(leftmost); }; const findDeletedColumns = (before, after, afterToBefore) => { var _localIdMatchedColumn; const beforeMap = TableMap.get(before.node); const afterMap = TableMap.get(after.node); // Merged or malformed tables do not have reliable column indices. if (beforeMap.hasMergedCells() || afterMap.hasMergedCells() || beforeMap.problems || afterMap.problems) { return []; } const beforeOffsets = columnOffsets(beforeMap, before.node); const positionMatched = positionMatchedColumns({ before, after, afterToBefore, beforeOffsets }); const surviving = positionMatched.size === afterMap.width ? positionMatched : (_localIdMatchedColumn = localIdMatchedColumns(before, after)) !== null && _localIdMatchedColumn !== void 0 ? _localIdMatchedColumn : uniquelyContentMatchedColumns(before, after, beforeMap.width, afterMap.width); if (!surviving) { return []; } return beforeOffsets.flatMap((_, column) => surviving.has(column) ? [] : [column]); }; const findCounterpart = ({ table, index, originalTables, newTables, mapping }) => { const from = mapping.map(table.pos, -1); const to = mapping.map(tableEnd(table), 1); const inside = mapping.mapResult(table.pos + 1); const candidates = newTables.filter(candidate => inside.deleted ? candidate.pos >= from && tableEnd(candidate) <= to : candidate.pos < inside.pos && inside.pos < tableEnd(candidate)); if (candidates.length === 1) { return candidates[0]; } return originalTables.length === newTables.length ? newTables[index] : undefined; }; const orient = ({ table, counterpart, mapping, isInverted }) => ({ before: isInverted ? counterpart : table, after: isInverted ? table : counterpart, afterToBefore: (isInverted ? mapping.invert() : mapping).invert() }); const changesInTable = (changes, table) => changes.filter(change => change.fromA < tableEnd(table) && change.toA > table.pos); const isNarrowingSafe = ({ replacedChanges, table, counterpart, originalDoc, newDoc }) => { const staysInside = replacedChanges.every(change => change.fromA >= table.pos && change.toA <= tableEnd(table) && change.fromB >= counterpart.pos && change.toB <= tableEnd(counterpart)); return staysInside || documentsDifferOnlyByTable(originalDoc, newDoc, table, counterpart); }; const planTableChange = ({ table, index, originalTables, newTables, changes, originalDoc, newDoc, mapping, isInverted }) => { const counterpart = findCounterpart({ table, index, originalTables, newTables, mapping }); if (!counterpart) { return undefined; } const { before, after, afterToBefore } = orient({ table, counterpart, mapping, isInverted }); if (TableMap.get(before.node).width <= TableMap.get(after.node).width) { return undefined; } const replacedChanges = changesInTable(changes, table); if (!replacedChanges.length) { return undefined; } if (!isNarrowingSafe({ replacedChanges, table, counterpart, originalDoc, newDoc })) { return undefined; } const spans = replacedChanges.flatMap(change => [...change.deleted, ...change.inserted]); return { replacedChanges, tableChange: { deletedColumns: findDeletedColumns(before, after, afterToBefore), fromA: table.pos, toA: tableEnd(table), fromB: counterpart.pos, toB: tableEnd(counterpart), deleted: spans, inserted: spans } }; }; /** Groups a deleted column's cell changes into one whole-table change. */ export const groupDeletedColumnChanges = (changes, originalDoc, newDoc, mapping, isInverted) => { const originalTables = tablesIn(originalDoc); const newTables = tablesIn(newDoc); const plans = []; const replaced = new Set(); originalTables.forEach((table, index) => { const plan = planTableChange({ table, index, originalTables, newTables, changes: changes.filter(change => !replaced.has(change)), originalDoc, newDoc, mapping, isInverted }); if (!plan) { return; } plans.push(plan); plan.replacedChanges.forEach(change => replaced.add(change)); }); if (!plans.length) { return [...changes]; } return [...changes.filter(change => !replaced.has(change)), ...plans.map(plan => plan.tableChange)].sort((left, right) => left.fromB - right.fromB); };