@atlaskit/editor-plugin-show-diff
Version:
ShowDiff plugin for @atlaskit/editor-core
281 lines (276 loc) • 9.66 kB
JavaScript
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);
};